Skip to content

에러 관측성 재설계 — 계획 수정 21일 무음에서 "문구가 그려졌다 = 기록됐다"까지 (2026-08-24~25)

  • 기간: 2026-08-24 ~ 08-25 (진단 세션 1 + 실행 세션 1. 오너 보고: "계획 세워서 저장한 다음 수정페이지 들어가서 세션 제목 수정하고 저장하려고하면 에러나면서 안된다" → 실행 지시: "issue 664 보고 후속작업좀 진행해줘" + D1~D6 확정. 랜딩 직후 오너가 보고한 유령 "진행중 운동"(이슈 #705)을 같은 세션이 후속 트랙으로 담당 — 새 그물의 영수증·이벤트로 진단한 첫 실전 수리)
  • 랜딩: #680(Phase 0, 068c217a) · #684(Phase 1-1·1-2, da9be0f9, 마이그레이션 660000) · #693(Phase 1-3, cc16fe12) · #695(Phase 2, 149963c9) · #697(Phase 3, 71f553f7) · #698(Phase 4, 082f015b, 마이그레이션 690000) · #689(D5-1·2·4, 6814129c) · #700(D5-3, f813cd4c) — 660000·690000 Production 적용, 클라는 Vercel 자동 배포. 후속 #705: #716(76e30cc2) · #724(e14bcf65) — 클라 전용(마이그레이션 0), Vercel success
  • 설계서: 이슈 #664 본문이 정본(예상 효과 표 포함) — artifact 0d8f770e-e6b7-4655-a96c-e0231109897d는 동일 내용의 오너용 사본
  • 정본: 관문 ① reportedUserError(src/react/userErrorPolicy.ts) · 관문 ② callMutationRpc(src/react/services/barbelicRepository.ts) · clientContractError·보고-1회 마커(src/react/services/appErrorReporter.ts) · 왕복 하네스(tests/react/planRoundTripHarness.test.mjs) · 관측 계약 문서 docs/architecture/observability-logging.md(en·ko)
  • 도구: 마이그레이션 생성 스크립트 2본(세션 스크래치패드, gitignored — 현행 함수 본문에서 파생해 전사 오류 차단) · 정찰 인벤토리 2본(스크래치패드 worklists/)
  • 게이트: publicErrorMessage 직접 사용 금지(소스 스캔) · 매퍼→쓰기 계약 직결 테스트 · 왕복 하네스 8케이스 · 계획 왕복 풀스택 e2e · mutationRpcGate 5케이스 · pgTAP(+kind 1·에러 요약 3) · OCC 충돌 1케이스 · 후속 #705: 초안 정리 실모듈 2케이스 + 부팅 신선도 폐기 3케이스
  • 버그리포트: bug-report/bug-014-20260824.md(확인 대기 — 오너 실기기) · 후속 #705: bug-020-20260824.md(해결) · bug-022-20260825.md(해결)
  • 계약: observability-logging.md "capture boundaries" 절 — 읽기·쓰기 게이트, 관문 ①, 보고-1회 마커(cause 체인)

Phase 현황

Phase내용상태
Phase 0계획 수정·삭제 사망 수리(sourceRef 매핑) + 왕복 게이트 2건✅ #680 (068c217a)
Phase 1-1client_contract_error kind 신설(660000) + 보고-1회 마커 + clientContractError✅ #684 (da9be0f9)
Phase 1-2reportedUserError 관문 + 계획 쓰기 경로 이관✅ #684
Phase 1-3미보고 표면 전면 이관(54곳 재조사, 29파일) + 직접 사용 금지 봉인✅ #693 (cc16fe12)
Phase 2쓰기 관문 callMutationRpc(27곳) + admin 실명 17곳 + 오기록 3건✅ #695 (149963c9)
Phase 3왕복 하네스 + 진입 fail-fast + 계획 편집 풀스택 e2e✅ #697 (71f553f7)
Phase 4admin 체크리스트 에러 요약(690000): 24h/7d·kind/surface·신규 유형✅ #698 (082f015b)
D5 별건bodyFatPercent 유도 · 수동 PR 왕복 게이트 · 죽은 API 2건 제거 · admin OCC✅ #689 · #700
후속 #705유령 "진행중 운동"(BUG-020): 초안 정리 no-op → operationId 펜스 + 실모듈 게이트✅ #716 (76e30cc2)
후속 #70524일 세션 이동(BUG-022): Production 응급복구 + 낡은 봉투 부팅 폐기✅ 복구 실측 + #724 (e14bcf65)

1. 배경

오너가 계획 수정 저장 실패를 보고했다. 실측 결과 저장 RPC는 서버로 나가지도 않았고 — 읽기 매퍼가 source_ref를 떨어뜨려 클라이언트 검증에서 죽었다 — 이 실패가 21일간 어떤 로그에도 없었다. 계획 수정·삭제·인라인 수정은 출시 후 성공 이력 0건(영수증 전수 조회). 전수 감사로 근본 문제 둘이 확정됐다: (A) 에러 보고가 호출부 재량(opt-in)이라 누락이 구조적으로 재생산된다(미보고 35/75, 쓰기 12/37), (B) "읽기가 쓰기 필수 필드를 공급한다"를 강제하는 테스트가 레포에 0개.

2. 문제 제기

읽기 매퍼가 쓰기 필수 필드를 떨어뜨렸다

buildPlannedSession summary 리터럴에 sourceRef 키가 없었다. 같은 파일의 완료 세션 매퍼 2곳은 매핑한다 — 동일 파일 내 대칭 붕괴. 기존 테스트는 createdAt·updatedAt은 단언하면서 sourceRef만 없었다.

에러 보고가 opt-in이라 21일 무음이 가능했다

표면 75곳 중 35곳(실행 세션 재조사에서 54곳)이 문구만 그리고 적재하지 않았고, 쓰기 RPC는 읽기(callScreenRpc 관문 자동 보고)와 달리 관문이 없었다. 택소노미에 "클라 계약 위반" 유형 자체가 없었다.

적재는 견고한데 아무도 안 봤다

client_error_events의 유일한 소비는 "오너 증상 보고 후 수동 SQL"이었다.

3. 해결 방안

원칙 (오너 결정 D1~D6, 2026-08-24)

  • D1~D4 "추천안대로": kind 신설 / 관문 이관(+직접 사용 금지 계약 테스트) / 쓰기 관문 채택 / 풀스택 E2E 추가.
  • D5 변경: "응 이거 다 수정해주고" — 별건 4건 전부 이 캠페인에서 처리(별도 트랙 분리안 기각).
  • D6 수정 채택: "일단 관리자화면에서 볼 수는 있게 해줘. 에러 유형 행도 추가하고, 신규 에러 확인은 오너가 직접하고, 확인하는 루틴은 없어도 될것같다" — 노출만, status 정책·운영 루틴 없음.

접근

보고를 기본값으로 뒤집는 관문 2겹(표면 ①·저장소 쓰기 ②) + 에러 객체 단위 보고-1회 마커(cause 체인 포함)로 이중 적재 차단. 왕복은 관측이 아니라 CI의 몫 — 실모듈 하네스와 풀스택 e2e. 기각: 35곳 개별 배선(36번째에서 재발), 기존 rpc_failure 재사용(시맨틱 오염), Actions cron 감시(결제 한도 충돌).

4. 적용한 내용

Phase 0 — 결함 수리 (#680)

sourceRef: row.source_ref || "" 1줄 + 게이트 2건. 수리 없이 게이트만 돌리면 Production과 동일 문구로 2건 실패함을 증명 후 랜딩. BUG-014 리포트 #681.

Phase 1 — 보고의 기본값화 (#684 660000, #693)

  • kind client_contract_error(DB check+인입 함수 전문 재선언+클라 enum), clientContractError(message,{diagnosticKey}), 보고-1회 마커(오너 수명, Phase 2에서 cause 체인 확장).
  • reportedUserError(error, fallback, {surface, operation, …}): 문구 반환 전 적재, PUBLIC_ERROR_MESSAGES 등재 코드는 예상 오류로 제외(문구 사전 = 예상 오류 레지스트리).
  • 전면 이관 29파일: 스토어 래퍼 5종은 operation 배선(코드 분기 42501·P0002·22023은 예상 오류 유지), 빈 결과·OAuth 복귀 등 error 객체 없는 분기는 합성 에러, 관리자 원문 표시 표면은 capture 직접(문구 유지). 봉인: src/react 전체 소스 스캔으로 publicErrorMessage 직접 사용 0 강제.
  • 의도적 제외: degradeBootstrapAuth(connectivity 텔레메트리 전용), vite/admin 2곳(standalone admin은 리포터 미설치).

Phase 2 — 저장소 쓰기 관문 (#695)

callMutationRpc: 쓰기 RPC 27곳 경유, 실명·operationId·소요시간·HTTP status 적재, 전체 응답 반환·비-throw(completedWorkoutRpcFailure의 status 캡처와 .code 분기의 참조 동일성이 하드 제약). 완료 세션 v4 3종은 report:false — 저하 큐잉(503→기기 보관)은 설계된 상태라 이벤트가 아니고 CASE-015 완주 계약이 이를 고정한다. admin 12종 뭉갬은 작업 실명 17곳으로 해소(RPC는 3종뿐, 9종은 테이블 쓰기). 오기록 3건 교정(3번째는 정찰 신규 발견: 통계 리프레시). wodup 실패 상태 기록 무검사 해소. 문서 2본 실코드 정렬.

Phase 3 — 왕복 계약 (#697)

하네스 8케이스: 서버 payload → 엄격 어댑터 → 매퍼 → 편집 드래프트 → 플로우 직렬화 → 실제 컨트롤러(setEnv) → DTO → 저장소 검증, identity 손 주입 0. 진입 fail-fast·updatedAt 무음 탈락 throw 승격·lgDraftFromPlanMode 폴백 필드 드랍 수리. 풀스택 e2e: 생성→재로드→재로드 identity만으로 제목 수정→updated_at 전진→삭제(CI migration-smoke 실DB 통과).

Phase 4 — 감지 (#698 690000)

get_admin_operations_checklist의 client_errors 체크 확장: 24h/7d 이벤트·kind·surface 카운트 + 릴리즈·스택 무관 identity의 24시간 신규 감지(fingerprint는 release 포함이라 배포마다 전부 신규가 되는 함정 — identity 좌표로 판정), admin 운영 화면 한 줄 노출. status 정책 불변.

D5 별건 (#689, #700)

  • bodyFatPercent: 수동 저장이 percent를 null 고정 → %는 정의상 kg/체중×100이라 유도(명시값 우선), 읽기 매퍼 왕복 공급. 서버 upsert는 (user,source,source_ref) 충돌이라 InBody 원본 행 파괴는 없었음(감사 표현 정정) — 최신값 읽기 소실이 실체.
  • 수동 PR: 재검증 결과 읽기(prLog)가 이미 eventId를 공급 — 왕복 게이트로 고정(감사 정정).
  • 죽은 API: withdrawUserConsent·deleteManualPr 전층 제거(부재 단언 교체). createCatalogExercise는 공개 API 표면 계약이 존재를 단언하는 서버 조합 프리미티브 미러라 제거 대상에서 제외(정찰의 3번째 후보 기각).
  • admin OCC: updateExercise를 읽어온 updated_at으로 펜스, 충돌은 LG409.

후속 트랙 — 유령 "진행중 운동" (이슈 #705, #716·#724, 정본 = 버그리포트 2건)

트랙 랜딩 직후 오너 보고. 새 그물의 영수증·OCC 표면화로 진단한 첫 실전 수리라 이 기록에 묶는다. 상세는 bug-020·bug-022가 정본.

  • BUG-020: 완료 기록 수정 저장 성공 후에도 기기 초안 봉투가 남아 재기동마다 유령 "진행중 운동" 배너 + 첫 저장 OCC 충돌. 원인 = 정리 키 불일치(수정 경로는 sourceRef를 세션 정본으로 덮어쓰는데 봉투는 draft-로컬 local:<op> — 가드된 delete가 조용한 no-op). 수리 #716: 정리를 봉투 identity 정본인 operationId만으로 펜스 + 실모듈(실제 캐시·커맨드·컨트롤러) 게이트 2케이스(수리 전 RED). 오너 실기기 확인으로 해결.
  • BUG-022: #716 이전에 이미 생존한 봉투의 부활 저장이 세션 date·title을 낡은 초안 shell로 대체 — 오너 24일 풀업 세션이 08-01/"스트렝스"로 이동(세트는 exercisesDirty=false 서버 보존으로 온전). 응급복구(Production, 오너 승인 "1. 응급복구"): date 원복 + server_revision 전진 + 통계 재계산 큐 → 잡 completed 실측. 근본 수리 #724: 부팅 복원 시 봉투 신선도 검증(expectedRevision < 서버 revision이면 폐기 + stale_edit_draft_discarded warning — Phase 4 admin 요약에서 빈도 관측), 오프라인 판별 불가는 fail-open(OCC가 최후 방어). 게이트 3케이스.

주요 결정과 그 근거

  • 관문 ②는 응답을 변형하지 않는다 — 포이즌 분류(directWorkoutWrite)가 .status/.code 참조 동일성에 걸려 있어서.
  • 보고-1회 마커는 오너 수명 + cause 체인 — 래핑(CompletedWorkoutRpcError) 뒤에도 1건.
  • 신규 에러 판정은 fingerprint가 아니라 identity 좌표 — release가 지문에 들어가는 구조 때문.

작업 중 드러난 것

  • 마이그레이션 번호 선점 2회(650000→#683, 680000→#694): 같은 날 병행 세션 다수 — README 규칙 6 재번호 절차로 처리, 리베이스 2회(#693은 WorkoutExtras 임포트 충돌).
  • CI 전용 파손 2건: ① tsx 신선 설치(Node 22)에서 .js/.ts 지정자 경유 싱글턴 동일성이 로컬(구버전 tsx)과 갈림 — 관문 테스트를 capture 주입 시임으로 재작성(전역 싱글턴 상태 비의존) ② dual-key 게이트(신규 snake??camel 금지)에 2회 걸림 — 신규 코드는 입력 형태를 한쪽으로 확정.
  • CASE-015 충돌: 관문 ②가 저하 큐잉 503을 적재하자 "적재 0건" 완주 계약이 깨짐 — 정책 매니페스트는 connectivity 전용 하드코딩이라 기대 선언 확장 대신 v4 3종 report:false가 올바른 경계였다.
  • 텍스트 앵커 계약 테스트 4건이 .rpc("…") 리터럴·"시그니처 1개"를 고정 — 게이트 호출 형태·재선언 규약(뒤가 이김)으로 재앵커(검증 의도 불변).
  • bash heredoc 이스케이프 맹글링 재발(기지 함정) — 테스트 코드 추가는 Write/Edit로.
  • (후속 #705) 정리·삭제 경로 테스트는 실제 스토어의 일치 의미론으로 — mock 호출 기록은 no-op을 통과시킨다(왕복 하네스 원칙의 초안 캐시 판). "충돌 처리기가 수습하니 불요" 같은 완화 장치 의존 판단은 BUG-022로 뒤집혔다 — OCC는 저장을 막았을 뿐 shell 역주입은 통과했다.
  • (후속 #705) 응급복구 SQL 함정: authenticated 함수는 DO 블록 set_config('request.jwt.claim.sub'…) 필요, 통계 큐 event_type은 stats_refresh_event_types 허용값만. 실패 시 원자 롤백은 실측 확인.

5. 적용 결과

항목결과
계획 수정·삭제·인라인 수정Production 성공 이력 0건 → 수리 랜딩·Vercel 배포. 계획 수정 영수증은 아직 대기(BUG-014 확인 대기) — 오너 1차 실기기 확인(21:5x KST)은 실측 결과 완료 기록 수정(13:20Z update_completed_session 영수증 ✓, 원래 정상 표면)이었고, 예정 상태 계획의 제목 수정 재시도 요청
완료 기록 수정 왕복오너 실기기에서 정상(13:20Z 영수증 실측) — 계획 완료 처리(12:56Z save_workout)와 함께 확인
새 그물의 첫 실전 포착오너 조작 3초 뒤 12:56:28Z response_contract_error(get_exercise_catalog, LG_SCHEMA_VALIDATION) 1건 적재 — release app-Ljicmo5M(구 번들) 귀속으로 배포 스큐(#694 서버 680000 선적용 vs 폰의 구 어댑터) 판정, 현 배포 app-yuSx98Go라 재로드로 자가 해소. 사고가 아니라 그물 실동작의 증거
이번 유형(클라 계약 위반)의 가시화21일 무음 → 첫 시도 즉시 client_contract_error 이벤트(진입 fail-fast 포함), Production 제약·인입 함수 수용 프로브 확인
표면 보고율미보고 54곳(재조사) → 0곳 + 직접 사용 금지 게이트로 고정
쓰기 RPC 보고관문 없음·미보고 11건 → 27곳 관문 경유 실명 적재(v4 3종은 설계상 컨트롤러 몫)
적재 해상도admin 1이름·오기록 2건(감사) → 작업 실명 17곳·오기록 3건 교정(1건은 신규 발견)
왕복 계약 CI0개 → 게이트 2 + 하네스 8 + 풀스택 e2e 1(실DB 통과)
감지수동 SQL → admin 화면 24h/7d·kind/surface·신규 유형 행. Production 화면 실측은 오너 확인 대기
DB660000·690000 Production 적용, remote-schema 게이트·프로브 확인
(후속 #705) 완료 기록 수정 후 재기동 유령 배너1 → 0 — 오너 실기기 확인("일단 원래 오류났던거는 해결된거 같아") · 수정 1회의 OCC 충돌 재시도 1 → 0
(후속 #705) 이동된 풀업 세션 ea98632ddate 08-01 → 08-24 원복·revision 전진·통계 재계산 completed(Production 실측). 제목 "스트렝스"만 원제 이력 미보존 — 오너가 앱에서 수정
스위트1,859 → 1,917 tests (본 트랙 8 PR + 후속 #716·#724 전부 green, migration-smoke 실DB 3회 통과 — 660000·690000 리플레이 + 계획 왕복 e2e)

6. 이번 개선으로 향상된 것

"문구가 그려졌다 = 기록됐다"가 불변식이 됐다

새 표면이 보고를 잊을 수 없다 — 잊으면 소스 스캔 게이트가 CI에서 깨진다. 구조적으로 남는 것: 관문 ①·②, 보고-1회 마커, 예상 오류 레지스트리(=문구 사전).

"읽기가 공급한다"가 CI의 몫이 됐다

sourceRef 유형(필드 소실 무음 잠복)은 이제 하네스·직결 게이트·풀스택 e2e 셋 중 하나에서 출시 전에 깨진다.

에러가 스스로 보이기 시작했다

적재 시점부터 코드 버그와 서버 장애가 분리되고(kind), admin 화면이 24시간·신규 유형을 요약한다 — 오너가 열어보면 보인다.

새 그물이 실전에서 두 번 일했다 (후속 #705)

배포 스큐 1건을 자동 포착했고, 유령 "진행중 운동"은 영수증 타임라인·OCC 표면화로 코드를 열기 전에 경로가 좁혀졌다. 수리가 남긴 것: "저장 성공 = 봉투 소멸" + "봉투는 세션보다 낡을 수 없다"(부팅 폐기) 실모듈 게이트 5케이스, 세션 date 이동 복구 절차(bug-022 4절).

남은 것

  • BUG-014 오너 실기기 확인: 앱 새로고침 후, 예정 상태 계획의 제목 수정 1회(1차 확인은 완료 기록 수정으로 판정) → plan_mutation_receipts에 같은 계획 2번째 save_plan 발생 확인 → 리포트 상태 갱신.
  • admin OCC 후속: 폼 로드 시점 버전의 UI 배관(오래 열린 폼의 전체 덮어쓰기), archetype upsert·synonym 행 펜스.
  • vite/admin standalone의 에러 리포터 배선(이관 보류 2곳).
  • CASE-016 재발 게이트의 edit 경로 확장은 하네스·e2e가 대체 — 별도 작업 불요 판단.