i***@)이 DB에 영속화되어리포트가 지목한 단일 라인은 증상일 뿐이다. git blame 실측 결과 단일 버그가 아니라 한 사람이 3개 커밋에 걸쳐 만든 잠복 → 발화 → 확산 사슬이었다.
customer-group.service.ts:403은 결과(증상)일 뿐, 발화 트리거는 별도 PR.email을 그대로 믿고 원본을 id로 재조회하지 않는다.i***@domain이 lead_contacts.contact_value에 영속화 → 시퀀스 발송 직전 verifyEmailCascade가 undeliverable 판정 → step 전량 skip.3개 커밋이 모두 같은 작성자다. 각각 잠복·발화·확산 역할을 한다.
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
mask-results.util.ts:4 JSDoc 명시), FE/에이전트가 그 출력을 다시 입력으로 되돌리는 round-trip 쓰기 경로가 존재했다. 마스킹 PR(#7542)이 read 응답만 보고 "이건 표시용"이라 단정 → 같은 엔드포인트가 저장의 SSOT source라는 점을 놓쳤다. 전형적 Overfitting: 유출 케이스만 보고 패치, 데이터 흐름 전체를 검토하지 않음.
마스킹 유틸 자신이 "DB엔 원본, 응답에서만 마스킹"을 명시했다 — 이 계약이 round-trip 쓰기에서 깨졌다.
utils/email.util.ts:9 | "시퀀스 발송 등 서버사이드 워크플로에서는 원본을 그대로 사용 (DB 저장값 미변경)" |
utils/mask-results.util.ts:4 | "DB에는 원본 저장하고, 프론트로 내려가는 응답 페이로드에서만 마스킹" |
customer-group.service.ts | 쓰기 경로에 %***% 거부 가드 부재 확인 → 마스킹값 무조건 통과 |
①이 근본 해결. ②~⑤는 backstop·복구·재발 방지.
createLeadsFromDiscoveryResults + POST /:id/leads가 클라 email을 무시하고 lead_discovery_results.email(원본)을 id로 재조회. SSOT=DB 원칙 복귀.createLeadsFromCSV write 직전 contact_value LIKE '%***%' reject — 재발 방지 backstop.lead_contacts.contact_value 마스킹 패턴 영속 차단.