aeroway 발송 한도 분석 — Rinda 시퀀스 워커 plan 가드 누락
aeroway 워크스페이스의 Google Workspace 계정 3개 동시 연동 사례에서 발견된 시퀀스 캠페인 발송 경로의 워크스페이스 누적 plan 한도 가드 부재 + 5가지 신규 발견 사항 + 5-Phase 개선 계획.
TL;DR
- 핵심 버그: 시퀀스 캠페인 발송 경로(가장 큰 volume)에 워크스페이스 누적 plan 한도 가드가 한 번도 존재하지 않았음 — audit #6 (2026-05-20) 회귀가 아닌 처음부터 부재.
- aeroway 실측: Team 플랜 명목 2,000/일 → 실효 3,200/일까지 새어나갈 수 있는 구조 (Google Workspace 계정 3개 × 400 + SES 무제한).
- 발송 경로 13개 전수조사: 시퀀스/manual/board broadcast 3 경로에서 동일 누락 발견. bulk · 시퀀스가 정확히 반대 비대칭(bulk: WS만, 시퀀스: 계정만).
- Phase 1 1줄 fix 만으로는 부분 해결: 3,200 → ~2,050 (race ±2-3%). race condition · recordUsage swallow · UTC/KST timezone · fail-open 등 6가지 남음.
- 권장: 5-Phase 3주 통합 계획 (PR1 핫픽스 4경로 동시 / PR2 정합성 / PR3 회귀 차단 / PR4 시드+grandfather / PR5 timezone).
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 는 단일 계정 row의 daily_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. 참고
핵심 파일
elysia-server/src/workers/bullmq/sequence-email-worker/steps/send-email.ts:340-431elysia-server/src/services/usage.service.ts:62-184, 437-460elysia-server/src/services/email-account.service.ts:1335-1391elysia-server/src/services/plan-limits.service.ts:133-323elysia-server/src/services/bulk-email.service.ts:693-697elysia-server/src/services/email-send.service.ts:638-657elysia-server/src/services/board-email-broadcast.service.ts
관련 커밋
ca002f318(PR #7714, audit #6, 5/20 manual/bulk 패치) — 본 분석의 회귀 누락 출처b65809d1c(manual daily_sent_count 누락 복구)2d19ccd97(burst guard 80% 도입)57a369533(하드코딩 plan → DB 중앙관리, 워커 enforce 부재 시점)