글로벌 린다세일즈 — 시퀀스 이메일 발송 정지

근본원인 분석 · 코드 전수분석 · 최적 해결책  |  beta(프로덕션)  |  2026-06-28  |  WS 50a26184…29b47e
TL;DR — 워커·DB·Redis promoter는 전부 정상인데 발송량이 0에 수렴(전체 하루 693건, 글로벌 sent_1d=0). 원인은 인프라 장애가 아니라 스케줄링 로직 3종 결함이다: ① stagger 누적 cap 부재로 BullMQ 잡 22.6만 건이 미래(최대 75일)로 밀림 → ② jobId dedup 때문에 loader가 앞당겨 재적재 불가 → ③ daily-limit deferred 재활성 경로 없음으로 한 번 밀린 execution이 영구 정지. delayed ZSET의 57%(12.9만)가 7일 초과 미래, promote 지연분은 0건(promoter 무죄).

1. 현황 — 발송이 멈춘 증거

0
글로벌 sent_1h / sent_1d
225,773
sequence-email delayed 적체
0
wait·active (워커 idle)
128,690
7일+ 미래로 밀린 잡 (57%)
지표글로벌전체(all)해석
sent_1h / sent_1d0 / 02 / 693사실상 정지 (정상이면 하루 수만 건)
BullMQ delayed225,773225,782전 워크스페이스 적체의 거의 전부가 글로벌
BullMQ wait / active0 / 00 / 0워커가 받을 일감 없음
pending execution323,861960,413대기 중인 발송
overdue (기한초과)801574,570글로벌 801은 paused 누수분
delivered / sent17,830 / 275,75411.7만 / 87.7만delivery 추적 6%만 (별도 결함)

delayed 22.5만 잡의 promote 예정시각 — promoter 무죄 입증

promote 예정잡 수판정
이미 지남 (score < now)0promoter 정상 — 지연분 없음
~5분 / 5분~1시간10 / 107정상 임박
1시간~1일7,772
1일~7일89,189밀림 시작
7일 초과 (최대 75일)128,690stagger 폭주 — 핵심

75일 = 6,487,500,000ms ÷ 10,000ms = idx 648,750 — 단일 계정에 누적된 64.8만 번째 잡. stagger 간격(10초)에 상한이 없어 산술적으로 도출됨.

2. 전수 데이터

시퀀스 19종

status주요 시퀀스 (active enrollment)
active9뷰티글로벌유통사DB 33,167 · 식품브랜드 28,487 · 뷰티AI제안 27,918 · 브랜드사6.1 19,381 · 뷰티AI제안2 19,297 · 브랜드→린다제안 10,889 · 유아생활용품 10,502
completed8AI캠페인6/2 30,650 · 뷰티RINDA파트너십 8,261 · GTM글로벌바이어 4,456 …
paused1뷰티유통사신규영업 — pending 797 누수 보유
draft1새 캠페인 만들기

Enrollment 214,697 (active 153,585)

statusactive step 분포
active153,585STEP0 (1통도 미발송)76,259 (50%)
completed53,875STEP11,801
bounced5,284STEP214,739
stopped1,502STEP337,316
unsubscribed444STEP423,470

active의 절반(76,259)이 STEP0 — 신규 enrollment가 첫 발송조차 못 받고 적체.

BullMQ 큐 — 병목은 단 한 곳

delayedwaitactive비고
sequence-email225,77300적체 집중
send-acc-* (490개)000per-account 큐 전부 비어있음 (failed 155)
그 외 전 큐~000정상

3. 근본원인 — 코드 전수분석

원인 ①  stagger 누적에 cap이 없어 잡이 미래로 폭주

같은 계정의 잡마다 idx × 10초를 누적 가산하는데 상한이 없다. 단일 계정에 enrollment가 집중되면(33k+) idx가 수십만까지 올라 delay가 며칠~75일이 된다.

src/lib/queue/queues.ts:1944–1952 · addSequenceEmailJobs()
// staggerDelay 에 상한이 없음 — idx 가 곧 delay(분)
const STAGGER_INTERVAL_MS = 10_000
const staggerDelay = baseDelay + acctIdx * STAGGER_INTERVAL_MS   // ← cap 부재
// 6,487,500,393ms 관측 = idx 648,750 × 10,000 + jitter(0~1500)

보조: sequence-email-scheduler.ts:100MAX_FUTURE_MS cap은 Redis throttle 슬롯만 압축할 뿐, BullMQ delayed의 delay 값은 압축하지 않음(applySchedulingDelay는 jitter만 추가).

최적 해결 (P0) — enqueue 시점에 천장 강제:
const staggerDelay = Math.min(baseDelay + acctIdx*STAGGER_INTERVAL_MS, MAX_FUTURE_MS)
또는 idx를 tick batch 크기(loader는 이미 500 cap)로 modulo. loader와 enqueue가 같은 천장을 공유하도록 통일.

