세트 스코어 — RPE 없는 세트에도 점수를 붙이기까지: 나누는 값을 "최근 수행 1RM"으로, 종료 화면에 즉시 표시 (2026-09-03)
- 기간: 2026-09-02 ~ 2026-09-03 (세션 2개 — 연구
7c3a9dcb(Phase 0), 설계·구현ee4db6e6; 오너 지시 원문 "#1164 참고해서 RPE를 기록하지 않은 세트의 세트강도를 추론할 수 있는 방법에 대해서 연구해줘") - 랜딩: PR #1174 (Phase 4-1~4-2,
797bcd69) — 마이그레이션20260904235900_set_score_recent_performed_v1, Vercel 배포 O(릴리스 PR 뒤) - 설계서: 이슈 #1164 댓글 — 연구 계획서(Phase 0) · Phase 1~2 결과 보고(후보 28종·나누는 값 대안 11종·"예상 효과·개선사항" 포함) · Phase 3 결정 기록 v2 + 추가 1·2
- 정본:
docs/data/rpe-performed-intensity.md(용어표·#1164 결정·산출 규칙) ·supabase/migrations/20260904235900_set_score_recent_performed_v1.sql(규칙 함수set_score_recent_next_v1, 상태 테이블user_exercise_set_score_states) ·src/react/ui/shared/setSemantics.tssetScore·src/react/services/completedSessionReceiptIdentity.tsreceiptSetScoresByClientSetId - 도구: 연구 세션 스크래치패드(레포 밖) —
simulate.mjs(후보 규칙 시뮬레이션, Production 관측 행 15,353행 내보내기 위),out/denominator-sim.mjs(나누는 값 대안),out/samplebias-sim.mjs(정답 표본 편향),out/newcandidates-sim.mjs(신규 규칙 16종),probe.mjs(Production 읽기 전용 프로브), 마이그레이션 조립build-migration.mjs(schema.sql 최신 본문에 앵커 1회 삽입) - 게이트: pgTAP
set_score_recent_performed_v1(28단언) ·tests/react/setScore.test.mjs·workoutCalculations(완료 화면 열) · 마이그레이션 자가 검증(이전 기능 생존 단언) · 기존 SQL 계약 테스트 96건 무수정 통과 - 버그리포트: 없음(기능 트랙)
- 계약:
docs/data/rpe-performed-intensity.md전면 개정(제목·용어표·#1164 결정·4절 산출·5절 표면 이행) ·docs/data/app-screen-rpc-contract.md세트 키 2개 추가
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | Production 실측·후보 12종 시뮬레이션·연구 계획서 게시(세션 7c3a9dcb) | ✅ 이슈 댓글 |
| Phase 1 | 1-1 재실행 검증 · 1-2 나누는 값 대안 11종 · 1-3 정답 표본 편향 · 1-4 추가 조사(신규 규칙 16종·외부 관행·문헌) · 1-5 설계안 반박 검증 4갈래(35건 반영) | ✅ 이슈 댓글 |
| Phase 2 | 설계안(추천안·후보 표·결정 목록) | ✅ 이슈 댓글 |
| Phase 3 | 오너 결정 — 지표의 뜻 재정의(세트 스코어), 나누는 값 최근 수행 1RM, 반복수판, 종료 화면 즉시 표시 | ✅ 결정 기록 v2 + 추가 1·2 |
| Phase 4-1 | 서버: 상태 테이블·관측 열·규칙 함수·투영 재발행·지표 맵·저장 답장 동봉·재계산 | ✅ PR #1174 (f81725a2) |
| Phase 4-2 | 화면: setScore·매퍼 6층·답장 접목·완료 화면 즉시 표시·이름·문서 | ✅ PR #1174 (a6eccd2a) |
| Phase 4-3 | 랜딩 1회(잠금·CI·verify·머지) → 릴리스 PR | ✅ 797bcd69 |
| Phase 4-4 | 이 기록 + 등록 2곳 | ✅ |
1. 배경
이슈 #885(2026-08-29)가 만든 "수행 강도"(세트 추정 1RM ÷ 기준 1RM × 10)는 RPE 숫자를 입력한 세트에만 계산됐다(D3). Production을 재보니 근력 관측 행의 94%(WodUp·Motra 인입 전부 + 앱 기록 대부분)에 RPE가 없어 화면이 —로 비어 있었고, 오너의 #885 기준 세션 두 개(8/6·8/24, 22세트)조차 난이도 버튼으로 기록해 화면에서는 값이 나온 적이 없었다. 오너가 "RPE를 기록하지 않은 세트의 세트 강도를 추론할 방법"을 연구하라고 지시했다.
2. 문제 제기
2-1. 화면 조건이 서버 계산 조건보다 좁았다
서버 추정기는 난이도 버튼(1~5)과 반복 실패로 RIR을 정하고, 입력이 없으면 RIR 0(한계까지 든 것)으로 가정해 값을 저장한다. 화면은 perceived_rpe 숫자 유무만 봤다. 난이도 버튼 세트 70개는 정확한 값이 있는데도 —였다(out/critic2-flagship.log).
2-2. 10을 넘는 값의 주범은 RPE 없음이 아니라 나누는 값이었다
기준 1RM은 "직전 세션의 채택값"이라 가벼운 세션 뒤엔 그만큼 내려간다. 기준이 그 세션 전까지의 과거 최고보다 낮은 행이 84.8%(격차 중앙값 18%)였고, 기준이 과거 최고의 절반 아래로 무너진 행도 앱 16·Motra 31·WodUp 508행이었다(20×20회 세트가 다음 세션의 기준이 되어 웜업 50×10이 18.5로 계산되는 식). 나누는 값만 과거 최고로 바꿔도 10 초과가 73~79% 사라졌다.
2-3. 지표의 이름이 뜻과 어긋났다
같은 100kg×5회를 "쉬웠다(RPE 6)"고 입력하면 여유가 있었다는 뜻이라 추정 1RM이 올라 값이 9.7, "겨우 했다(RPE 10)"면 8.5가 된다. 이 숫자는 "얼마나 힘들었나"가 아니라 "오늘 보인 힘 수준"인데 이름이 "수행 강도"라 오너 직관과 반대로 읽혔다.
2-4. RPE 없이 낸 값은 낮게만 틀린다
실제 RPE·난이도가 있던 89세트에서 RIR 0 가정값은 평균 0.5 낮고(8회 이하 최대 0.83, 9~12회 최대 2.8), 같은 곡선의 최저값이라 높게 나오는 일은 구조상 없다. 인입 기록에는 노력을 짐작할 신호가 전혀 없었다(RPE·실패 표시 0, 목표=실제 횟수 100%, 세트 시간은 동전 던지기 수준).
3. 해결 방안
원칙 (오너 결정, 2026-09-03 채팅 → 이슈 "Phase 3 결정 기록 v2 + 추가 1·2")
- 이름 = 세트 스코어. "더 무겁게·더 많이·더 여유 있게 들수록 높은 점수." (D1)
- 나누는 값 = 최근 수행 1RM: 가장 최근에 한계까지 간 세트로 확인한 값. 확실한 신호(RPE 10·실패)가 있을 때만 바뀌고 내려갈 수 있으며, 추정으로 올릴 때는 역대 최고 1RM(실제 1회 기록)을 넘지 않는다. 처음 하는 종목은 세션 끝 값으로 나눈다. (D3·⑥·첫 세션)
- RPE 없는 세트도 산출한다 — 서버의 RPE 10 가정값 그대로, 입력 세트와 구분 표기 없음, 안내 문구 없음. (D5·D7)
- 메인 세트만. (D8)
- 추가 무게 0인 최대 반복수 종목(풀업·딥스)은 같은 규칙을 최대 반복수에 적용한다. (추가 2)
- 운동 종료 화면에 바로 보여야 한다 — 저장 답장에 서버가 계산해 실어 준다. (추가 1)
- 뜻: 10 초과 = 최근에 확인한 힘을 넘어섰다 = 1RM 다시 잴 때.
접근 — 기각한 대안도 남긴다
| 대안 | 실측 | 판정 |
|---|---|---|
| 세트 유형·반복수별 RIR 가정(C1·D·S) | 정답 오차 0.20~0.23으로 가장 정확 | 보류 — 인입은 웜업·탑 라벨 0%라 "전부 RIR 2"로 퇴화, 서버 추정 정책(결측 RIR 규칙) 변경 = major·전량 재계산 |
| 세션 문맥(사다리·같은 무게 반복·피로) | 0.24~0.41 | 기각 — D보다 못함, 같은 무게 반복의 실제 RIR 방향이 가정과 반대 |
| 무게만 / 하한 밴드 / 10 상한 | 2.06 / 1.05 / 2.43 | 기각 — 너무 낮거나 D2(상한 없음) 위배 |
| 구간 표시('7.9~8.8') | 서버 하한..대표 구간이 정답을 19%만 품음 | 기각 |
| 나누는 값 = max(직전세션 추정, 실제 1RM)(클라 전용) | 10 초과 39.9/31.8/35.5 → 28.7/22.3/14.2% | 중간안으로 제시 → 오너가 "최근 수행 1RM" 개념으로 대체 |
| 나누는 값 = 역대 추정 1RM 중 최대값(서버 열) | 10 초과 9.9/8.6/7.6% | 최근 수행 1RM의 출발점(④ 바닥 규칙)으로 흡수 |
| 추정값 흐리게·'약'·'이상' 표기 | 설계안 원안 | 기각 — 오너 "너무 그러면 혼란스러워", 숫자만 |
| 앱이 직접 계산(오프라인 즉시 표시) | 공식 2곳·일치 테스트 필요 | 보류 — 저장 답장 방식으로 시작, 필요 시 후속 |
4. 적용한 내용
Phase 4-1 — 서버 (f81725a2, 마이그레이션 20260904235900)
user_exercise_set_score_states(user_id·exercise_id·recent_performed_1rm_kg·recent_performed_max_reps) + 관측 열recent_performed_1rm_kg·recent_performed_max_reps.set_score_recent_next_v1(before, cap, signal, fail_cap, floor): 신호 설정(내림 자유·올림 상한) → 0회 실패 내림 → 완료 세트 바닥 올림(상한). 1RM판·반복수판·저장 답장 미리보기 공용.refresh_user_strength_estimation_projection_core재발행: 세션 시간순 루프 안에서 세션별 신호 최대·0회 실패 최소 무게·완료 세트 추정 최대·1회 성공 최대 무게를 모아 규칙 적용, 그 세션의 관측 행에 "세션 전 값"(첫 세션은 세션 끝 값) 기록, 상태 upsert. 기존 기준 선택·집계·기록은 한 글자도 안 바꿈.refresh_user_max_rep_projection재발행: 세션 시간순 루프 신설(종전엔 세션 순서 개념이 없었다).strength_set_metrics_map_json키 2개,workout_set_score_preview_v1(user, session)+save_workout_v4·update_completed_session_v4답장set_scores동봉(원자 값 후처리 뒤 계산 — 답장 시점의 행은 원자 값 전이라는 #1143 계약을 존중).- 관측 행 있는 사용자 전원 재계산(마이그레이션 안
enqueue_user_exercise_stats_refresh_job+_now처리, 실패 시 중단), 자가 검증(규칙 예시 7건·열·이전 기능 생존 토큰·문 auth.uid() 1회·권한).
Phase 4-2 — 화면 (a6eccd2a)
setSemantics.setScore(set, exercise): 1RM판 → 반복수판 순으로 재료를 찾아 계산, 메인 세트만, 중량 맨몸 종목 미산출. 구performedIntensity(RPE 게이트) 폐기.- 매퍼 6층(
screenRpc.ts·persistedSetProjection.ts(선택 키, max-rep 전부-아니면-전무 밖)·screenRpcAdapters.ts·calendarReadModel.ts·calendarReadModelMapper.ts·barbelicMappers.ts(snake 단일 키)·barbelicViewMappers.ts(확정 사본 리셋 포함)·STRENGTH_PROJECTION_RESET),WorkoutFlow.makeFlowSet재료 통과. - 저장 답장:
barbelicRepository.validateWorkoutMutationReceipt가set_scores파싱 →receiptSetScoresByClientSetId(번호표로 뒤집기) →saveUiWorkoutSession반환에setScores→WorkoutFlow즉시 저장.then에서 초안 세트에 접목 → 완료 화면setScore. - 열 이름 "세트 스코어"(종료 화면·세션 상세),
IntensityGuide문구 개정, 계약 문서·RPC 계약 문서·docs 색인.
주요 결정과 그 근거
- 규칙을 순수 함수 하나로: 투영 2개와 저장 답장 미리보기가 같은 본문을 쓰게 해 "같은 규칙·같은 숫자"를 보장하고 pgTAP 단위 단언이 가능해졌다.
- 첫 세션 = 세션 끝 값: 오너가 첫 세션에도 점수를 원했고, 다음 세션이 쓸 값과 같은 값이라 앞뒤가 맞는다.
- 상한 = 역대 최고 1RM: 추정으로 기록을 넘어 올라가면 "10 초과 = 잴 때" 신호가 꺼진다. 상한이 있으면 잴 때까지 신호가 켜져 있고 실제로 재면 꺼진다.
- 저장 답장 동봉(앱 자체 계산 아님): 공식이 서버 한 곳에 남는다. 2026-08-21 "앱에서 1RM 추정 공식 없음" 결정 유지.
- 마이그레이션 조립은 스크립트로: 820행 코어를 손으로 옮기지 않고 schema.sql 최신 본문에 앵커 1회 삽입(앵커 유일성 단언).
작업 중 드러난 것
- JS
String.replace의 치환 문자열에서$$는 리터럴$가 된다 — 조립 스크립트가as $$를as $로 만들어 SQL 계약 테스트가 함수 본문을 못 읽었다. 함수형 치환으로 수리(메모리 기존 함정 재확인). - 관측 행의
measured_1rm_before_kg는 "가장 최근에 잰 1RM"이 아니라 "지금까지 1회로 든 최고"(오르기만 함) — 오너가 원한 "최근 값"을 위해 별도 상태(최근 수행 1RM)가 필요했던 이유. - 반복수 관측 테이블에는 세션 순서·기준 개념이 없어 세션 시간순 루프를 새로 만들어야 했다.
- 로컬 pgTAP은 샌드박스 스택(레포 밖 config + junction)으로 11초에 95파일이 돈다 — CI 왕복 없이 마이그레이션 오류를 잡았다(plan 수 불일치 1회).
tabSwitchLatencyProbe테스트는 Windows CRLF에서만 실패(기존 함정), CI Linux에서는 통과.
5. 적용 결과
| 항목 | 전 → 후 |
|---|---|
| 값이 보이는 세트(메인, 서버 추정 1RM 있음) | RPE 숫자 세트만(앱 16·인입 0) → 전부(첫 세션 포함) |
| 10 초과 비율(메인, Production 관측 행 근사) | 앱 46.3% / Motra 31.8% / WodUp 35.5% → 약 12.2% / 9.8% / 9.8% |
| 오너 8/6 백스쿼트 세션 | 11세트 전부 — → 점수 표시, 10 초과 4세트(180~210) → 2세트(200·210: 최근 수행 1RM 200 기준) |
| 종료 화면 | 항상 — → 저장 답장 뒤 즉시 점수(오프라인은 답장 전까지 —) |
| 풀업·딥스(추가 무게 0) | — → 반복수판 점수 |
| pgTAP | 94파일 1,436건 → 95파일 1,464건 PASS |
| 미검증(정보) | 완료 화면·세션 상세의 시각 품질, 실기기 흐름(저장 카드 → 점수 채워진 완료 화면)은 단위·pgTAP로만 검증. Production 재계산 결과(10 초과 비율 실측)는 릴리스 뒤 프로브로 확인 |
6. 이번 개선으로 향상된 것
RPE를 안 넣어도, 가져온 기록이어도 점수가 보인다
유저 A가 WodUp에서 가져온 60→125kg 사다리는 — 여섯 개에서 「약 5.0 / 6.7 / 8.3 / 9.2 / 10.0 / 10.4」가 되고, 10을 넘는 건 실제로 그때까지의 최고를 넘긴 125kg뿐이다.
10 초과가 "1RM 다시 잴 때"라는 하나의 뜻을 갖는다
가벼운 세션 뒤 통째로 10을 넘던 현상이 사라지고, 기록을 오래 안 잰 사람은 잴 때까지 신호가 켜져 있다가 재면 꺼진다.
구조적으로 남는 것
- 갱신 규칙의 정본이 순수 함수 하나(
set_score_recent_next_v1)에 있고 pgTAP 7단언으로 고정된다. - 저장 답장에 세트 지표를 싣는 통로(
set_scores)가 생겨 다른 "저장 직후 즉시 표시"에 재사용할 수 있다. - 1RM 용어 통일표(직전세션 추정 1RM / 실제 1RM / 역대 최고 1RM / 최근 수행 1RM / 세트 스코어)가 계약 문서에 산다.
- 마이그레이션 조립 스크립트 방식(최신 본문 + 앵커 1회 삽입 + 이전 기능 생존 단언)이 대형 함수 재발행의 안전한 절차로 남는다.
남은 것
- 릴리스 v0.15.0(PR #1177, 2026-09-03 10:56 UTC 머지) → 이슈 #1164 [반영완료]. Production 확인(읽기 전용 조회, 2026-09-03): 마이그레이션 적용, 관측 행 있는 사용자 5명 백필 완료, 세트 스코어 상태 행 453건(최근 수행 1RM 채워진 행 271건). 메인 세트 10 초과 비율(최근 수행 1RM이 있는 관측 행 기준): 앱 15.9%(302세트 중 48) / Motra 8.6%(2,239 중 192) / WodUp 8.9%(7,475 중 662) — 예측 12.2 / 9.8 / 9.8%와 같은 자릿수. Production smoke의 '연속 수정' 검사 1회 실패는 같은 smoke 계정으로 두 워크플로(deploy.yml smoke 잡, prod-smoke.yml 카나리)가 동시에 돌아 생긴 충돌이며 재실행에서 통과했다.
- 낮은 값으로 1RM을 다시 재는 "1RM 측정 세션" 기능(서버
rm_test표시는 있으나 화면 사용 0건) — 별도 트랙. - 리포트 "세트 강도 분포" 도넛·데스크톱 세트행 "세트 강도 %"는 아직 직전세션 추정 1RM 기준 — #885 Phase 3에서 세트 스코어 기준으로 교체.
- 중량 풀업·딥스(추가 무게 있는 맨몸 계수 종목)는 두 판 모두
—— 체중 합산 추정은 정책 변경이 필요한 별도 트랙. - 오프라인에서도 종료 화면에 즉시 보이려면 앱 자체 계산(공식 2곳)이 필요 — 필요해지면 후속.