넛지 스팸 이슈 · 상세 구현 계획 (v2)

발신 도메인 로테이션 → 고정 Primary 전환 · 실행 플랜

근본 원인(SES governor 라운드로빈)을 최소 변경으로 제거하는 코드·DB 플랜과, 선택적 notify.rinda.ai 트랙을 노드 단위로 분해했습니다.
작성: 이철희 · 2026-07-13 · 근거: SES · Cloudflare DNS · Infisical · alpha DB · 코드 확정(ses-domain-governor.service.ts)

TL한 줄 요약

  • 근본 원인 = resolveGovernedSender('marketing') 풀 라운드로빈 (ses-domain-governor.service.ts:156-178). 마케팅 넛지를 10개 도메인에 분산 → 어느 도메인도 강한 발신자 평판을 못 쌓음.
  • 진짜 fix = governor marketing 분기 ~10줄 + DB is_default 1행. "고정 primary + 일일캡 스필오버"로 전환하면 전 마케팅 볼륨이 1개 도메인에 집중 → 평판 최속 축적 + 모든 수신자가 동일 From.
  • 선행 GATE: primary로 지정할 도메인이 실제 SES 검증 identity인지 먼저 확정(현재 조사에 25개만 잡히고 system_marketing 10개가 안 보이는 모순 有 — 미검증 도메인으로 발송 시 DKIM 실패·바운스).
  • notify.rinda.ai 신규 도메인은 근본 해결이 아니며 critical path에서 분리 — 원하면 병렬 워밍업 트랙(4~6주)으로.

1근본 원인 (코드 확정)

마케팅 넛지 발신 주소는 env 고정이 아니라 발송 시점마다 governor가 10개 도메인 풀에서 회전 선택합니다. 수신자별 시작점을 잡은 뒤 rate 경합(1건/10초) 또는 일일캡에서 다음 도메인으로 회전 — 같은 유저가 매번 다른 발신자를 보게 되고 도메인별 평판이 축적되지 않습니다.

// 현행 marketing 분기 (ses-domain-governor.service.ts:156-178)
const pool = await getSystemMarketingPool()          // system_marketing 10개
const start = stableIndex(routingKey, pool.length)   // 수신자별 시작점(회전)
do {
  for (let i = 0; i < pool.length; i++) {
    const d = pool[(start + i) % pool.length]
    const r = await tryAcquireDomain(d)
    if (r === 1) return { fromEmail: `hello@${d.domain}` }
    // r===0(rate) OR -1(cap) → 다음 도메인으로 회전  ← 근본 원인
  }
  await sleep(MARKETING_RETRY_SLEEP_MS)
} while (Date.now() < deadline)
항목실제 값
marketing Fromhello@{10개 중 회전 도메인} (governor off일 때만 env info@notify.grinda.ai fallback)
system_marketing 풀 (10)channel·dispatch·dusk·entry·family·field·lane·line·run·web.rinda.ai — 전부 day38·cap 300
재사용 가능한 기존 인프라is_default 컬럼(현재 전부 false)·daily_cap 스필오버·rate 대기 루프(MARKETING_MAX_WAIT_MS)

2노드 DAG

Critical Path CP ═ · 병렬 가능 = 동일 layer. 비용 [S]≤50 [M]≤150 [L]≤300 LOC.

Layer0 (조사·게이트)          Layer1 (구현)                Layer2 (검증)        Layer3
┌───────────────────────┐
│N0.1* S  SES identity   │═══╗
│  primary 검증 확정 GATE│   ║
├───────────────────────┤   ║  ┌────────────────────┐
│N0.2  S  일일 발송량    │   ╠═▶│N1.1* M governor 분기│═╗
│  cap 300 충분성        │   ║  │  고정primary+스필오버│ ║  ┌──────────────┐
└───────────────────────┘   ║  ├────────────────────┤ ╠═▶│N2.1* S 단위TC │═╗ ┌───────────────┐
                            ╠═▶│N1.3* S is_default=1 │═╝  │  +send-ci     │ ╠▶│N3.1* S 배포후 │
                            ║  ├────────────────────┤    └──────────────┘ ║ │  스팸률 모니터│
                            ║  │N1.2  S pool ORDER BY│────────────────────┘ └───────────────┘
                            ║  └────────────────────┘
                   (선택)   ╚═▶ N1.4 M  notify.rinda.ai 병렬 워밍업 트랙 ───▶ (4~6주)

