당첨자를 놓치지 않으려면 — 아웃박스·큐 완충·어뷰징 방어

2025-12-03

24편에서 "정확히 1000번째 당첨"을 빈틈없는(gapless) 카운터 로 잡았습니다. 순번은 1..N 으로 정확히 배정되고, 당첨자는 DB에 하나로 확정됩니다. 여기까지가 정확성 이었죠.

그런데 이걸 실서비스에 올린다고 생각하면, 24편 끝에서 스스로 짚었던 구멍 세 개 가 남습니다.

  1. 당첨 통지가 샐 수 있다 — 당첨은 DB에 확정됐는데, 상금 지급 이벤트 발행이 실패 하면?
  2. 시작 순간 스파이크 — 이벤트가 열리는 그 초에 수천 요청이 한 카운터 행 에 몰립니다.
  3. 어뷰징 — 상금이 걸리면 봇·매크로가 요청을 쏟아붓습니다.

이번 편은 이 셋을 하나씩 닫습니다.

💻 이 편의 코드는 github.com/kahnco/go-ddd-shoppart-32 태그, internal/promotion 에 있습니다.

1. 아웃박스 승격 — "커밋됐으면 반드시 발행"

24편의 구현은 이렇게 발행했습니다.

res, _ := repo.Enter(ctx, eventID, userID) // 응모 커밋(당첨 확정)
if res.Winner && !res.Already {
    pub.Publish(ctx, WinnerDetermined{...})  // ← 커밋 "후" 발행
}

문제는 커밋과 발행 사이의 틈 입니다. Enter 로 당첨이 DB에 확정된 직후 브로커가 잠깐 죽거나, 이 프로세스가 크래시하면 — 당첨은 남았는데 통지는 안 나갑니다. 상금을 줘야 할 사람에게 못 주는, 돈이 걸린 유실입니다. 반대로 브로커의 at-least-once 전달로 같은 통지가 두 번 올 수도 있어, 상금을 두 번 줄 위험도 있습니다.

정답은 아웃박스(4·9편에서 주문에 썼던 패턴)입니다. 발행을 응모와 같은 트랜잭션 으로 끌고 들어옵니다. 당첨이 확정되는 바로 그 트랜잭션에서, 통지 이벤트를 아웃박스 테이블에 함께 적재 합니다.

// 당첨을 이벤트 행에 못박는 그 트랜잭션 안에서,
// 통지 이벤트를 아웃박스에 함께 적재한다 → "커밋됐으면 반드시 발행".
evt := domain.WinnerDetermined{EventID: eventID, UserID: userID, Seq: next, DeterminedAt: now}
payload, _ := json.Marshal(evt)
tx.Exec(ctx,
    `INSERT INTO promotion_outbox (subject, event_name, payload, dedup_id)
     VALUES ($1,$2,$3,$4) ON CONFLICT (dedup_id) DO NOTHING`,
    "promotion."+evt.EventName(), evt.EventName(), payload, evt.DedupID())

이제 당첨 확정과 통지 적재는 원자적으로 함께 커밋됩니다. 둘 다 남거나, 둘 다 없습니다 — 중간 상태가 없습니다. 실제 브로커 발행은 별도의 릴레이 가 맡습니다. 아웃박스에서 미발행 행을 잠그고(FOR UPDATE SKIP LOCKED), 발행하고, 발행 표시를 한 트랜잭션 으로 처리합니다.

rows, _ := tx.Query(ctx, `
    SELECT id, subject, event_name, payload, COALESCE(dedup_id,'')
    FROM promotion_outbox WHERE published_at IS NULL
    ORDER BY id LIMIT 100
    FOR UPDATE SKIP LOCKED`)          // 여러 릴레이가 같은 행을 두 번 잡지 않는다
// … publish(m) 성공한 것만 …
tx.Exec(ctx, `UPDATE promotion_outbox SET published_at=now() WHERE id = ANY($1)`, published)

발행이 실패하면 표시를 안 하니 다음 주기에 다시 시도합니다(at-least-once). 그리고 중복 수신은 봉투에 실은 결정적 DedupID(promotion-winner:{event_id})로 막습니다 — 다운스트림이 이 키로 멱등 처리하면 몇 번을 받아도 상금은 한 번입니다.

