본문으로 건너뛰기

2026-04-29 P0 클러스터 회고 — 테스트 신뢰성 위기

Tier: A (회수 불가능 production 사고 군집) · 회의 형식: 5인 역할 3회차 (발산 → 충돌 → 수렴) · 결정 ADR: decisions/0030-test-trust-crisis-recovery

요지

2026-04-29 하루 동안 production 에서 P0 사고 3건 동시 노출. 테스트 4,698 케이스 통과 + production fail. 사용자(개발팀 대표) 발언 "우리가 왜 테스트코드를 만들죠... 우리의 노력이 물거품이 되요" — 단순 hotfix 가 아닌 테스트·CI 신뢰성 정의 자체의 위기.

본 회고는 5인 역할 회의 R1·R2·R3 전 과정을 보존한다. 결정만 분리한 SSoT 는 ADR 0030.

사고 클러스터 (3건)

사고 1 — 재동의 모달 영구 stuck (PR #1168)

증상: 사용자가 "약관·정책이 변경되었습니다" 모달에서 "동의하고 계속" 클릭 → "처리 중..." spinner 영구. 서비스 진입 차단.

Root cause:

  • packages/business-logic/src/users/consent-records.ts:113reconsentUser 가 Firestore .set({merge:true}) 에 dot-path 키 ("consents.terms") 사용
  • Firestore Admin SDK .set() 은 dot-path 의미 해석 안 함 — .update() 만 지원
  • 결과: users/{uid} = { "consents.terms": v, ... } literal key 로 저장
  • (workspace)/layout.tsxgetUserConsents 가 nested path consents.terms.version 읽음 → 영원히 undefined → needsRecconsent=true 영구 → 모달 재마운트
  • ReconsentModal.handleAccept success path 에 setSubmitting(false) 누락 → spinner 도 영구

테스트가 못 잡은 이유: business-logic-users-consent.test.ts 의 fakeFirestore 가 dot-path semantic 모방 못 해 data["consents.terms"] literal key expect 로 통과. 테스트 자체가 잘못된 동작을 정상으로 검증.

사고 2 — web build 실패 (PR #1169)

증상: pnpm --filter=@neohollo/web build 가 client bundle 단계에서 node:fs·node:net 외부 모듈 에러로 깨짐. App Hosting production deploy 실패.

Root cause:

  • Client Component (Step2LoanAndType.tsx · InterestCalculatorDialog.tsx) 가 @neohollo/business-logic/cases barrel 에서 순수 함수 calcLoanAmounts import
  • barrel cases/index.tscreate.tscreateCase (firebase-admin 의존 mutation) 도 re-export
  • Next.js Turbopack tree-shaking 한계로 firebase-admin → google-auth-library → gaxios → node-fetch → fetch-blob 까지 client bundle 로 끌려옴
  • ADR 0028 추출 sweep (50+ PR) 의 부작용 — pure 함수와 mutation 동거 + barrel pollution 가이드 부재

CI 가 못 잡은 이유: PR #1058 머지 시점에 type-check + vitest 통과로 green. next build 가 CI 에 없어 App Hosting production 배포까지 가서야 노출.

사고 3 — ops 콘솔 KPI=0 (ADR 0029 origin)

증상: ops 콘솔 dashboard KPI 모두 0 표시. "테넌트 0건" 으로 오인.

Root cause:

  • 12 곳의 v8 호환 가드 typeof adminDb.collection !== "function" 가 v9 modular SDK 인스턴스에서 항상 true (v9 에는 .collection 메서드 자체 없음) → 모든 Firestore 호출 silent skip
  • fallback cast ({} as Firestore) 가 환경변수 미설정 시 빈 객체 노출해 가드 동기 키움
  • LLM 코딩 hedging 패턴 — "그럴듯한 방어 가드" 가 silent failure 의 산실

진단 비용: PR #1132·#1136 진단 실패 후 PR #1157 에서 진짜 원인 발견까지 40 분 + 3 PR.

공통 origin 패턴

"성공한 척 + 다른 layer 에서 깨짐":

  • write 성공 반환 + retrieval 위치 불일치 (사고 1)
  • 빌드/타입체크 통과 + production bundle 깨짐 (사고 2)
  • 가드 통과 + 실제 호출 silent skip (사고 3)

세 사고 모두 코드/테스트 layer 통과 → production 또는 사용자 화면에서 노출. 사용자 시각에서 모두 silent 0 (영구 spinner / 흰 화면 / KPI 0). 사용자 해석은 모두 "느려" 또는 "데이터 없네" — "실패" 라는 단어를 떠올리지 않음.


Round 1 — 독립 발산 (Diverge)

5인 subagent 병렬 실행. 각 역할이 타인 입장 모른 채 자기 시각으로만 발언.

P1 변호사 — 사용자 시각

원초적 입장: "오늘 같은 날이 한 번만 더 있으면 이 서비스를 의뢰인 사건에 못 씁니다. 베타든 뭐든 '로그인했는데 대시보드가 안 뜬다' 는 변호사 입장에서 지각·기일 해태와 같은 무게. 막힌 화면이 약관·동의 모달이라는 게 가장 악질적 — 동의 절차가 무한 루프 도는 SaaS 가 변호사법·약관규제법 시비 한 번이라도 받으면 변호사 자격으로 그걸 변호 못 합니다."

핵심 근거:

  1. 변호사 실무 — 시간이 곧 의뢰인 신뢰: 의뢰인 미팅 직전 로그인 막힘 = "준비 안 된 변호사" 인식. C-A·C-G 는 선택지 아닌 최소 위생.
  2. 사용자 경험 — spinner 의 의미: 5초 이상 영구 spinner = 변호사가 탭 닫고 영구 분류. "성공한 척 하는 코드" 는 변호사 직역에서 가장 위험한 패턴.
  3. 법적 책임 3종:
    • 약관규제법 §3 명시·설명의무 — 동의가 잘못된 dot-path 로 저장되면 동의의 효력 자체 다툼 대상. 베타 면책으로 막을 수 없는 "동의 귀속의 흔들림"
    • 변호사법 §26 비밀유지의무 — silent skip 패턴이 사건·증거 저장 경로에 있으면 의뢰인 채권증서가 저장 안 됐다는 사실조차 모름
    • 베타 면책의 한계 — 면책은 기능 미완을 커버하지 사건 진행 자체를 막는 사고를 커버하지 않음

우선순위: C-G > C-A > C-F > C-D/E > C-B/C/H

놓치면 안 될 질문:

  • (개발자·PM) 변호사 첫 5 화면이 매 배포마다 자동으로 한 번이라도 열리는 보장 메커니즘을 언제까지 ship?
  • (사무장·PM) 이미 잘못 저장된 동의 데이터 케이스가 production 에 있다면 통보·재동의·로그 보존의 SOP 는?

P2 사무장 — 운영 시각

원초적 입장: "오늘 아침 7시 50분 출근해서 로그인 눌렀는데 '처리 중...' 만 빙빙 돌더라. 변호사님께 '오늘 너홀로 안 됩니다' 카톡 보냈다. 종이 다이어리 꺼냈다. 10년 전으로 돌아갔다. 비타협 절대선은 '매일 쓰는 도구가 아침에 켜졌을 때 켜져 있어야 한다' 한 줄. 4,698 vs 1, 사무장 입장에선 1 이 4,698 보다 크다."

핵심 근거:

  1. 사무장 일상 — silent fail 은 사무장이 사고를 떠안음: 시스템이 망가지면 변호사는 "사무장이 일 안 한 것" 으로 인식. 동의서 받은 줄 알고 사건 진행 → 미동의 들통 → 사무장 책임. ops KPI=0 은 운영팀이 사무장한테 전화 — "왜 사용 안 하세요"
  2. 의뢰인 응대 — spinner 가 보안성 의심까지 번짐: 의뢰인이 "동의서 너홀로로 받으신다고..." 하는데 모달 못 띄움 = "이 사무실 개인정보 관리 제대로 하나" 의심
  3. 변호사 신뢰 — 베타 변명이 안 통하는 도구가 있음: 매일 아침 첫 화면이 깨지면 베타 변명 안 통함. 다음 달 구독은 변호사가 결정, 변호사는 사무장 의견 묻는다.

놓치면 안 될 질문:

  • (개발자·PM) 매일 사무장 1순위 동선 5단계 (로그인→대시보드→일정→동의→사건검색) 가 production 에서 매일 자동 실행되어 깨지면 알림 가는 시스템 존재?
  • (디자이너·변호사) silent spinner 금지를 디자인 시스템 수준에서 컴포넌트 primitive 에 박을 수 있나?

P3 PM — 우선순위·ROI 시각

원초적 입장: "테스트 4,698 통과 + production 3종 fail = 테스트 자산 ROI 음수로 보이는 사건. 자율 모드 동력 손상 임계점. 양보 불가: 코드 layer 통과 ↔ production 노출 사이 gap 닫는 안전망 1개 (C-A 또는 C-B 중 1개) 이번 주 ship. 보류: C-H 사고 후 broader audit 의무화는 절차 over-spec. 명시적 양보 불가: ship-then-layer 정신은 유지 — 이번 사고는 코드 검증 layer 의 미비고 컴플라이언스 layer 와 다른 axis."

핵심 근거:

  1. C-A 단독으로 사고 3종 모두 1차 가드 가능성next build 2분 추가 cost 가 사고 1회 회수 비용 대비 압도적으로 작음
  2. ADR 0028 sweep 멈추지 말 것 — C-D 가이드만 ship (cost 거의 0), C-E 전수 audit 은 origin 확정 (C-C) 후. 순서 C-A → C-D → C-C → (필요시) C-E
  3. ship-then-layer 와 본 사고는 다른 axis — scope creep 차단. 안전망 (CI build · fail-loud) 은 ship 의 일부, layer 가 아님

놓치면 안 될 질문:

  • (개발자·사무장) fakeFirestore 와 emulator 의 semantic gap 이 정확히 어디인가? 사고 3건 중 몇 건이 emulator 통합으로 잡히고 몇 건이 CI build 만으로 잡히나?
  • (변호사·디자이너) 사용자 분노 회복의 최소 충분 조건? 공식 응답 vs C-A ship + 회고 commit

P4 디자이너 — UX 안전망 시각

원초적 입장: "이번 4-29 사고 3건은 코드 버그 3건이 아니라 디자인 시스템 부재 사고 1건이 세 가지 형태로 발현된 것. 영구 spinner·흰 화면·KPI 0 — 표면은 다르지만 사용자 신호는 동일하게 '아무 일도 안 일어났다'. ADR 0029 시점에 '코어 안건으로 격상' 요청을 P3 가 별도 회의 5-13 데드라인으로 분리한 것은 같은 origin 사고를 다음 회까지 보장한 결정 — 13일 채 가기 전에 정확히 같은 origin 사고로 입증."

핵심 근거:

  1. silent 0 패턴: 세 사고 모두 사용자가 "실패" 라는 단어를 떠올리지 않음. 코드 layer 만 강화하면 다음 사고는 이형으로 또 나옴. 표시 layer 통일이 본질
  2. 시간 영역 SLA: 03초 spinner / 310초 카피 격상 / 10초 초과 자동 격상 ("다시 시도"·"새로고침") / 30초 초과 강제 에러. 영구 spinner 자체가 발생 불가능한 구조
  3. 상태 4 분기 시스템: loading / success / empty / error — 0 의 세 가지 의미 (빈 vs 실패 vs 진짜 0) 시각 구분. SystemStatusBadge 표준 컴포넌트 + Next.js error.tsx · not-found.tsx 표준화

놓치면 안 될 질문:

  • (전원) ADR 0029 후속 5-13 별도 회의 결정이 4-29 사고 origin 의 일부라는 점에 동의? 동의면 본 회고에 4-state · SLA · SystemStatusBadge 통합, 5-13 폐지
  • (PM·개발자) useServerAction() 강화판 (자동 finally · 자동 toast) 도입? success/failure 가 같은 코드 분기에 의존하는 구조 자체가 결함

P5 개발자 — 기술 책임 시각

원초적 입장: "이 3건은 전부 내 손으로 머지된 코드. 변명 안 됨. 특히 사고 1 dot-path 버그는 테스트가 잘못된 동작을 정상으로 검증 — 4,698 테스트 신뢰가 무너졌다는 사용자 분노는 정확. 비타협: C-A + C-C. 전자는 2분짜리 변경으로 사고 2 100% 차단. 후자는 dot-path · FieldValue · runTransaction · arrayUnion 같은 Firestore 고유 의미를 production 과 동치 보장하지 않으면 사고 1 이 다른 도메인에서 또 터짐."

핵심 근거:

  1. 사고 1 root: fakeFirestore 가 production semantic 의 부분집합도 아닌 "다른 객체". data["consents.terms"] literal expect 로 통과. 테스트 인프라가 production 과 다른 의미론. 같은 패턴이 13 도메인 × N 함수 어디 깔렸는지 모름
  2. 사고 2 root: ADR 0028 추출 sweep 의 barrel re-export 회귀 + CI 에 next build 없음. pure/mutation 동거 + Turbopack tree-shaking 한계 + sideEffect 미선언
  3. 사고 3 root: LLM hedging 패턴이 그럴듯하게 들어오는 구조적 문제. ADR 0029 한 단락 정책으로 부족 — 검출 자동화 (lint rule) 없으면 다음 PR 에서 또 들어옴

놓치면 안 될 질문:

  • (테스트 인프라 담당) fakeFirestore 봉쇄 옵션: (a) emulator 통합 의무 / (b) fakeDb 폐기·emulator 단일화 / (c) fakeDb 검증 layer — 1인+LLM 환경 운영 가능?
  • (PM) ADR 0028 sweep 같은 대규모 횡단 작업 회귀 게이트: 매 PR next build 강제 vs sweep 마지막 PR preview smoke?

Round 2 — 상호 반박 (Clash)

R1 의 6 충돌 + 11 질문을 5 역할 크로스 토크로 해소.

충돌 1 — C-A (CI build) vs C-G (production smoke) 우선순위

  • P3 반박: 사고 3건 중 build 실패 (사고 2) 는 C-A 만으로 100% 차단. 사고 1 (dot-path) 는 fakeDb gap 봉쇄 (C-C) 가 정답. 사고 3 (KPI=0) 는 lint·테스트가 적절. C-G 가 잡는 unique case 가 0 — C-A + C-C + lint 가 잡는 영역의 부분집합
  • P5 보완: 절반 동의. 그러나 production 환경 변수·Secret Manager·App Hosting 빌드 환경 같은 prod-only 차이는 emulator·CI 가 못 잡음. C-G unique case = production-only 환경 사고. 0 아님. C-A 우선·C-G 후순위 동의
  • P1 양보: C-G 1위 양보, 단 조건부 — C-A + C-C ship 후 1주일 내 production smoke 도입 timeline 확약 비타협
  • P2 보완: alerting 채널 (Cloud Logging error spike → 카톡/Slack) 별도 ship 요구. cost 작고 사고 발견 시간 단축에 결정적

결론:

  • 즉시 ship: C-A
  • 1주 내 ship: C-G phase 1 (HTTP 200 + alerting)
  • 분리 후속: C-G phase 2 (preview channel 자동화, ROI 검증 후)

충돌 2 — fakeFirestore 운명

  • P5 강 입장: fakeDb 폐기 + emulator 단일화. fakeDb 가 production 과 다른 의미론 가지면 안전망 아닌 거짓 신호. CI 5분 추가는 사고 1건 회수 비용 대비 무시
  • P3 반박: 방향 옳지만 immediate 시행 ROI 음수. test 시간 10초→5분, emulator brittle, sweep cadence 손상. 점진 안: gap audit → 해당 함수만 emulator 통합. fakeDb 전면 폐기는 6개월 timeline 별도 ADR
  • P5 보완: P3 단계화 동의. 즉시 안건 — (1) fake-firestore.ts .set 이 dot-path 키 받으면 명시 throw (2) dot-path · FieldValue · batch 사용 함수 명시 주석 (3) emulator 통합 신규 mutation 의무
  • P2 추가 요구: 다른 도메인 dot-path 패턴 1주 내 grep audit 결과 사무장 보고. 추가 발견 시 즉시 단일화 격상

결론:

  • 즉시 1 PR: fakeDb .set dot-path 키 throw + 다른 도메인 grep audit
  • 1주 내: dot-path · FieldValue · batch 사용 함수 주석 + 신규 mutation emulator 통합 의무
  • 6개월 ADR: fakeDb 단계 폐기 → emulator 단일화 (별도 회의)

충돌 3 — ADR 0029 amendment vs 5-13 별도 회의 분리

  • P4 강 주장: 분리 결정이 4-29 사고 origin 의 일부. 13일 못 가서 같은 origin 사고 재현. 5-13 회의 폐지, ADR 0030 에 통합
  • P3 양보·반박: origin 분석 정당. 그러나 한 ADR 비대화 → 실행 추적 어려움. 타협안: ADR 0030 에 UX 안전망 4 항목 (4-state·SLA·SystemStatusBadge·AsyncBoundary) 결정 명시 + 5-13 폐지하고 ADR 0030 후속 task 흡수. 토큰 spec 은 ADR 0031 분리 (디자이너 주도)
  • P4 수용: P3 타협 OK. 단 5-13 폐지가 명문화 — "별도 회의 분리 패턴 자체가 origin 의 일부" 기록

결론:

  • ADR 0030 에 UX 2차 안전망 결정 4 항목 명시
  • 5-13 회의 폐지, ADR 0030 후속 task 로 흡수
  • ADR 0031 (2주 내, 디자이너 주도) — 디자인 토큰 spec

충돌 4 — C-H 사고 후 broader audit 의무화

  • P2 찬: 사용자 직접 코칭 원칙. 의무화 안 하면 자율 세션에서 또 잊음
  • P3 반박: 의무화 over-spec. 모든 사고에 audit 의무는 ship cadence 죽이고 또 다른 사고 origin. tier 별 차등: P0 만 의무, P1 이하 권장
  • P5 보완: tier 차등 동의. 추가: audit 결과 retrospectives/ commit 의무. P0 hotfix PR 본문 끝에 "동형 패턴 audit: N건/0건" 한 줄 강제 (PR template)
  • P1 요구: timeline SLA — P0 발견 → hotfix → audit → 결과 commit 24시간 이내

결론:

  • P0 한정 broader audit 의무화 (P1 이하 권장)
  • PR 본문에 "동형 패턴 audit: N건" 강제
  • 24시간 SLA
  • PR template 체크박스 추가

충돌 5 — useServerAction() wrapper 강화

  • P4 제안: success/failure 가 같은 코드 분기 의존하는 구조 자체가 결함. wrapper 가 무조건 finally 에서 spinner 풀고 실패 시 toast 자동
  • P5 강 동의: 추가 — wrapper 가 timeout SLA 강제 (10초 격상·30초 강제 에러). raw await action() 패턴 ESLint 차단
  • P3 보완: 마이그레이션 cost (~100+ 곳). 신규 호출부 lint 강제 (즉시), 기존은 sweep PR 1건 일괄 전환

결론 (전원 합의):

  • useServerAction() 강화판 ship: finally 강제 reset + 자동 toast + timeout SLA
  • 신규 lint 강제 (즉시), 기존 sweep PR (1주 내)
  • ESLint no-restricted-syntax 로 raw await *Action() 차단

충돌 6 — 사용자 분노 회복 응답 형식

  • P3 질문: over-spec 응답이 또 다른 cost. 자율 모드 동력 손상 회복 안 되면 ship 페이스 위협. 최소 충분 조건?
  • P1 답: 분노 회복은 "같은 사고 안 일어남" 의 증명이지 사과문 아님. (1) ADR 0030 ship + (2) C-A·C-C·wrapper 즉시 시행 + (3) 1주 후 status update commit
  • P4 보완: 분노 자체가 정당한 코칭이었음을 ADR 0030 에 기록 — 사용자가 broader audit 의무화 / 회고 회의 / 영구 문서화 모두 직접 코칭 → 본 회고 골격. 분노가 학습 자산

결론:

  • 별도 사과문 X (over-spec)
  • ADR 0030 에 사용자 코칭 학습 자산화 명시
  • 1주 후 status update commit

Round 3 — 수렴·결정 (Decide)

즉시 시행 (1주 내)

Task담당산출물SLA
T1 CI 에 pnpm --filter=@neohollo/web build 추가P5.github/workflows/*.yml즉시
T2 fake-firestore.ts .set dot-path throw + 다른 도메인 grep auditP51 PR + audit 결과 retrospectives/ commit즉시
T3 useServerAction() 강화판 (finally·toast·timeout SLA) + 신규 lintP5 + P4wrapper + ESLint rule + 마이그레이션 sweep PR1주
T4 C-G phase 1 — production HTTP 200 smoke (5 화면) + Cloud Logging 알림 채널P5smoke script + 카톡/Slack alert1주
T5 외부 사용자 동의 corruption 영향 audit (가입 사용자 N명, 잘못 저장된 literal key 확인)P5audit 결과 retrospectives/ commit1주
T6 PR template 강제 항목 추가 (P0 hotfix 시 동형 패턴 audit 1줄)P3.github/pull_request_template.md즉시
T7 CLAUDE.md "ADR 0028 패키지 구조 — pure/mutation 분리" 가이드P3 + P5CLAUDE.md 1 섹션즉시
T8 LLM hedging 패턴 ESLint rule (typeof X.collection !== "function" · ({} as Firestore) · 빈 catch)P5eslint.config.mjs1주

별도 ADR 후속

  • ADR 0031 (2주 내, P4 주도) — 디자인 시스템 4-state · 시간 SLA · SystemStatusBadge · AsyncBoundary spec
  • 6개월 ADR — fakeDb 단계 폐기 → emulator 단일화 (P5 주도)

폐지

  • 5-13 회의 (/meeting 빈 상태 vs 실패 상태 UI 패턴 통일) — ADR 0030 에 흡수
  • ❌ C-H 사고 후 audit 전면 의무화 — P0 한정 차등으로 축소
  • ❌ C-G phase 2 (preview channel 전체 자동화) — phase 1 ROI 검증 후 결정

사용자 분노 회복 응답

별도 사과문 없음 (over-spec). 본 회고 ADR ship + 1주 후 status update commit 으로 회복.

ADR 0030 본문에 사용자 코칭의 학습 자산화 기록 — broader audit 의무화 / 회고 회의 / 영구 문서화 모두 사용자 직접 코칭 → 본 회고 골격.


Minority Report

P3 PM Minority — fakeDb 단일화 시점

"P5 의 fakeDb 폐기 → emulator 단일화 방향은 옳다. 그러나 6개월 timeline 도 1인+LLM 환경에서 over-spec 일 수 있음. emulator 환경 brittle 함이 또 다른 사고 origin 가능. 별도 ADR 결정 시점에 emulator 안정성 데이터 (1주간 CI 실패율) 가 input 되어야. 데이터 없이 단일화 결정 시 다른 형태의 신뢰 위기 발생 가능."

P4 디자이너 Minority — UX 토큰화 강도

"ADR 0031 디자인 토큰 spec 이 2주 timeline 으로 분리됐지만, P0 사고 회복은 1주 내 즉시 시행 묶음에 들어갔다. UX 안전망이 또 후순위로 밀린 것 — 같은 origin 패턴이 ADR 0030 안에서도 재현. AsyncBoundary wrapper 만이라도 즉시 시행 묶음 (T3) 에 포함 — 이게 빠지면 또 다음 사고에서 영구 spinner 발생 가능."

수용: T3 의 useServerAction 강화판에 AsyncBoundary timeout wrapper 명시적으로 포함. ADR 0031 은 시각 토큰 (4-state 색상·SystemStatusBadge 컴포넌트 디자인) 만 다룸.

P1 변호사 Minority — 외부 사용자 통보 SOP

"T5 (외부 사용자 영향 audit) 결과 잘못 저장된 케이스 발견 시 통보·재동의·로그 보존 SOP 가 본 ADR 에 명시되지 않았음. audit 만 하고 SOP 없으면 발견 시점에 또 회의해야 함. 선제 SOP 명시 필요 — 1) 영향 사용자 식별 → 2) 재동의 모달 강제 trigger → 3) 동의 로그 (이전 잘못 저장 + 신규 정상 저장) 양쪽 보존 → 4) ADR 0030 에 통보 commit."

수용: T5 결과에 "발견 시 SOP" 절차 사전 명시. 본 회고 문서 §부록 A 로 추가 (아래).


부록 D — T4 production smoke ship 결과 (ADR 0030 T4 follow-up commit)

일시: 2026-04-29 (T4 ship 시점) 스크립트: scripts/smoke-production.ts Workflow: .github/workflows/production-smoke.yml (cron 0 21 * * * UTC = 매일 06:00 KST)

검증 화면 5종:

화면허용 status결과
/ (랜딩)200, 3xx✓ 200 (1367ms)
/login200, 3xx✓ 200 (1787ms)
/signup200, 3xx✓ 200 (1783ms)
/portal (의뢰인 4자리 코드 진입)200, 3xx✓ 307 redirect (1779ms)
/api/healthcheck (optional)200 (없으면 404 OK)✓ 404 optional

summary: pass 5 / fail 0. Production 정상.

알림 채널 설정 — 사용자 직접 액션 필요:

GitHub Repository Settings → Secrets → SLACK_SMOKE_WEBHOOK 에 Slack incoming webhook URL 등록. 미설정 시 workflow 실패는 GitHub Actions UI 에서만 노출. Slack webhook 생성 방법:

  1. Slack Workspace → Apps → "Incoming Webhooks" 추가
  2. 알림 받을 채널 선택 → webhook URL 복사
  3. GitHub Secrets 에 SLACK_SMOKE_WEBHOOK 키로 등록

카톡 webhook (별도) 은 카카오 디벨로퍼 채널 설정 필요 — 후속 단계.

T4 한계:

  • 인증 필요 페이지 (/dashboard, /cases, /cases/[id]) 는 미인증 시 /login 으로 redirect 되어 화면 자체의 server render 실패는 못 잡음. 다음 phase 에서 service account 기반 인증 + headless 검증 또는 preview channel deploy + smoke 묶음 검토 (ADR 0030 폐지 항목 C-G phase 2 — phase 1 ROI 검증 후 결정)
  • ops 콘솔 (/dashboard) KPI=0 같은 사고 (ADR 0029 origin) 는 본 smoke 가 직접 못 잡음 — service account smoke 또는 별도 monitoring 필요

부록 C — T5 production users 동의 corruption 영향 audit 결과 (ADR 0030 T5 follow-up commit)

일시: 2026-04-29 (T5 ship 시점) · read-only audit 스크립트: scripts/audit-consent-corruption.ts 검사 대상 literal key: consents.terms · consents.privacy · consents.beta · consents.marketingEmail · consents.marketingEmail.withdrawnAt

항목
총 user docs1
영향 사용자 수1 건 (사용자 본인)
발견 literal key 종류3 종 (consents.terms · consents.privacy · consents.beta)
consents.marketingEmail* literal0 건

해석: PR #1168 hotfix 머지 이전 시점에 재동의 모달에서 "동의하고 계속" 클릭한 사용자(본인) doc 에 garbage literal key 3 종이 남음. PR #1168 fix 후 새 동의는 nested 위치 (consents.terms.version 등) 에 정상 저장. 베타 단계 라 영향 사용자 1 명 (사용자 본인) — 외부 변호사 사용자 추가 가입 전 PR #1168 hotfix 가 머지되어 broader 영향 0.

SOP 적용 (부록 A 정합):

  1. 이력 보존: audit/consents/entries/{uid}originalLiteralKeys (raw 값) + cleanupAt (ISO) + reason 저장 — 정통망법 §50 ⑥
  2. literal key 삭제: users/{uid} 에서 3종 FieldValue.delete() (reversible — audit 컬렉션에 raw 값 보존)
  3. 재동의 모달 trigger: cleanup 후 nested consents.*.version 이 현 버전과 일치하면 모달 안 뜸 (정상 진입). 불일치면 layout 의 needsRecconsent 검사가 trigger → 사용자가 새로 동의 클릭하면 정상 nested 위치 저장

cleanup 스크립트: scripts/cleanup-consent-corruption.ts

  • --dry-run (기본): write 안 함, 발견 사례만 콘솔 출력
  • --apply: 실제 cleanup (audit 보존 + literal 삭제)
  • --uid <specific-uid>: 특정 uid 만 처리

T5 ship PR 본문에서는 audit 스크립트 + dry-run 검증만 수행. --apply 실제 실행은 사용자 명시 승인 후 별도 단계 (production write 이므로 reversible 이지만 confirm 권장).

T5b — cleanup --apply 실행 결과 (2026-04-29 사용자 승인 후)

1차 시도.update({"consents.terms": FV.delete()}) 사용. 콘솔 로그 "✓ audit 보존 + literal key 삭제 완료" 출력. 그러나 audit re-run 결과 여전히 1건 잔존.

원인 (동일 origin 패턴 재발견): Firestore Admin SDK 의 .update() 는 dot-path 키를 nested path 의미로 해석consents.terms 는 nested consents.terms 필드 (즉 consents 객체 안의 terms 키) 를 가리킴. literal "consents.terms" 키 자체 삭제는 이 방법으로 불가. 이번 사고 군집의 본질 패턴 ("성공한 척 + 다른 layer 에서 깨짐") 이 cleanup 작업 자체에서 재현된 셈.

2차 시도 (수정): new FieldPath("consents.terms") 단일 segment 로 명시:

import { FieldValue, FieldPath } from "firebase-admin/firestore";
for (const key of literalKeys) {
await doc.ref.update(new FieldPath(key), FieldValue.delete());
}

audit re-run 결과: ✅ affectedCount: 0. 모든 literal key 삭제 완료. audit/consents/entries/{uid} 에 originalLiteralKeys + cleanupAt + reason 보존됨 (정통망법 §50 ⑥).

메타 학습 (회고 안의 회고):

이번 cleanup 1차 시도 함정은 ADR 0030 의 본질 메시지를 cleanup 절차 자체에서 입증한 사례:

  1. "성공한 척" 패턴이 어디든 들어옴 — write 가 success 반환했어도 실제 효과는 다른 layer 에서 검증해야 함
  2. audit + cleanup 후 반드시 audit re-run — cleanup 스크립트 자체에 "verify after apply" step 추가가 후속 개선 후보 (T5c)
  3. FieldPath vs dot-path 의미 불일치 는 fakeFirestore audit (T2) 에서 식별 못 한 또 다른 Firestore semantic gap — 6개월 fakeDb → emulator 단일화 ADR 결정 시점에 본 케이스도 input 자료로 활용

부록 B — T2 grep audit 결과 (ADR 0030 T2 follow-up commit)

일시: 2026-04-29 (T2 ship 시점) 검색 패턴: .set(data, {merge:true}) 의 첫 인자 객체 키에 dot 포함

워크스페이스dot-path .set 안티패턴 발견
packages/business-logic/src/0 건 (PR #1168 fix 가 완벽)
apps/web/lib · apps/web/app0 건
apps/ops/lib · apps/ops/app0 건
functions/src/0 건
scripts/0 건

유일 매치: apps/ops/lib/firebase/client.ts:108 — ADR 0029 사고 설명 주석 (의도적 보존). 코드 아님.

결론: PR #1168 fix 후 동일 origin 패턴 재현 0 건. 본 T2 ship 의 fakeDb throw 가드 (assertNoDotPathKeys) 는 회귀 차단용 — 미래 새 호출자가 같은 패턴 작성 시 [fakeDb] ref.set/tx.set/batch.set: dot-path 키 "X" 사용 금지 즉시 throw → 테스트 실패 → 머지 차단.

검증된 회귀 가드 3 경로:

  • apps/web/__tests__/fake-firestore-dot-path-guard.test.ts — ref.set / tx.set / batch.set 3 경로 + .update dot-path 허용 (정상) 5 케이스
  • business-logic-users-consent.test.tsdata["consents.terms"] literal key undefined assert (T2 와 별개로 PR #1168 시점에 추가됨)

6개월 ADR 후보 input: fakeDb 폐기 → emulator 단일화 결정 시점의 안정성 데이터 — 본 throw 가드 도입 후 false positive (정상 nested 데이터를 throw 로 오인) 발생률 1주간 측정. 0 이면 단일화 우선순위 낮음, 1+ 면 emulator 가치 증명.


부록 A — 동의 corruption 발견 시 SOP

T5 audit 결과 잘못 저장된 동의 케이스 발견 시:

  1. 영향 사용자 식별users/{uid} 에 literal key ("consents.terms" 등) 존재하는 모든 uid 추출
  2. 재동의 모달 강제 triggerconsents.terms.version 을 "force-reconsent" 같은 sentinel 값으로 set → layout 검사가 needsRecconsent=true 판정
  3. 로그 양쪽 보존 — 잘못 저장된 literal key 는 audit 보존 후 삭제 (FieldValue.delete()), 신규 동의는 정상 nested 위치에 저장. 양쪽 timestamp 와 user-agent 는 audit/consents/{uid} 별도 컬렉션에 보존 (정통망법 §50 ⑥ 정합)
  4. commit — audit 결과 + 영향 사용자 N명 + 통보 시점 retrospectives/ 에 follow-up commit

후속 회의 (별도)

  • /meeting 디자인 시스템 4-state 토큰 spec tier=B — ADR 0031 발산. P4 주도, 2주 내
  • /meeting fakeDb 폐기 → emulator 단일화 결정 tier=B — 6개월 후, 안정성 데이터 input 후
  • /meeting C-G phase 2 preview channel 자동화 tier=C — phase 1 ROI 검증 후

관련 자료

  • 결정 ADR: decisions/0030-test-trust-crisis-recovery
  • 관련 ADR: decisions/0028 · decisions/0029
  • 관련 PR: #1167 (docs sweep) · #1168 (dot-path hotfix) · #1169 (build hotfix)
  • 사용자 코칭 발언 (보존):

    "전체 코드를 분석해봐야 하지 않나요?" (PR #1168 후) "빌드 깨지는건 정말 크리티컬합니다. 우리가 왜 테스트코드를 만들죠..." (PR #1169 후) "회고하는 회의를 하는게 좋지않나요? 반드시 정확히 정밀하게 짚고 넘어가야해요" "회고회의는 특별한 탭 또는 공간에 기록되어야 하지 않을까요? 문서로 반드시 남겨야 해요"