계획 저장 사고 수리 — 첫 실사용 저장이 밟은 잠복 결함 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포함) · pgTAPhome_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_sessions1행(2026-08-23, 17세트)·plan_mutation_receipts1행. 데이터 손실 없음. - 저장 직후
react_render_error/fatal(surfacedesktop-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_dashboard가LG_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-check | pgTAP 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 가드는 누락을 크래시로 바꾼다.