✅ Fix 머지 완료
lead-enrich 429 폭주 → sequence-email stuck — 원인 + Fix
2026-06-05 · alpha #8198 · beta #8200 머지 완료
📅 2026-06-05 07:21 KST 📦 5-Layer Defense (헥사고날) 🏷 5 파일 / +144 / -2
한 줄 원인
발신계정 18개 분산은 정상. 진짜 stuck 원인은 lead-on-demand-enrich worker 가 Hunter/Findymail 결제 한도 (429) 에 부딪혀 무한 retry 폭주 → 같은 Node.js process 의 sequence-email-loader 가 event loop 차례 못 얻어 overdue 30k+ 잡을 BullMQ 에 enqueue 못 함. catch 블록이 모든 에러를 enrichment_failed 로 매핑 → auto-trigger 가 다시 enqueue → 무한 루프.
1. 사건 타임라인
시간 (KST) 이벤트
04:11 beta CD 배포 healthcheck 240s 타임아웃 — Redis OOM 첫 의심
04:28 Redis maxmemory 4G → 8G + swap=0 PR 머지 (#8175/#8176)
05:13 AI 캠페인 6/2 사용자 pause+resume → 18계정 분산 적용
05:35 글로벌 식품 브랜드사 pause+resume → 18계정 균등 발송 시작 (분당 52건)
05:40~ 발송 급감 (1-3건/분) — 진짜 원인 추적 시작
05:55 Hunter 429 폭주 + lead-enrich 86k 적체 발견
06:30 5-Layer Defense 코드 작성
07:21 fix(lead-enrich) 양쪽 머지 (alpha #8198, beta #8200)
2. 진짜 원인 — 무한 retry 사슬
┌─────────────────────────────────────────────┐
│ Findymail 결제 한도 초과 (월 cycle) │
│ ↓ 429 too_many_requests │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ run-fast-enrich.ts:122-130 catch 블록 │
│ → 429 분기 없이 모두 enrichment_failed │
│ → reason='internal_error' │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ worker → lead_enrichment_state.status = │
│ 'enrichment_failed' 저장 │
│ → BullMQ attempts:3 자동 재시도 │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ auto-enrich-trigger.ts:58 WHERE 절 │
│ status IN ('pending', 'enrichment_failed')│
│ → 같은 lead 자동 재enqueue (무한 루프) │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ concurrency 25 × pLimit(25) × 86k 큐 │
│ → Hunter/Findymail API 호출 폭주 │
│ → Node.js event loop ~89s 동결 │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ sequence-email-loader (30s tick) │
│ → DB scan & enqueue 차례 못 얻음 │
│ → overdue 30,339건 BullMQ 에 안 들어감 │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ sequence-email worker active=0 │
│ → 발송 stuck (분당 1-3건) │
└─────────────────────────────────────────────┘
3. 분산 자체는 정상 (참고)
캠페인 enrollment 분배
AI 캠페인 6/2 33,132 18계정 균등 (847-953/계정, CV 3%)
글로벌 식품 브랜드사 29,903 18계정 균등 (1,265-1,424/계정, CV 3%)
chat 보존 (이미 sent step1) 22,171 thread 일관성 위해 sticky 유지
발신계정 분산은 SHA256 sticky binding 으로 완벽 동작. 문제는 BullMQ 처리율 — lead-enrich 가 점유한 event loop.
4. Fix — 5-Layer Defense (헥사고날)
Layer 파일 책임
1. Domain services/lead-enrichment-fast/errors.ts (신규)ProviderQuotaExhaustedError + isQuotaErrorMessage() + isTransient()
2. Adapter findymail.service.tsin-service retry 소진 후 429 → throw
3. Usecase run-fast-enrich.tscatch + re-throw (worker 가 UnrecoverableError 변환)
4. Worker lead-on-demand-enrich.worker.tspermanent → UnrecoverableError, transient → BullMQ exp retry
5. DB status (같은 worker) status='unreachable' 저장 → auto-trigger WHERE 절 제외
핵심 메커니즘
UnrecoverableError — BullMQ attempts:3 자동 retry 차단 (workspace 별 전체 86k 폭주 중단)
status='unreachable' ≠ 'enrichment_failed' — auto-enrich-trigger 의 WHERE IN ('pending', 'enrichment_failed') 에 안 들어가 무한 재enqueue 루프 차단
1일 negative cache 만료 후 다음날 자연 회복 (Findymail 결제 cycle 갱신 가정)
isTransient (Retry-After < 10분) 분기 — 일시 429 는 BullMQ exp retry 유지 (영구 vs 일시 구분)
5. 시나리오 검증 (7 cases all pass)
# 시나리오 결과
S1 Findymail 영구 결제 한도 ✅ UnrecoverableError + status=unreachable
S2 일시 429 (Retry-After < 10분) ✅ isTransient → BullMQ exp retry
S3 다중 provider (Hunter/Findymail/MV) ✅ provider 별 lift
S4 같은 워크스페이스 burst (86k) ✅ status=unreachable → 재enqueue 안 됨
S5 결제 cycle 갱신 ✅ negative cache 1d 만료 → 자연 회복
S6 Worker crash 중간 ✅ jobId dedupe + stalled 감지
S7 코드 배포 중 (기존 delayed 잡) ✅ 새 catch 적용 — migration safe
6. 로그 최적 (Pino structured)
1. [Findymail] Quota exhausted after in-service retries — lifting to domain error
{ endpoint, maxRetries }
2. [FastEnrich] Provider quota exhausted — re-throwing for worker UnrecoverableError
{ jobId, leadId, provider, retryAfterMs, message }
3. [LeadEnrichWorker] Provider quota exhausted — permanent (blocking retry)
{ leadId, workspaceId, provider, retryAfterMs, isTransient, durationMs, jobId }
3단계 로그 → adapter → usecase → worker 호출 chain 추적
structured field: provider, retryAfterMs, isTransient, durationMs
level: warn (의도된 차단, 시스템 정상)
prefix: 기존 관행 ([Findymail] / [FastEnrich] / [LeadEnrichWorker]) 유지
7. 코드 작성 history
파일 작성자 최근 변경
run-fast-enrich.tsGyudong Kim 2026-05-19 (a56bd6c03)
lead-on-demand-enrich.worker.tsGyudong Kim 2026-06-05 (1b4c03022) — lockDuration 30s→5m fix
auto-enrich-trigger.tsGyudong Kim 2026-05-19 (a56bd6c03)
findymail.service.tsGyudong Kim 이전 작성
lead-enrich 모듈 전체는 Gyudong Kim 작성. 오늘 (2026-06-05) lockDuration 5분 상향 fix 도 같은 사람 — 같은 incident 의 lock-만료 측면을 fix 했지만 근본 retry 폭주는 미해결. 이번 PR 이 그 나머지 (5-Layer Defense).
8. 후속 작업 (P1~P3)
우선순위 작업 설명
P1 Redis circuit breaker workspace × provider 5분 open — 첫 lead 만 429, 나머지 즉시 skip
P1 Hunter / MV adapter 의 429 throw 현재는 Findymail 만 throw
P1 worker 컨테이너 분리 lead-enrich 와 sequence-email Node.js process 격리
P2 Domain Event (Slack alert + metric) quota_exhausted Workspace 자동 알림
P2 Token bucket rate limiter provider 호출 전 선제 차단
P3 Workspace 월 USD ceiling 재활성화 현재 의도적 disabled ("free feature")
Cloudflare Pages · Rinda Incident Memo · 2026-06-05 · alpha #8198 + beta #8200 머지