정리하면 세 겹입니다. 당첨 확정(정확히 하나) + 아웃박스 적재(같은 트랜잭션) + 발행 재시도(릴레이) + DedupID(다운스트림 멱등). 이제 "커밋됐으면 반드시, 그리고 한 번만 유효하게" 통지됩니다.

2. 큐 완충 — 접수와 순번 배정을 나누다

24편의 핫 로우 경합을 기억할 겁니다. 카운터 한 행에서 모두가 줄을 서니, 처리량이 그 행의 속도에 묶입니다. 평소엔 괜찮지만 이벤트가 열리는 그 초 — 사람들이 새로고침을 눌러 대는 스파이크 — 엔 부담입니다.

여기서 발상을 바꿉니다. 접수(빠름)와 순번 배정(순차)을 분리 합니다.

  • 접수 는 그 자리에서 순번을 매기지 않고, 요청을 큐(NATS)에 흘리고 202 를 즉시 돌려줍니다.
  • 순번 배정 은 뒤에서 단일 순차 소비자 가 큐를 꺼내며 처리합니다(24편의 gapless 카운터 그대로).
// 접수: 순번을 매기지 않고 큐에 흘린 뒤 202
func (h *QueuedEntryHandler) enqueue(w http.ResponseWriter, req *http.Request) {
    requestID := headerOr(req, "Idempotency-Key", newRequestID())
    h.queue.Enqueue(req.Context(), eventID, userID, requestID)
    writeJSON(w, http.StatusAccepted, queuedResponse{RequestID: requestID, Status: "queued"})
}

큐가 스파이크를 버퍼로 흡수 하니, 접수 응답은 빠르고 카운터는 소비자가 일정한 속도 로 두드립니다. 사용자는 결과를 상태 엔드포인트 로 확인합니다.

POST /events/{id}/entries        → 202 { request_id, status: "queued" }
GET  /events/{id}/entries/me     → 200 { seq, winner }  |  202 { status: "processing" }

한 가지 좋은 부수 효과 가 있습니다. 접수 시각(requested_at)을 큐에 실으면, 단일 소비자가 도착 순서대로 처리하니 순번이 도착 순서에 가깝게 매겨집니다. 24편에서 "1000번째는 커밋 순서지 도착 순서가 아니다"라고 정직하게 짚었는데, 큐 완충은 그 공정성 을 도착 순서 쪽으로 당겨 줍니다.

물론 공짜는 아닙니다.

  • 결과가 비동기 입니다 — 응모하자마자 "당첨!"을 못 보고, 상태를 한 번 더 확인해야 합니다(폴링 또는 23편의 SSE 로 밀어 주기).
  • 단일 소비자는 처리량 상한 입니다 — 대신 지연이 커질 뿐 정확성은 그대로입니다.

그래서 접수 모드를 환경변수로 전환 가능하게 뒀습니다. 평소엔 동기(즉시 순번), 대형 이벤트엔 큐 완충 — 정확성 코드는 한 줄도 바뀌지 않고 입구만 갈아 끼웁니다.

3. 어뷰징 방어 — 한 주체가 쏟아붓지 못하게

상금이 걸리면 반드시 몰려옵니다 — 사람도, 봇도. 이미 1인 1응모(응모 기록의 (event, user) 유니크)로 한 사람이 순번을 여럿 먹는 건 막혀 있습니다. 그런데 요청 폭주 는 별개입니다. 한 계정·한 IP 가 초당 수백 번 두드리면, 카운터든 큐든 앞단이 시달립니다.

그래서 토큰 버킷 레이트 리미터 를 재사용 가능한 플랫폼 유틸로 두고, 응모 입구에 미들웨어로 씌웠습니다.

// 키(사용자·IP)별 버킷: 용량 burst 로 시작, 초당 refill 개 회복. 요청마다 토큰 1개.
func (l *Limiter) Allow(key string) bool {
    b := l.bucket(key)
    b.tokens += elapsed * l.refill        // 지난 시간만큼 보충(상한 burst)
    if b.tokens >= 1 { b.tokens--; return true }
    return false                          // 토큰 없음 → 429
}

