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:113의reconsentUser가 Firestore.set({merge:true})에 dot-path 키 ("consents.terms") 사용- Firestore Admin SDK
.set()은 dot-path 의미 해석 안 함 —.update()만 지원 - 결과:
users/{uid} = { "consents.terms": v, ... }literal key 로 저장 (workspace)/layout.tsx의getUserConsents가 nested pathconsents.terms.version읽음 → 영원히 undefined →needsRecconsent=true영구 → 모달 재마운트ReconsentModal.handleAcceptsuccess 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/casesbarrel 에서 순수 함수calcLoanAmountsimport - barrel
cases/index.ts가create.ts의createCase(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 가 변호사법·약관규제법 시비 한 번이라도 받으면 변호사 자격으로 그걸 변호 못 합니다."
핵심 근거:
- 변호사 실무 — 시간이 곧 의뢰인 신뢰: 의뢰인 미팅 직전 로그인 막힘 = "준비 안 된 변호사" 인식. C-A·C-G 는 선택지 아닌 최소 위생.
- 사용자 경험 — spinner 의 의미: 5초 이상 영구 spinner = 변호사가 탭 닫고 영구 분류. "성공한 척 하는 코드" 는 변호사 직역에서 가장 위험한 패턴.
- 법적 책임 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 보다 크다."
핵심 근거:
- 사무장 일상 — silent fail 은 사무장이 사고를 떠안음: 시스템이 망가지면 변호사는 "사무장이 일 안 한 것" 으로 인식. 동의서 받은 줄 알고 사건 진행 → 미동의 들통 → 사무장 책임. ops KPI=0 은 운영팀이 사무장한테 전화 — "왜 사용 안 하세요"
- 의뢰인 응대 — spinner 가 보안성 의심까지 번짐: 의뢰인이 "동의서 너홀로로 받으신다고..." 하는데 모달 못 띄움 = "이 사무실 개인정보 관리 제대로 하나" 의심
- 변호사 신뢰 — 베타 변명이 안 통하는 도구가 있음: 매일 아침 첫 화면이 깨지면 베타 변명 안 통함. 다음 달 구독은 변호사가 결정, 변호사는 사무장 의견 묻는다.
놓치면 안 될 질문:
- (개발자·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."
핵심 근거:
- C-A 단독으로 사고 3종 모두 1차 가드 가능성 —
next build2분 추가 cost 가 사고 1회 회수 비용 대비 압도적으로 작음 - ADR 0028 sweep 멈추지 말 것 — C-D 가이드만 ship (cost 거의 0), C-E 전수 audit 은 origin 확정 (C-C) 후. 순서 C-A → C-D → C-C → (필요시) C-E
- 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 사고로 입증."
핵심 근거:
- silent 0 패턴: 세 사고 모두 사용자가 "실패" 라는 단어를 떠올리지 않음. 코드 layer 만 강화하면 다음 사고는 이형으로 또 나옴. 표시 layer 통일이 본질
- 시간 영역 SLA: 0
3초 spinner / 310초 카피 격상 / 10초 초과 자동 격상 ("다시 시도"·"새로고침") / 30초 초과 강제 에러. 영구 spinner 자체가 발생 불가능한 구조 - 상태 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 root: fakeFirestore 가 production semantic 의 부분집합도 아닌 "다른 객체".
data["consents.terms"]literal expect 로 통과. 테스트 인프라가 production 과 다른 의미론. 같은 패턴이 13 도메인 × N 함수 어디 깔렸는지 모름 - 사고 2 root: ADR 0028 추출 sweep 의 barrel re-export 회귀 + CI 에 next build 없음. pure/mutation 동거 + Turbopack tree-shaking 한계 + sideEffect 미선언
- 사고 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
.setdot-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로 rawawait *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 audit | P5 | 1 PR + audit 결과 retrospectives/ commit | 즉시 |
T3 useServerAction() 강화판 (finally·toast·timeout SLA) + 신규 lint | P5 + P4 | wrapper + ESLint rule + 마이그레이션 sweep PR | 1주 |
| T4 C-G phase 1 — production HTTP 200 smoke (5 화면) + Cloud Logging 알림 채널 | P5 | smoke script + 카톡/Slack alert | 1주 |
| T5 외부 사용자 동의 corruption 영향 audit (가입 사용자 N명, 잘못 저장된 literal key 확인) | P5 | audit 결과 retrospectives/ commit | 1주 |
| T6 PR template 강제 항목 추가 (P0 hotfix 시 동형 패턴 audit 1줄) | P3 | .github/pull_request_template.md | 즉시 |
| T7 CLAUDE.md "ADR 0028 패키지 구조 — pure/mutation 분리" 가이드 | P3 + P5 | CLAUDE.md 1 섹션 | 즉시 |
T8 LLM hedging 패턴 ESLint rule (typeof X.collection !== "function" · ({} as Firestore) · 빈 catch) | P5 | eslint.config.mjs | 1주 |
별도 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) |
/login | 200, 3xx | ✓ 200 (1787ms) |
/signup | 200, 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 생성 방법:
- Slack Workspace → Apps → "Incoming Webhooks" 추가
- 알림 받을 채널 선택 → webhook URL 복사
- 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 docs | 1 |
| 영향 사용자 수 | 1 건 (사용자 본인) |
| 발견 literal key 종류 | 3 종 (consents.terms · consents.privacy · consents.beta) |
consents.marketingEmail* literal | 0 건 |
해석: PR #1168 hotfix 머지 이전 시점에 재동의 모달에서 "동의하고 계속" 클릭한 사용자(본인) doc 에 garbage literal key 3 종이 남음. PR #1168 fix 후 새 동의는 nested 위치 (consents.terms.version 등) 에 정상 저장. 베타 단계 라 영향 사용자 1 명 (사용자 본인) — 외부 변호사 사용자 추가 가입 전 PR #1168 hotfix 가 머지되어 broader 영향 0.
SOP 적용 (부록 A 정합):
- 이력 보존:
audit/consents/entries/{uid}에originalLiteralKeys(raw 값) +cleanupAt(ISO) +reason저장 — 정통망법 §50 ⑥ - literal key 삭제:
users/{uid}에서 3종FieldValue.delete()(reversible — audit 컬렉션에 raw 값 보존) - 재동의 모달 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 절차 자체에서 입증한 사례:
- "성공한 척" 패턴이 어디든 들어옴 — write 가 success 반환했어도 실제 효과는 다른 layer 에서 검증해야 함
- audit + cleanup 후 반드시 audit re-run — cleanup 스크립트 자체에 "verify after apply" step 추가가 후속 개선 후보 (T5c)
- 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/app | 0 건 |
apps/ops/lib · apps/ops/app | 0 건 |
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.ts—data["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 결과 잘못 저장된 동의 케이스 발견 시:
- 영향 사용자 식별 —
users/{uid}에 literal key ("consents.terms"등) 존재하는 모든 uid 추출 - 재동의 모달 강제 trigger —
consents.terms.version을 "force-reconsent" 같은 sentinel 값으로 set → layout 검사가 needsRecconsent=true 판정 - 로그 양쪽 보존 — 잘못 저장된 literal key 는 audit 보존 후 삭제 (
FieldValue.delete()), 신규 동의는 정상 nested 위치에 저장. 양쪽 timestamp 와 user-agent 는audit/consents/{uid}별도 컬렉션에 보존 (정통망법 §50 ⑥ 정합) - 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 후) "회고하는 회의를 하는게 좋지않나요? 반드시 정확히 정밀하게 짚고 넘어가야해요" "회고회의는 특별한 탭 또는 공간에 기록되어야 하지 않을까요? 문서로 반드시 남겨야 해요"