글로벌 린다세일즈 Bounce 분석 + 최적 정책 설계

2026-06-02 · workspace_id 50a26184-0383-41a6-b29a-8954ca29b47e · 데이터 기반 권장안
🚨 즉시 조치 필요 — 현재 시퀀스 bounce rate 24.7% (정상범위 5%의 4.9배). 진행률 3%인 상태에서 그대로 두면 33,132건 × 24.7% = ~8,170건 추가 hard bounce. AWS SES 계정 정지 위험 (5% 초과 시 일시 정지, 10% 초과 시 계정 정지).

1. 캠페인 표면 수치

리드 풀
33,132
gtm_beauty_upload 소스 (verified=0)
진행률
3%
시작 ~수시간
총 발송
865
반송 (bounce)
214
24.7% · 정상의 5배
오픈율
5.4%
37건
클릭율
5.2%
22건 · 오픈의 42%
회신율
0.3%
2건

2. 발송워커 처리 흐름

sequence-email-worker/processor.ts 의 step 0~5 + Step 2의 6-gate pipeline + mvFailOpenGateverifyEmail() 의 캐시 흐름.

sequence-email-worker flow
Pipeline 6 gates 요약 (verify-email/pipeline.ts)
  1. formatGate — regex 검증
  2. roleGateisUndeliverableEmail (noreply/postmaster/billing 등). info/sales/contact 는 GENERIC_PREFIXES 에 있지만 호출 안 됨
  3. dummyGate — jane.doe/test/example
  4. disposableGate — disposable-domains.json
  5. dedupGate — sequence+step+to_email 중복
  6. mvFailOpenGate (Tier 2)verifyEmail() 호출:
    • ev:<email> 캐시 (50y) → hit 시 즉시 return
    • bounce:bl:<email> 캐시 (hard 90d / soft 7d)
    • mx:v1:<domain> 캐시 (positive 24h / negative 1h) → DNS resolveMx fallback
    • MV API call (144 req/s throttle)
    • KR 도메인 보정 (.kr/.한국)
    • Option B fallback (API 실패 시 hasMx ? risky(score=20) : undeliverable(0))
    정책: undeliverable || (risky && score≤20) → skipStep, 나머지 → pass. CreditsExhausted catch → pass (fail-OPEN).

3. 근본 원인

L1. 리드 풀의 98% 가 unverified

글로벌 ws lead_emails 33,132건 중 verified=1 단 ~2%. 90.4% 가 source='gtm_beauty_upload' 이고 코드베이스에 grep 매치 0건 = 외부 스크립트/수동 SQL 로 raw insert. 정식 verifyEmailCascade 경로 우회.

L2. mvFailOpen 정책의 의도된 통과