키는 사용자(X-User-Id) 우선, 없으면 IP. 한도를 넘으면 429 Too Many RequestsRetry-After 를 돌려줍니다. 버스트와 회복 속도는 환경변수라, 이벤트 규모에 맞춰 조절합니다.

여기에 재시도 안전망 이 하나 더 있습니다. 접수 모드에서 Idempotency-Key 헤더를 받아 요청 ID 로 씁니다 — 네트워크가 흔들려 클라이언트가 같은 요청을 재전송해도, 소비자가 이 키로 중복을 걸러 한 번만 처리합니다. 레이트 리밋(폭주 차단) + 멱등 키(재시도 안전) + 1인 1응모(순번 독점 차단)가 세 층의 방어 를 이룹니다.

물론 이건 방어의 시작입니다. 진짜 상금 이벤트라면 인증(13편 JWT)·기기 지문·행동 분석·캡차까지 얹습니다. 하지만 레이트 리밋 없이는 그 어떤 것도 앞단이 먼저 무너집니다.

눈으로 확인 — 테스트가 지킨다

세 기능 모두 테스트로 못박았습니다(기존 gapless·-race·testcontainers 는 그대로 유지).

  • 아웃박스 — 당첨 이후에만 이벤트가 적재되고, 릴레이가 한 번 발행한 뒤엔 다시 발행하지 않음(발행 표시). 인메모리와 진짜 Postgres 양쪽에서 확인.
  • 큐 소비자 — 같은 request_id 를 두 번 처리해도 순번은 한 번만 소비. 다음 사용자는 정확히 그다음 번호.
  • 접수·상태 — 접수는 202+요청 ID, 상태는 처리 전 202→처리 후 200.
  • 레이트 리밋 — 버스트까지 허용, 초과는 거절, 시간이 지나면 회복, 키마다 독립, 미들웨어는 429.

정직하게 — 무엇을 얻고 무엇을 미뤘나

  • 아웃박스도 완벽한 정확히-한번은 아닙니다. 발행은 여전히 at-least-once 이고, "정확히 한 번"은 다운스트림의 멱등(DedupID) 이 완성합니다. 아웃박스가 보장하는 건 "유실 없음"이고, 중복 제거는 받는 쪽 몫입니다.
  • 큐 완충은 지연과 맞바꿉니다. 즉시 결과가 중요한 소규모 이벤트엔 동기가 낫고, 스파이크가 두려운 대형 이벤트엔 큐가 낫습니다. 둘 다 갖고 입구만 바꾸는 게 실용적입니다.
  • 레이트 리밋은 인메모리 라 단일 인스턴스 기준입니다. 여러 replica 로 확장하면 Redis 기반 분산 리미터 로 올려야 합니다 — 지금은 포트/미들웨어 모양을 잡아 두고, 구현만 갈아 끼우면 되게 뒀습니다.

정리 — 정확성 다음은 견고함

  • 아웃박스 승격: 당첨 통지를 응모와 같은 트랜잭션 에 적재해 유실을 없애고, 릴레이(SKIP LOCKED)+DedupID 로 발행·멱등을 완성했습니다.
  • 큐 완충: 접수와 순번 배정을 분리해 스파이크를 흡수하고, 접수 시각으로 공정성 을 도착 순서에 당겼습니다. 입구는 환경변수로 전환.
  • 어뷰징 방어: 토큰 버킷 레이트 리밋(사용자/IP)+멱등 키+1인 1응모의 세 층.

핵심 한 줄 — 정확성(정확히 N번째)을 잡은 다음의 과제는 견고함입니다. 당첨자를 정확히 고르는 것만큼, 그 당첨을 잃지 않고·버티고·지켜 내는 것까지가 실서비스입니다. 기획서 한 줄 뒤에는, 늘 이 세 가지가 숨어 있습니다.

이번 편 전체 코드·테스트는 리포의 part-32 태그에 있습니다.