통계를 유효 무게 위로 — 중량 풀업 추정 1RM 절반·보조 종목 거꾸로 PR에서 계산은 유효 무게·표시는 추가 무게 프레임, 과거 기록 재계산, 계획 볼륨 배수·커스텀 등록 선택지까지 (2026-09-04)
- 기간: 2026-09-03 ~ 2026-09-04 (세션 4개 — 분석·Phase 3~4 코드
65afb2ab, Phase 5·PR5104a0a8, CI 6차 확인·머지·릴리스 PR·기록654a2352; 선행 #1200 분석5e5afb45. 오너 결정 D7(2026-09-03)·D9~D11(2026-09-04)) - 랜딩: PR #1223 (Phase 3~5, squash
ccd51042) — 마이그레이션20260909010000_effective_load_backfill_v1(Phase 4) ·20260909010100_effective_load_stats_v1(Phase 3) ·20260909010200_effective_load_plan_multiplier_v1(Phase 5), 엣지 함수 없음, Vercel 배포 O - 설계서: 없음(분석·Phase 계획·"예상 효과·개선사항" = 이슈 #1202 본문 + Phase 5 계획 댓글 2026-09-04)
- 정본: 서버
strength_display_frame_kg_v1(값, 원본 세트 id)(표시 프레임 환산 한 곳) · 근력 관측 코어refresh_user_strength_estimation_projection_core·최대 반복수refresh_user_max_rep_projection(유효 무게 입력) ·planned_session_card_stats_v1(예상 볼륨 = stats_load_kg × load_multiplier × reps) ·create_custom_exercise(load_multiplier) / 클라ui/shared/bodyweightSemantics.tsbwEffLoad(완료 화면 폴백)·topSetBwTag("체중−Nkg") ·barbelicMappers계획 카드 폴백 - 도구: 정본 추출-치환 빌더
build_1202.py(Phase 3)·build_p5.py(Phase 5) +fnlib.py(세션 scratchpad, 레포 밖) · Production 드라이런dryrun_save.mjs(Management API, 5본 묶음 + 끝 raise 롤백) · 수치 프로브probe_1202.sql·probe_p4_inline.sql·probe_p5_inline.sql - 게이트: pgTAP
effective_load_stats_v1.test.sql(16건)·effective_load_plan_multiplier_v1.test.sql(10건) · e2eerror-cases/CASE-031-custom-dumbbell-load-multiplier-round-trip· 단위dayTopSetsEffectiveLoad·catalogWriteHardCut·mappers·bodyweightFactorProfiles·calendarExactSummaryProjection·check:dual-key(455 기준선 불변) ·designContract마커workout.custom.loadMultiplier - 버그리포트: BUG-074(선행 #1200과 공유 — 이 트랙이 통계·과거 기록 몫을 닫는다)
- 계약:
docs/data/app-screen-rpc-contract.md(+ko) 탑세트 행 키load_multiplier·assist_kg, 계획 카드estimated_volume정의 ·docs/contracts/day-summary-props.md"체중−Nkg" ·docs/data/rpc-catalog.mdcreate_custom_exerciseload_multiplier·docs/data/rpe-performed-intensity.md(Phase 3, 세트 강도 유효 무게 프레임)
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 3-1 | 근력 관측 입력을 유효 무게로(반복수판 분기 = 든 무게 0이고 보조 없음) | ✅ PR #1223 |
| Phase 3-2 | 표시 프레임 환산 정본 1곳(strength_display_frame_kg_v1), PR 이벤트·등급 무변경 | ✅ PR #1223 |
| Phase 3-3 | 세트 스코어·세트 강도 유효 무게 프레임(공식·클라 계산 무변경, 세트 지표 맵에 환산 재료 동봉) | ✅ PR #1223 |
| Phase 3-4 | 계약 문서·용어 | ✅ PR #1223 |
| Phase 4 | 과거 기록 재계산(어시스트 보조 칸 이동·덤벨 배수 스냅샷 전 출처·스텝업 계수 0·관측 재물질화·잘못된 PR 이벤트 제거) | ✅ PR #1223 |
| Phase 5-1 | 계획 예상 볼륨에 무게 배수(D10) | ✅ PR #1223 |
| Phase 5-2 | 커스텀 종목 등록 "덤벨·케틀벨 2개" 선택지(D11) | ✅ PR #1223 |
| Phase 5-3 | 하루 상세 탑세트 보조 표시 "체중−Nkg"(#1200에서 이관) | ✅ PR #1223 |
1. 배경
선행 트랙 #1200(2026-09-03~04, 작업 기록)이 세트 유효 무게를 max(0, 든 무게 × 배수 + 체중 × 체중계수 − 보조 무게) 하나로 정본화하고 카탈로그를 배정했다. 그러나 그 값 위에 서야 할 통계 네 가지(추정 1RM·PR·세트 스코어·등급)는 여전히 "유저가 적은 무게"만 보고 있었고, 과거 기록은 새 정본을 모르는 스냅샷 그대로였다. 오너 확인(2026-09-03): "중량 풀업 볼륨은 체중+무게가 맞는데, 기록 측정은 보통 체중을 안 넣지 않나?" → 맞다. 계산은 유효 무게로 하되 기록·표시는 추가 무게로 남긴다(D7).
2. 문제 제기
Production 실측(2026-09-03, 체중 80kg 유저 기준):
| 상황 | 지금 | 맞는 값 |
|---|---|---|
| 중량 풀업 +10kg 5회 | 추정 1RM +11~12kg(공식을 추가 무게 10kg에만 적용) | 유효 무게 90 × (1 + 5/30) = 105 → +25kg |
| 어시스트 딥 머신 보조 65kg 10회 | 추정 1RM 86kg·PR 이벤트 1건, 보조를 줄이면 기록 하락 | 유효 무게 15 → 보조를 줄이면 상승, PR 이벤트 없음 |
| 맨몸 풀업 12회 | 최대 반복수만(1RM 없음) | 유효 무게 80 → 추정 1RM 112 → "+32kg" 추정 + 최대 반복수 유지 |
| 세트 스코어 | 추가 무게끼리 나눠 의미가 약함, 보조는 방향 반대 | 유효 무게끼리 나눔 |
| 과거 덤벨 기록 936세트(가져온 것)·47세트(앱) | 배수 1 스냅샷 → 볼륨 절반 | 배수 2 |
| 계획 예상 볼륨 | stats_load_kg × reps — 덤벨 벤치 20kg×10 계획 200, 완료하면 400 | 400 |
| 커스텀 종목 등록 | 배수 선택지 없음 → 항상 1(관리자만 수정) | 등록 시 선택 |
근본: 근력 관측·최대 반복수 관측이 stats_load_kg(든 무게)만 쓰고 stats_effective_load_kg(유효 무게)는 볼륨에만 쓰였다. 관측 파이프라인은 든 무게 0인 세트를 최대 반복수 경로로 보내므로 맨몸·보조 세트가 유효 무게 경로에 들어갈 수 없었다.
3. 해결 방안
원칙 (오너 결정 D7·D9~D11)
| 결정 | 채택 | 기각 |
|---|---|---|
| D7 계산은 유효 무게, 표시·PR은 추가 무게 프레임 | 채택 — 관측 행은 유효 무게로 저장, 유저가 보는 추정 1RM만 (값 − 체중×계수) ÷ 배수로 환산 | 표시까지 유효 무게(관례와 어긋남) / PR 기준을 유효 무게로(체중이 늘면 PR이 되는 부작용) |
| 반복수판 분기 = "든 무게 0이고 보조 없음" | 채택 — 맨몸 풀업은 추정 1RM과 최대 반복수 둘 다, 보조 세트는 추정 1RM만 | 이슈 본문의 "체중계수 0이면서 든 무게 0"(맨몸 풀업의 반복수판이 사라짐) |
| D9 가져온 덤벨 기록에도 배수 2 | 채택 — 표본(덤벨 벤치 16·22kg, 레터럴 레이즈 5lb)이 한 개 무게 | 앱 입력분만 적용(같은 운동이 출처에 따라 절반) |
| D10 계획 예상 볼륨에 배수 | 채택 — planned_sets.load_multiplier 스냅샷 + 함수 4종 | 계획은 그대로(완료 볼륨과 어긋남) |
| D11 커스텀 등록 선택지 | 채택 — 무게 항목이 켜져 있을 때만 "한 개 / 2개(양손 각각)" | 관리자 사후 배정만(유저가 등록한 덤벨 종목은 절반으로 남음) |
접근
서버 함수는 schema.sql의 마지막 정의를 기계로 추출해 치환하고 개수를 단언하는 빌더로 조립했다(Phase 3 build_1202.py, Phase 5 build_p5.py). Production에는 #1200 열이 아직 없으므로(릴리스 v0.16.0 대기) #1200 마이그레이션 2본 + 이 트랙 3본을 이어 붙인 묶음을 Management API로 보내 끝에서 raise로 롤백하는 드라이런으로 통검했다. 재계산은 refresh_user_exercise_stats_from을 직접 부르지 않고 enqueue_user_exercise_stats_refresh_job + process_user_exercise_stats_refresh_jobs_for_user_now(#1189 방식)로 실사용자를 한 번에 돌렸다.
4. 적용한 내용
Phase 3 — 통계를 유효 무게 위로 (20260909010100)
strength_display_frame_kg_v1(p_kg, p_source_set_id): (값 − 세션 체중 × 체중계수) ÷ 무게 배수. 바벨은 항등, 덤벨 2개는 한 개 무게, 중량 풀업은 "+Nkg", 보조 종목은 음수.- 근력 관측 코어(
refresh_user_strength_estimation_projection_core)·최대 반복수(refresh_user_max_rep_projection)·세트 지표 맵(strength_set_metrics_map_json)·세트 스코어 미리보기(workout_set_score_preview_v1)·종목 상세 연간 시간선(get_exercise_pr_detail_year, 3인자 오버로드) 재발행. 관측 행에stats_effective_load_kg·bodyweight_share_kg열. - 유저가 보는 추정 1RM(세션 종목 집계·종목 통계·기간 통계·연간 시간선·데스크톱 세트 칸)만 환산. PR 이벤트·등급·세트 스코어 공식은 변경 없음.
- 실사용자 재계산 1회(대상 = 체중계수>0·배수≠1·보조 세트가 있는 세션을 가진 유저; 빈 CI DB는 0명).
Phase 4 — 과거 기록 재계산 (20260909010000, Production 드라이런 실측)
| 대상 | 처리 | 결과(드라이런) |
|---|---|---|
| 어시스트 딥 머신·풀업 머신 | 모든 세트에 무게가 적힌 세션 종목만 무게 칸 → 보조 칸, 스냅샷 {보조, 횟수}·체중계수 1.0 | 딥 머신 1건 3세트(유효 무게 36kg). 무게 0이 섞인 7건(딥 2·풀업 5)은 보조를 알 수 없어 그대로 |
| 어시스트 딥 잘못된 PR 이벤트 | 재계산으로 소멸 | 1 → 0건, 추정 1RM 표시 −53("보조 53kg") |
| 덤벨·더블 케틀벨 배수 2 스냅샷(D9) | 전 출처 | 세션 종목 245건(Motra 134·WodUp 97·앱 14), 세트 743건 |
| 스텝업/다운 4종 | 체중계수 0 | 2세션(버피 박스 스텝업·웨이티드 스텝다운), 유효 무게 0 |
| 관측 재물질화 | 근력 15,355·최대 반복수 3,613 | 덤벨 벤치 표시 1RM 24/30/29(한 개 무게 프레임, 종전과 동일) |
Phase 5 — 무게 배수 후속 (20260909010200)
- 5-1
planned_sets.load_multiplier(1|2) + BEFORE 트리거snapshot_planned_set_load_multiplier_v1(카탈로그에서 복사, 종목 교체 시 재스냅샷) + 기존 행 UPDATE. 예상 볼륨 =stats_load_kg × load_multiplier × reps—planned_session_card_stats_v1·get_calendar_day_summary·get_calendar_month_summary·build_home_dashboard_core_v2재발행(구 v2_core 2종은 이미 drop된 사본이라 제외). 클라 폴백(barbelicMappers계획 카드)도 동일. 드라이런: 계획 세트 139행 중 배수 2 = 6행, 카탈로그와 어긋남 0. - 5-2 모바일
WorkoutPicker커스텀 폼·데스크톱DesktopPlanEditorPickerParts커스텀 폼에 "덤벨·케틀벨 개수" 선택지(무게 항목이 켜져 있을 때만, 기본 한 개, 마커workout.custom.loadMultiplier) →createUserCustomExercise→ 저장소 payloadload_multiplier(1|2, 그 외 폼 검증 오류) →create_custom_exercise(1|2, 그 외 22023). 방금 등록한 종목을 바로 고를 때 진행 초안이 배수를 알도록 등록 응답에loadMultiplier동봉. - 5-3
get_calendar_day_summary탑세트 행에assist_kg·load_multiplier동봉,calendarTopSetToUiassistKg,topSetBwTag"체중−20kg". - 완료 화면 볼륨 폴백(작업 중 드러난 것):
bwEffLoad가 배수·보조를 몰라 덤벨 벤치 20kg×10을 완료 화면에서 200kg으로 보여 줬다(저장 뒤 일지는 400kg). 배수·보조를 받도록 확장, 진행 초안 종목에loadMultiplier스냅샷.
주요 결정과 그 근거
- 관측 행은 유효 무게 그대로, 환산은 읽기 지점 한 곳: 세트 스코어(세트 유효 무게 ÷ 직전세션 유효 무게 1RM)가 같은 프레임끼리 나뉘고, 표시만 관례(추가 무게)를 따른다.
- PR 이벤트 무변경: 관례대로 추가 무게가 가장 큰 것. 체중 80에서 +30kg(실제 110)과 체중 85에서 +28kg(실제 113) 중 PR은 +30kg.
- 계획 배수는 스냅샷: 세션 종목과 같은 수명 주기(카탈로그 값이 바뀌어도 과거 계획은 저장 당시 값).
작업 중 드러난 것
fnlib.blocks()는 이름당 마지막 정의만 잡는다 —get_exercise_pr_detail_year는 3인자·5인자·v3_core가 있어fn_sig로 골랐다.create_custom_exercise의 unique_violation 대조 줄은 두 분기에 같은 문장이 있어 개수 2로 치환.- 근력 코어의 자기 검증 DO가 함수 본문 문자열을 대조한다 — 재발행 시 그 문자열들을 보존해야 마이그레이션이 멈추지 않는다.
- 세트 지표 맵의 관측 대조 조건에
stats_effective_load_kg비교를 추가했다 — 체중을 나중에 고치면 옛 관측이 무시되고 즉석 추정으로 대체된다(의도). get_calendar_day_summary_v2_core·get_calendar_month_summary_v2_core는 마지막 정의가 남아 있지만 뒤에서 drop된 사본 — "마지막 정의 = 살아 있는 함수"가 아니다. 재발행 대상은 drop 여부까지 확인한다.- 통계 무결성 검사 함수도 프레임을 안다: CI 1차 실행에서 stats worker가 "Stats integrity validation failed"(P0001)로 멈췄다.
validate_user_exercise_stats_integrity_engine_v1이 관측 기대값을 든 무게(stats_load_kg)로 다시 계산하고, 세션 롤업·기록·종목 통계·기간 통계의 추정 1RM을 관측 값과 그대로 대조하고 있었다. Phase 3 코어가 유효 무게로 계산하고 표시 프레임으로 환산하므로 검사도 같은 입력·같은 환산으로 재발행했다(20260909010200§4). Production 드라이런은 재계산을validate_integrity=false로 돌려 이 검사를 건너뛰었기 때문에 드러나지 않았다 — 이후 드라이런에 실사용자 3명 검사 호출을 넣어 ok를 확인했다. 재계산 로직을 바꾸면 검사 함수를 같이 바꾸고, 드라이런에서 검사까지 부른다. - 맨몸 세트는 세트 스코어 한 판에만: CI 2차 실행에서 리포트 구간·일 요약 집계가 물구나무 푸쉬업 3세트를 6으로 셌다. Phase 3부터 맨몸 세트에도 유효 무게 기반 추정 1RM 관측이 생기면서 1RM판과 반복수판에 같은 세트가 겹친 것. 세트 스코어 관측 조회(
set_score_observations_v1)에서 반복수판 후보(든 무게 0·보조 없음)를 1RM판에서 제외하고, 세트 지표 맵에는 그 세트의 1RM판 나누는 값을 싣지 않아 클라이언트도 반복수판으로 계산한다. 추정 1RM 키는 그대로("+Nkg" 표시). 드라이런에 "같은 세트가 두 번 채점되는 수"를 프로브로 넣어 0을 확인했다. - 사전 검증 누락(오너 지적 2026-09-04): PR #1223은 CI 5회 실패 뒤에야 통과했다. 원인은 전부 테스트·검사 쪽(무결성 검사 함수 프레임, 맨몸 세트 이중 채점, pgTAP 픽스처 3건, e2e CASE-031 도우미 조회 열 누락)이었고, #1200의 CASE-030 체중 열 누락과 같은 유형이 반복됐다. 이 PC의 로컬 샌드백스 스택(
scratchpad/sbx/supabase+ junction, 메모리 §4)으로 pgTAP 102파일 1,629건이 2분 30초, CASE-031이 16초에 돌아가는데도 "로컬 미실행 — CI 몫"으로 넘긴 것이 잘못. 전역 지시 §20(서버 테스트는 PR 전에 로컬 샌드박스에서 먼저)과 이슈 #1224(랜딩 잠금 획득 시 로컬 pgTAP 통과 기록 없으면 거부)로 절차화했다. - e2e 공용 도우미(
waitForSessionByTitle·waitForCustomExercise)는 고정된 열만 조회한다 — 새 열을 단언하려면 같은 행을 따로 읽는다(Number(undefined)는 NaN이라 "값이 null"로 오독하기 쉽다). - 세트 노력 척도 토큰은
effort_scale_version = '1.0.0'(CHECK 제약) — pgTAP 픽스처의'barbelic.effort.v1'은 제약 위반. - 달력 투영 단위 테스트가 탑세트 행을
deepStrictEqual로 대조한다 — 행에 키를 더하면 기대값도 같이 더한다(assistKg: null). - e2e 케이스 등록 게이트는 README에 체크포인트 제목 원문과 route 항목 수만큼의 Step 행을 요구한다.
5. 적용 결과
| 항목 | 결과 |
|---|---|
| 중량 풀업 추정 1RM | 45kg×3(체중 몫 83.7) → 관측 135(유효) → 표시 51.3 — 종전 "추가 무게에만 공식" 대비 유효 무게 기준(드라이런 프로브) |
| 맨몸 풀업 | 6회(유효 120) → 추정 1RM 149, 표시 29~57("+Nkg"), 최대 반복수 관측 유지 |
| 어시스트 딥 머신 | 잘못된 PR 이벤트 1 → 0, 표시 −53("보조 53kg"), 보조를 줄이면 상승 |
| 덤벨 2개 과거 기록 | 세션 종목 245건 배수 2, 표시 1RM은 한 개 무게 프레임 그대로 |
| 계획 예상 볼륨 | 덤벨 벤치 20kg×10 계획 200 → 400(pgTAP 900/700 케이스) |
| 커스텀 등록 | 배수 선택 가능(pgTAP 2 저장·기본 1·3 거부, e2e CASE-031) |
| 탑세트 태그 | 보조 세트 "체중" → "체중−20kg"(단위 테스트·pgTAP assist_kg 동봉) |
| 게이트 | 로컬 npm run check 전 단계 + 단위 2,504건 + check:unused 통과. CI 6차 통과(verify·scope·migration-smoke 초록, 2026-09-04 03:20 KST; 1~5차 실패는 전부 테스트·검사 쪽 — 앱 결함 0건) |
| Production 적용(릴리스 v0.17.0, 2026-09-06 머지) | 프로브 2026-09-07: 세트 값 행의 stats_effective_load_kg 채움 16,704 / 16,704(100%) · 어시스트 딥 머신 PR 이벤트 0 · 세션 종목 load_multiplier = 2 247건(9/4 245 + 이후 기록) · 계획 세션의 배수 2 세부 세트 3행(9/4 드라이런 6행 — 계획이 완료로 바뀌면 계획에서 빠지므로 시점에 따라 변함) · create_custom_exercise 본문에 load_multiplier 있음 |
| 미검증 | 완료 화면·탑세트 태그의 시각 품질(자동 검증 밖) |
6. 이번 개선으로 향상된 것
중량·맨몸·보조 종목의 기록이 한 축 위에서 읽힌다
유저 A가 중량 풀업 +10kg 5회를 하면 추정 1RM이 "+25kg"으로, 유저 B가 어시스트 풀업 보조 20kg 8회를 하면 "보조 Nkg"으로 표시되고, 보조를 줄일수록 기록이 오른다. 맨몸 풀업만 하는 유저 C도 추정 1RM("+Nkg")과 최대 반복수를 함께 본다.
과거 덤벨 기록의 볼륨이 실제로 든 무게가 된다
WodUp·Motra에서 가져온 기록까지 배수 2가 적용돼, 같은 운동이 출처에 따라 반씩 다르게 집계되지 않는다.
계획과 완료의 볼륨이 같은 자로 잰다
덤벨 벤치 20kg×10 계획의 예상 볼륨 400 = 완료 볼륨 400. 유저가 등록한 커스텀 덤벨 종목도 등록 시 "2개"를 고르면 처음부터 ×2.
구조적으로 남는 것
표시 프레임 환산 함수 1곳 · 관측 행의 유효 무게 열 · 계획 세트 배수 스냅샷(세션 종목과 같은 규칙) · 커스텀 등록 RPC의 배수 입력 · 클라 폴백 bwEffLoad 한 공식 · pgTAP 26건·e2e CASE-031.
남은 것
- 어시스트 머신 과거 세션 종목 7건(무게 0이 섞인 것)은 보조 무게를 알 수 없어 볼륨 0으로 남는다 — 유저가 수정 화면에서 보조 kg를 적으면 그때 계산된다.
- 데스크톱 계획 편집기의 세트별 유효 kg 표시(
effOf)와 데스크톱 통계 폴백은 서버 값이 있으면 서버 값을 쓰므로 표시는 맞지만, 서버 값이 없는 순간의 폴백은 배수를 모른다(완료 화면만 이번에 수리).