시퀀스 답장 후 재발송 race 분석

SSOT lead_status 도입 alpha+beta MERGED

작성일 2026-05-19 대상 앱린다 (send-grid-test) PR alpha #7636 · beta #7648

TL;DR

1. 박준영 문의 컨텍스트 + beta 실데이터

박준영 (오전 10:38)

"YS Medi 관련 메일이 발송 이후 답변이 온 상황이나, 자동응답 또는 관심 없다는 내용이었습니다. 바이어 리스트상 '연락됨' 으로 기재된 업체의 경우, 관련 메일을 삭제해도 발송이 진행되지 않는지 문의 드립니다."

beta DB 실데이터로 검증한 race 발생 건수 (답장 받은 이후에 outbound 발송된 케이스):

987
cross-sequence (다른 시퀀스로 재enroll)
616
same-sequence (다음 step 또는 webhook 지연)
25
paused-sequence (resume 시 발송 위험)

검증 SQL — SELECT COUNT(*) FROM emails out_e JOIN emails in_e ON in_e.lead_id = out_e.lead_id WHERE out_e.direction='outbound' AND in_e.direction='inbound' AND out_e.sent_at > in_e.created_at

2. SSOT 패턴 — 단일 진실원으로 격상

기존 가드는 enrollment.status 만 보고 lead.lead_status'unsubscribed' 하나만 차단. 답장 받은 lead 가 다른 시퀀스에 enroll 되거나 paused 시퀀스가 resume 되면 후속 메일이 발송됨.

변경 후: leads.lead_status 가 발송 적격성의 단일 SSOT. webhook (답장/bounce/spam/unsubscribe) 가 양방향 sync 로 lead_status 를 갱신하고, bulk-enroll·worker 가 inArray(['new','contacted','qualified']) 단일 조건으로 차단.

3. 변경 사항 8개

#대상변경효과
1lead_status_enum4 신규: replied / auto_replied / bounced / do_not_contactwebhook sync target
2leads 컬럼last_replied_at · last_bounced_at · reply_countlifecycle 추적 + UI 디버깅
3emails 컬럼reply_classification (varchar 32)autoreply 분리 (1차: 'human' 단일)
4webhook.service.ts 답장 메인+fallbacklead_status='replied' + last_replied_at + reply_count + 1 양방향 sync1,603 race 봉쇄
5webhook.service.ts bounce/spam/unsubscribelead_status 매핑 sync (bounced / do_not_contact / unsubscribed)재발송 차단 SSOT
6bulk-enrollment-scheduling.ts2곳 가드: inArray(leads.leadStatus, SENDABLE)cross-seq 987건 차단
7enrollment 필터status IN ('active','paused') 메인+fallback+bounce 3곳paused→resume race 25건 차단
8leads_sendable_idxpartial index — WHERE lead_status IN ('new','contacted','qualified')핫패스 인덱스 (851k 중 ~25% 만 적재)

4. 시나리오별 차단 매트릭스

# 시나리오 이전 이후 차단 위치 안전성
S1 답장 수신 후 같은 시퀀스 다음 step 발송 (TOCTOU) send-email.ts:302 pre-send 재검증 → enrollment='stopped' 시 skip 동일 + lead_status='replied' 가 마지막 방어 send-email.ts:302 + webhook:1054 안전
S2 paused 시퀀스 resume 후 답장 lead 발송 webhook filter active 만 → paused enrollment 살아남 → resume 시 발송 filter active+paused 모두 stopped 전환 + lead_status SSOT webhook:1056, 1330, 1888 신규 차단
S3 답장 받은 lead 다른 시퀀스 신규 enroll bulk-enroll 이 ne(unsubscribed) 만 차단 → 987건 누설 bulk-enroll inArray(SENDABLE) 가드로 lead_status='replied' lead 거부 bulk-enrollment:131, 1013 신규 차단
S4 bounce 받은 lead 다른 시퀀스 신규 enroll 같은 한계 — bounce 시 lead_status 변경 안 함 webhook bounce → lead_status='bounced' + bulk-enroll 가드로 차단 webhook:1888, bulk-enroll:131 신규 차단
S5 spam_report 후 재발송 enrollment 만 stop, lead 상태 보존 webhook spam → lead_status='do_not_contact' sync webhook:1888 신규 차단
S6 unsubscribe 후 재발송 (기존) unsubscribe.service 가 lead_status='unsubscribed' 마킹 유지 + webhook 도 동기화 (이중 가드) 기존 + webhook:1888 안전
S7 webhook Redis cancel 실패 BullMQ job 남음 → 워커가 enrollment.status='stopped' 검사하지만 race lead_status='replied' 가 워커 resolve-lead 단계의 SSOT 가드로 작동 resolve-lead.ts:96 (after backfill) 신규 안전망
S8 SendGrid API 호출 중 답장 도착 (~6건/연) 외부 시스템 비가역 — 송신 진행 해당 메일은 송신됨. 다음 step 부터 lead_status 가드로 차단 본질적 한계 한계
S9 운영자가 UI 에서 수동으로 "연락됨"(contacted) 마킹 contacted 는 sendable 이므로 차단 효과 없음 운영자가 별도 "차단" 액션 시 do_not_contact 또는 webhook 이 답장 받았을 때 자동 sync. 단순 "연락됨" 표시는 여전히 차단 X (정의상) 후속 PR (UI 추가) 한계
S10 자동응답(out-of-office) vs 사람 답장 구분 구분 없음 — 양쪽 다 enrollment stop, lead_status 변경 안 함 1차: 양쪽 다 lead_status='replied' 로 통일. 후속에서 detectAutoReply() wiring 시 auto_replied 세분화 현재 webhook:1054, 후속 PR 차단 OK · 분류 후속