현 정책 score≤20 차단 은 jaykim 인시던트 (#4214, 2026-04-09) 의 직접 결과 — "MV unknown 의 79%가 유효". 그래서 score=30 (unknown), score=50 (catch_all) 은 모두 send 허용. 글로벌 ws B2B 도메인은 MV SMTP probe 차단 비율이 높아 unknown/catch_all 응답이 60%+ → 통과율 폭증.

L3. NDR 이중 기록 (UI 인플레이션)

email-event.service.ts:293-343 — SES가 보낸 MAILER-DAEMON NDR이 inbound 메일박스로 와서 direction=inbound, status=bounced 로 emails 테이블에 INSERT. bounce 1건 → DB row 2개 (outbound + inbound NDR). admin UI 카운트가 ~2배로 부풀어보임 (오늘 outbound 195 + inbound 137 = 332).

4. MV 실측 데이터 (각 50 random sample)

Score 분포 — bounced 50 vs delivered 50

MV Score분류bounced 50delivered 50
5undeliverable (invalid/no_mailbox)40
20Option B fallback (API timeout, MX 있음)1810
30risky unknown (ip_blocked/greylisted)122
50risky catch_all1510
95deliverable (ok/good)128

핵심 관찰: Score 20 (Option B fallback) 은 bounce/delivered 양쪽에 고르게 분포 — MV API timeout 자체는 bounce 신뢰 시그널이 아님. Score 30 unknown 은 bounce 가능성 6배. Score 95 deliverable 중 1건은 MV false-positive (catch-all 도메인의 dead mailbox).

4-A. 추가 Bounce 통계

Bounce type 분포 (24h, outbound)

bounce_type건수비율의미
hard16374.8%영구 실패 (mailbox 없음) — SES reputation 직격
soft5525.2%일시 실패 (mailbox full / policy) — 재시도 가능

시간대별 분포 (KST, 2026-06-02)

시간발송bouncebounce rate
12시1072220.6%
13시1968040.8% ← peak
14시2654517.0%
15시2094521.5%
16시942627.7%

13시 peak에 196건 발송 중 80건 bounce. 5% SES 임계값 초과 후에도 발송 지속 → SES reputation 손상 진행 중. 자동 pause hook 절실.

다른 ws 비교 (7일)

ws발송bouncedrate판정
글로벌 린다세일즈 (오늘만)86521424.7%🚨 SES 정지 위험
제이에이치유통2,3631998.42%⚠️ 모니터링 필요
aeroway2,628582.21%정상범위
린다세일즈2,896150.52%우수
YS Medi6,557210.32%우수

5. Confusion Matrix — 임계값별 trade-off

임계값TP
(block bounce)
FN
(miss bounce)
FP
(block delivered)
precisionrecallYouden's J
≤20 (현재)2228100.690.440.24
≤303416120.740.680.44 ← 최적
≤50491220.690.980.54
≤70491220.690.980.54
≤95500500.501.000.00
≤50 정책의 함정 — recall 98% 매력적이지만 delivered 50건 중 22건 (44%) 정상 발송을 잘못 차단. 사용자가 우려한 "모두 차단되는 이슈" 가 실제로 발생.
≤50 임계값에서 잘못 차단되는 delivered 22건 (FP 사례)
Score분류이메일
50catch_allinfo@thebase.in, info@gentlemenx.com, export@thebeautystorysgp.com, india@velnik.com, info@gblg.com, chicor_com@shinsegae.com, support@lamior.com, info@nbcgroup.kz, sales@arti.trade, steve@vitarain.com
20Option B fallbackinfo@agkujatrading.com, sales@gcc-partners.com, info@octipharma.com, info@alazimperfumes.com, customerservice@beautyonwheels.ae, solution@scanmed.com.sg, export@dreamgeneraltrading.com, contact@alharamainperfumes.in, info@kelam.com.mv, export@medicello.com
30unknownglobal3@nutricare.co.kr, info@beautyprestige.ae

6. Domain-level prior bounce rate (강력한 추가 시그널)

같은 도메인의 24시간 발송 결과를 보면 도메인 자체가 bounce 결정적임. 같은 도메인 ≥3건 발송 중:

✅ Bounce 0% 도메인

도메인발송bounce
gmail.com490
khaleejtimes.com110
lvmh.com40
beiersdorf.com40
razer.com50
gulfnews.com40

❌ Bounce 100% 도메인

도메인발송bounce
dosinternational.co.kr33
asbahproducts.com33
emirates-online.net33
atninfo.com33
stayve.me33
nextgenzuae.com33
leaderhealthcare.in33
medwin.ae33

패턴 — 캠페인이 도메인마다 info@/sales@/export@ 3 prefix 로 발송. 도메인의 첫 hard bounce 가 발생하면 같은 도메인의 다른 prefix 도 99% 확률로 bounce. 즉 도메인 1번 bounce 시 그 도메인의 나머지 enrollment 즉시 차단 만으로도 effective recall ~70% 달성 가능.

7. 2026 최적 정책 설계 — 3-Layer Adaptive

Layer 1. mvFailOpen base (현 정책 유지)

undeliverable || score≤20 → block. 모든 ws 기본값. recall 44%, FP 20%. 변경 없음 (다른 ws의 recall 보호).

Layer 2. Adaptive threshold (per-ws bounce rate 기반)

rolling 24h bounce_rate < 5%   → ≤20 유지 (현재)
5% ≤ rate < 10%               → ≤30 강화 (precision 0.74, recall 0.68)
rate ≥ 10%                    → ≤30 + 활성 시퀀스 자동 pause (SES protect)

구현: Redis 캐시 bounce_rate:ws:<id> 5분 TTL. mv-fail-open.gate.ts 에서 getEffectiveScoreThreshold(workspaceId) 호출.

Layer 3. Domain 3-Strike Rule (가장 효과적인 추가 게이트)

같은 도메인 24h hard bounce ≥ 2건 → 그 도메인 발송 즉시 차단

실측 데이터로 검증: 글로벌 ws 의 도메인 prior 패턴 (100% bounce 도메인 8개+) 만 잡아도 추가 차단 ~50건. Redis domain:bl:<domain> zset, hard 90d / soft 7d. bounce webhook에서 자동 갱신.

Upstream. gtm_beauty_upload backfill (장기 정공법)

33,132 unverified lead 에 verifyEmailCascade batch — 발송 시작 전 사전 검증. 그렇지 않으면 다음 캠페인도 같은 패턴 반복.

8. 구현 코드 (2026 권장 패턴)

Patch 1. mv-fail-open.gate.ts — 임계값 동적화 (Layer 2)

// 기존 (hard-coded ≤20):
if (verdict?.result === "risky" && verdict.score <= 20) {
  return skipStep("skipped", `Tier2 risky low-score: ${toEmail}`)
}

// 변경:
const threshold = await getEffectiveScoreThreshold(ctx.job.data.workspaceId)
if (verdict?.result === "risky" && verdict.score <= threshold) {
  return skipStep("skipped", `Tier2 risky score≤${threshold}: ${toEmail}`)
}

Patch 2. bounce-rate.service.ts (신규) — rolling 24h 계산 + Redis 캐시

import { RedisCache } from "./redis-cache.service"
import { db } from "../db"
import { emails } from "../db/schema/emails"
import { and, eq, gte, sql } from "drizzle-orm"

const cache = RedisCache.fromConfig({
  enabled: true,
  keyPrefix: "bounce_rate:ws:",
  ttlMs: 5 * 60 * 1000, // 5min — adaptive 변경 빈도 vs DB 부하 trade-off
  timeoutMs: 200,
})

export async function getRollingBounceRate(workspaceId: string): Promise<number> {
  const cached = await cache.get<number>(workspaceId)
  if (cached !== null) return cached

  const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000)
  const [row] = await db
    .select({
      sent: sql<number>`COUNT(*) FILTER (WHERE status IN ('delivered','opened','clicked','bounced','sent','spam'))::int`,
      bounced: sql<number>`COUNT(*) FILTER (WHERE status = 'bounced')::int`,
    })
    .from(emails)
    .where(and(eq(emails.workspaceId, workspaceId), eq(emails.direction, "outbound"), gte(emails.createdAt, cutoff)))

  const rate = row && row.sent > 0 ? row.bounced / row.sent : 0
  await cache.set(workspaceId, rate)
  return rate
}