CP: N0.1 → N1.1/N1.3 → N2.1 → N2.2(PR) → N3.1
✓게이트: N0.1 미확정 시 N1.3(primary 지정) 진행 금지 — 미검증 도메인 발송 = 바운스

3GATE — SES identity 확정 선행 필수

BLOCKING

모순: alpha DB는 220개 rinda 서브도메인을 워밍업·발송 중이라는데, SES 조사에는 25개만 잡히고 system_marketing 10개(entry·channel 포함)가 하나도 안 보임. 실제 발송되려면 SES 검증 identity가 필수이므로 SES 조사가 페이지네이션 미조회로 잘렸을 가능성이 큼.

왜 게이트인가: primary로 지정한 도메인이 SES 미검증이면 DKIM 서명 실패 → 스팸/바운스 직행. primary 확정 전 반드시 검증 여부를 확인.

할 일

  1. aws-ses 스킬로 identity 목록을 페이지네이션 끝까지 재조회 — entry.rinda.ai(primary 후보)의 VerificationStatus=SUCCESS + Easy DKIM SUCCESS 확인.
  2. 미검증이면 → 이미 검증된 system_marketing 도메인 중 워밍 성숙도 높은 것으로 primary 후보 교체.

4코드 변경 N1.1 · CP

resolveGovernedSender marketing 분기를 primary-first + 일일캡에서만 스필오버로 교체. rate 경합은 회전이 아니라 같은 primary에서 대기.

// after — marketing 분기
const pool = await getSystemMarketingPool()   // is_default DESC 정렬 (N1.2)
if (pool.length === 0) return null
const deadline = Date.now() + MARKETING_MAX_WAIT_MS
let idx = 0                                     // primary = pool[0] 고정 시작
do {
  const d = pool[idx]
  if (await withinGlobalRate()) {
    const r = await tryAcquireDomain(d)
    if (r === 1) return { fromEmail: `${MARKETING_LOCAL_PART}@${d.domain}` }
    if (r === -1 && idx < pool.length - 1) { idx++; continue }  // 일일캡 소진 → 다음 도메인만 스필오버
    // r === 0 (rate) → 회전하지 않고 같은 primary에서 대기
  }
  await sleep(MARKETING_RETRY_SLEEP_MS)
} while (Date.now() < deadline)
return { fromEmail: `${MARKETING_LOCAL_PART}@${pool[0].domain}` }  // fail-open: primary
파생 정리(surgical): stableIndex()routingKey 사용처가 marketing 분기뿐 → 이 변경으로 orphan이 되면 함수·createHash import까지 제거. transactional(OTP) 분기는 변경 없음.

N1.2 — getSystemMarketingPool 정렬 (rinda-managed-domains.service.ts)

풀 쿼리에 ORDER BY is_default DESC, domain ASC 추가로 primary가 항상 pool[0]. (구현 시 현행 ORDER BY 확인 후 조정)

5DB 변경 N1.3 · CP

워밍 성숙한 system_marketing 도메인 1개를 primary로. GATE(N0.1)에서 SES 검증 확인된 도메인 사용. 예시는 entry.rinda.ai.

-- manual-migration 013 (idempotent). 단일 primary 보장.
UPDATE rinda_managed_sending_domains
   SET is_default = false
 WHERE send_role = 'system_marketing' AND is_default = true;

