Incident Analysis · Beta

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

시퀀스 019e8c37 · 워크스페이스 019e8b6f · 분석일 2026-06-04

결론: 표시(프론트)용으로만 걸어야 할 이메일 마스킹이 저장 경로로 역류lead_contacts.contact_valuei***@domain 형태로 영속화됨. 이 마스킹된 주소가 그대로 발송 직전 검증 API(verifyEmailCascade)에 전달 → MV가 "수신불가" 판정 → step 스킵. 사용자 가설이 정확히 맞음.

11,943
마스킹된 lead_contacts 이메일 (source=NULL)
8,912
그중 is_primary=true → 실제 발송 대상
28%
최근 7일 skip 중 마스킹 유발 (3,594 / 12,878)
4,104
원본 없이 마스킹만 보유한 lead (복구 불가)

이 시퀀스의 발송대상 34명은 전원 primary 이메일이 마스킹됨 → 전량 스킵 예정. 현재 56건 Tier2 undeliverable: i***@... 스킵 확인. 오늘(6/4)도 신규 마스킹 행이 계속 유입 중.

1 발송 기준 — 어떤 이메일이 나가는가

sequence-email-worker가 발송 직전 게이트 파이프라인을 통과해야 발송됨. recipient는 primary 이메일 우선 선택.

단계게이트위치스킵 조건
recipientprimary 이메일 우선, 없으면 임의 이메일resolve-lead.ts:56
Tier 0format / role / dummy / disposable / dedupverify-email/pipeline.ts형식·역할주소·중복
Tier 2 유료verifyEmailCascade(toEmail, {maxTier:1})mv-fail-open.gate.ts:77verdict ≠ "send"fail-CLOSED 스킵

핵심: mv-fail-open.gate.ts:77recipient 이메일 문자열을 그대로 검증 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 어느 시점에 ***가 저장되는가 — 실증된 데이터 흐름

Discovery 실행 → 후보를 원본 이메일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***@..." createLeadsFromCSVlead_contacts INSERT └ contact_value = 마스킹값, source 미지정→NULL, is_primary=true, leadSource="Lead Discovery" 시퀀스 enrollment이 primary(마스킹) 이메일을 recipient로 선택 └ verifyEmailCascade("i***@...") → undeliverable → 스킵

DB 증거

검증 쿼리결과해석
마스킹 contact의 lead_sourceLead Discovery: 8,902 leads / 11,933건createLeadsFromDiscoveryResults 경로 확정
마스킹 행 source전량 NULLCSV insert가 source 미세팅 → 일치
lead_discovery_results.email 마스킹71 / 171,394 (0.04%)결과 테이블은 원본 → 누수는 저장 단계
마스킹 lead의 원본 공존 여부공존 4,808 / 마스킹만 4,1044,104건은 원본 복구 불가

정확한 시점: 마스킹은 ②(SSE 표시용)에서 걸리지만, ③ 저장 단계가 원본(lead_discovery_results)을 다시 읽지 않고 표시용 payload를 재사용하기 때문에 표시용 마스킹값이 그대로 lead_contacts에 영속화된다.

4 해결 방안

  1. 저장 경로 원본 재조회createLeadsFromDiscoveryResultsresults[].email(표시용)을 신뢰하지 말고, lead_discovery_results.email(원본)을 leadId로 재조회해 사용. customer-group.service.ts:403
  2. 저장 방어 가드createLeadsFromCSV/updateLeadlead_contacts write 직전 contact_value LIKE '%***%' 값 거부·경고. 마스킹값의 영속화를 원천 차단.
  3. 발송 안전망resolve-lead / mv-fail-open.gate 진입 시 toEmail*** 있으면 즉시 스킵+경고 (유료 MV 크레딧 낭비 방지).
  4. 데이터 복구 — 원본 공존(4,808) lead는 마스킹 행만 제거. 마스킹만 보유(4,104) lead는 lead_discovery_results에서 원본 backfill 후 교체.
  5. 영속 제약lead_contacts.contact_valueCHECK (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 후 교체 (후속)