8/12 푸시 저크 무게 소실·수정 화면 휴식 NaN 표시 — 잘못 등록된 기록 유형을 수리 티켓으로 정정하고, 수정 초안이 휴식 원본만 읽게 (2026-09-04)
- 기간: 2026-09-04 (세션 2개 — 분석·계획
ea8375e7, 구현·랜딩0590ff1d. 오너 보고 "8/12 세션 수정 화면에 푸시 저크 무게가 없고 휴식이 NaN:NaN", go "#1235 진행해줘") - 랜딩: PR 1265(Phase 1·2,
13a611a0) — 마이그레이션20260912020200_custom_exercise_load_profile_repair_1235, 엣지 함수 없음. main(=스테이징) 반영, Production 은 릴리스 v0.17.0(PR #1227) 레인 - 설계서: 없음(수리 건) — 분석·Phase 계획·"예상 효과·개선사항" 절은 이슈 #1235 본문
- 정본: 유저 기록 원본 불변 계약
docs/contracts/user-fact-immutability.md§3(수리 티켓) — 이 트랙이 첫 실제 사례. 앱 층은barbelicMappers.tsbuildCompletedSession(restSeconds원본) ·barbelicViewMappers.tslgEditDraftRestSeconds - 도구: 실데이터 사본 드라이런(세션 scratchpad
rebuild.sh·snap-before.sql·audit.sql— 레포 밖),npm run ci:local, Management API 읽기 전용 프로브 - 게이트: pgTAP
supabase/tests/database/custom_exercise_load_profile_repair_1235.test.sql(13 assert), 단위 테스트tests/react/mobileCompletedSessionEdit.test.mjs(휴식 초 단위). 로컬 pgTAP 통과 기록:npm run ci:local --full— 검증: ci:local full · verify 통과 (5단계) · db reset(마이그레이션 전체 적용) 통과 · pgTAP 통과 106파일/1800 assert(2026-09-04 17:30 KST, HEAD 33cd31ba) · e2e local 11·empty 7·cardio 6 통과 · e2e-browser 35/35·viewport 13/13(포트 4173 점유로 내 미리보기 4273에 E2E_APP_URL 로 따로 실행, 커밋 7f0422c3 기준) - 버그리포트:
bug-report/bug-078-20260904.md - 계약: 변경 없음(#1236 계약 §3·§5 를 그대로 적용)
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 1 | 종목 3행·세부 종목 3행 기록 유형 정정 마이그레이션(수리 티켓 issue#1235) + pgTAP | ✅ PR 1265 (13a611a0) |
| Phase 2 | 수정 초안 휴식을 원본 초 단위로(NaN:NaN 수리) + 단위 테스트 | ✅ PR 1265 |
| Phase 3 | 무게 재입력 — 릴리스 v0.17.0 적용 뒤 오너가 수정 화면에서 기억나는 값을 입력(없으면 비워 둠) | ⬜ 오너(요청 아님, 정보) |
| Phase 4 | 버그 리포트 BUG-078 + 이 기록 + 등록 2곳 | ✅ docs PR #1270 |
1. 배경
오너가 8/12(수) 세션을 수정 화면으로 열었더니 B. 푸시 저크·A. 푸시 프레스(둘 다 그 날 운동 중 "새 종목 추가"로 만든 커스텀 종목)의 세트에 무게가 없었다 — kg 칸 자체가 없고 반복수·RPE만 보인다. 같은 세션의 공식 카탈로그 종목(프론트 스쿼트·파워 클린+저크)은 80~160kg 가 그대로다. 휴식 칸은 1·5세트 휴식 0:00, 2~4세트 휴식 NaN:NaN.
이 보고의 원인 조사에서 "8/13 마이그레이션이 유저가 입력한 무게 16건을 지웠다"가 드러났고, 오너 지시 "서비스 중에는 절대 발생하면 안 돼"로 재발 방지는 별도 트랙 #1236(유저 기록 원본 불변) 이 됐다. #1236 이 먼저 랜딩했고(PR #1254), 이 트랙은 그 절차(수리 티켓·유실 증명·이력)를 처음으로 실제 데이터에 적용한다.
2. 문제 제기
커스텀 종목 3개가 "반복수만 기록" 유형으로 등록돼 있었고, 그 세트 16개의 무게가 null 이었다
Production 실측(2026-09-04): 푸시 저크 1c081d05(8/12 10:20Z 생성)·푸시 프레스 0803ea08(8/12 10:10Z)·싱글레그 케틀벨 데드리프트 61c2cdf5(8/11) 모두 recording_fields ['reps'], 오너 소유(owner_user_id 032f15dc), source barbelic. 세션 세부 종목 3행(a937f308·ac8ef063·c46a52cc)도 ['reps']. 세트 16행 load null / effective_load 0, 반복수·RPE·휴식은 남아 있음. 8/12 당시 앱은 "새 종목 추가"에서 기록 유형을 보내지 않아 reps 로 등록했고, 세트 입력 화면은 종목 유형과 무관하게 kg 칸을 보여 무게가 저장됐으며, 8/13 03:37 KST 마이그레이션 20260812001100(PR #407)이 "기록 유형에 없는 값은 자리표시자"로 보고 load = null 로 바꿨다(통계 재계산 작업 reason "Atomic recording profile placeholder scrub" 2026-08-12 18:37:23Z, exercise_ids 에 세 종목). 시점 복원 꺼짐·백업 7일·원본 payload 비어 있음 → 원래 숫자는 복구 불가.
수정 초안이 휴식 표기 문자열을 초 숫자로 착각했다
서버 상세 → 화면 변환(buildCompletedSession)은 휴식을 "1:28"(표기) 또는 ""(충분히 휴식)로만 만들고, 완료 세션 → 수정 초안 변환(lgSessionToEditWorkoutDraft)이 그 문자열을 그대로 rest 에 넣었다. 운동 기록 화면은 rest 를 초 숫자로 보고 Number("1:28") = NaN 을 분:초로 그렸다. "" 는 != null 이라 휴식 0:00. 저장 시엔 completedSessionRestSeconds 가 문자열을 초로 다시 해석해 데이터는 무해 — 표시만 틀렸고, 8월 초부터 잠복(변환 함수 8/3, 표시 줄 7/31). 완료 세션을 수정 화면으로 여는 모든 사용자에게 보였다.
3. 해결 방안
원칙 (오너 결정, 2026-09-04)
- D1 (go "#1235 진행해줘"): Phase 1 을 데이터 수정 마이그레이션으로 진행. #1236 원칙 P0 "유저가 기록한 것은 원본 — 시스템은 수리 티켓으로만, 이력을 남기고" 를 그대로 적용.
- D2: 무게 재입력은 오너 선택 — 기억나는 값이 있으면 릴리스 뒤 수정 화면에서, 없으면 종목 유형만 고쳐 둔다.
- D3: Phase 2(휴식 표시, 전체 사용자)를 같은 트랙에서 진행.
접근
| 안 | 내용 | 판단 |
|---|---|---|
| 종목을 새로 만들어(무게+반복수) 세션에 다시 기록 | 오너 손 | 기각 — 같은 이름 종목 2개·이력 분리 |
| 앱에 "내 커스텀 종목 기록 유형 변경" 기능 | 새 RPC·화면 | 기각 — 이번 수리에 과함(수정 RPC 없음). 필요하면 별도 트랙 |
| 데이터 수정 마이그레이션(수리 티켓) + 재입력 | 종목 3행·세부 종목 3행 ['reps'] → ['load','reps'], 티켓 issue#1235, 손댄 행만 자가 검증 | 채택 — 시스템이 원본을 만지는 유일한 문. 이전 값은 이력에 남아 되돌릴 수 있다 |
| 휴식: 문자열을 초로 되돌리는 변환만 추가 | 초안 변환 한 줄 | 기각(§22 땜질) — 표기 문자열이 원본 자리에 들어가는 구조가 남는다 |
| 휴식: 상세가 원본(초)을 함께 내려주고 초안은 원본만 읽는다 | restSeconds 원본 통과 + lgEditDraftRestSeconds | 채택 — #1236 계약 §5 앱 층 "표기 문자열(투영)을 원본 자리에 넣지 않는다". 옛 캐시 상세(문자열만)는 예외 처리로 초 변환 |
4. 적용한 내용
Phase 1 — 기록 유형 정정 마이그레이션 (PR 1265, 20260912020200_custom_exercise_load_profile_repair_1235)
- 첫 줄
select set_config('lift_guild.repair_ticket', 'issue#1235', true);+-- loss-audit:줄(사본 드라이런 결과). 손댄 행은 임시 테이블repair_1235_target(테이블명·id·이전 값)에 기록. - ①
exercises3행recording_fields ['reps'] → ['load','reps'](id·주인·"지금도 ['reps']" 조건 — 재실행 0행) +required_inputs투영 재계산(-- user-fact: projection-only). ②session_exercise_part3행 같은 정정(세트 값은 건드리지 않음 — #1236 Phase 2 이후 값 삭제 트리거 없음). ③ 자가 검증: 기록 유형·투영·세트 16행 전후 동일(행 수·반복수·RPE·휴식·무게·종류)·user_fact_history티켓 행 수 = 손댄 행 수. 빈 DB 에서는 0행이라 공허 통과(실데이터 검증은 사본 드라이런·릴리스 뒤 프로브). - pgTAP
custom_exercise_load_profile_repair_1235.test.sql(13): 픽스처(커스텀 종목['reps']·세션·세부 종목·세트 2개)에 같은 문장을 재현 — 티켓 없으면 42501, 티켓이면 통과 + 이력 2행(이전 값["reps"]), 세트 원본 무변경·세트 이력 0, 투영{all:[load,reps]}, 세션 상세 payload 에["load","reps"].
Phase 2 — 수정 초안 휴식 원본화 (PR 1265)
buildCompletedSession:restSeconds: nullableFiniteNumber(set.rest_seconds)를 표기rest옆에 추가.lgCompletedExercises:restSeconds·freeRest통과.lgSessionToEditWorkoutDraft:rest·restSeconds를lgEditDraftRestSeconds(set)하나로 —freeRest→ null, 원본 초 → 그대로, 문자열"m:ss"(옛 캐시) → 초. 흐름 초안(makeFlowSet)이rest가 비면restSeconds로 채우므로 둘 다 같은 값이어야 '충분히 휴식' 세트에 0초가 되살아나지 않는다.- 단위 테스트: 휴식
["", "1:28", "0:01", "1:07"(옛 캐시), ""]→ 초안[null, 88, 1, 67, null]·freeRest [t,f,f,f,t], 흐름 초안까지 유지.
주요 결정과 그 근거
- Production 되돌림 감사 대신 실데이터 사본 드라이런: Production 은 v0.17.0 전(옛 테이블명
session_exercises)이라 새 테이블명을 쓰는 이 파일을npm run db:loss-audit로 돌릴 수 없다. #1237 과 같은 기법 — 매일 사본(2026-09-04 03:38Z, 세트 16,628행)을 별도 샌드박스(barbelic-ci-local/repair1235dry)에 복원, Production 꼬리20260909000100뒤 대기 마이그레이션 18본을 파일마다psql -1로 적용, 보호 테이블 스냅샷 → 이 파일 → 비교. required_inputs는 명시 갱신: 기본값 트리거(exercises_required_inputs_default)는 UPDATE 때 옛 값이 새 기록 유형에서 "유효"(required_inputs_valid_v1)하면 그대로 두므로{all:[reps]}가 남는다 — 드라이런 1차가 자가 검증으로 잡았다.- 임시 표의 종류 값 = 테이블명: 이력 대조는
user_fact_history.table_name과 같은 문자열이어야 한다('exercise' 로 적어 3≠6 으로 걸림 — 드라이런 2차).
작업 중 드러난 것
- 사본 드라이런이 두 번 자가 검증에 걸렸다(위 두 항목) — 빈 DB pgTAP 만으로는 둘 다 안 잡힌다(픽스처가 없으면 공허 통과). 데이터 수리 마이그레이션은 실데이터 사본 드라이런이 필수라는 것을 다시 확인.
- CI 헛빨간불 1건(재현 불가): 3차 CI(커밋
3eba02ba)의 browser-journeys 샤드 1이 Playwright 결과 18/18 통과·비정상 0·재시도 0인데 스텝만 실패로 끝났다(JSON 리포트unexpected 0, flaky 0, 오류 없음).gh run rerun --failed로 같은 커밋을 다시 돌려 전부 초록. 러너 쪽 종료 코드 문제로 분류. - 이력 대조 자가 검증은
to_regclass('public.user_fact_history')로 감싸 두었다 — 보호 층 이전 상태의 DB 에서도 파일이 죽지 않게. - 사전 검증 누락 1건(CI 1회 빨간불): PR #1265 첫 CI 의 verify 잡이 dual-key 게이트(
check:dual-key,barbelicMappers.ts이중 키 허용 254 기준)에서 실패했다 — 내가 넣은set.rest_seconds ?? set.restSeconds한 줄이 새 이중 키 자리였다. 로컬npm run ci:local --full이 e2e-browser 단계에서 포트 4173 점유로 중단돼 요약 줄이 안 나왔는데, 잘린 로그 꼬리만 보고 verify 를 통과로 읽었다(ci:local 은 verify 가 실패해도 db·e2e 를 이어 돈다). 로컬로 재현 가능했던 실패. 수리 = 서버 행 키rest_seconds하나만 읽기(커밋a99293f0), 이후ci:local --only verify,db로 verify 통과를 직접 확인하고 다시 푸시. 교훈: ci:local 이 중간에 죽으면 단계별 결과 줄(━━·통과/실패)을 전부 확인한다.
5. 적용 결과
| 항목 | 결과 |
|---|---|
종목 3행 recording_fields | ['reps'] → ['load','reps'] (사본 드라이런 확인, Production 은 릴리스 뒤 프로브) |
종목 3행 required_inputs | {all:[reps]} → {all:[load,reps]} |
세부 종목 3행 recording_fields | ['reps'] → ['load','reps'] — 세션 상세 payload 의 세 세부 종목이 ["load","reps"] = 수정 화면에 kg 칸이 생김 |
| 세트 16행 | 반복수·RPE·휴식·무게(null)·행 수 전후 동일(변경 0) |
| 유실 감사(사본) | 파괴 0·삭제 0, 그 밖의 보호 테이블 변경 0, 이력 6행(티켓 issue#1235), 재실행 0행(멱등) |
| 수정 화면 휴식 표기 | 휴식 NaN:NaN → 1:28, 충분히 휴식 세트 휴식 0:00 → 충분히 휴식/종료 (단위 테스트로 확정, 시각 확인은 사람 눈) |
| pgTAP | 105파일/1789 assert 통과(신설 13 포함) |
| 무게 값 | 복구 불가 — 재입력 전까지 null. 재입력 뒤에야 볼륨·추정 1RM·세트 스코어에 잡힌다 |
| Production(릴리스 v0.17.0, 2026-09-06 머지) | 프로브 2026-09-07: 푸시 저크·푸시 프레스·싱글레그 케틀벨 데드리프트 recording_fields ['load','reps']·required_inputs {all:[load,reps]} · 세부 종목 3행 ['load','reps'] · user_fact_history 티켓 issue#1235 6행(exercises 3·session_exercise_part 3) |
6. 이번 개선으로 향상된 것
오너가 8/12·8/11 세션의 무게를 다시 넣을 수 있다
수정 화면에 kg 칸이 돌아온다. 종목 자체의 기록 유형이 무게+반복수가 되므로 앞으로 이 종목으로 기록할 때도 무게 칸이 뜬다.
완료 세션 수정 화면의 휴식이 제대로 보인다 (전체 사용자)
NaN:NaN 대신 1:28, 충분히 쉰 세트는 "충분히 휴식", 마지막 세트는 "종료".
구조적으로 남는 것
- #1236 수리 티켓 절차의 첫 실제 사례: 티켓 선언 → 사본 드라이런 유실 증명 → 손댄 행만 자가 검증 → 이력 자동 보관. 다음 데이터 수리는 이 파일을 본보기로 쓴다.
- 수정 초안은 휴식 원본(초)만 읽는다는 계약을 단위 테스트가 고정.
남은 것
- Phase 3: 릴리스 v0.17.0 뒤 오너가 기억나는 무게를 수정 화면에서 입력(선택). 8/12 세션의 세트 스코어·추정 1RM 은 그 뒤에야 계산된다.
- 8/13 마이그레이션이 공식 카탈로그 맨몸 종목(예: 파이크 푸쉬업)에 적힌 무게도 지웠을 가능성 — 확인할 방법이 없다(원본 payload 없음). 범위 밖.
- 앱에서 내 커스텀 종목의 기록 유형을 바꾸는 기능은 없다 — 필요해지면 별도 이슈.