export async function getEffectiveScoreThreshold(workspaceId: string): Promise<number> {
  const rate = await getRollingBounceRate(workspaceId)
  if (rate >= 0.10) {
    // Layer 4 trigger — 자동 pause (idempotent)
    await autoPauseActiveSequences(workspaceId).catch(() => {})
    return 30
  }
  if (rate >= 0.05) return 30
  return 20
}

Patch 3. domain-3-strike.gate.ts (신규) — Layer 3

import { RedisCache } from "../../../../../services/redis-cache.service"
import type { Gate } from "../types"
import { pass, skipStep } from "../types"

const domainCache = RedisCache.fromConfig({
  enabled: true,
  keyPrefix: "domain:bl:",
  ttlMs: 90 * 24 * 60 * 60 * 1000, // hard 90d
  timeoutMs: 100,
})

export const domain3StrikeGate: Gate = {
  name: "domain3Strike",
  async run({ ctx, toEmail }) {
    const domain = toEmail.split("@")[1]?.toLowerCase()
    if (!domain) return pass()

    const entry = await domainCache.get<{ count: number; firstAt: string }>(domain)
    if (entry && entry.count >= 2) {
      return skipStep("skipped", `Domain ${domain} has ${entry.count}+ hard bounces`)
    }
    return pass()
  },
}

// pipeline.ts 에 추가 (mvFailOpen 보다 먼저)
export const DEFAULT_PIPELINE: Gate[] = [
  formatGate, roleGate, dummyGate, disposableGate, dedupGate,
  domain3StrikeGate, // ← 신규
  mvFailOpenGate,
]

Patch 4. bounce-check.service.ts — onBounceDetected 확장 (domain counter)

export async function onBounceDetected(toEmail: string, bounceType: "hard" | "soft" | "complaint" = "hard"): Promise<void> {
  // 기존: blacklist + cache invalidate + lead_enrichment_state propagation
  await Promise.all([addToBounceBlacklist(toEmail, blacklistType), invalidateVerificationCache(toEmail)])

  // 신규: domain-level counter (Layer 3)
  if (bounceType === "hard") {
    const domain = toEmail.split("@")[1]?.toLowerCase()
    if (domain) await incrementDomainBounceCounter(domain)
  }
  // ... (기존 lead_enrichment_state 로직 유지)
}