원인 ②  jobId dedup으로 미래 잡을 앞당겨 재적재 불가

모든 적재 경로가 jobId = seq-email-${executionId} 고정. BullMQ addBulk는 같은 jobId가 delayed에 이미 있으면 silently 무시(delay·data 갱신 안 함). 한 번 먼 미래로 박힌 잡은 어떤 경로로도 당길 수 없다 → loader가 매 tick 재적재해도 jobsEnqueued:0.

queues.ts:1840–1862 (dedup) · sequence-email-loader.worker.ts:254 · daily-limit-deferral.service.ts:206
// getJob(jobId) 가 waiting/delayed/active 면 skip → 갱신 없이 그대로 둠
if (existing && ['waiting','delayed','active'].includes(state)) return  // 앞당기기 불가
최적 해결 (P0) — dedup을 "존재하면 skip"이 아니라 "존재하면 job.changeDelay(newDelay)로 갱신"으로 변경. 또는 먼 미래 잡을 remove() 후 재적재(데일리 deferral의 active-skip 패턴 차용).

원인 ③  daily-limit deferred 재활성 경로 부재

일일 한도 도달 시 그 계정의 오늘자 pending을 전부 deferred로 뒤집고 scheduled_at += 24h. 그러나 loader의 due 쿼리는 WHERE status='pending'만 잡고, deferred → pending을 되돌리는 크론·스케줄러가 코드 전체에 없다. 재진입은 오직 그 execution의 +24h delayed 잡이 fire될 때만 가능 → 그 잡이 원인 ①②로 안 풀리면 영구 정지.

send-email.ts:1027–1034 · daily-limit-deferral.service.ts:128–211 · loader WHERE: sequence-email-loader.worker.ts:180 · drizzle-execution-repo.adapter.ts:80,172
최적 해결 (P0) — ⓐ loader SELECT를 status IN ('pending','deferred') AND scheduled_at < cutoff로 확장하거나, ⓑ 자정/주기 크론으로 scheduled_at <= now()deferredpending으로 일괄 복원.

원인 ④  paused 시퀀스의 pending execution 누수

시퀀스 pause 시 BullMQ 잡은 지우지만 sequence_step_executions.status는 건드리지 않아 overdue pending이 영구 잔존(글로벌 797건). 재개 시 rescheduleOverdueExecutions의 NOW-shift 부하와 완료판정 오염을 유발.

queues.ts:2084–2277 (cancelSequenceJobs — execution status 미변경) · sequence-lifecycle.worker.ts:69–98
해결 (P1) — pause 시 pending을 복원 가능한 보존상태로 마킹(enrollment 경로의 skipped 정책을 시퀀스 pause에도 일관 적용).

원인 ⑤  untracked send path → delivered 추적 6%

warmup 발송이 SES로 나가지만 emails가 아닌 warmup_message에만 기록 → 그 메일의 모든 SES 이벤트가 orphan 처리(emails row not foundorphan retry cap reached — deleting). delivered가 emails 통계로 환원되려면 emails.sesMessageId 매칭이 선행돼야 하는데 row가 없음.

warmup-send.worker.ts:215–250 (emails INSERT 없음) · ses-event.service.ts:230–294 (followup만 fallback, warmup 미조회)
해결 (P1)ses-event.service의 fallback lookup에 warmup_message.ses_message_id를 추가하거나, untracked 경로도 emails row를 남기도록 통일.

원인 ⑥·⑦  부수 — skip 부풀림 / 외부 요인

해결 (P2) — dual-write 정리(단일 경로로 수렴) · Anthropic 크레딧 충전 또는 budgeted provider 폴백 점검 · orphan mission finalize 로직 보강.

4. 최적 해결책 — 실행 순서

A. 즉시 운영 복구 코드 배포 불필요 · 분 단위

지금 멈춘 22.5만 잡을 흐르게 하는 1회성 조치:

  1. 먼 미래 잡 일괄 재스케줄sequence-email:delayed에서 score가 7일 초과인 잡을 대상 execution의 실제 scheduled_at 기준으로 changeDelay 또는 remove 후 재적재. (128,690건)
  2. deferred 복원scheduled_at <= now()deferred execution을 pending으로 UPDATE → 다음 loader tick이 정상 enqueue.
  3. paused 누수 정리 — 글로벌 "뷰티유통사신규영업"의 overdue pending 797을 보존상태로 마킹.

⚠️ 재스케줄 시 stagger를 다시 cap 없이 적용하면 재발 — 반드시 B-①을 먼저 핫픽스하거나, 재주입 시 천장을 건 스크립트로 수행.

B. 코드 핫픽스 재발 방지 · P0

