세트 유효 무게 정본화 — 덤벨 볼륨 절반·보조 종목 거꾸로 계산에서 "유효 무게 = 든 무게×배수 + 체중×계수 − 보조" 한 공식으로 (2026-09-04)
- 기간: 2026-09-03 ~ 2026-09-04 (세션 3개 — 분석·Phase 1~2 코드
5e5afb45, 리베이스·게이트·PR65afb2ab, CI 수리·랜딩5104a0a8. 오너 보고 원문 "덤벨은 덤벨 하나 무게를 적고 볼륨은 ×2로 반영돼야 하는데 되어 있는지 확인 / 중량 풀업은 볼륨이 (체중+중량)이어야 하는데 같은 케이스가 무엇인지 확인") - 랜딩: PR #1219 (Phase 1~2, squash
3aec21c9, 2026-09-04 01:48 KST) — 마이그레이션20260908235900_effective_load_multiplier_assist_v1·20260909000100_effective_load_catalog_assignment_v1, 엣지 함수 없음, Vercel 배포 O - 설계서: 없음(분석·Phase 계획·"예상 효과·개선사항" = 이슈 #1200 본문, 오너 결정 D1~D8)
- 정본: 서버
training_effective_load(load, multiplier, body_weight, bodyweight_factor, assist)+ 트리거set_exercise_set_effective_load·refresh_session_effective_loads·snapshot_session_exercise_load_multiplier_v1/ 클라 폴백src/react/services/bodyweightTraining.tseffectiveTrainingLoad(load, bw, bwf, {loadMultiplier, assistKg})·sessionAggregateFormulas.ts/ 기록 원자src/react/types/recording.ts(6종째assist) / 카탈로그 3값exercises.bodyweight_factor·load_multiplier·recording_fields - 도구: 정본 추출-치환 빌더
build_migration.py+fnlib.py(세션5e5afb45scratchpad, 레포 밖 — schema.sql 마지막 정의를 뽑아 치환하고 개수를 단언) · Production 드라이런dryrun.mjs(Management API, 전문 + 끝 raise 롤백) · 카탈로그 대조 프로브probe_names.mjs(세션65afb2abscratchpad) - 게이트: pgTAP
supabase/tests/database/effective_load_multiplier_assist.test.sql(25건) · 단위effectiveTrainingLoadFormula·sessionDetailBodyweightEffectiveLoad·recordingProfileRoundTrip·screenRpcContracts·limitsRegistry("보조 무게 400kg") · e2eerror-cases/CASE-030-assisted-exercise-effective-load-round-trip·check:dual-key(assistKg/assist_kg경계 헬퍼) ·designContract마커workout.set.assist - 버그리포트: BUG-074(덤벨 볼륨 절반·보조 종목 거꾸로 계산)
- 계약:
docs/data/app-screen-rpc-contract.md(+ko) 세트 행assist_kg·세션 종목load_multiplier·docs/data/limits-registry.md보조 무게 400·무게 배수 {1,2} ·docs/data/rpc-catalog.mdget_exercise_catalog·build_session_detail_payload_v1키 ·docs/contracts/day-summary-props.md
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 1-1 | 종목 무게 배수 열 + 세션 종목 스냅샷 + 관리자 편집칸 | ✅ PR #1219 |
| Phase 1-2 | 보조 무게 기록 원자 assist(열·CHECK·함수 17종 재발행) | ✅ PR #1219 |
| Phase 1-3 | 유효 무게 공식 5인자 재정의 + 재계산 트리거 3종 | ✅ PR #1219 |
| Phase 1-4 | 클라 전층(타입·직렬화·입력·표시·관리자) | ✅ PR #1219 |
| Phase 1-5 | 계약 문서·한도 장부·용어 통일 | ✅ PR #1219 |
| Phase 2 | 카탈로그 배정(배수 2 81종 · 보조+횟수 7종 · 체중계수 0 4종) — 오너 표 확인 | ✅ PR #1219 |
| Phase 3~4 | 통계(추정 1RM·PR·세트 스코어·등급)를 유효 무게 위로 이전 + 과거 기록 재계산 | ⬜ 이슈 #1202 (코드 완성, 랜딩 전) |
| Phase 5 | 계획 예상 볼륨 배수 반영(D10) · 커스텀 종목 등록 "덤벨 2개" 선택지(D11) · 탑세트 보조 표시(이관) | ⬜ 이슈 #1202 (오너 결정 2026-09-04) |
1. 배경
유저가 덤벨 벤치프레스를 "20kg 10회"로 적으면 실제로 든 무게는 양손 합쳐 40kg인데, 볼륨은 200kg으로 잡혔다. 반대로 어시스트 딥 머신에서 "보조 65kg 10회"를 적으면 앱은 65kg를 든 것으로 읽어 볼륨 650kg·추정 1RM 86kg·PR 이벤트까지 만들었다. 보조를 늘릴수록 기록이 좋아지는 방향이다. 오너가 덤벨 ×2와 중량 풀업 볼륨을 확인해 달라고 한 것이 출발점이고, 조사 중 보조 종목의 역방향 계산이 드러나 "땜질이 아닌 근본 방식"을 찾으라는 지시로 이어졌다.
Production 실측(2026-09-03): 덤벨 종목 104개(한 팔 9), 기록 983세트(앱 47·WodUp 453·Motra 483). 보조 종목은 어시스트 머신 2종(30세트, 무게 칸에 보조 또는 0)·밴드 어시스트 5종(244세트, 무게 칸 없음·볼륨 0). 체중계수 1.0인 스텝업/다운 4종. 체중을 안 적은 세션 19개.
2. 문제 제기
2-1. 유효 무게 공식에 "덤벨이 몇 개인가"가 없었다
서버 training_effective_load(load, body_weight, bodyweight_factor) = 든 무게 + 체중×체중계수. 종목이 덤벨인지 보지 않고, 클라 폴백 두 파일도 같다. 입력 화면에 "한쪽 무게" 안내도 없어 유저마다 적는 기준이 달랐다.
2-2. 무게 칸 하나에 "든 무게"와 "보조 무게"가 섞였다
어시스트 머신은 보조 무게를 무게 칸에 적게 돼 있어, "무게 = 든 무게"를 전제로 한 통계(추정 1RM·PR·세트 스코어·기록 보기·보드 % 미리 채우기)가 전부 거꾸로 읽었다. 밴드 어시스트 5종은 아예 무게 칸이 없어 볼륨 0.
2-3. "체중에서 뺀다"는 개념이 없었다
체중계수 허용 범위 0~2 검사가 서버 수십 곳·클라 7곳에 박혀 있고, 유효 무게 함수는 더하기만 한다. 보조 → 맨몸 → 중량이 한 축(−/0/+)으로 이어지지 않았다.
2-4. 스텝업/다운 체중계수 1.0은 개별 판단이 없었다
8월 카탈로그 정리 때 맨몸 분류에 일괄 적용된 값. 덤벨·바벨 스텝업은 이미 0이라 같은 동작이 종목마다 달랐다.
3. 해결 방안
원칙 (오너 결정 D1~D8, 2026-09-03)
유효 무게 = max(0, 든 무게 × 배수 + 체중 × 체중계수 − 보조 무게)ExRx의 'Actual Resistance'(체중 + 보조(음수)), Hevy의 보조 종목 볼륨(체중 − 보조), Strength Level 풀업 기준선(초보 −3kg ~ 상급 +73kg, 음수 포함 한 축)과 같은 정의다.
| 결정 | 채택 | 기각 |
|---|---|---|
D1 종목마다 무게 배수(load_multiplier 1/2) 숫자 하나 | 채택 | 덤벨 분류를 한손/양손으로 나누기(커스텀 279개는 분류가 빈 값, 케틀벨·보조까지 못 덮음) |
| D2 스텝업/다운 체중계수 0 | 채택(오너) | ExRx 역학은 약 89%라고 알렸으나 결정 유지 |
D3 보조 무게는 별도 기록 항목 assist | 채택 | 무게 칸을 배수 −1로 재해석(볼륨만 맞고 1RM·PR·세트 스코어는 계속 거꾸로) |
| D4 밴드는 굵기별 kg 표 도우미(얇음 10 / 보통 18 / 굵음 27 / 아주 굵음 40) | 채택 | 밴드 종목을 횟수만 기록(세트 스코어에 1RM이 필요) |
| D7 계산은 유효 무게, 표시·PR은 추가 무게 프레임("+30kg"·"보조 20kg"·"맨몸") | 채택 → #1202 | 표시까지 유효 무게(관례와 어긋남) |
| D8 이슈 2개(계산 정본+카탈로그 / 통계 이전+재계산) | 채택 | 한 PR(기록 원자 40지점 + 관측 파이프라인 재발행이 겹쳐 너무 큼) |
용어(오너 지시): '실제 저항' 대신 유효 무게. 코드·문서에 이미 있는 effective_load·stats_effective_load_kg와 같은 이름이며, 이번 변경은 같은 열의 정의를 확장한 것이다(새 열·새 이름 없음). "유효 반복수"(RIR 반영 반복수)와 헷갈리지 않게 뒤 명사까지 붙여 쓴다.
접근
기록 원자 신설은 #932 칼로리 원자 때의 절차를 그대로 썼다: schema.sql의 마지막 정의를 기계로 뽑아 치환하고 개수를 단언하는 빌더로 마이그레이션을 조립하고, Production에 전문을 보내 끝에서 raise로 롤백하는 드라이런으로 검증한 뒤 랜딩한다. 무게 배수는 카탈로그 값이 세션 종목에 스냅샷으로 복사되는 bodyweight_factor와 같은 수명 주기를 따르되, 저장 엔진을 고치지 않고 BEFORE 트리거 한 개로 복사한다.
4. 적용한 내용
Phase 1 — 계산 정본 (#1219, 20260908235900)
- Phase 1-1
exercises.load_multiplier numeric(3,1)(1 또는 2) ·session_exercises.load_multiplier스냅샷 · 트리거snapshot_session_exercise_load_multiplier_v1(insert·종목 교체 시 카탈로그에서 복사) · 관리자 종목 화면 "무게 배수" 편집칸(DesktopAdminExercises→ PostgRESTupdateExercise). - Phase 1-2
exercise_sets.assist_kg·planned_sets.assist_kg(0 초과 400 이하) · recording_fields CHECK 3벌에assist추가 · 함수 17종 재발행(정렬·문법·canonical, apply/scrub/rescrub, 완료 검증 v4·계획 검증 v3, 보드 검증, 카탈로그 생성 2, 읽기 6 —get_exercise_catalog·build_session_detail_payload_v1에load_multiplier키). - Phase 1-3
training_effective_load5인자 재정의(3인자 drop) ·set_exercise_set_effective_load(of에assist_kg) ·refresh_session_effective_loads(세션 종목 of에load_multiplier·exercise_id). - Phase 1-4 원자
assist(camelassistKg/ snakeassist_kg) 타입·직렬화·읽기 어댑터 전층, 경계 헬퍼assistKgOf·loadMultiplierFrom· 세트 입력에 보조 kg 칸 + 밴드 굵기 4칩 · 표시보조 20kg × 8회(세션 상세·하루 상세·피드·기록 보기) ·COMPLETED_WORKOUT_WRITE_LIMITS.maxAssistKg. - Phase 1-5 계약 문서 4종·한도 장부·주석 용어("유효 하중" → "유효 무게").
Phase 2 — 카탈로그 배정 (#1219, 20260909000100)
시스템 종목만, 이름(name_ko) 목록으로 지정. 없는 이름은 예외로 멈추고, 손댄 행만 postcheck.
| 구분 | 종목 | 전 → 후 |
|---|---|---|
| 무게 배수 2 (81종) | 덤벨 78종(벤치프레스·플라이·로우·데드리프트·스쿼트·런지·클린·프레스·레이즈·컬·익스텐션 계열, 파머스 캐리/홀드, 데빌프레스, 박스 스텝오버) + 더블 케틀벨 3종(프론트랙 스쿼트·쓰러스터·롱사이클) | 1 → 2 |
| ×1 유지 (42종) | 한 개로 하는 종목: 고블렛 스쿼트·풀오버·원암 로우·덤벨 스내치·킥백·프리처 컬·힙쓰러스트·굿모닝·오버헤드 익스텐션, 원암/싱글암 계열, 단일 케틀벨 전부 | 변화 없음 |
| 보조 무게 + 횟수 (7종) | 어시스트 풀업 머신·어시스트 딥 머신(전: 무게+횟수) · 밴드 어시스트 풀업·친업·딥스·바 머슬업·링 머슬업(전: 횟수만) | 체중계수 0 → 1.0, 입력칸 |
| 체중계수 0 (4종) | 스텝업·패트릭 스텝업·버피 박스 스텝업·웨이티드 스텝다운 | 1.0 → 0 |
과거 세션의 스냅샷(recording_fields·bodyweight_factor·load_multiplier)은 손대지 않았다 — 재계산은 #1202 Phase 4.
주요 결정과 그 근거
- 스냅샷은 트리거로: 세션 저장 RPC·계획 채택·인입 3경로를 전부 고치는 대신
session_exercisesBEFORE 트리거 하나가 카탈로그 값을 복사한다. 저장 엔진 무변경, 종목 교체 시 재스냅샷. - 보조는 원자, 무게 칸 재해석 안 함(D3): 무게 칸 = 든 무게라는 전제를 깨지 않아야 1RM·PR·세트 스코어·보드 % 미리 채우기가 그대로 맞는다.
- 어시스트 종목은 별도 종목 유지: Hevy·Strong·Fitbod 모두 별도 종목. 같은 축 위에서 계산되므로 종목을 합치지 않아도 성장선은 이어진다.
작업 중 드러난 것
exercises에origin·slug·measurement_type열이 없다(#1074·#1175 이후) — 시스템 종목 =owner_user_id is null, id는 uuid. postcheck의 임시 종목 insert는 (id, name_ko, brid, recording_fields, target_muscles, bodyweight_factor)만.validate_completed_workout_payload_v4(p_actor uuid, p_payload, p_allow_existing_ids)— 첫 인자 actor 필수(칼로리 원자 때와 다름).- 리베이스 2회(#1194·#1211·#1214 랜딩 뒤): 우리가 재발행한 함수 17종과 그 사이 들어온 마이그레이션이 재발행한 함수는 이름이 겹치지 않았다(같은 함수 다중 트랙 재발행 되덮기 함정 회피 확인). 충돌은
deploymentManifest·schema.sql(renumber가 재계산)·WorkoutRecord.tsximport 한 줄(#1214 UiModal). - 로컬
npm run check에서tabSwitchLatencyProbe테스트가 한 번 실패했다 — 파일을\n으로 대조하는데 Windows autocrlf 체크아웃이 CRLF. 기본 체크아웃 main도 같고 이 브랜치와 무관, 재실행에서는 통과. - 계획 예상 볼륨(
estimated_volume= stats_load_kg × reps)은 무게 배수를 반영하지 않는다 — 완료 볼륨과 달라질 수 있음(#1202 또는 별도 결정). - 캘린더 하루 요약 RPC는
load_multiplier를 싣지 않는다(클라가 서버stats_effective_load_kg를 쓰므로 표시는 맞음). 하루 상세 탑세트의 맨몸 태그("체중+Nkg")는 보조 경우 미반영(탑세트 행에 assist 없음 — 남은 것). create_catalog_exercise·create_custom_exerciseRPC는load_multiplier를 받지 않는다(관리자 PostgREST 수정 경로만) — 신규 등록은 기본 1 → 오너 결정 D11(2026-09-04)로 등록 화면에 선택지를 넣는다(#1202 Phase 5).- e2e CASE-030이 CI 4번째 실행에서 "세션 체중 NaN"으로 실패했다. CI 증거물(네트워크 기록)로 보면 앱은 체중 80kg을 저장 요청에 실었고 서버도 정상이었다 — 테스트 공용 도우미
waitForSessionByTitle이 세션 행을 읽을 때body_weight_kg열을 조회 목록에 넣지 않아 undefined였다. 테스트가 같은 행의 체중 열을 따로 읽도록 고쳤다(d141c876). 결함처럼 보이는 빨간불도 요청·응답 기록으로 어느 쪽인지 먼저 가른다.
5. 적용 결과
| 항목 | 결과 |
|---|---|
| 덤벨 2개 종목 볼륨 | 덤벨 벤치 20kg 10회: 200 → 400kg (표시는 20kg 그대로) — 트리거 흐름 단언(스냅샷 2 → 세트 20kg → 유효 40) Production 드라이런 통과 |
| 보조 종목 볼륨 방향 | 어시스트 딥 보조 20kg 10회(체중 80): 든 무게로 읽던 200 → 유효 60×10=600, 보조를 줄이면 상승 — 드라이런 단언(보조 20 → 60) + e2e CASE-030 |
| 체중 변경·배수 1 되돌림 | 체중 90 → 70 / 배수 1 → 20 — 드라이런 단언 통과 |
| 밴드 어시스트 5종 | 볼륨 0 → (체중 − 보조)×횟수 — 보조 kg를 적은 새 세트부터(과거 244세트는 보조 값이 없어 그대로) |
| 스텝업/다운 4종 | 체중계수 1.0 → 0 — 새 세션부터. 과거 세션 3건은 #1202 Phase 4 |
| 카탈로그 배정 | 배수 2 81종 / 보조+횟수 7종 / 체중계수 0 4종 — 드라이런 postcheck 통과, 오너 표 확인 |
| 게이트 | 로컬 npm run check·check:unused 통과. CI는 5회 실행 끝에 통과 — 앞 4회 실패는 전부 테스트 쪽(① pgTAP 기대값 3파일 ② main 자체 회귀 CASE-011, #1214 팝업이 탭바를 덮음 → PR #1220·BUG-073 ③ CASE-030 체중 삽입 권한 ④ CASE-030 체중 조회 누락), 앱·서버 결함 0건 |
| 미검증 | 과거 기록의 볼륨·주간 합계 변화(스냅샷 미변경이라 아직 0건, #1202 Phase 4) · 보조 입력칸·밴드 칩의 시각 품질(자동 검증 밖) · Production 적용 결과(릴리스 v0.16.0 뒤 프로브: 배수 2 81행·어시스트 7종 기록 항목·유효 무게 함수 5인자) |
6. 이번 개선으로 향상된 것
덤벨·케틀벨 2개 종목의 볼륨이 실제로 든 무게가 된다
유저 A가 덤벨 벤치 20kg 10회를 적으면 볼륨 400kg. 입력은 종전처럼 한 개 무게.
보조 종목이 "덜 도와줄수록 잘한 것"으로 읽힌다
유저 B가 어시스트 풀업 머신에서 보조 20kg 8회를 적으면 상세에 "보조 20kg × 8회", 볼륨은 (체중 − 20)×8. 보조를 15kg로 줄이면 볼륨이 오른다. 밴드는 굵기 칩으로 kg를 고른다.
구조적으로 남는 것
유효 무게 공식 한 곳(서버 1 + 클라 폴백 1) · 기록 원자 6종 체계(load·assist·reps·distance·duration·calories) · 종목 카탈로그 3값(체중계수·무게 배수·기록 항목) · 한도 장부 보조 400kg · 정본 추출-치환 빌더 + Production 드라이런 절차 2회째 표준 적용.
남은 것
- 릴리스 v0.16.0(PR #1201)이 나가면 Production 프로브 뒤 이슈 #1200
[v0.16.0 반영완료]. - #1202 Phase 3~4(코드 완성, #1219 위로 리베이스 후 랜딩): 추정 1RM·PR·세트 스코어·등급을 유효 무게 위로(표시는 추가 무게 프레임), 과거 기록 재계산(어시스트 30세트 보조 칸 이동·덤벨 스냅샷·스텝업 3세션·관측 재물질화·잘못된 PR 이벤트 1건 제거). 가져온 덤벨 936세트도 배수 2 적용(오너 결정 D9, 2026-09-04).
- #1202 Phase 5(오너 결정 2026-09-04): 계획 예상 볼륨에 무게 배수 반영(D10) · 커스텀 종목 등록 화면에 "덤벨·케틀벨 2개로 하는 종목" 선택지(D11) · 하루 상세 탑세트 맨몸 태그의 보조 경우("체중−20kg") 표시.