Skip to content

세트 유효 무게 정본화 — 덤벨 볼륨 절반·보조 종목 거꾸로 계산에서 "유효 무게 = 든 무게×배수 + 체중×계수 − 보조" 한 공식으로 (2026-09-04)

  • 기간: 2026-09-03 ~ 2026-09-04 (세션 3개 — 분석·Phase 1~2 코드 5e5afb45, 리베이스·게이트·PR 65afb2ab, 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.ts effectiveTrainingLoad(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(세션 5e5afb45 scratchpad, 레포 밖 — schema.sql 마지막 정의를 뽑아 치환하고 개수를 단언) · Production 드라이런 dryrun.mjs(Management API, 전문 + 끝 raise 롤백) · 카탈로그 대조 프로브 probe_names.mjs(세션 65afb2ab scratchpad)
  • 게이트: pgTAP supabase/tests/database/effective_load_multiplier_assist.test.sql(25건) · 단위 effectiveTrainingLoadFormula·sessionDetailBodyweightEffectiveLoad·recordingProfileRoundTrip·screenRpcContracts·limitsRegistry("보조 무게 400kg") · e2e error-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.md get_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 → PostgREST updateExercise).
  • 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_v1load_multiplier 키).
  • Phase 1-3 training_effective_load 5인자 재정의(3인자 drop) · set_exercise_set_effective_load(of에 assist_kg) · refresh_session_effective_loads(세션 종목 of에 load_multiplier·exercise_id).
  • Phase 1-4 원자 assist(camel assistKg / snake assist_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_exercises BEFORE 트리거 하나가 카탈로그 값을 복사한다. 저장 엔진 무변경, 종목 교체 시 재스냅샷.
  • 보조는 원자, 무게 칸 재해석 안 함(D3): 무게 칸 = 든 무게라는 전제를 깨지 않아야 1RM·PR·세트 스코어·보드 % 미리 채우기가 그대로 맞는다.
  • 어시스트 종목은 별도 종목 유지: Hevy·Strong·Fitbod 모두 별도 종목. 같은 축 위에서 계산되므로 종목을 합치지 않아도 성장선은 이어진다.

작업 중 드러난 것

  • exercisesorigin·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.tsx import 한 줄(#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_exercise RPC는 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") 표시.