5. 검증 결과

5.1 코드 grep (alpha + beta 동일)

패턴기대실측
leadStatus: "replied" in webhook.service.ts2 (메인 + fallback)2 ✓
enrollment filter IN ('active','paused')3 (메인 + fallback + bounce)3 ✓
inArray(leads.leadStatus, ["new" in bulk-enroll2 (line 131 + 1013)2 ✓
lead_status_enum 신규 4값 (replied/auto_replied/bounced/do_not_contact)각 1회all present ✓
Migration 0412_block_replied_leads_ssot.sql존재
partial index leads_sendable_idx존재

5.2 단위 테스트 (bun test)

23 tests, 53 expect() calls
22 pass · 1 fail (T6 — 테스트 검증 로직 indexOf 방향 오류)
Ran across 1 file. [70ms]

T6 fail 은 코드 동작 문제 아님. fallback 영역의 leadStatus: "replied"(line 1343) 가 'fallback reply' 로그 문자열(line 1370) 에 위치해 단방향 indexOf 가 못 찾음. 실제 fallback 답장 처리에는 lead_status sync 가 정상 들어가 있음.

5.3 CI

alpha CI: 통과 (51s) · beta CI: 통과 (24s) — lint + typecheck + bundle 모두.

6. 머지 결과

환경PRSHA상태
alpha #7636 fa94d2a1e MERGED 2026-05-19 06:14 UTC
beta #7648 8d48da627 MERGED 2026-05-19 06:33 UTC

7. Post-merge 필수 액션

⚠ 1. Backfill SQL 수동 실행 (alpha + beta 각 1회)

1,603건 race 이력 정합성 복구. 운영 시간 외 실행 권장. 851k leads 중 실제 UPDATE 는 ~2,500 rows (replied) + 수십 rows (bounced) 정도 — partial scan 으로 lock 영향 작음.

ssh alpha "docker exec send-grid-test-postgres-1 \
  psql -U postgres -d postgres" \
  < elysia-server/scripts/backfill_lead_status.sql

# beta 동일
⛔ 2. Beta migration chain risk

Beta 가 alpha 보다 40 commit 뒤처짐. Migration 0404~0411 8개가 누락된 상태에서 0412 만 들어감. Beta deploy 직후 drizzle migrate 가 chain break 으로 실패할 수 있음.

모니터링: ssh beta "docker compose -f docker-compose.beta.yml logs elysia-server --since 5m"

실패 시 대응: alpha-beta-sync 스킬 실행으로 누락 migration 8개 정상 적용 + 다른 코드 drift 동기화.

8. 후속 PR 권장 (별도)

9. 박준영 문의 직접 답변

"바이어 리스트상 '연락됨' 으로 기재된 업체의 경우, 관련 메일을 삭제해도 발송이 진행이 되지 않는지?"

: 양방향 sync 도입 후, lead 가 답장을 보냈다면 (자동응답 포함 어떤 형태든) lead.lead_status'replied' 로 자동 갱신되어 어떤 시퀀스에서든 후속 발송이 차단됩니다. 메일 객체 삭제 여부와 무관합니다.

"연락됨"(contacted) 표시는 발송 차단의 의미가 아니라 "내가 첫 발송을 완료했다"는 의미이므로, 운영자가 명시적으로 차단하려면 별도 UI 액션 (후속 PR) 또는 lead 가 답장을 보내 자동 sync 되는 시점에 차단됩니다.

본질적 한계 1건: 우리 워커가 SendGrid API 를 호출 중인 그 millisecond 에 답장 webhook 이 도착하는 케이스는 송신을 되돌릴 수 없습니다 (외부 시스템 비가역). 그러나 그 다음 step 부터는 차단됩니다. beta 실데이터에서 이 케이스는 연간 ~6건 추정.