RCA · 2026-05-31

SES Bounce 알람 근본원인 분석

오늘(2026-05-31) 베타에서 두 워크스페이스의 SES bounce rate 가 임계(2%) 를 크게 초과한 알람이 발생. 어제(2026-05-30) 머지한 발송워커 검증 코드(PR #8016, #8017, #8018) 가 bounce 를 막았어야 한다는 가설을 검증.

🚨 레인보우팜 (019e6c21): 1h 115발송 / 63 bounce (54.78%) ⚠️ 스페로네_0430 (fe1c9fd2): 1h 111발송 / 6 bounce (5.41%)

최종 판단

두 가지 근본 원인.

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 주소가 검증 없이 발송됨.

사용자 가설 vs 실제

사용자 가설

"오늘 발송워커에 검증 로직을 추가했으니 bounce 가 발생하면 안 된다."

실제

"코드는 추가됐지만 default OFF 라 호출되지 않는다. 그리고 upstream SSOT 가 단절돼 있어 활성화돼도 isVerified=false 가 그대로 들어온다."

가설 검증 — 3가지 시나리오

가설 A · 새 Pipeline 코드가 잘못 동작 (회귀)기각

증거 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.

가설 B · 새 Tier 2 검증이 활성됐지만 효과 없음 (provider 장애 등)기각

증거 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)만 채우는 중.

가설 C · 검증이 비활성 상태 + upstream SSOT 단절확정

증거 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 문제 아님.

핵심 도식 — bounce 가 일어난 흐름

┌─ 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                       │
└──────────────────────────────────────────────────────────────────┘

13개 lead_contacts INSERT 경로 — is_verified 처리 매트릭스

경로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 (확인 필요) (확인 필요) ?

추가 결함 — on-demand-enrich 의 primary 슬롯 보존

// 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 로만 저장됨.

레인보우팜 실측:

베타 운영 실측 (2026-05-31 알람 시점)

측정레인보우팜스페로네_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 들이 bounce 를 막지 못했나 (PR 별 의도 명확화)

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 차단 가능.

처방

즉시 (오늘)

RX-1 레인보우팜 발송 일시 중지 — 82.6% bounce 로 AWS SES 자동 sender suspension 위험. 계정 전체 영향 가능. RX-2 스페로네_0430 모니터링 — 임계 2% 의 3.6배. 7.2% 가 지속되면 5% 경고선 진입. RX-3 aws-ses 스킬로 send.grinda.ai 의 24h bounce/complaint rate 확인 + AWS suppression 동기화. RX-4 Hybrid (D) Tier 2 카나리 즉시 활성 — Infisical beta 환경에
VERIFY_SEND_TIME_ENABLED=true
VERIFY_SEND_TIME_WORKSPACE_ALLOWLIST=019e6c21-f28c-7045-80ea-2151554d75b2,fe1c9fd2-02d7-4ab2-ac49-195665f3de86
설정. 두 워크스페이스의 신규 enqueue 부터 Tier 2 MV 가 invalid 차단.

단기 (1주)

RX-5 레인보우팜 lead 일괄 재검증 스크립트 — MillionVerifier batch 로 lead_contacts.is_verified 갱신 + undeliverable drop. RX-6 buyer-search 의 도메인 파싱 결함 수정 — 11.css 같은 HTML 파편이 도메인으로 파싱되지 않게. RX-7 enrichment 의 role-based 자동 추측(info@, marketing@, contact@) 차단 검토.

중기 (2주~) — 근본 해결

RX-8 SSOT 통합 PR — 9개 INSERT 경로의 is_verified 셋업을 lead_emails 의 cascade 결과로 동기화. 가능한 방법:
  • (a) DB trigger: lead_emails.verified=1 insert/update 시 동일 email 의 lead_contacts.is_verified mirror
  • (b) 9개 INSERT 경로 각각에서 INSERT 직전 lead_emails 조회 후 결과 반영
  • (c) view/MV 로 lead_contacts 를 lead_emails 기반 derived 화 (가장 큰 변경)
RX-9 primary 슬롯 promotion 로직 수정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)