마스킹 이메일(i***@) 원본 복구 계획
대상 11,943 contact · 활성 시퀀스 43개 영향 · 2026-06-04
요지 — 마스킹된 이메일을 두 경로로 원본 복원: ① 같은 lead에 이미 있는 원본으로 대체(58%), ② lead_discovery_results 원본 백필(16%). 나머지 23%는 원본이 없어 재검증/비활성화. 전제: 코드 출혈 차단(hotfix)이 먼저 배포돼야 복구가 재오염되지 않는다.
A 복구 워터폴 — 마스킹 행을 4계층으로 분류
마스킹값 i***@geneloto.com.tr은 도메인 + local-part 첫 글자를 보존한다. 이를 이용해 원본 후보를 매칭. 실측 결과:
| 계층 | 건수 | 판별 기준 | 복구 방법 |
|---|---|---|---|
| T1 | 6,903 | 같은 lead_id에 비마스킹 이메일 contact 존재 | 마스킹 행 삭제 + 원본을 primary 승격 |
| T2 | 1,935 | workspace+도메인+첫글자로 lead_discovery_results 원본 유일 매칭 | 제자리 UPDATE로 원본 교체 |
| T2b | 366 | discovery 후보 2개+ (모호) | 회사명/website 추가 매칭 → 유일화, 실패 시 수동 |
| T3 | 2,739 | 어디에도 원본 없음 | 재enrich 또는 contact 비활성화 |
※ 단위 = lead_contacts 행. lead 단위로는 원본 공존 4,808 / 마스킹만 4,104.
0 출혈 차단 (선행 필수)
- 코드 hotfix — customer-group.service.ts:403
createLeadsFromDiscoveryResults가results[].email대신lead_discovery_results.email(원본)을leadId로 재조회. - write 가드 —
lead_contactsinsert/update 직전contact_value LIKE '%***%'거부+경고(공통 유틸). - 발송 가드 — resolve-lead.ts/mv-fail-open.gate.ts에서
toEmail에***있으면 즉시 skip(유료 MV 호출 회피). - 활성 출혈 정지(선택) — 마스킹 primary인 active enrollment 2,380건을 일시
paused로 두어 복구 완료까지 무의미한 skip/크레딧 낭비 차단.
1 백업 — 롤백 가능하게
-- 복구 대상 전체 스냅샷 (타임스탬프 테이블) CREATE TABLE lead_contacts_mask_backup_20260604 AS SELECT lc.*, l.workspace_id, l.lead_source FROM lead_contacts lc JOIN leads l ON l.id=lc.lead_id WHERE lc.contact_type='email' AND lc.contact_value LIKE '%***%'; -- 기대: 11943 rows
2 T1 원본 공존 — 6,903건
마스킹 행은 사실상 원본의 중복. 단, 마스킹 행이 is_primary=true면 삭제 전에 원본을 primary로 승격해야 발송 대상이 살아난다.
-- ① 원본을 primary 승격 (마스킹이 primary였고 원본이 non-primary인 lead) UPDATE lead_contacts o SET is_primary=true FROM lead_contacts m WHERE m.lead_id=o.lead_id AND m.contact_type='email' AND m.is_primary AND m.contact_value LIKE '%***%' AND o.contact_type='email' AND o.contact_value NOT LIKE '%***%'; -- ② 마스킹 행 삭제 (원본 sibling 보유 lead 한정) DELETE FROM lead_contacts m WHERE m.contact_type='email' AND m.contact_value LIKE '%***%' AND EXISTS(SELECT 1 FROM lead_contacts o WHERE o.lead_id=m.lead_id AND o.contact_type='email' AND o.contact_value NOT LIKE '%***%');
GROUP BY lead_id HAVING count(*) FILTER(is_primary)>1).3 T2 discovery 백필 — 1,935건
원본이 같은 lead엔 없지만 lead_discovery_results에 유일 매칭. 제자리 UPDATE로 마스킹값을 원본으로 교체(행 유지 → is_primary·id 보존).
-- workspace+도메인+첫글자 유일 매칭 원본으로 교체 WITH rw AS ( SELECT lower(r.email) em, split_part(lower(r.email),'@',2) dom, left(lower(r.email),1) fc, s.workspace_id ws FROM lead_discovery_results r JOIN lead_discovery_sessions s ON s.id=r.session_id WHERE r.email LIKE '%@%' AND r.email NOT LIKE '%***%'), uniq AS (SELECT ws,dom,fc, min(em) em FROM rw GROUP BY ws,dom,fc HAVING count(DISTINCT em)=1) UPDATE lead_contacts m SET contact_value=u.em, is_verified=false, source='mask_recovery' FROM leads l, uniq u WHERE m.lead_id=l.id AND m.contact_type='email' AND m.contact_value LIKE '%***%' AND u.ws=l.workspace_id AND u.dom=split_part(lower(m.contact_value),'@',2) AND u.fc=left(lower(m.contact_value),1) AND NOT EXISTS(SELECT 1 FROM lead_contacts o -- T1에서 이미 처리된 건 제외 WHERE o.lead_id=m.lead_id AND o.contact_type='email' AND o.contact_value NOT LIKE '%***%');
is_verified=false로 두고 Phase 5에서 cascade 재검증. source='mask_recovery'로 복구분 추적.
4 T2b 모호(366) + T3 원본없음(2,739)
T2b — 회사명 추가 매칭
도메인+첫글자 후보가 2개+. lead_discovery_results.company_name ↔ leads.company_name(또는 web_address↔website_url)를 매칭에 추가해 유일화. 그래도 다수면 수동 검토 큐로.
T3 — 원본 부재 (선택지)
| 옵션 | 내용 | 권장 |
|---|---|---|
| 재-enrich | 도메인 보존됨 → contact-enrichment worker로 회사 도메인 재탐색해 새 이메일 확보 | 권장 도메인 있음 |
| 비활성화 | 마스킹 contact를 is_primary=false + 별도 플래그로 발송 제외 → 무의미 skip 중단 | 즉시 완화 |
| 방치 | 발송 가드(Phase 0-3)만 의존 | 크레딧 낭비 |
5 재검증 & 캠페인 재개
- 재검증 — Phase 2 승격 원본 + Phase 3/4 백필 이메일을 cascade 검증 큐에 enqueue (
is_verified=false대상). - enrollment 정합 — Phase 0에서 pause한 enrollment를
active복귀. primary가 유효 이메일을 가리키는지 재확인. - pending 재스케줄 — 마스킹 때문에 skip된
sequence_step_executions(error_message LIKE '%***%') 중 재발송 정책 대상이면 재큐잉(중복 발송 주의 — 이미 sent된 step 제외).
6 영속 방어 (재발 차단)
-- DB 레벨 최종 방어선 ALTER TABLE lead_contacts ADD CONSTRAINT lead_contacts_no_mask CHECK (contact_value NOT LIKE '%***%') NOT VALID; -- NOT VALID: 기존 행 검사 보류(복구 후), 신규 write만 차단 → 복구 완료 후 VALIDATE
+ Phase 0의 코드 가드(저장·발송)와 2중화. maskEmail은 응답 serializer 한 곳에만 적용하도록 격리(서비스 반환값엔 절대 금지).
✓ 실행 순서 · 안전장치
| 단계 | 유형 | 안전장치 |
|---|---|---|
| 0 출혈 차단 | 코드 배포 | alpha→beta 검증 후 배포, 가드 단위테스트 |
| 1 백업 | DDL | 스냅샷 테이블 11,943행 확인 |
| 2 T1 | UPDATE+DELETE | 트랜잭션, dry-run COUNT, primary 유일성 검증 |
| 3 T2 | UPDATE | 트랜잭션, 교체 전후 카운트, 샘플 육안 |
| 4 T2b/T3 | UPDATE/enqueue | 수동검토 큐, 비활성 reversible |
| 5 재검증 | job | 중복발송 가드(sent step 제외) |
| 6 제약 | DDL | NOT VALID → 복구 후 VALIDATE |
SELECT count(*) FROM lead_contacts WHERE contact_type='email' AND contact_value LIKE '%***%' = 0 (또는 비활성 T3 잔량만).