횟수 종목 세트 스코어 기준 상한 정정 — 가볍게 한 세트가 죽을 힘으로 한 세트보다 높은 점수를 받던 것에서, 올림 상한을 "한계 세트로 확인한 횟수"만으로 (2026-09-04)
- 기간: 2026-09-04 (세션 2개 — 분석·설계
d4ff5cb5, 구현·랜딩774cdb20; 오너 질문 원문 "바벨스쿼트 세트 스코어 22.0 왜?" → 8/12 세션 화면의 횟수판 점수 14.2·12.9·… → "숫자가 저렇게 높게 나오는 게 설계가 이상하다") - 랜딩: PR #1249 (Phase 1~2,
d13550ec) — 마이그레이션20260910003000_reps_set_score_limit_cap_v1, Vercel 배포 없음(서버만, 앱 변경 0), Production 적용은 릴리스 PR #1227 v0.17.0 - 설계서: 이슈 #1241 본문(문제 상황·원인·해결 방안·Phase 계획·"예상 효과·개선사항") + Phase 설계 댓글
- 정본:
docs/data/rpe-performed-intensity.md(용어표 "한계 세트로 확인한 횟수"·반복수판 규칙) ·supabase/migrations/20260910003000_reps_set_score_limit_cap_v1.sql(refresh_user_max_rep_projection루프의session_limit_reps,workout_set_score_preview_v1반복수판 첫 세션 블록) - 도구: 세션 스크래치패드(레포 밖) —
build-migration.mjs(schema.sql 최신 본문에서 두 함수를 통째로 가져와 앵커 1회 치환),build-dryrun.mjs+prod-query.mjs(Production 드라이런: 마이그레이션 전체 + 전 → 후 프로브를 한 요청으로 보내고 마지막 raise로 롤백) - 게이트: pgTAP
set_score_recent_performed_v1(28 → 36단언, 기존 2단언 값 12 → 14) · 마이그레이션 자가 검증(규칙 함수 3예시·두 함수 본문 문자열·#1202 규칙 생존) ·tests/react/setScore.test.mjs무수정 통과 · 로컬npm run ci:local -- --full:검증: ci:local full · verify 통과 · db reset(마이그레이션 전체 적용) 통과 · pgTAP 통과 102파일/1637 assert · e2e-local·empty·cardio 통과 · e2e-browser 통과 31/31 · e2e-viewport 통과 13/13(2026-09-04 03:43~03:58 UTC, 3회 분할) · preflight 기록 102파일·1637 assert @ 04:01 UTC - 버그리포트: 없음(코드 오동작이 아니라 규칙 설계 정정)
- 계약:
docs/data/rpe-performed-intensity.md용어표 1행 추가 · 반복수판 상한 정의 개정
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 분석·Production 실측·설계·Phase 계획 게시, 오너 설계 승인(세션 d4ff5cb5) | ✅ 이슈 본문·댓글 |
| Phase 1 | 서버: 투영 루프 상한·저장 답장 미리보기 상한·관측 행 전원 재계산·자가 검증·pgTAP | ✅ PR #1249 |
| Phase 2 | 계약 문서(용어표·반복수판 규칙) | ✅ PR #1249 |
| Phase 3 | ci:local --full → Production 드라이런 → PR 1개·CI 1회 → 랜딩 잠금 → 릴리스 PR #1227 합류 → 이 기록 | ✅ |
1. 배경
세트 스코어(#1164)는 "세트 추정 1RM ÷ 최근 수행 1RM × 10"이고, 풀업·딥스·레그레이즈처럼 무게 없이 횟수만 세는 종목은 "추정 최대 횟수 ÷ 최근 수행 최대 반복수 × 10"(반복수판)이다. 나누는 값은 세션이 끝날 때마다 규칙 함수 set_score_recent_next_v1로 갱신되는데, "완료 세트의 추정치가 높으면 올림"(규칙 ④)에는 "올릴 때 상한 = 역대 최고 기록"(규칙 ⑥)이 붙어 있다. 무게판에서 역대 최고 기록은 일부러 1회를 든 실제 1RM이라 드물게만 존재한다.
오너가 8/12 세션 화면에서 횟수판 점수가 14.2·12.9·12.9·12.5·11.3·12.5·11.8로 거의 다 10을 넘는 것을 보고 설계가 이상하다고 지적했다.
2. 문제 제기
2-1. 횟수 종목은 "역대 최고 기록"이 항상 존재해 상한이 매번 걸렸다
횟수 종목은 어떤 세트든 한 번 하면 그 횟수가 곧 기록이 된다. 그래서 여유가 있었다고 표시한 세트(가뿐함 → 추정 최대 횟수 = 실제 + 5)는 추정치가 상한(실제 횟수)에 잘려 나누는 값 = 실제 횟수가 되고, 점수는 무조건 10을 넘었다. 유저 A가 턱걸이를 처음 해서 6개 @ 가뿐함이면 11 ÷ 6 × 10 = 18.3, 버튼을 안 눌렀으면 6 ÷ 6 = 10.0 — 가볍게 한 세트가 죽을 힘으로 한 세트보다 높은 점수를 받았다.
2-2. "처음 하는 종목은 가장 잘한 세트가 10.0"이라는 원칙이 횟수 종목에서만 깨졌다
무게판은 실제 1회 기록이 없으면 상한이 없어 첫 세션의 최고 세트가 10.0이 된다. 횟수판은 첫 세션부터 상한이 걸려 같은 원칙이 성립하지 않았다.
Production 실측 (2026-09-04, 읽기 전용)
| 구분 | 메인 세트(기준값 있음) | 10 초과 | 비율 |
|---|---|---|---|
| 횟수판, 난이도 버튼 1~4 누름 | 45 | 25 | 55.6% |
| 횟수판, 버튼 없음 | 3,541 | 138 | 3.9% |
| 무게판 전체(비교) | 10,016 | 878 | 8.8% |
3. 해결 방안
원칙 (오너 결정 2026-09-04, 이슈 #1241)
- D1 — 횟수 종목의 올림 상한은 한계 세트로 확인한 횟수만 인정한다. 한계 세트 = 메인 세트 중 최대 노력 버튼(
effort_level5) 또는 실패 세트(성공 횟수 ≥ 1). 없으면 상한 없음. 횟수 종목은 변수가 횟수 하나라 1RM 환산이 필요 없고, "6개 @ 가뿐함"은 "최소 11개는 된다"는 자기 신고로 본다. - D2 (기본값) — 버튼별 여유 횟수 표(가뿐함 +5 · 할만함 +3.5 · 어려움 +2 · 한계 근접 +1)는 손대지 않는다. RPE 체계 전환(#1237)에서 RIR = 10 − RPE로 바뀐다.
- D3 (기본값) — 과거 세션 포함 관측 행 전체 재계산.
접근
| 대안 | 판정 |
|---|---|
| 올림 상한을 한계 세트로 확인한 횟수로 한정(채택) | 바뀌는 것이 상한 하나뿐. 신호·바닥·규칙 함수·점수 공식·앱은 그대로. 무게판의 "의도적으로 확인한 값만 상한"과 같은 원칙 |
| 횟수를 Nuzzo 1RM 곡선에 넣어 무게판과 같은 저울로 | 기각 — 변수가 하나라 환산 불필요, 20회 초과는 곡선 범위 밖 |
| 체중계수 1.0 종목(풀업·딥스)만 1RM판으로 이동 | 기각 — 맨몸 1회 세트가 실제 1RM 기록으로 잡혀 기준이 체중에 묶이는 새 문제 |
| 첫 세션만 최고 세트 10.0 강제 | 기각 — 두 번째 세션부터 재발 |
| 점수 상한(예: 15) | 기각 — "상한 없음"(#885 D2) 위배 |
| 버튼별 여유 횟수 축소 | 기각 — 근거 없는 조정, 저횟수 폭발은 남음 |
4. 적용한 내용
Phase 1 — 서버 (#1249, 마이그레이션 20260910003000_reps_set_score_limit_cap_v1)
refresh_user_max_rep_projection— 최근 수행 최대 반복수 루프의 세션별 집계에서 상한 재료session_max_reps(아무 세트의 최대 횟수)를session_limit_reps(메인 세트 중 effort_level 5 또는 실패 세트, 성공 횟수 ≥ 1)로. 다른 줄은 손대지 않았다(1RM판·집계·롤업 불변).workout_set_score_preview_v1(저장 답장 미리보기) — 처음 하는 종목의 반복수판 나누는 값 블록에서 세션 내 상한(v_single)을 한계 세트로 한정하고, 역대 상한(v_record)을user_exercise_stats의 최고 횟수 대신 관측 행의 한계 세트 최대 횟수로. 투영과 같은 값이 나오도록v_single도 메인 세트만 본다.- 관측 행이 있는 사용자 전원 재계산(같은 트랜잭션, 실패 시 중단). 자가 검증: 규칙 함수 3예시·두 함수 본문 문자열(
pg_proc.prosrc)·#1202 규칙 생존. - pgTAP
set_score_recent_performed_v1: 기존 R3·지표 맵 단언 12 → 14(R2 12회 RPE 8 → 추정 14, 한계 세트 없음 → 상한 없음), 새 단언 8건(풀업 첫 세션 6회 @ 가뿐함 → 11 · 다음 세션 한계 9회 → 상태 9 · 버튼 없는 6회×3 → 6 · 미리보기 11/6). - 앱 변경 없음 —
setSemantics.setScore는 서버가 준 두 값을 나누기만 한다.
Phase 2 — 계약 문서 (#1249)
docs/data/rpe-performed-intensity.md용어표에 "한계 세트로 확인한 횟수" 추가, 반복수판 규칙의 "상한 = 역대 최고 반복수"를 개정하고 턱걸이 예시를 붙였다.
주요 결정과 그 근거
- 두 함수는 schema.sql의 마지막 정의 본문을 통째로 가져와 앵커 1회 치환으로 조립(#1164 방식). 손 병합 금지 — 같은 두 함수를 #1215(운동 기록 계층 리모델링)도 재발행하므로, 나중에 랜딩하는 쪽이 main 최신 본문에서 다시 조립한다(#1215·#1237 이슈에 메모 남김).
- 미리보기
v_single을 메인 세트만 보게 바꾼 것은 투영 루프(메인 세트만)와 같은 값을 내기 위해서다. 종전에는 미리보기만 탑 세트까지 봤다.
작업 중 드러난 것
- Production 드라이런은 Management API 쿼리 러너 한 요청에 마이그레이션 전체 + 전 → 후 프로브를 넣고 마지막 DO 블록의 raise로 롤백했다(러너가 요청 단위로 트랜잭션을 묶는 것을
create table+ raise 프로브로 먼저 확인). 재계산 포함 31초. - 조립 스크립트의
String.replace(문자열, 치환문)에서 치환문 안의$'(정규식 uuid 패턴 끝)가 "매치 뒤 텍스트"로 해석돼 파일이 부풀었다 — 치환은 항상 함수(() => to)로. - CI 1회(PR #1249) 5잡 전부 초록. 로컬 1회차에서 리포트 구간 pgTAP(#1180
set_score_report_bands_v1) 4단언이 옛 상한값(R3 6.7)으로 고정돼 있던 것을 잡아 갱신 — CI 전에 로컬에서 잡힌 사례. 다른 세션의ci:local이 4173 포트를 쓰는 동안 브라우저 단계가 종료 코드 3으로 멈춰, 그 세션이 끝난 뒤--only browser,viewport로 마저 돌렸다(다른 세션 서버는 죽이지 않는다).
5. 적용 결과
Production 드라이런(2026-09-04, 롤백) 안에서 잰 전 → 후. Production 반영 뒤 같은 프로브를 다시 돌려 확인한다(릴리스 v0.17.0).
| 항목 | 전 | 후 |
|---|---|---|
| 횟수판 메인 세트, 난이도 버튼 1~4 누름 — 10 초과 | 25 / 45 (55.6%) | 0 / 45 |
| 오너 8/12 17:39 세션 횟수판 메인 8세트 — 10 초과 | 7 | 0 |
| 8/12 20:26 세션 횟수판 메인 10세트 — 10 초과 | 9 | 0 |
| 8/12 14:21 세션 횟수판 메인 9세트 — 10 초과 | 3 | 3 (한계 세트로 확인한 값을 넘은 세트 = 의도된 신호) |
| 횟수판 메인 세트, 버튼 없음 — 10 초과 | 138 / 3,541 (3.9%) | 141 / 3,568 (4.0%) |
| 무게판 메인 세트 — 10 초과 | 878 / 10,016 | 878 / 10,016 (불변) |
| 근력 관측 행 15,355행의 추정 1RM·최근 수행 1RM | — | 전부 동일(diff 0) |
pgTAP set_score_recent_performed_v1 | 28단언 | 36단언 통과 |
로컬 ci:local --full | — | 검증: ci:local full · verify 통과 · db reset(마이그레이션 전체 적용) 통과 · pgTAP 통과 102파일/1637 assert · e2e-local·empty·cardio 통과 · e2e-browser 통과 31/31 · e2e-viewport 통과 13/13(2026-09-04 03:43~03:58 UTC, 3회 분할) · preflight 기록 102파일·1637 assert @ 04:01 UTC |
| Production 반영 뒤 프로브(릴리스 v0.17.0, 2026-09-07) | 버튼 세트 25 / 45 | RPE 6~9.9(옛 버튼 1~4 포함) 횟수판 메인 세트 10 초과 7 / 54(13%) · 8/12 16:40 세션 0 / 8 · 버튼 없음 141 / 3,547(4.0%) · 횟수판 전체 148 / 3,602(4.1%). 남은 7세트는 전부 같은 사유 — 풀업 8/24 6회 @ RPE 7(추정 9회) 5세트와 7/17 7회 @ RPE 8·9 2세트가, 6/26 한계 세트(7회 @ RPE 10 — #1237 이 옛 난이도를 RPE 10 으로 변환하면서 한계 세트가 됨)로 확인한 7회를 넘은 것. "확인된 힘을 넘었다 = 끝까지 해 볼 때"라는 의도된 신호(위 8/12 14:21 행과 같은 종류). 9/4 드라이런의 0/45 는 그 시점엔 풀업에 한계 세트가 없어 나누는 값이 가장 잘한 세트였기 때문 |
6. 이번 개선으로 향상된 것
처음 하는 횟수 종목도 가장 잘한 세트가 10.0
유저 A가 턱걸이 첫날 6개 @ 가뿐함이면 기준이 11이 되어 10.0(전 18.3). 다음에 8개 @ 어려움(추정 10)이면 9.1, 한계까지 9개면 기준이 9로 확정되어 8.2, 그 뒤 7개 @ 가뿐함(추정 12)이면 13.3 — "확인된 것보다 잘 될 것 같으니 끝까지 해 봐라"라는 신호로 10 초과가 켜진다.
여유가 있었다고 표시해도 무조건 10을 넘지 않는다
난이도 버튼을 쓰는 유저의 횟수판 세트가 절반 이상 10 초과이던 것이 0이 됐다. 매일 6개 × 5세트를 버튼 없이 하는 유저는 전과 같이 항상 10.0이다.
구조적으로 남는 것
- 무게판과 횟수판의 "상한" 정의가 같은 원칙(의도적으로 확인한 값만)으로 정리됐고, 계약 문서 한 줄과 pgTAP 8단언으로 고정됐다.
- 마이그레이션 자가 검증이 두 함수 본문의 상한 필터와 #1202 규칙 생존을 함께 단언한다.
남은 것
- #1237(난이도 버튼 → RPE 편입)이
effort_level컬럼을 물리 삭제하면 두 함수의 필터를perceived_rpe >= 10으로 옮겨야 한다(이슈에 메모). - #1215가 같은 두 함수를 재발행하므로 나중에 랜딩하는 쪽이 재조립한다(이슈에 메모).
- 8/12 푸시 프레스·푸시 저크가 횟수 종목으로 잘못 등록된 문제(7/24~8/13 커스텀 등록 결함)는 이 트랙 범위 밖(#1235).