RCA · 2026-05-31
오늘(2026-05-31) 베타에서 두 워크스페이스의 SES bounce rate 가 임계(2%) 를 크게 초과한 알람이 발생. 어제(2026-05-30) 머지한 발송워커 검증 코드(PR #8016, #8017, #8018) 가 bounce 를 막았어야 한다는 가설을 검증.
두 가지 근본 원인.
① RC-1 — 검증 로직은 코드에만 추가됐고 활성화되지 않았다. PR #8016 의 Tier 2 MV 검증은 VERIFY_SEND_TIME_ENABLED=false default 로 머지 됐고, 베타 env 에 해당 환경변수가 설정돼 있지 않아 code default(false)로 동작. 즉 send-time 검증은 사실상 호출되지 않음. (베타 DB 의 tier2 트레이스 24h 0건이 증거)
② RC-2 — buyer-search 의 검증 결과가 lead_contacts.is_verified 로 동기화되지 않는다. 13개의 lead_contacts INSERT 경로 중 단 1개(lead-on-demand-enrich.worker.ts)만 cascade pre-verify 후 is_verified=true 로 저장. 나머지 9개 경로는 검증 없이 is_verified=false 로 그대로 INSERT. 발송워커는 lead_contacts 만 읽기 때문에 invalid 주소가 검증 없이 발송됨.
"오늘 발송워커에 검증 로직을 추가했으니 bounce 가 발생하면 안 된다."
"코드는 추가됐지만 default OFF 라 호출되지 않는다. 그리고 upstream SSOT 가 단절돼 있어 활성화돼도 isVerified=false 가 그대로 들어온다."
증거 1. 회귀 테스트 verify-email-hybrid-d.test.ts 6/6 + gates.test.ts 22/22 + validate-status pipeline.test.ts 9/9 통과 — 외부 동작 보존 검증.
증거 2. 베타 운영 로그에 "Duplicate: ...@gmail.com already sent in this step" 메시지 확인 — Pipeline 의 dedupGate 가 정상 produce. 이전 절차형과 외부 메시지 동일.
증거 3. CI(send-ci.sh) lint + type-check + build 15s 통과. 베타 CD success.
증거 1. 베타 env 에 VERIFY_SEND_TIME_ENABLED 환경변수 자체가 없음 (ssh beta · docker exec env 확인).
증거 2. 24시간 동안 sequence_step_executions.error_message ILIKE '%tier2%' 결과 0건 — Tier 2 호출 흔적 0.
증거 3. Redis email_verification:v1:ev:* 캐시 668k 활성하지만 sequence-email 측 호출은 없고 enrichment-side(verifyEmailCascade)만 채우는 중.
증거 1. VERIFY_SEND_TIME_ENABLED code default false + env 미설정 → Tier 2 skip.
증거 2. 두 워크스페이스의 24h 발송 lead 의 lead_contacts.is_verified 분포 — 전부 false:
스페로네_0430: delivered 132 + opened 18 + bounced 6 + sent 1 + clicked 3 = 160건 전부 is_verified=false
레인보우팜: bounced 71 + delivered 15 = 86건 전부 is_verified=false
+ secondary verified=true 53건이 있는데 primary 아니어서 발송 안 됨
증거 3. 13개 INSERT 경로 중 lead-on-demand-enrich.worker.ts 만 cascade pre-verify 후 is_verified=true 로 INSERT. 나머지 9개는 검증 없이 false 로.
증거 4. bounce 사유가 모두 SMTP "메일박스 없음" (수신자측 rejection) — sender reputation 문제 아님.
┌─ buyer-search / lead-discovery ──────────────────────────────────┐
│ verifyEmailCascade("enrichment_time") ─→ MV → Findymail → Hunter │
│ ↓ (검증 결과) │
│ lead_emails 테이블 ◄── verified=1, metadata.cascade │
│ ✗ (단절: lead_contacts 로는 자동 동기화 안 됨) │
└──────────────────────────────────────────────────────────────────┘
↓
┌─ 9개 우회 INSERT 경로 ────────────────────────────────────────────┐
│ lead.service.ts (4) │
│ lead-import.service.ts (3, CSV) │
│ bigquery-search.service.ts (2) │
│ contact-enrichment.service.ts (3) │
│ customer-group.service.ts (2) │
│ lead-bulk.service.ts (1) │
│ lead-pipeline-gmail-backfill.service.ts (1) │
│ ↓ │
│ lead_contacts ◄── is_primary=true, is_verified=false (검증 안 함) │
└──────────────────────────────────────────────────────────────────┘
↓
┌─ sequence-email-worker (Pipeline #8016~#8018 머지 후) ────────────┐
│ Step 0 validate-status ✓ pass │
│ Step 1 resolve-lead (primary lead_contact 읽음, verified=false) ✓ │
│ Step 2 verify-email Pipeline │
│ Tier 0 cheap gate format/role/dummy/disposable/blacklist ✓ │
│ Tier 1 SSOT trust is_verified=false → skip 아님 │
│ Tier 2 MV multi-signal │
│ └─ config.verifySendTime.enabled = false ⊘ skip │
│ │
│ → 발송 → SES → 메일박스 없음 → bounce │
└──────────────────────────────────────────────────────────────────┘
| 경로 | is_verified | 검증 단계 | 안전성 |
|---|---|---|---|
lead-on-demand-enrich.worker.ts:287 |
true | verifyEmailCascade("enrichment_time") · block 시 insert 안 함 |
✓ 유일하게 안전 |
lead.service.ts:254 |
false | 없음 | ✗ |
lead.service.ts:274 |
false | 없음 | ✗ |
lead.service.ts:495 |
false | 없음 | ✗ |
lead.service.ts:521 |
false | 없음 | ✗ |
lead-import.service.ts:332, :367, :633 (CSV) |
false (default) | 없음 | ✗ |
bigquery-search.service.ts:1033, :1045 |
false | 없음 | ✗ |
contact-enrichment.service.ts:394, :430, :465 |
false | 없음 | ✗ |
customer-group.service.ts:226, :237 |
false | 없음 | ✗ |
lead-bulk.service.ts:567 |
false (default) | 없음 | ✗ |
lead-pipeline-gmail-backfill.service.ts:468 |
(확인 필요) | (확인 필요) | ? |
// lead-on-demand-enrich.worker.ts:286-306
INSERT INTO lead_contacts (..., is_primary, is_verified)
SELECT ...,
NOT EXISTS ( ─┐
SELECT 1 FROM lead_contacts │ "이미 primary 가 있으면
WHERE lead_id = ${args.leadId}::uuid │ secondary 로 저장"
AND contact_type = 'email' │
AND is_primary = true │
), ─┘
true ← verified=true 는 보장됨
WHERE NOT EXISTS (... LOWER(contact_value) = LOWER(${args.email}) ...)
의도: "유저가 선택한 primary 보존". 실제: 유저가 선택한 적 없는 자동 import primary(검증 안 됨) 가 자리를 차지해, cascade 가 검증한 이메일은 secondary 로만 저장됨.
레인보우팜 실측:
is_verified=false: 86건 ← 발송 대상is_verified=true: 53건 ← 검증된 이메일이 있는데 primary 가 아니어서 발송 안 됨is_verified=false: 20건| 측정 | 레인보우팜 | 스페로네_0430 |
|---|---|---|
| 24h 발송 status | bounced 71 / delivered 15 (82.6% bounce) | delivered 132 / opened 18 / bounced 12 / clicked 3 / sent 1 (7.2% bounce) |
| 1h 알람 시점 | 115 / 63 (54.78%) | 111 / 6 (5.41%) |
| 발송 도메인 | send.grinda.ai (단일) |
send.grinda.ai (단일) |
| bounce 사유 | 대부분 SMTP "메일박스 없음" — 550 5.1.1 / 550 5.4.1 / RESOLVER.ADR.RecipientNotFound |
동일 패턴 |
발송 lead 의 lead_contacts.is_verified |
전체 false (86/86) | 전체 false (160/160) |
| 발송 시점 | 전부 머지 후 (5/30 06:50 UTC 이후) | 전부 머지 후 |
| Tier 2 호출 (24h) | 0건 (flag off 정상) | |
enrollment stop_reason='unreachable' (24h) |
0건 (Hybrid (D) 안전선 작동) | |
| 의심 도메인 | 11.css (HTML/CSS fragment 가 도메인으로 잘못 파싱) |
— |
| PR | 의도 | 오늘 사고에 대한 영향 |
|---|---|---|
| #8016 Hybrid (D) | Tier 1 SSOT skip + Tier 2 MV multi-signal 추가. default OFF로 머지 — 카나리 활성 시에만 동작 | 활성 안 됨 → no-op. bounce 차단 효과 0. |
| #8017 verify-email Pipeline | 8개 gate 분리 + 단위 테스트화. 외부 동작 보존 refactor | 외부 동작 동일 — 검증 강도 변경 없음. bounce 영향 0. |
| #8018 validate-status Pipeline | 4개 gate 분리 + 단위 테스트화. 외부 동작 보존 refactor | 외부 동작 동일. bounce 와 무관 (이 step 은 enrollment/sequence 상태 검증). |
| #8019 [beta cherry-pick] | 위 3개를 beta 에 묶어서 cherry-pick | 위와 동일. |
요약. 코드 추가 ≠ 활성화. PR #8016 의 Tier 2 MV 검증은 VERIFY_SEND_TIME_ENABLED=true 가 Infisical 에 설정돼야 작동. 머지만으로는 no-op. 그리고 활성화돼도 SSOT 단절(RC-2)을 해결하지 않으면 is_verified=false 인 lead 가 그대로 Tier 2 로 들어오긴 하나, 이때는 Tier 2 MV 가 cache hit/MV API 로 invalid 차단 가능.
aws-ses 스킬로 send.grinda.ai 의 24h bounce/complaint rate 확인 + AWS suppression 동기화.
RX-4
Hybrid (D) Tier 2 카나리 즉시 활성 — Infisical beta 환경에 VERIFY_SEND_TIME_ENABLED=trueVERIFY_SEND_TIME_WORKSPACE_ALLOWLIST=019e6c21-f28c-7045-80ea-2151554d75b2,fe1c9fd2-02d7-4ab2-ac49-195665f3de86lead_contacts.is_verified 갱신 + undeliverable drop.
RX-6
buyer-search 의 도메인 파싱 결함 수정 — 11.css 같은 HTML 파편이 도메인으로 파싱되지 않게.
RX-7
enrichment 의 role-based 자동 추측(info@, marketing@, contact@) 차단 검토.
is_verified 셋업을 lead_emails 의 cascade 결과로 동기화. 가능한 방법:
lead_emails.verified=1 insert/update 시 동일 email 의 lead_contacts.is_verified mirrorlead_emails 조회 후 결과 반영is_verified=false primary 가 있고 새로 들어오는 게 is_verified=true 면 promote (현재는 무조건 secondary 로).
RX-10
bun check:lead-contact-sync 데이터 정합성 검사 추가 — lead_emails.verified=1 인데 lead_contacts.is_verified=false 인 row 카운트가 임계 초과 시 CI fail.
| 유형 | 위치 |
|---|---|
| 실 cascade 호출 | elysia-server/src/workers/bullmq/lead-on-demand-enrich.worker.ts:231 |
| 실 cascade 결과 저장 | lead-on-demand-enrich.worker.ts:249-272 (lead_emails) / :286-306 (lead_contacts) |
| 발송 시점 lead_contact 조회 | elysia-server/src/workers/bullmq/sequence-email-worker/steps/resolve-lead.ts:33-62 |
| Pipeline Tier 1/2 분기 | elysia-server/src/workers/bullmq/sequence-email-worker/steps/verify-email/gates/ssot-trust.gate.ts, .../mv-fail-open.gate.ts |
| flag 정의 | elysia-server/src/config.ts:472-485 (verifySendTime 블록) |
| 베타 env 실 상태 | ssh beta · docker exec ... env | grep VERIFY_SEND_TIME → 미설정 (= code default false) |