Recovery Plan · Beta DB

마스킹 이메일(i***@) 원본 복구 계획

대상 11,943 contact · 활성 시퀀스 43개 영향 · 2026-06-04

요지 — 마스킹된 이메일을 두 경로로 원본 복원: ① 같은 lead에 이미 있는 원본으로 대체(58%), lead_discovery_results 원본 백필(16%). 나머지 23%는 원본이 없어 재검증/비활성화. 전제: 코드 출혈 차단(hotfix)이 먼저 배포돼야 복구가 재오염되지 않는다.

11,943
복구 대상 마스킹 contact
74%
자동 복구 가능 (T1+T2)
23%
원본 없음 (재enrich/비활성)
43
현재 발송 중 활성 시퀀스

A 복구 워터폴 — 마스킹 행을 4계층으로 분류

마스킹값 i***@geneloto.com.tr도메인 + local-part 첫 글자를 보존한다. 이를 이용해 원본 후보를 매칭. 실측 결과:

T1 · 6,903 (58%)
T2 · 1,935
366
T3 · 2,739 (23%)
계층건수판별 기준복구 방법
T16,903같은 lead_id에 비마스킹 이메일 contact 존재마스킹 행 삭제 + 원본을 primary 승격
T21,935workspace+도메인+첫글자로 lead_discovery_results 원본 유일 매칭제자리 UPDATE로 원본 교체
T2b366discovery 후보 2개+ (모호)회사명/website 추가 매칭 → 유일화, 실패 시 수동
T32,739어디에도 원본 없음재enrich 또는 contact 비활성화

※ 단위 = lead_contacts 행. lead 단위로는 원본 공존 4,808 / 마스킹만 4,104.

0 출혈 차단 (선행 필수)

복구보다 먼저. 코드 수정 없이 데이터만 복구하면 신규 검색·저장이 또 마스킹값을 써서 재오염된다(오늘도 유입 중).
  1. 코드 hotfixcustomer-group.service.ts:403 createLeadsFromDiscoveryResultsresults[].email 대신 lead_discovery_results.email(원본)을 leadId로 재조회.
  2. write 가드lead_contacts insert/update 직전 contact_value LIKE '%***%' 거부+경고(공통 유틸).
  3. 발송 가드resolve-lead.ts/mv-fail-open.gate.ts에서 toEmail*** 있으면 즉시 skip(유료 MV 호출 회피).
  4. 활성 출혈 정지(선택) — 마스킹 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 '%***%');
중복 primary 방지: ① 후 한 lead에 email primary가 1개만 남는지 검증 (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_nameleads.company_name(또는 web_address↔website_url)를 매칭에 추가해 유일화. 그래도 다수면 수동 검토 큐로.

T3 — 원본 부재 (선택지)

옵션내용권장
재-enrich도메인 보존됨 → contact-enrichment worker로 회사 도메인 재탐색해 새 이메일 확보권장 도메인 있음
비활성화마스킹 contact를 is_primary=false + 별도 플래그로 발송 제외 → 무의미 skip 중단즉시 완화
방치발송 가드(Phase 0-3)만 의존크레딧 낭비

5 재검증 & 캠페인 재개

  1. 재검증 — Phase 2 승격 원본 + Phase 3/4 백필 이메일을 cascade 검증 큐에 enqueue (is_verified=false 대상).
  2. enrollment 정합 — Phase 0에서 pause한 enrollment를 active 복귀. primary가 유효 이메일을 가리키는지 재확인.
  3. 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 T1UPDATE+DELETE트랜잭션, dry-run COUNT, primary 유일성 검증
3 T2UPDATE트랜잭션, 교체 전후 카운트, 샘플 육안
4 T2b/T3UPDATE/enqueue수동검토 큐, 비활성 reversible
5 재검증job중복발송 가드(sent step 제외)
6 제약DDLNOT VALID → 복구 후 VALIDATE
모든 write 단계는 ① dry-run SELECT COUNT → ② BEGIN 트랜잭션 → ③ 영향 행수 확인 → ④ COMMIT. 최종 검증: SELECT count(*) FROM lead_contacts WHERE contact_type='email' AND contact_value LIKE '%***%' = 0 (또는 비활성 T3 잔량만).