마스킹 이메일(i***@)이 DB에 영속화되어 시퀀스 발송이 전량 스킵된 원인
시퀀스 019e8c37 · 워크스페이스 019e8b6f · 분석일 2026-06-04
결론: 표시(프론트)용으로만 걸어야 할 이메일 마스킹이 저장 경로로 역류해 lead_contacts.contact_value에 i***@domain 형태로 영속화됨. 이 마스킹된 주소가 그대로 발송 직전 검증 API(verifyEmailCascade)에 전달 → MV가 "수신불가" 판정 → step 스킵. 사용자 가설이 정확히 맞음.
이 시퀀스의 발송대상 34명은 전원 primary 이메일이 마스킹됨 → 전량 스킵 예정. 현재 56건 Tier2 undeliverable: i***@... 스킵 확인. 오늘(6/4)도 신규 마스킹 행이 계속 유입 중.
1 발송 기준 — 어떤 이메일이 나가는가
sequence-email-worker가 발송 직전 게이트 파이프라인을 통과해야 발송됨. recipient는 primary 이메일 우선 선택.
| 단계 | 게이트 | 위치 | 스킵 조건 |
|---|---|---|---|
| recipient | primary 이메일 우선, 없으면 임의 이메일 | resolve-lead.ts:56 | — |
| Tier 0 | format / role / dummy / disposable / dedup | verify-email/pipeline.ts | 형식·역할주소·중복 |
| Tier 2 유료 | verifyEmailCascade(toEmail, {maxTier:1}) | mv-fail-open.gate.ts:77 | verdict ≠ "send" → fail-CLOSED 스킵 |
핵심: mv-fail-open.gate.ts:77 이 recipient 이메일 문자열을 그대로 검증 API에 전달한다. 그 문자열이 i***@cfd-tr.org면 MillionVerifier는 당연히 undeliverable을 반환한다 (실재하지 않는 주소).
// mv-fail-open.gate.ts const result = await verifyEmailCascade(toEmail, { maxTier: 1, caller: "send_time" }) if (result.verdict !== "send") return skipStep("skipped", `send-time ${result.verdict}: ${toEmail}`) // toEmail = "i***@..."
2 설계 의도 — 마스킹은 "프론트 표시용"이 맞다
두 곳의 docstring이 명시한다:
| 위치 | 명시 내용 |
|---|---|
| utils/email.util.ts:9 | "시퀀스 발송 등 서버사이드 워크플로에서는 원본을 그대로 사용 (DB 저장값 미변경)" |
| utils/mask-results.util.ts:4 | "DB 에는 원본 이메일을 그대로 저장하고… 응답 페이로드에서만 마스킹" |
즉 프론트 노출만 가리는 게 정상이다. maskEmail("john@x.com") → "j***@x.com". 그런데 이 표시용 값이 저장 경로로 새어 들어갔다.
3 어느 시점에 ***가 저장되는가 — 실증된 데이터 흐름
lead_discovery_results에 저장
└ 실측: 171,394건 중 마스킹 71건뿐 → 이 테이블은 원본 보관 ✓
② SSE emit(UI 프레임)용으로 maskEmail() 적용 — 정상
└ adapter.ts:362 / runner-entry.ts:1337 → FE·에이전트는 i***@ 만 봄
③ 저장(save-to-group) — 여기서 누수 ★
createLeadsFromDiscoveryResults(results) 가 받는 results[].email 이
lead_discovery_results의 원본을 재조회하지 않고,
FE·에이전트가 들고 있던 마스킹된 표시용 payload를 그대로 사용
└ customer-group.service.ts:403 primaryEmail: r.email // = "i***@..."
④ createLeadsFromCSV → lead_contacts INSERT
└ contact_value = 마스킹값, source 미지정→NULL, is_primary=true, leadSource="Lead Discovery"
⑤ 시퀀스 enrollment이 primary(마스킹) 이메일을 recipient로 선택
└ verifyEmailCascade("i***@...") → undeliverable → 스킵
DB 증거
| 검증 쿼리 | 결과 | 해석 |
|---|---|---|
마스킹 contact의 lead_source | Lead Discovery: 8,902 leads / 11,933건 | createLeadsFromDiscoveryResults 경로 확정 |
마스킹 행 source | 전량 NULL | CSV insert가 source 미세팅 → 일치 |
lead_discovery_results.email 마스킹 | 71 / 171,394 (0.04%) | 결과 테이블은 원본 → 누수는 저장 단계 |
| 마스킹 lead의 원본 공존 여부 | 공존 4,808 / 마스킹만 4,104 | 4,104건은 원본 복구 불가 |
정확한 시점: 마스킹은 ②(SSE 표시용)에서 걸리지만, ③ 저장 단계가 원본(lead_discovery_results)을 다시 읽지 않고 표시용 payload를 재사용하기 때문에 표시용 마스킹값이 그대로 lead_contacts에 영속화된다.
4 해결 방안
- 저장 경로 원본 재조회 —
createLeadsFromDiscoveryResults가results[].email(표시용)을 신뢰하지 말고,lead_discovery_results.email(원본)을leadId로 재조회해 사용. customer-group.service.ts:403 - 저장 방어 가드 —
createLeadsFromCSV/updateLead등lead_contactswrite 직전contact_value LIKE '%***%'값 거부·경고. 마스킹값의 영속화를 원천 차단. - 발송 안전망 —
resolve-lead/mv-fail-open.gate진입 시toEmail에***있으면 즉시 스킵+경고 (유료 MV 크레딧 낭비 방지). - 데이터 복구 — 원본 공존(4,808) lead는 마스킹 행만 제거. 마스킹만 보유(4,104) lead는
lead_discovery_results에서 원본 backfill 후 교체. - 영속 제약 —
lead_contacts.contact_value에CHECK (contact_value NOT LIKE '%***%')migration으로 재발 차단.
복구 SQL 초안 (dry-run 카운트 먼저)
-- 1) 원본이 공존하는 lead: 마스킹 행만 삭제 (안전) DELETE FROM lead_contacts lc WHERE contact_type='email' AND contact_value LIKE '%***%' AND EXISTS (SELECT 1 FROM lead_contacts o WHERE o.lead_id=lc.lead_id AND o.contact_type='email' AND o.contact_value NOT LIKE '%***%'); -- 2) 마스킹만 보유한 lead: lead_discovery_results 원본으로 backfill 후 교체 (후속)