정말 여러 대로 — 내구 접수·SSE 팬아웃·분산락 종료 배치
2025-12-13
26편에서 이벤트를 "여러 대"로 나눴습니다 — 레이트리밋을 Redis로 공유하고, 당첨 결과를 SSE로 밀었죠. 그런데 실제로 replica 를 여러 개 띄워 보면, "한 대일 때 안 보이던" 구멍 셋이 또 드러납니다.
- 큐 접수가 유실될 수 있다 — 접수를 큐에 흘렸는데, 그 큐가 무영속이면 재시작 순간 아직 처리 안 된 접수가 사라집니다.
- SSE가 인스턴스 경계를 못 넘는다 — 사용자의 SSE 연결은 인스턴스 A에 붙었는데, 그 사용자의 응모를 인스턴스 B가 처리하면? A의 구독자는 영영 못 받습니다.
- 종료 배치를 여러 대가 중복 실행한다 — "당첨 확정 후 이벤트 종료"를 모든 replica 가 각자 돌리면 낭비이고, 무거운 배치라면 문제가 됩니다.
이번 편은 이 셋을 닫습니다.
💻 이 편의 코드는 github.com/kahnco/go-ddd-shop 의
part-34태그에 있습니다.
1. 접수를 잃지 않으려면 — JetStream 내구 소비자
25편에서 접수를 202 로 받고 큐에 흘렸습니다. 그런데 그 큐가 코어 NATS(무영속)면, at-most-once 입니다 — 구독자가 잠깐 죽거나 재시작하는 사이에 온 접수는 그냥 사라집니다. 응모는 돈이 걸린 약속인데, 접수를 잃으면 안 됩니다.
해법은 5편에서 이미 썼던 JetStream 입니다. 접수 주제를 스트림에 담으면, 발행된 접수가 디스크에 영속되고, 내구 소비자(durable) 가 자기 위치를 기억하며 ack/재전송합니다. 재시작해도 놓치지 않습니다.
우리 이벤트버스는 이미 컨텍스트별 스트림을 만들고 있었는데 — promotion 스트림이 빠져 있었습니다.
// 영속이 필요한 주제만 담는다. 접수는 잃으면 안 되고, 당첨은 아웃박스가 재전송한다.
// notify.* (SSE 팬아웃)는 일부러 제외 — 놓쳐도 되는 실시간 신호라 영속 불필요.
{"PROMOTION", []string{"promotion.entry_requested", "promotion.winner_determined"}},
사실 이건 잠재 버그 이기도 했습니다. compose 는 promotion 을 NATS_JETSTREAM=1 로 띄우는데, 스트림이 없으면 JetStream 발행은 "이 주제를 담는 스트림이 없다"며 실패 합니다. 26편까지의 부하 측정은 코어 모드로 돌려 안 드러났을 뿐, JS 모드에선 접수가 안 됐을 겁니다. 스트림을 추가하니 접수가 영속되고, 구독 이전에 발행한 접수도 나중에 붙은 소비자에게 전달됩니다(테스트로 확인).
한 가지 더 다듬었습니다. 내구 소비자는 처리 실패 시 재전송하고, 끝내 실패하면 DLQ로 보냅니다(14편). 그런데 "이미 종료된 이벤트"나 "시작 전" 같은 거부는 재시도해도 결과가 같은 영구 거부입니다. 이걸 계속 재전송하면 DLQ만 더럽혀지죠.
res, err := svc.Enter(ctx, eventID, userID)
if err != nil {
if isTerminal(err) { // 종료·시작전·없는 이벤트 = 영구 거부
log.Warn("영구 거부(드롭)", ...)
return nil // ack — 재전송/DLQ 로 보내지 않는다
}
return err // 일시적 오류(DB 장애 등)만 재시도
}
재시도는 일시적 오류에만. 영구 거부는 ack 로 조용히 떨굽니다.
2. SSE가 여러 대를 건너가려면 — 코어 pub/sub 팬아웃
26편의 SSE 허브는 인스턴스 로컬 이었습니다. 한 대일 땐 완벽하지만, 여러 대면 이렇게 어긋납니다.
사용자 alice 의 SSE 연결 → 인스턴스 A 의 허브에 구독
alice 의 응모 처리(순번 배정) → 인스턴스 B 의 소비자
B 가 자기 로컬 허브로 push → A 의 alice 는 못 받음 ✗
소비자는 한 대에서만 돌지만(내구 소비자는 큐 그룹이라 하나가 받음), SSE 연결은 어느 대에 붙었을지 모릅니다. 그래서 배정 결과를 모든 인스턴스로 뿌려야 합니다.
여기서 중요한 구분 — 이 알림은 영속이 필요 없습니다. 놓쳐도 사용자가 재연결하면 스냅샷으로 현재 상태를 받으니까요. 그래서 JetStream(영속)이 아니라 코어 NATS pub/sub 를 씁니다. 그리고 큐 그룹 없이 구독하면 — 큐 그룹이 "여럿 중 하나"에게만 주는 것과 반대로 — 모든 구독자가 각자 받습니다(팬아웃).
// 소비자: 배정 결과를 코어로 브로드캐스트(스트림에 저장 안 함)
notifier.NotifyEntry(eventID, userID, res) // → promotion.notify.entry 로 Broadcast
// 인스턴스마다: 브리지가 팬아웃을 받아 자기 로컬 허브로 push
bus.SubscribeBroadcast("promotion.notify.entry", func(env) error {
hub.NotifyEntry(p.EventID, p.UserID, ...) // A의 alice 든 C의 bob 이든, 붙어 있는 곳에서 흘러나간다
})
이제 소비자가 어느 인스턴스에서 배정했든, 브로드캐스트가 모든 인스턴스에 닿고, SSE가 붙어 있는 인스턴스 가 그 결과를 흘려보냅니다. 소비자는 통지 대상을 EntryNotifier 포트로만 알아, 로컬 허브(단일 인스턴스)든 브로드캐스트(다중 인스턴스)든 코드가 같습니다.
3. 종료 배치를 한 대만 — Redis 분산락
당첨이 확정되면 이벤트를 종료 합니다 — 더 응모를 받지 않고, 낙첨자 알림·정산·집계 마감 같은 한 번만 해야 하는 일 을 합니다. 그런데 replica 가 여럿이면, 각자 "종료해야겠다"며 동시에 배치를 돌릴 수 있습니다.
정확히 한 대만 돌게, Redis 분산락을 씁니다. SET key token NX PX ttl — 키가 없을 때만 잠그고, TTL을 줘서 잠근 인스턴스가 죽어도 자동으로 풀립니다(데드락 방지). 해제는 자기 잠금일 때만(토큰 비교 후 삭제, Lua로 원자적).
token, ok, _ := lock.Acquire(ctx, "promotion:close:"+eventID)
if !ok { return } // 다른 인스턴스가 잡았다 → 넘긴다
defer lock.Release(ctx, key, token)
closed, _ := svc.MarkClosed(ctx, eventID) // 한 대만 종료 배치 실행
각 인스턴스는 주기적으로 종료를 시도 하지만, 잠금을 잡은 하나만 실제로 MarkClosed 를 실행합니다. 그리고 MarkClosed 자체도 멱등 입니다 — closed_at IS NULL AND winner_user_id IS NOT NULL 일 때만 종료하니, 잠금이 어떤 이유로 이중 획득돼도 종료는 한 번뿐입니다. 잠금(무거운 배치 중복 방지) + 멱등(최종 안전망) 의 두 겹이죠. 종료 시엔 EventClosed 를 아웃박스에 적재하고(25편), 이후 응모는 409 closed 로 막습니다.
Redis가 없는 단일 인스턴스에선 항상 획득 성공하는 Nop 잠금 으로 같은 코드 경로를 씁니다 — 분산 여부가 로직을 바꾸지 않습니다.
눈으로 — 풀 스택 스모크
JS + 큐 + Redis 를 모두 켜고 실제로 돌려 봤습니다(target=2).
POST alice → 202, POST bob → 202 (JetStream 스트림에 영속 접수)
GET alice/me → {"seq":1,"winner":false} (내구 소비자가 순번 배정)
GET bob/me → {"seq":2,"winner":true} (2번째 = 당첨)
[5s 뒤] "이벤트 종료 배치 실행 — 이 인스턴스가 확정" (분산락 잡은 한 대)
POST charlie → 소비자 로그: "영구 거부(드롭)" (종료 후 응모, DLQ 안 감)
접수·배정·당첨·종료·종료후 거부가 전부 의도대로 흘렀습니다. 그리고 이 스모크가 하나를 잡아 줬습니다 — 종료 후 charlie 의 접수가 재시도 5회 후 DLQ로 가던 것을, "영구 거부는 드롭"으로 고친 게 바로 이 관찰에서 나왔습니다.
정직하게 — 여러 대의 청구서
- 큐 접수는 낙관적 입니다. 종료된 이벤트에 큐로 접수하면
202를 받고, 거부는 소비자에서 비동기로 일어납니다(드롭). 사용자는 상태가 끝내 "배정 안 됨"으로 남는 걸로 압니다. 즉시409를 주려면 접수 시점에 종료 여부를 한 번 읽어야 하는데, 그건 버퍼링 이점을 조금 깎습니다 — 트레이드오프입니다. - SSE 팬아웃도 영속이 아닙니다. 브로드캐스트를 놓친 인스턴스의 구독자는 그 순간 push를 못 받습니다. 대신 재연결 시 스냅샷으로 복구되니, "실시간이되 최종적으로 일관"입니다.
- Redis 분산락은 만능이 아닙니다. TTL·클록 스큐·네트워크 분단에 취약해, 엄밀한 상호배제가 생명인 곳(이중 지불 방지 등)엔 Redlock이나 합의(etcd/consul)를 씁니다. 우리처럼 "중복 실행을 줄이되 멱등이 최종 안전망"인 경우엔 단순 락으로 충분합니다.
- 스트림 저장엔 비용 이 듭니다(디스크·복제). 영속이 필요한 주제만 담고, 실시간 신호는 코어로 — 그래서 notify.* 를 스트림 밖에 뒀습니다.
정리 — "여러 대"는 매번 다시 푼다
- 접수를 JetStream 스트림에 영속 시켜 재시작에도 잃지 않게 하고(누락됐던 스트림도 채우고), 영구 거부는 재시도 없이 드롭했습니다.
- 배정 결과를 코어 pub/sub로 팬아웃 해, SSE가 어느 인스턴스에 붙어 있든 닿게 했습니다.
- Redis 분산락으로 종료 배치를 한 대만 돌리고, MarkClosed 멱등으로 이중 안전망을 뒀습니다.
핵심 한 줄 — "여러 대로 나눈다"는 한 번의 결정이 아니라, 상태를 가진 모든 조각을 다시 푸는 일 입니다. 접수는 영속으로, 알림은 팬아웃으로, 배치는 분산 조율로 — 인메모리 한 대에선 공짜였던 것들이, 여러 대에선 각자 값을 치릅니다. 그리고 그 값을 치를지는, 정말 여러 대로 갈 때 정하면 됩니다.
이번 편 전체 코드·테스트·스모크는 리포의
part-34태그에 있습니다.