여러 대로 나눠도 — 분산 레이트리밋·SSE 통지·부하로 재본 큐 완충
2025-12-08
25편에서 이벤트를 견고하게 만들었습니다 — 아웃박스로 당첨 통지를 잃지 않고, 큐로 스파이크를 완충하고, 레이트리밋으로 폭주를 막았죠. 그런데 그 셋에는 "한 대일 때만 맞는" 가정이 남아 있습니다.
- 레이트리밋 은 인메모리였습니다 — 서버를 여러 대로 늘리면 각 인스턴스가 따로 세어, 한도가 replica 수만큼 뚫립니다.
- 큐 모드의 결과 는
202뒤에 폴링 으로 확인해야 했습니다 — 우리는 이미 23편에서 폴링을 push로 바꿨는데 말이죠. - 큐 완충이 정말 효과가 있는지 는 "그럴 것이다"로 넘어갔습니다 — 숫자로 확인한 적이 없습니다.
이번 편은 이 셋을 닫습니다.
💻 이 편의 코드는 github.com/kahnco/go-ddd-shop 의
part-33태그에 있습니다.
1. 레이트리밋을 여러 대가 공유하게 — Redis 토큰 버킷
25편의 리미터는 프로세스 안 맵이었습니다. 한 대일 땐 완벽하지만, promotion 을 replica 3개 로 늘리면 이렇게 됩니다.
사용자가 초당 3회로 제한돼야 하는데 —
replica A: "내가 본 건 1회" ┐
replica B: "내가 본 건 1회" ├─ 로드밸런서가 흩뿌리면 실제로는 초당 9회 통과
replica C: "내가 본 건 1회" ┘
한도가 인스턴스 수만큼 뚫립니다. 레이트리밋은 본질적으로 공유 상태 라, 상태를 프로세스 밖 공유 저장소(Redis) 로 옮겨야 합니다.
핵심은 원자성 입니다. "토큰을 읽고→보충하고→하나 쓰고→저장"이 한 번에 일어나야, 두 replica 가 동시에 같은 버킷을 건드려도 어긋나지 않습니다. Redis 에서 이건 Lua 스크립트 로 보장합니다 — EVAL 은 Redis 안에서 원자적으로 실행됩니다.
-- 토큰 버킷을 원자적으로: KEYS[1]=버킷, ARGV=[burst, refill/s, now(ms), 요청량]
local tokens = tonumber(data[1]) or burst
local delta = math.max(0, now - ts) / 1000.0
tokens = math.min(burst, tokens + delta * refill) -- 흐른 시간만큼 보충
local allowed = 0
if tokens >= want then tokens = tokens - want; allowed = 1 end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], ttl) -- 노는 키는 스스로 사라지게
return allowed
Go 쪽은 이 스크립트를 EVAL 로 부르기만 합니다. 인메모리 리미터와 같은 의미(버스트·초당 회복)라, 포트도 미들웨어도 그대로고 구현만 갈아 끼웁니다. REDIS_URL 이 있으면 분산, 없으면 인메모리 — 12편에서 재고 저장소를 인메모리↔Postgres 로 바꾼 것과 똑같은 방식입니다.
한 가지 운영 판단 을 코드에 박았습니다.
allowed, err := limiter.Allow(ctx, key)
if err == nil && !allowed { /* 429 */ }
// err != nil (Redis 장애) → 통과시킨다(fail-open)
Redis 가 죽었다고 모든 응모를 막아 버리면, 리미터의 장애가 서비스 전체의 장애가 됩니다. 그래서 리미터 오류 시엔 통과(fail-open) 시킵니다 — 어뷰징을 잠깐 덜 막더라도 정상 사용자를 막지 않는 쪽을 택한 겁니다. (돈이 아주 크다면 반대로 fail-closed 가 맞을 수도 있습니다. 정답은 없고, 명시적 선택 이 중요합니다.)
진짜 Redis(testcontainers)에 붙여, 인스턴스가 달라도 한 버킷을 공유 하는지까지 확인했습니다 — 리미터 객체 두 개가 같은 Redis 를 보면, 버스트를 합쳐서 소진합니다.
2. 당첨 결과를 폴링 대신 실시간으로 — SSE
큐 완충 모드(25편)에서 접수는 202 로 즉시 끝나고, 사용자는 결과를 상태 엔드포인트로 폴링 해야 했습니다. "내 순번 나왔나?"를 반복해서 물어보는 거죠. 우리는 23편에서 이미 이 패턴을 서버가 미는 방식으로 바꿨습니다. 같은 걸 여기에도 적용합니다.
readmodel 에 썼던 허브 를 promotion 에 얹습니다. (이벤트, 사용자) 키로 구독자를 관리하고, 순차 소비자가 순번을 배정하는 순간 그 사용자의 스트림으로 결과를 밀어 줍니다.
// 소비자가 응모를 처리한 직후 — 결과를 그 사용자 스트림으로 push
res, _ := svc.Enter(ctx, eventID, userID)
if c.hub != nil {
c.hub.NotifyEntry(eventID, userID, res) // {seq, winner} 를 SSE 로
}
브라우저는 접수 뒤 GET /events/{id}/entries/me/stream 을 엽니다. 핸들러는 연결 직후 현재 상태(스냅샷) 를 한 번 보내고 — 이미 배정됐을 수도 있으니 — 이후 배정 결과를 push 받습니다.
POST …/entries → 202 { request_id } (접수)
GET …/entries/me/stream → data: {"status":"processing"} (아직)
→ data: {"seq":1000,"winner":true} (배정되는 순간 push!)
폴링의 지연·낭비가 사라지고, 당첨이 확정되는 바로 그 순간 사용자 화면에 뜹니다. 허브의 push 는 23편처럼 논블로킹 이라, 느린 구독자가 소비자를 막지 않습니다.
3. 큐 완충, 정말 효과 있나 — 부하로 재보다
25편에서 "큐가 스파이크를 흡수한다"고 했지만, 숫자 없이 넘어갔습니다. 정직하지 못했죠. 그래서 부하 테스트 도구(cmd/promotion-loadtest)를 만들어 직접 쟀습니다 — 지연 분포(p50/p95/p99)·처리량·상태코드를 찍습니다.
같은 조건(Postgres 백엔드, 동시 80, 총 8000 응모)에서 sync 와 queue 를 비교했습니다.
── SYNC (Postgres 행 잠금, 즉시 순번) ──
8000 req, 4.43s, 1,806 req/s
지연 p50 39.8ms p95 65.1ms p99 129.7ms max 397ms
상태 200 = 8000
── QUEUE (202 접수, 배정은 뒤에서) ──
8000 req, 0.35s, 22,771 req/s
지연 p50 3.4ms p95 5.5ms p99 7.4ms max 9.5ms
상태 202 = 8000
접수 처리량은 약 12.6배, 꼬리 지연(p99)은 130ms → 7.4ms 로 약 17배 좋아졌습니다. 이유는 분명합니다 — sync 는 응모마다 카운터 행을 FOR UPDATE 로 잠그고 기다립니다. 스파이크가 몰리면 그 한 행에서 줄을 서니 꼬리가 길어집니다. 반면 queue 는 요청을 큐에 흘리고 즉시 202 — 접수는 잠금과 무관하니 균일하게 빠릅니다.
여기서 정직해야 합니다. queue 가 "12배 빠르다"는 건 접수(acceptance) 만입니다. 실제 순번 배정은 뒤에서 일어납니다. 실제로, queue 는 8000건을 0.35초 만에 접수 했지만, 잠시 뒤 DB 카운터를 보니 배정은 4564건까지 진행돼 있었습니다 — 나머지는 큐에서 소비자가 일정한 속도로 처리 중이었죠.
queue: 접수 8000건 (0.35s) → 배정은 뒤에서 진행 (샘플 시점 4564/8000)
즉 큐 완충은 일을 없앤 게 아니라 옮긴 것 입니다 — 사용자를 기다리게 하는 대신, 큐에 쌓아 두고 뒤에서 감당 가능한 속도로 처리합니다. 그래서 트레이드오프가 분명합니다.
- 즉시 결과가 중요(소규모, "눌렀더니 바로 당첨!")하면 sync.
- 시작 순간 스파이크가 무섭고, 결과는 잠시 뒤 SSE 로 밀어 줘도 되면 queue.
우리는 둘 다 갖고 입구만 환경변수로 바꿉니다. 그리고 이제 그 선택을 감(感)이 아니라 숫자 로 합니다.
정직하게 — 분산의 청구서
- Redis 는 새 의존성이자 홉 입니다. 레이트리밋마다 네트워크 왕복이 붙고, Redis 자체가 SPOF 가 됩니다(그래서 fail-open). 아주 뜨거운 경로라면 로컬 캐시 + 주기적 동기화 같은 하이브리드도 고려합니다.
- SSE 는 연결당 자원 입니다(고루틴 + 채널). 동시 접속이 많으면 부담이라, 대규모에선 연결을 여러 인스턴스로 나누고 push 를 fanout 해야 합니다(23편의 그림자 그대로).
- 부하 숫자는 이 환경의 것 입니다 — 노트북 한 대, Postgres 한 대. 절대값이 아니라 sync/queue 의 상대 차이 가 핵심입니다. 진짜 용량 산정은 운영 환경에서 다시 재야 합니다.
- 큐도 만능이 아닙니다 — 배정이 뒤로 밀리니 "당첨 발표"까지 지연이 생기고, 단일 소비자는 처리량 상한입니다. 대신 접수는 절대 안 무너집니다.
정리 — 한 대의 정답이 여러 대의 정답은 아니다
- 레이트리밋을 Redis 로 옮겨 replica 들이 한도를 공유하게 했습니다(원자적 Lua 토큰 버킷, fail-open).
- 당첨 결과를 SSE 로 밀어, 큐 모드의 폴링을 push 로 바꿨습니다.
- 부하로 큐 완충을 측정 해, 접수 p99 를 130ms→7ms 로 줄이는 걸 숫자로 확인했습니다 — 단, 그건 일을 뒤로 옮긴 것 이라는 대가와 함께.
핵심 한 줄 — 한 대에서 맞던 설계가 여러 대에서도 맞으리란 보장은 없습니다. 인메모리 카운터·인메모리 리밋·폴링은 한 대에선 충분하지만, 확장하는 순간 각각 공유 상태·분산 조율·푸시로 다시 풀어야 합니다. 그리고 "완충이 효과 있다" 같은 주장은, 재보기 전까진 믿지 않는 게 분산 시스템을 다루는 정직한 태도입니다.
이번 편 전체 코드·테스트·부하 도구는 리포의
part-33태그에 있습니다.