Skip to content

세트 스코어 — 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.ts setScore · src/react/services/completedSessionReceiptIdentity.ts receiptSetScoresByClientSetId
  • 도구: 연구 세션 스크래치패드(레포 밖) — 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 0Production 실측·후보 12종 시뮬레이션·연구 계획서 게시(세션 7c3a9dcb)✅ 이슈 댓글
Phase 11-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·머지) → 릴리스 PR797bcd69
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.validateWorkoutMutationReceiptset_scores 파싱 → receiptSetScoresByClientSetId(번호표로 뒤집기) → saveUiWorkoutSession 반환에 setScoresWorkoutFlow 즉시 저장 .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) → 반복수판 점수
pgTAP94파일 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곳)이 필요 — 필요해지면 후속.