2026-06-05 · 분석: Claude Code (Opus 4.7) · 발단: 박준영 PM

aeroway 발송 한도 분석 — Rinda 시퀀스 워커 plan 가드 누락

aeroway 워크스페이스의 Google Workspace 계정 3개 동시 연동 사례에서 발견된 시퀀스 캠페인 발송 경로의 워크스페이스 누적 plan 한도 가드 부재 + 5가지 신규 발견 사항 + 5-Phase 개선 계획.

TL;DR

1. aeroway 워크스페이스 현황 (베타 DB 실측)

플랜
Rinda_Team
월간정기 · ₩495,000 · active
일일 한도 (명목)
2,000
emails_daily_limit
월간 한도 (명목)
50,000
emails_monthly_limit
실효 일일 한도
3,200
계정 4개 합산 (현재 버그)

연동된 이메일 계정 4개 (모두 active)

계정 Provider 타입 daily_limit sent 등록일
aeroway@steelbrotech.com unipile Google Workspace 400 0 2026-01-13
aeroway@mail.rinda.ai ses 시스템 default NULL 1,309 2026-05-18
aeroway_1@steelbrotech.com unipile Google Workspace 400 0 2026-06-03
aeroway_2@steelbrotech.com unipile Google Workspace 400 0 2026-06-03

박준영 PM 이 본 "구글 계정 3개" = steelbrotech.com 도메인 3개. SES default(rinda.ai)는 시스템 기본 발송 백엔드. aeroway_1, aeroway_2는 그저께(6/3) 새로 추가됨.

2. 핵심 버그 — 시퀀스 워커 워크스페이스 누적 plan 가드 부재

BUG reserveSendQuota단일 계정 rowdaily_sent_count 만 plan limit과 비교. 워크스페이스 전체 합산 비교 없음. 결과: 계정 N개를 연동하면 plan 한도를 우회하여 N×provider_cap 까지 발송 가능.

현재 동작 (시퀀스 캠페인 발송 경로)

sequence-email-worker/steps/send-email.ts:363-431:

const planLimits = await getPlanLimits(workspaceId)            // plan 조회 O
const planDailyLimit = planLimits.emailsDailyLimit             // 2000 (Team)
const effectiveDailyLimit = Math.min(providerDailyCap, planDailyLimit)
await reserveSendQuota(emailAccount.id, effectiveDailyLimit, ...) // 계정 row에만 UPDATE

email-account.service.ts:1369-1383 의 실제 SQL:

UPDATE user_email_accounts SET daily_sent_count = daily_sent_count + 1
WHERE id = $accountId
  AND daily_sent_count < $effectiveDailyLimit   -- 단일 계정 row만 비교

5/20 audit #6 (커밋 ca002f318, PR #7714) 회귀

PR description 인용:

"emailsDailyLimit / emailsMonthlyLimit were never enforced for single or CSV bulk email sends."

작성자가 명시한 패치 대상은 "single or CSV bulk" 두 경로. diff 2개 파일 13줄만 변경. 시퀀스 캠페인 경로는 시야 밖이었음. → 회귀가 아니라 처음부터 부재. git log 추적 결과 시퀀스 워커에 checkUsageLimit / assertEmailUsage 키워드 매치 0건.

3. 발송 경로 13개 전수조사 매트릭스

