Skip to content

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.ts buildCompletedSession(restSeconds 원본) · barbelicViewMappers.ts lgEditDraftRestSeconds
  • 도구: 실데이터 사본 드라이런(세션 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·이전 값)에 기록.
  • exercises 3행 recording_fields ['reps'] → ['load','reps'](id·주인·"지금도 ['reps']" 조건 — 재실행 0행) + required_inputs 투영 재계산(-- user-fact: projection-only). ② session_exercise_part 3행 같은 정정(세트 값은 건드리지 않음 — #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·restSecondslgEditDraftRestSeconds(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:NaN1:28, 충분히 휴식 세트 휴식 0:00충분히 휴식/종료 (단위 테스트로 확정, 시각 확인은 사람 눈)
pgTAP105파일/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 없음). 범위 밖.
  • 앱에서 내 커스텀 종목의 기록 유형을 바꾸는 기능은 없다 — 필요해지면 별도 이슈.