async function incrementDomainBounceCounter(domain: string): Promise<void> {
  const existing = await domainCache.get<{ count: number; firstAt: string }>(domain)
  const next = existing
    ? { count: existing.count + 1, firstAt: existing.firstAt }
    : { count: 1, firstAt: new Date().toISOString() }
  await domainCache.set(domain, next, 90 * 24 * 60 * 60)
}

Patch 5. sequence-auto-pause.service.ts (신규) — Layer 4

const PAUSED_FLAG_KEY = "ws_auto_pause:"
const pauseCache = RedisCache.fromConfig({ enabled: true, keyPrefix: PAUSED_FLAG_KEY, ttlMs: 60_000 })

export async function autoPauseActiveSequences(workspaceId: string): Promise<void> {
  // idempotent — 1분 dedup
  if (await pauseCache.get<boolean>(workspaceId)) return
  await pauseCache.set(workspaceId, true)

  await db.update(sequences).set({ isActive: false, pausedReason: "auto_pause_high_bounce_rate" })
    .where(and(eq(sequences.workspaceId, workspaceId), eq(sequences.isActive, true)))

  // Slack 알림
  await slackNotify({ channel: "#alerts-ses", text: `🚨 ws ${workspaceId} bounce rate > 10% — sequences auto-paused` })
}

Patch 6. gtm_beauty_upload backfill (BullMQ job, 장기)

// workers/bullmq/lead-backfill-verify.worker.ts (신규)
export const leadBackfillVerifyWorker = createWorker("lead-backfill-verify", async (job) => {
  const { leadId, email } = job.data
  const cascade = await verifyEmailCascade(email, { caller: "backfill" })
  if (cascade.deliverable === false) {
    await db.update(leadEnrichmentState).set({ status: "unreachable" }).where(eq(leadEnrichmentState.leadId, leadId))
  } else if (cascade.deliverable === true) {
    await db.update(leadEmails).set({ verified: 1, verifiedAt: new Date() })
      .where(and(eq(leadEmails.leadId, leadId), eq(leadEmails.emailLower, email.toLowerCase())))
  }
}, { concurrency: 5, limiter: { max: 50, duration: 1000 } }) // MV 144 req/s 의 35% 사용

// 발송 전 1회 호출: source='gtm_beauty_upload' AND verified=0 일괄 enqueue

9. 2026 모범 사례 적용

패턴적용 위치이유
Per-ws adaptive configLayer 2 (getEffectiveScoreThreshold)다른 ws 의 recall 손실 방지. 전역 hard-coded 대신 측정-기반 자동 조정
Redis-backed counter + TTLLayer 3 (domain:bl:zset), Layer 4 (ws_auto_pause flag)워커 재시작 survive + 다중 인스턴스 SSOT
Fail-OPEN 의도적 유지mvFailOpen (CreditsExhausted 통과)2026-05-15 YS Medi 인시던트 (fail-CLOSED 로 enrollment 영구 stop) 회피
Async observabilitySlack alert + Grafana metricAuto-pause 작동 시 즉시 알림. 사후 분석 가능
Idempotent triggersautoPauseActiveSequences 1분 dedup다중 워커 동시 호출 시 race condition 방지
Backfill via BullMQlead-backfill-verify.worker33k batch 를 hot path 로 처리하지 않음. concurrency + limiter 로 MV credit 보호
Schema-less domain blacklistRedis key prefix domain:bl:<domain>DB index 추가 없이 O(1) 조회. 90d TTL 자동 expire

10. 실행 우선순위

우선조치예상 효과위험
🚨 NOW글로벌 ws 활성 시퀀스 즉시 pause
UPDATE sequences SET is_active=false WHERE workspace_id='50a26184...'
bounce 폭주 차단 (8,170건 추가 방지)없음 (수동 재개 가능)
P0Layer 3 (Domain 3-Strike Rule) 추가가장 강력 — 100% bounce 도메인 즉시 차단. precision 0.95+ 예상도메인 첫 mailbox는 통과 (acceptable)
P0Layer 2 (Adaptive threshold) 추가per-ws 정책. 글로벌 ws만 ≤30, 다른 ws 영향 없음FP 10→12 (+2건)
P1시퀀스 자동 pause @ 10% bounce rateSES reputation 보호없음
P1upstream gtm_beauty_upload backfill batch근원 차단. 다음 캠페인 자동 보호cascade credit 사용 (~33k credit, MV 잔량 876k 로 충분)
P2NDR inbound 이중 기록 제거 (email-event.service.ts:299-325)UI 수치 정확화 (~2배 인플레 해소)NDR 보관 안 됨 — admin debug 영향 (acceptable)

9. 데이터 출처