UPDATE rinda_managed_sending_domains
   SET is_default = true
 WHERE send_role = 'system_marketing' AND domain = 'entry.rinda.ai';

-- (권장) 역할별 단일 primary 강제 — 부분 유니크 인덱스
CREATE UNIQUE INDEX IF NOT EXISTS uq_managed_default_per_role
  ON rinda_managed_sending_domains (send_role) WHERE is_default;
cap 검토(N0.2): primary daily_cap = 300. 마케팅 넛지 실제 일일 볼륨이 300 초과면 (a) 워밍 진척에 맞춰 primary cap 상향, 또는 (b) 2~3개 도메인 primary 그룹으로 스필오버 상시 허용. 발송량 확인 후 결정.

6검증 · 롤백

검증 (성공 기준)

  • 단위 TC__setGovernorRedisForTest로 Redis mock. ①캡 여유 시 항상 primary 반환 ②rate 경합 시 회전 안 함 ③primary 캡 소진 후에만 2번째 도메인.
  • CIsh send-ci.sh (lint+type+build) 통과.
  • 실발송 — 테스트 넛지 1통 → From hello@entry.rinda.ai 일관 확인.
  • 배포 후(N3.1)warmup_metric_daily/SES 이벤트로 primary 스팸률 추이 모니터(목요일 배포~).

롤백

  • 1차 — PR revert (governor 분기·pool 정렬 원복).
  • 즉시 완화 — DB is_default 되돌리면 정렬만 바뀌고 회전 로직 복귀 전까진 여전히 primary-first. 완전 원복은 코드 revert.
  • 비상SES_GOVERNOR_ENABLED=false → env fallback(notify.grinda.ai) 단일 발송(회전 자체가 사라짐).

배포 절차

1
N2.2 worktree-pr 스킬로 alpha PR 생성 → squash merge.
2
manual-migration 013 적용 (alpha) — is_default 지정.
3
목요일 배포 후 48h 스팸률 모니터 — 개선 확인되면 beta 동기화(alpha-beta-sync).

7(선택) notify.rinda.ai 병렬 트랙 critical path 제외

근본 fix와 독립. 평판 격리를 위해 전용 도메인을 원할 때만. 전 단계 스킬 자동화 가능(조사 완료). day0 → 4~6주 워밍업 필요하므로 지금 스팸 문제는 ①이 먼저 해결.

#단계스킬내용
1SES identity + Easy DKIMaws-sesnotify.rinda.ai 도메인 생성 → DKIM 토큰 3개 발급
2DNS 8레코드 (entry 패턴 미러링)cloudflareDKIM CNAME×3 + MAILFROM(bounces.notify MX10+SPF TXT) + DMARC TXT, 전부 proxied=false
3config set 연결aws-sesrinda-marketing 연결(SNS→SQS 이미 가동)
4워밍업 등록alpha-dbrinda_managed_sending_domains(send_role=system_marketing, cap 50 램프) + warmup_account
5primary 승격alpha-db워밍 성숙 후 is_default를 notify로 이전 (①과 동일 메커니즘)
표기 불일치: 마케팅 env는 이미 notify.grinda.ai, 요청은 notify.rinda.ai, governor 풀은 .rinda.ai — 세 갈래. 트랙 착수 전 어느 도메인으로 통일할지 확정 필요.

8리스크 · 오픈 이슈

리스크영향대응
primary 도메인 SES 미검증높음 DKIM 실패·바운스N0.1 게이트에서 선확정, 미검증 시 검증된 도메인으로 교체
단일 도메인 볼륨 초과(cap 300) 스필오버 잦음N0.2 발송량 확인 → cap 상향 또는 primary 그룹화
평판 집중의 양날 primary 평판 악화 시 전체 영향스필오버 도메인 유지(청정 풀) + 모니터링
notify.grinda vs rinda 혼선낮음 정합성도메인 표기 통일 결정