# 경로 WS 누적 plan 가드 계정 cap 가드 recordUsage 심각도 비고
1 시퀀스 캠페인 워커 ✓ reserve post fire-forget P0 가장 큰 volume. aeroway 직접 영향
2 Manual /emails/send ✓ (audit #6) post bump post fire-forget OK 답장/일대일/AI 답장
3 CSV bulk ✓ (audit #6) post fire-forget P1 계정 cap 미체크 → provider error 빈발
4 Board broadcast P0 sgMail.send 직접 호출 — 전체 우회
5 Followup welcome/nudge P2 시스템성 (정책 결정 필요)
6 Email verification P2 Transactional (정상)
7 DSAR 응답 P2 법적 의무 (정상)
8 Reply nudge / arrival P2 소량 실시간
9 Sequence preview P3 개별 사용자 프리뷰
10 Warmup P2 격리됐는데 코드 명시 없음
11 Nudge config P3 개별 알림
12 Lead import 알림 ✓ (단건) P3 import 당 1건
13 Board email broadcast worker P1 관리자 도구 (#4와 같은 경로)
비대칭의 패턴 시퀀스 워커는 계정 cap 만, bulk 는 워크스페이스 plan 만 — 정확히 반대 방향으로 누락. 통합 fix 필요.

4. 시퀀스 워커 발송 1건이 통과하는 65+개 제한

카테고리 제한 수 핵심 제한 출처
A. BullMQ 레벨 5 concurrency 40 · rate 22/sec · lock 120s · stall 2 · job TTL config.ts
B. Status 4-gate 6 sequence/enrollment/execution status · atomic claim (CAS) validate-status/*.gate.ts
C. Lead 가드 10 존재 · 구독상태 · contactable · format · role · disposable · dedup · bounce blacklist · MV verify resolve-lead.ts
D. Account / Mailbox 6 account active · slot 6 동시성 · Gmail hard cap (200/400) · circuit breaker 3회 send-email.ts · circuit-breaker.ts
E. Quota / Rate (핵심) 15 daily/monthly reset · effectiveDailyLimit = min(provider, plan) · atomic reserve · 80% burst guard A·B · daily defer +24h · monthly hit send-email.ts · burst-guard.service.ts
F. Content / Language 7 subject · language guard block/review · placeholder · corrupt body · deliverability send-email.ts · resolve-content.ts
G. Compliance 3 legal address (CAN-SPAM, grace 2026-06-26) · tracking policy PECR/GDPR · List-Unsubscribe email-send-guard.service.ts
H. Schedule 3 daily hit → +24h defer · burst → 자정까지 분산 · producer scheduling skip send-email.ts
I. Throttle (provider별) 6 SES 8+4s · Unipile 10+20s · SendGrid 0.75+0.75s · jitter · 60s cap · fail-open throttle.ts
J. Idempotency 4 Redis marker (TTL 2h) · DB status sent|delivered · TOCTOU re-check send-email.ts
합계 65+ 3중 방어 (Redis + DB atomic + provider rate)

발송 흐름 순서도

[A] BullMQ 진입 → [B] Status 4-gate → [C] Lead 6-gate → [D] Account 가드 ↓ [E] daily/monthly reset → BURST GUARD A → reserveSendQuota (계정 row +1) ↓ ├ daily hit → +24h defer [F] Content/Language → BURST GUARD B └ monthly hit → earlyReturn ↓ [I] throttle reserve → [J] TOCTOU + idempotency → [G] tracking/unsubscribe ↓ emailService.sendEmail() ← 실제 API 호출 ↓ markAsSent → DB save → execution='sent' → enrollment progress → recordUsage (fire-forget) ↓ finally: account slot 반환

5. Phase 1 1줄 fix 만으로 남는 문제 6가지

핵심 질문 시퀀스 워커에 assertEmailUsageWithinLimit(workspaceId, 1) 1줄만 추가하면 aeroway 문제가 완전 해결되는가? 아니오 — 부분 해결.

aeroway 시나리오 별 실효 한도 비교

시나리오 명목 한도 현재 실효 Phase 1 적용 후
시퀀스 1개 계정 발송 2,000 400 400
시퀀스 3 Google + SES 2,000 3,200 ~2,050
시퀀스 + 한국 timezone 2,000 4,400 ~4,100
시퀀스 + recordUsage 실패 빈발 2,000 drift ~2,200
Board broadcast 5,000명 2,000 5,000 2,000

남는 문제 6가지

# 문제 위치 aeroway 영향 해결 Phase
1 Race condition — 한도 도달 직전 동시 통과 usage.service.ts:170 SELECT ↔ recordUsage atomic 아님 ±1~3% 초과 (worker concurrency 40) Phase 5 (atomic counter)
2 recordUsage swallow — 카운터 drift send-email.ts:710 .catch(warn) DB 장애 시 한도 우회 Phase 2 (retry+reconcile)
3 fail-open — DB 장애 시 가드 무력화 usage.service.ts:412-422 allowed: true 장애 빈도 낮으나 abuse 가능 Phase 2 (degraded flag)
4 mailbox rotation 쏠림 — 한 계정만 burst mailbox-rotation.service.ts hash 기반 발송 속도 저하 별도 (rotation 알고리즘)
5 UTC vs KST 자정 reset send-email.ts:346 toISOString() 한국 사용자 실효 2배 Phase 5 (workspace tz)
6 다른 발송 경로 우회 board broadcast 등 aeroway 가 board 미사용이면 0 Phase 1 board 포함 시 해결

6. 통합 개선 계획 — 5 Phase / 3주

Phase 우선순위 핵심 작업 소요 의존
1. 핫픽스 4경로 P0 시퀀스 + manual verify + board + 복합 인덱스 동시 추가 1d
2. 비대칭 보정 + 정합성 P1 bulk 계정 cap + recordUsage retry + reconcile cron + degraded flag 2d PR1
3. 회귀 영구 차단 P1 check:send-paths lint + 경로 인벤토리 docs 1d PR1
4. 시드 + grandfather P2 email_accounts_limit 시드 보정 + 기존 워크스페이스 보호 + 영업 align 2d PR3
5. Timezone + race + 정합성 P3 UTC→workspace tz · idempotency race · system 이메일 정책 RFC 1w PR1, PR2

Phase 1 핵심 코드 변경 — send-email.ts:344

// ── Sending limit enforce ──
// (A1) 워크스페이스 누적 plan 한도 사전 가드 — audit #6 시퀀스 누락 복구.
try {
  await assertEmailUsageWithinLimit(workspaceId, 1)
} catch (err) {
  if (err instanceof TooManyRequestsError) {
    logger.warn({ jobId, workspaceId, err: err.message },
      "[SequenceEmailWorker] workspace plan limit reached")
    if (job.data.userId) {
      followupEmailService
        .triggerReactiveNudge(job.data.userId, workspaceId, "email_daily_limit_reached")
        .catch((e) => logger.warn({ err: e, workspaceId }, "nudge failed"))
    }
    await sequenceService.updateStepExecutionStatus(executionId, "failed", err.message)
    await sequenceService.checkAndCompleteEnrollmentIfLastStep(enrollmentId, stepOrder)
    return earlyReturn(err.message, startTime)
  }
  throw err
}

복합 인덱스 migration

CREATE INDEX CONCURRENTLY IF NOT EXISTS usage_logs_ws_email_today_idx
  ON usage_logs (workspace_id, created_at DESC)
  WHERE usage_type = 'email_send';

7. 결정 필요 사항

# 결정 사항 결정자 마감
1 board broadcast 가 plan 한도 적용 대상인가? (관리자 권한 vs 워크스페이스 quota) PM + CTO Phase 1 시작 전
2 aeroway 6/3 계정 2개 추가 사유 — 고객사 의도였는지 확인 박준영 Phase 1 머지 전
3 Phase 5 timezone 옵션 (A: schema migration / B: UI 명시만) PM + 엔지니어링 리드 Phase 4 종료 시
4 System 이메일 8개 경로의 plan 한도 정책 PM Phase 5 시작 시

8. 참고

핵심 파일

관련 커밋