Incident Root-Cause Analysis · 활성·미해결

마스킹 이메일(i***@)이 DB에 영속화되어
시퀀스 발송이 전량 스킵된 원인

리포트가 지목한 단일 라인은 증상일 뿐이다. git blame 실측 결과 단일 버그가 아니라 한 사람이 3개 커밋에 걸쳐 만든 잠복 → 발화 → 확산 사슬이었다.

분석일 2026-06-04 환경 beta (린다세일즈) 상태 미해결 (P0) 유입 추정 lead_contacts 11,943건

TL;DR

  • 도입자: 전부 동일인(이예인). 리포트가 지목한 customer-group.service.ts:403은 결과(증상)일 뿐, 발화 트리거는 별도 PR.
  • 근본 결함: 마스킹을 직렬화(출력) 경계에 걸었는데, 같은 응답이 저장(입력) payload로 round-trip 된다. 서버 저장 경로가 클라가 보낸 email을 그대로 믿고 원본을 id로 재조회하지 않는다.
  • 결과: i***@domainlead_contacts.contact_value에 영속화 → 시퀀스 발송 직전 verifyEmailCascade가 undeliverable 판정 → step 전량 skip.

1 도입 타임라인 — git blame 실측

3개 커밋이 모두 같은 작성자다. 각각 잠복·발화·확산 역할을 한다.

시점PR / 커밋작성자역할
2026-04-17 #4663
ba26335d2
이예인 잠복 BuyerListFromSessionModal.tsx:127이 getResults 응답을 그대로 primaryEmail: r.email로 csvData 재구성해 저장. 당시 r.email=원본이라 무해.
2026-05-15 #7542
5dc7e639c
이예인 발화 트리거 GET /db/sessions/:id/resultsmaskResultsEmails 적용. "응답 페이로드에서만 마스킹 = display only" 가정이, 그 응답이 저장 payload source이기도 한 사실을 간과 → 잠복 round-trip이 live leak으로 전환.
2026-05-29 #a317b58 이예인 확산 createLeadsFromDiscoveryResults(에이전트 자동 저장)도 동일하게 r.email(마스킹) 사용. runner-entry 호출은 2026-06-01 비활성화됐으나 함수 자체는 두 번째 입구로 잔존.

2 실제 누수 경로 (검증 완료)

read 응답이 write 입력으로 되돌아오는 round-trip이 핵심. 쓰기 경로에 마스킹 거부 가드는 존재하지 않는다.

GET /db/sessions/:id/results
  └─ ok({ results: maskResultsEmails(results) })        ← #7542, 원본→"i***@"
       │   bridge.routes:478 / lead-discovery.routes:964
       ▼
FE  BuyerListFromSessionModal.tsx:108  getResults()        ← 마스킹값 수신
    BuyerListFromSessionModal.tsx:127  primaryEmail: r.email ← #4663, 마스킹 그대로POST /customer-groups/:id/leads  (body.csvData)
       ▼
createLeadsFromCSV → lead_contacts.contact_value = "i***@..."  ← 영속화, source=NULL
       ▼
시퀀스 enrollment → mv-fail-open.gate.ts:77 verifyEmailCascade("i***@")
       ▼
undeliverable → step 전량 skip
근본 원인 한 줄. 마스킹은 "캡처·HAR·세션 공유 유출 차단" 목적의 출력 전용 변환인데(mask-results.util.ts:4 JSDoc 명시), FE/에이전트가 그 출력을 다시 입력으로 되돌리는 round-trip 쓰기 경로가 존재했다. 마스킹 PR(#7542)이 read 응답만 보고 "이건 표시용"이라 단정 → 같은 엔드포인트가 저장의 SSOT source라는 점을 놓쳤다. 전형적 Overfitting: 유출 케이스만 보고 패치, 데이터 흐름 전체를 검토하지 않음.

3 설계 의도 위반

마스킹 유틸 자신이 "DB엔 원본, 응답에서만 마스킹"을 명시했다 — 이 계약이 round-trip 쓰기에서 깨졌다.

utils/email.util.ts:9"시퀀스 발송 등 서버사이드 워크플로에서는 원본을 그대로 사용 (DB 저장값 미변경)"
utils/mask-results.util.ts:4"DB에는 원본 저장하고, 프론트로 내려가는 응답 페이로드에서만 마스킹"
customer-group.service.ts쓰기 경로에 %***% 거부 가드 부재 확인 → 마스킹값 무조건 통과

4 영향 범위 (beta 실측)

지표수치비고
마스킹된 lead_contacts 이메일11,943건전량 source=NULL
실제 발송 대상 (is_primary=true)8,912건recipient로 선택됨
최근 7일 skip 중 마스킹 유발28%3,594 / 12,878
원본 없이 마스킹만 보유 (복구 불가)4,104건재탐색 필요
원본 공존 (backfill 가능)4,808건lead_discovery_results에서 복구

5 수정 우선순위

①이 근본 해결. ②~⑤는 backstop·복구·재발 방지.

  1. 서버 재조회 (근본 해결)createLeadsFromDiscoveryResults + POST /:id/leads가 클라 email을 무시하고 lead_discovery_results.email(원본)을 id로 재조회. SSOT=DB 원칙 복귀.
  2. 쓰기 가드createLeadsFromCSV write 직전 contact_value LIKE '%***%' reject — 재발 방지 backstop.
  3. 발송 안전망resolve-lead / gate에서 마스킹 toEmail 즉시 skip + 알림.
  4. 데이터 복구원본 공존 4,808건 backfill, 복구불가 4,104건은 격리/재탐색 표시.
  5. CHECK 제약마이그레이션으로 lead_contacts.contact_value 마스킹 패턴 영속 차단.