Skip to content

계획 저장 사고 수리 — 첫 실사용 저장이 밟은 잠복 결함 2건 (2026-08-22)

  • 기간: 2026-08-22 (사고 15:13 KST → 같은 날 수리)
  • 랜딩: 이 PR 1건 — 마이그레이션 20260821430000(홈 계획 카드 source_ref) + 클라이언트 한 줄(로컬 계획 카드 source) + pgTAP 1파일·행동 테스트 1건. Production 적용은 마이그레이션 1건(적용 즉시 홈 차단 해제), 클라이언트는 main 배포로 반영
  • 정본: docs/data/app-screen-rpc-contract.md(+ docs/ko/…) current_month.plans 행 — 카드는 source_ref를 싣는다; tests/react/screenRpcContracts.test.mjs 홈 픽스처(source_ref 포함) · pgTAP home_plan_card_source_ref.test.sql
  • 풀스택 E2E 케이스: error-cases/CASE-016-desktop-plan-create-save-crash-home-lockout/ — 사고 경로(종목 A·B·C + 자유 기록 D 작성 → 저장 → 홈 이동 → 새로고침 → 계획 재열기)를 실제 데스크톱 UI로 돌려 두 결함을 모두 잠근다(생성 저장 후 셸 생존 + 새로고침 홈 하이드레이션·읽기 모델·DB 행 대조). 로컬 Red 확인: 클라이언트 한 줄을 빼면 저장 직후 치명적 오류 셸에서 멈춘다.

1. 증상

실사용자 한 명이 데스크톱 웹에서 계획(종목 A·B·C + 자유기록 D, 17세트)을 입력하고 저장하자 앱이 꺼졌고, 다시 들어가니 아무것도 되지 않았다.

2. 실측 (Production 읽기 전용 프로브 3회)

  • 저장 자체는 성공: planned_sessions 1행(2026-08-23, 17세트)·plan_mutation_receipts 1행. 데이터 손실 없음.
  • 저장 직후 react_render_error/fatal(surface desktop-app) 1건. 번들 프레임을 되짚으니 lgBuildSessionsByDate → lgSessionToLite → sessionSourceOf에서 "Session source must be either sessions or planned_sessions" throw — 저장 후 정본 plans에 추가한 로컬 카드에 source가 없다. workoutWriteController.saveUiWorkoutPlan의 plan 리터럴이 source를 싣지 않았고, 서버 영수증 (id·date·sourceRef·updatedAt·receipt)에도 없다. 수정 저장은 기존 카드를 spread해서 살았고 생성 저장만 죽는다. 로컬에서 같은 카드로 재현(throw) → source: "planned_sessions"면 통과.
  • 재진입 때 get_home_dashboardLG_SCHEMA_VALIDATION path=current_month.plans.0.source_ref로 7회 연속 실패. 클라이언트 adaptHomePlanRows는 카드의 source_ref를 문자열 필수로 요구하는데 (07-28 계약 하드 컷오버 이후), 서버 build_home_dashboard_core_v2의 계획 카드 projection은 한 번도 source_ref를 내보낸 적이 없다(베이스라인 이전 전 버전 확인). 이번 달에 '예정/미완료' 계획이 하나라도 있으면 홈이 열리지 않는다.
  • 두 결함 모두 7월 말부터 잠복. Production에서 07-20 이후 계획 저장은 이 건이 유일 — 첫 실사용자가 밟았다. 홈 계약 테스트 픽스처는 source_ref를 들고 있어 서버와 어긋난 채 초록이었다 (픽스처·계약·테스트 3중 정합 함정의 재발, design-fixture-contract-drift 계열).

3. 수리

결함수리검증
홈 계획 카드 source_ref 부재(서버)20260821430000: build_home_dashboard_core_v2를 직전 정의(240000) 그대로 재발행하며 계획 카드에 'source_ref', plan.source_ref 한 키 추가 + 정의 텍스트 post-checkpgTAP home_plan_card_source_ref.test.sql(5 assert: 정의 텍스트·카드 1장·id·source_ref 원문·card-only 유지)
생성 저장 로컬 카드 source 부재(클라이언트)workoutWriteController.ts plan 리터럴에 source: "planned_sessions"workoutWriteCommands.test.mjs 행동 테스트: 생성 저장 후 정본 카드가 source=planned_sessions이고 lgBuildSessionsByDate가 throw하지 않는다

4. 사용자 복구

마이그레이션이 Production에 적용되는 순간 해당 계정의 홈이 다시 열린다(클라이언트 변경 불필요 — 카드가 이제 계약대로 온다). 저장된 계획은 그대로 남아 있다. 생성 저장 크래시는 main 배포 후 사라진다.

5. 교훈

  • 읽기 모델 계약은 SQL 경계에서도 잠가야 한다 — 픽스처가 필드를 들고 있다고 서버가 내보내는 건 아니다. 이번에 pgTAP가 그 역할을 맡았다.
  • 쓰기 후 정본에 로컬에서 합성하는 카드는 서버 카드와 같은 식별 필드(source)를 갖춰야 한다. 렌더 경로의 throw 가드는 누락을 크래시로 바꾼다.