#수정파일
stagger에 Math.min(…, MAX_FUTURE_MS) 천장queues.ts:1944
dedup을 skip → changeDelay() 갱신으로 전환queues.ts:1840–1862
loader SELECT에 deferred 포함 또는 복원 크론 신설loader.worker.ts:180

①②③은 서로 맞물림 — 셋을 함께 배포해야 "밀림 → 고착 → 영구정지" 사슬이 끊긴다.

C. 후속 정리 P1–P2

④ paused execution 상태 일관화 · ⑤ SES warmup fallback lookup · ⑥ dual-write 단일화 · ⑦ Anthropic 크레딧/폴백. 발송 정지와 독립이므로 A·B 안정화 후 순차 진행.

5. regression — 누가·어느 커밋에서 깨졌나

결론 — 한 커밋이 아니라 4~6월에 걸쳐 들어간 두 개의 독립 regression이 6월 글로벌 대량 enrollment에서 동시 발화했다. 책임 커밋의 author는 모두 chlee(Cheolhee Lee) 본인. repo는 2026-04-19 #4837에서 orphan root로 squash 재임포트돼 그 이전 최초 작성자는 이력에서 소실됨.

책임 커밋 (영향순)

#sha날짜PRauthor무엇을 깨뜨렸나
1626616fdd05-19#7665chlee결정타: STAGGER_INTERVAL_MS 400→10,000(25배). cap 없는 idx×interval이 75일 미래까지 폭발
2dfdb1b28504-26#5862Cheolhee Lee근본 분기점: cron-tick loader를 WHERE status='pending'만으로 만들며 deferred→pending 재활성 짝 누락 → daily-limit 잡 영구정지
36f2cd3e4e04-28#6150chlee결함① 근원: cap 없는 acctIdx × STAGGER_INTERVAL_MS 공식 최초 도입 (worker cap은 BullMQ delay 미보호)
49cc701f8506-05#8191chleeworkspace-level deferred write 복제 — reactivator 없이 2번째 정지 경로 추가
53603fc81506-05#8210chleeper-account adapter가 pending-only 필터 그대로 상속 → 결함을 새 발송 경로로 확산
6826c3363406-05#8220chleePER_ACCOUNT_SEND_ENABLED canary — 결함을 실제 트래픽에 노출

결함②(jobId dedup-skip)는 regression이 아님#4837 원설계부터 있던 정상 중복방지 장치인데, #7665가 잡을 수십 일 미래로 밀어내며 "앞당겨 재적재 불가" 사각지대가 드러난 것. changeDelay(#7649)는 수동 step-수정 경로에만 연결, loader 정상 경로엔 미연결.

회귀 타임라인

04-19 #4837 squash 재임포트 (baseline: dedup-skip 설계 + account deferred-write 이미 존재) 04-26 #5862 ⚠️ cron-tick loader → DB status를 due 게이트 SSOT로. deferred 재활성 누락 ← 결함③ 시작 04-28 #6150 stagger 공식 도입 (cap 없음, idx×1125ms) ← 결함① 씨앗 04-30 #6331 stagger 1125→400 (오히려 완화) 05-19 #7649 changeDelay 도입 — 수동 경로만 (loader 미연결) 05-19 #7665 💥 STAGGER 400→10,000 (25배) → 75일 미래 폭발 ← 결함① 발화 06-05 #8191/#8210/#8220 workspace defer 복제 + per-account 확산 + canary 노출 ← 전 경로 확산

핵심 인과

두 결함은 서로 독립이지만 같은 증상(발송 0)으로 수렴한다:

  1. #5862 — daily-limit 도달 계정의 execution이 deferred로 바뀌면 loader가 영영 다시 안 집어듦(write/read가 같은 enum을 영구히 어긋나게 사용). 되돌릴 크론이 코드 전체에 없음.
  2. #7665 — 단일 계정 집중 enrollment(글로벌 33k+)에서 idx×10초가 누적돼 잡이 며칠~75일 미래로 박힘. dedup 때문에 loader가 당겨오지도 못함.

왜 6월에 터졌나 — ①②는 4~5월에 잠복해 있었고, 글로벌 린다세일즈가 6월에 단일 계정에 수만 건씩 집중 enrollment(뷰티유통사 33k 등)를 활성화하면서 stagger idx가 임계를 넘고 per-account canary(#8220)가 켜지며 동시 발화. 소규모 시퀀스 시절엔 idx가 작아 잡이 초~분 단위 미래라 자연 소화됐다.


데이터 출처: beta 프로덕션 PG18 + Redis8 BullMQ 실측 · elysia-server 코드 전수분석(3-way) · git 커밋 이력 추적(2-way). 코드 라인·sha는 분석 시점 기준이며 배포 전 재확인 권장.