복합종목 세트 스코어 — 첫 동작 값만 남던 —에서 구성 동작별 점수 평균까지, 복합으로만 하는 동작도 나누는 값이 생긴다 (2026-09-03)
- 기간: 2026-09-03 (분석·오너 결정 D1~D6은 세션
e397e483이 이슈 #1180 논의에서 분리해 이슈 본문·댓글로 기록, 구현·랜딩은 세션7b747cfb; 오너 지시 원문 "복합종목도 세트 스코어를 낸다. 구성 동작별로 각각 계산해서 평균한다") - 랜딩: PR #1211 (Phase 1~3,
be0bef03) — 마이그레이션20260908005900_composite_set_score_v1, Vercel 배포 O(Production은 다음 릴리스 PR에서 적용) - 설계서: 이슈 #1189 본문(배경·오너 결정 D1~D5·Phase 계획·"예상 효과·개선사항") + 댓글(D6 추가, Phase 1~3 완료 보고)
- 정본:
docs/data/rpe-performed-intensity.md§4 "복합 세트" ·supabase/migrations/20260908005900_composite_set_score_v1.sql(refresh_user_strength_estimation_projection_core최근 수행 1RM 단계,set_score_observations_v1복합 묶기) ·src/react/services/compositeExerciseModel.ts(movementSetScores) ·src/react/ui/shared/setSemantics.ts(setScore동작 평균) ·src/react/services/completedSessionReceiptIdentity.ts(receiptSetScoresByClientSetId복합 매핑) - 도구: 마이그레이션 빌더
build-migration-1189.mjs+migration-1189-template.sql(세션 스크래치패드, 레포 밖) — Productionpg_get_functiondef원문(로컬 체인 결과와 동일 확인)에 최근 수행 1RM 집계 조건만 끼워 넣는다 · Production 실측probe.mjs(Management API 읽기 전용) - 게이트: pgTAP
composite_set_score_v1(29단언) ·tests/react/setScore.test.mjs(복합 평균·부분·답장 매핑·완료 화면 3건 추가) ·tests/react/compositeSessionDetailDisplay.test.mjs(세션 상세 복합 줄 1건 추가) · 마이그레이션 자가 검증(코어 3집계 포함·투표 CTE 자격 행만·단일 시그니처·조회 함수 묶기·명시 신원·앱 롤 실행 불가·이전 소비자 생존) - 버그리포트: 없음(기능 트랙 — 오너 지시로 시작)
- 계약:
docs/data/rpe-performed-intensity.md§2 표시 대상·§4 복합 세트·§5 집계 대상
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 분석(왜 —인지 5가지 원인)·Phase 계획·오너 결정 D1~D6 | ✅ 이슈 본문·댓글 (세션 e397e483) |
| Phase 1 | 서버: 최근 수행 1RM 갱신에 복합 동작 행 포함, 집계 조회 복합세트 1건 평균, 복합 관측 사용자 재계산, 미리보기·지표 맵 확인, 세트 수 통계 실측 | ✅ PR #1211 |
| Phase 2 | 클라: 복합 합치기가 동작별 재료 묶음 보존, setScore 동작 평균, 저장 답장 복합 매핑, 테스트 | ✅ PR #1211 |
| Phase 3 | 문서 개정 + 랜딩 1회(잠금·renumber·CI·verify·머지) | ✅ be0bef03 |
| Phase 4 | 이 기록 + 등록 2곳 | ✅ |
1. 배경
세트 스코어(이슈 #1164, v0.15.0)는 세트 추정 1RM ÷ 최근 수행 1RM × 10이다. 역도·크로스핏 유저가 자주 쓰는 복합종목("1 클린 + 1 저크")은 세션 상세·완료 화면에서 이 값이 항상 —였다. 이슈 #1180(리포트 도넛 교체) 논의 중 오너가 "복합종목도 세트 스코어를 낸다. 구성 동작별로 각각 계산해서 평균한다"고 지시해 이 트랙이 분리됐다.
2. 문제 제기
유저 A가 "1 클린 + 1 저크" 60kg 3세트를 하면 서버는 클린 행·저크 행으로 쪼개 저장하고 "같은 복합 묶음의 몇 번째 세트"인지 표식을 붙인다. 계산 재료는 있었지만 다섯 곳이 막혀 있었다.
나누는 값이 영원히 비는 구조
동작별 관측 행은 트리거가 "트레이닝 콤플렉스"로 표시해 1RM 추정 투표·PR 기록에서 뺀다(앞 동작의 피로가 단독 1RM을 오염시키기 때문 — 이 제외는 옳다). 그런데 세트 스코어의 나누는 값(최근 수행 1RM)을 찍는 단계도 투표 자격이 있는 행만 봐서, 클린을 복합으로만 하는 유저는 나누는 값이 생기지 않았다. Production 실측(2026-09-03): 복합 동작 관측 행 1,548(사용자 2명·종목 29) 중 나누는 값 없음 394, 복합으로만 하는 사용자×종목 35쌍.
화면은 첫 동작 값만 남겼다
동작별 행을 복합세트 한 줄로 합칠 때 첫 동작의 값을 그대로 펼쳐 저크의 재료는 버렸다. 저장 답장도 client 세트 id 하나에 동작 행 답장이 여러 개 오는데 마지막 것이 앞 것을 덮었다.
리포트 집계에서 복합세트가 0세트였다
리포트 도넛·히스토그램·하루 상세(이슈 #1180)의 집계 조회는 투표 자격 행만 세어 복합 동작 행이 빠졌다(실측 0행).
반복수판은 문서와 달랐다
풀업·딥스(반복수판)에는 복합 제외가 없어 "5 풀업 + 5 딥스" 복합엔 점수가 붙고 있었다 — 문서("복합은 —")와 어긋났고, 동작별 값이 따로 나와 평균 규칙도 없었다.
세트 수 통계의 복합 처리가 미확인
리포트 세트 수가 복합을 동작별로 세는지 1세트로 세는지 실측이 필요했다(Phase 1 ⑤).
3. 해결 방안
원칙 (오너 결정 D1~D6, 2026-09-03)
- D1 점수가 있는 동작만 단순 평균(소수 1자리). 없는 동작은 빼고, 하나도 없으면
—. 예: 클린 9.4 + 저크 10.2 → 9.8. - D2 복합세트도 최근 수행 1RM은 갱신한다. 처음 하는 동작이면 세션 끝 값으로 나눠 가장 잘한 세트가 10.0. 추정 1RM(종목 상세)·PR 기록 계산은 지금처럼 복합을 뺀다.
- D3 리포트·하루 상세 집계는 복합세트 1개 = 1세트, 점수는 평균값(세션 상세와 같은 숫자).
- D4·D6 횟수 동작(풀업·딥스)·중량 맨몸 동작도 같은 규칙, 예외 없음.
- D5 새 이슈, #1180 랜딩 뒤 랜딩. 데스크톱은 #1180 D3대로 제외.
접근
| 대안 | 판단 |
|---|---|
| 복합 동작 행의 투표 제외(training_complex)를 풀어 일반 행처럼 취급 | 기각 — 추정 1RM·PR이 앞 동작 피로로 오염된다(D2가 명시적으로 제외 유지) |
| 관측 행의 제외 표시는 그대로 두고, 나누는 값을 찍는 단계의 집계 조건만 "자격 행 또는 복합 행"으로 넓힌다 | 채택 — 투표·PR 로직은 한 글자도 바뀌지 않고, 저장 답장 미리보기(원래 복합 구분 없음)와 같은 규칙이 된다 |
| 서버가 복합세트 평균 점수를 세트 지표 맵에 실어 준다 | 기각 — 세션 상세는 동작 행마다 값을 받고 앱이 합치는 구조. 앱이 동작별 재료를 묶어 보존하고 화면 공식이 평균하는 편이 계약 변경이 없다 |
| 집계 조회가 복합세트를 동작마다 세고 점수만 평균 | 기각 — D3 "1세트". 세션·묶음 키·세트 번호로 묶어 1건, 종목은 첫 동작으로 귀속(종목 필터는 묶은 뒤) |
일반 세트 수 통계(set_count)도 복합을 1세트로 맞춤 | 보류 — 이슈 범위 밖. 실측 결과(동작 행마다 1세트)를 오너 결정 D7로 남김 |
4. 적용한 내용
Phase 1 — 서버 (#1211, 20260908005900_composite_set_score_v1)
refresh_user_strength_estimation_projection_core(uuid, uuid[], bigint)재발행: Production 실정의(#1175 재발행본과 동일 확인)에서 최근 수행 1RM 단계의 세 집계(한계 신호·완료 세트 바닥·1회 성공)의 조건을eligible→eligible or exclusion_reason = 'training_complex'로. 복합 안의 1회 성공도 상한 올림에 포함(미리보기와 같은 규칙).set_score_observations_v1재발행: 1RM판에 복합 동작 행 포함, 두 판 합집합을session_exercises.composite_meta의 묶음 키·동작 순서와 이어 (세션·묶음 키·세트 번호)로 묶어round(avg, 1)1건. 종목·세션 종목·세트 id는 첫 동작 것,p_exercise_ids필터는 묶은 뒤. 기간 집계·일 요약은 이 조회를 그대로 쓰므로 무변경.- 복합 관측 행이 있는 사용자 재계산(같은 트랜잭션, 실패 시 중단; Production 2명) + 자가 검증.
- 미리보기
workout_set_score_preview_v1·세트 지표 맵: 원래 동작 행별 값을 주고 있어 무변경(pgTAP로 확인). - 세트 수 통계 실측: 하루 요약·기간 통계
set_count는exercise_sets행 수 = 동작 행마다 1세트(3세트 복합 → 6). 세션 상세 화면 "메인 n세트"는 합친 세트(3). 무변경, D7로 기록. - pgTAP 29단언: 클린+저크 무게 상이 컴플렉스 3세트 첫 세션(나누는 값 80·70, 투표 제외·일별 추정 없음 불변) → 다음 날 클린 단독 85×1(80으로 나눠 10.6, 상태 85, 추정 1RM은 단독만) → 집계 3건 7.3/8.7/10.0(종목 = 클린, 저크로 물으면 0) → HSPU+풀업 반복수판 복합 2건 6.5/10.0 → 기간 통계 (3, 8.7)·일 요약 히스토그램 → 지표 맵 → 처음 하는 복합의 미리보기 4세트.
Phase 2 — 클라 (#1211)
restoreCompletedCompositeExercises: 세트에movementSetScores(동작 순서, 재료 5키, 없는 동작 null) 보존 — 세션 상세·일지 하루 상세 두 경로.setSemantics.setScore: 묶음이 있으면 동작별 점수(소수 1자리) → 점수 있는 동작만 평균 → 소수 1자리. 서버 집계와 같은 순서. 투영 리셋(STRENGTH_PROJECTION_RESET)에 포함.receiptSetScoresByClientSetId: 같은 client 세트 id에 서버 세트 id가 여러 개 = 복합 → 낱개 재료는 비우고movementSetScores로.WorkoutFlow.makeFlowSet·barbelicViewMappers가 묶음을 통과. 타입WorkoutSet·WorkoutMutationReceiptSetScore에 필드 추가(저장 화이트리스트 밖 = 저장 안 함).- 세션 상세 줄·완료 화면은 기존
setScore호출 그대로.
Phase 3 — 문서·랜딩
docs/data/rpe-performed-intensity.md§2 표시 대상 문구 개정, §4 "복합 세트" 항목(동작별 평균·나누는 값 갱신·집계 단위·재료 통로·세트 수 통계 차이), §5 집계 대상 한 줄.- §15 잠금 → 재번호 → PR → CI 1회 → verify → 머지.
주요 결정과 그 근거
- 집계의 종목 귀속 = 첫 동작: 기간 총계는 종목별 기간 통계 행의 합이라, 동작마다 귀속하면 복합세트가 두 번 세어진다(D3 위반). 첫 동작 귀속이면 총계 정확·종목별 행은 "클린 + 저크"를 클린에서만 본다.
- 동작별 반올림 → 평균 → 반올림: 서버 조회가 동작 점수를 소수 1자리로 낸 뒤 평균하므로 앱도 같은 순서 — 세션 상세와 리포트가 같은 숫자.
- 복합 1회 성공을 상한 올림에 포함: 기록(PR)엔 안 잡히지만 실제로 1회 든 무게라, 빼면 복합 무게가 기록을 넘을 때 점수가 11+로 고정된다. 미리보기가 원래 그렇게 계산하고 있었다.
작업 중 드러난 것
- 임시 번호 관행(
YYYYMMDD235900)이 같은 날 두 트랙(#1194 PR #1206)과 정확히 충돌 — 랜딩 잠금·재번호가 그대로 흡수했다. - 카탈로그 슬러그
high-clean은 없고hang-clean·power-clean(=하이 클린)이 있다 — pgTAP 픽스처는hang-clean. - TypeScript 경계 게이트는 변수명
any도 "explicit any"로 잡는다(let any→hasValue). - 평균 테스트 값은
.x5를 피한다 — JS(Math.round)와 numeric(round)의 반올림 경계가 다를 수 있다. - #1180이 Production 미적용(릴리스 대기)이라 이 마이그레이션의 Production 드라이런은 불가(전제 함수 부재) — 릴리스 레인이 번호 순서로 적용한다.
5. 적용 결과
| 항목 | 결과 |
|---|---|
| 복합세트 세트 스코어(세션 상세·완료 화면) | — → 동작별 점수 평균 — 단위 테스트(7.3·9.8·10.0·부분 평균), 세션 상세 렌더 7.3/10.0/— |
| 복합으로만 하는 동작의 나누는 값 | 없음 → 첫 복합 세션 끝 값(pgTAP 클린 80·저크 70, 다음 단독 세션 80으로 나눔) |
| 추정 1RM·PR 기록 | 불변 — pgTAP: 복합 행 eligible=false·training_complex 유지, 일별 추정 없음, 추정 1RM은 단독 세션 값 |
| 리포트·하루 상세 집계 | 복합 3세트 → 0세트 → 3세트(평균 점수), pgTAP 기간 통계 (3, 8.7)·일 요약 히스토그램 |
| 반복수판 복합 | 동작별 따로 → 평균 1건(pgTAP 6.5/10.0) |
| 서버 검증 | 샌드박스(main 마이그레이션 전부 + 이번) pgTAP 98파일 1,535건 통과 |
| 로컬 게이트 | npm run check 전 게이트 통과, 단위 2,469/2,470(남은 1건 = Windows 줄바꿈에서만 실패하는 탭 전환 계측 테스트) · 타입·미사용·경계 통과 |
| CI | PR #1211 verify·scope·migration-smoke 통과 |
| Production | 미적용 — 다음 릴리스 PR에서 적용. 적용 뒤 프로브: 복합 동작 관측 행 중 recent_performed_1rm_kg 없음 394 → 0, 집계에 잡힌 복합 행 0 → 722 세트, client_error_events 새 kind 0 |
| 시각 품질 | 세션 상세·완료 화면의 값 표기는 기존 칩 그대로(자동 렌더 검증만) |
6. 이번 개선으로 향상된 것
복합(콤플렉스)으로 훈련하는 유저에게 세트 스코어가 보인다
"1 클린 + 1 저크"만 하는 유저도 세션 상세·완료 화면·리포트에서 같은 점수를 본다. 첫 세션은 가장 잘한 세트가 10.0, 이후 세션은 그 값을 기준으로.
추정 1RM·PR은 그대로 안전하다
복합 안의 동작은 여전히 추정 1RM 투표·기록에서 빠진다 — 나누는 값만 넓혔다.
구조적으로 남는 것
- "복합세트 = 동작별 관측을 묶어 평균" 규칙이 서버 조회 한 곳(
set_score_observations_v1)과 앱 공식 한 곳(setScore)에 고정된다. - 세트의 동작별 재료 묶음(
movementSetScores)이 읽기 모델·저장 답장 두 통로의 공통 형태.
남은 것
- 다음 릴리스 PR에서 Production 적용 → 프로브 → 이슈 #1189 [반영완료].
- 오너 결정 D7: 일반 세트 수 통계(
set_count)의 복합 처리(동작 행마다 1세트 vs 1세트). - 데스크톱 세트 스코어 전환(#1180 D3 후속) 때 복합 평균도 같이.