Skip to content

횟수 종목 세트 스코어 기준 상한 정정 — 가볍게 한 세트가 죽을 힘으로 한 세트보다 높은 점수를 받던 것에서, 올림 상한을 "한계 세트로 확인한 횟수"만으로 (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 3ci: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 누름452555.6%
횟수판, 버튼 없음3,5411383.9%
무게판 전체(비교)10,0168788.8%

3. 해결 방안

원칙 (오너 결정 2026-09-04, 이슈 #1241)

  • D1 — 횟수 종목의 올림 상한은 한계 세트로 확인한 횟수만 인정한다. 한계 세트 = 메인 세트 중 최대 노력 버튼(effort_level 5) 또는 실패 세트(성공 횟수 ≥ 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 초과70
8/12 20:26 세션 횟수판 메인 10세트 — 10 초과90
8/12 14:21 세션 횟수판 메인 9세트 — 10 초과33 (한계 세트로 확인한 값을 넘은 세트 = 의도된 신호)
횟수판 메인 세트, 버튼 없음 — 10 초과138 / 3,541 (3.9%)141 / 3,568 (4.0%)
무게판 메인 세트 — 10 초과878 / 10,016878 / 10,016 (불변)
근력 관측 행 15,355행의 추정 1RM·최근 수행 1RM전부 동일(diff 0)
pgTAP set_score_recent_performed_v128단언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 / 45RPE 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).