아웃박스 신뢰성 — 다중 replica에서 중복 없이 발행하기
2025-09-18
7편에서 주문 서비스를 쿠버네티스에 replica 2개로 굴렸고, 4편에서 트랜잭셔널 아웃박스로 이벤트 발행을 안전하게 만들었습니다. 그런데 이 둘을 겹치면 조용한 문제가 생깁니다 — 주문 파드가 둘이면, 아웃박스 릴레이도 둘 입니다. 두 릴레이가 아웃박스를 각자 폴링하다, 같은 이벤트를 둘 다 발행 할 수 있죠.
지금까지는 4편의 소비자 멱등성이 이 중복을 겨우 흡수했습니다. 하지만 그건 최후의 방어일 뿐, 브로커 트래픽은 두 배가 되고 경합의 여지도 남습니다. 이 편에서 뿌리부터 잡습니다.
💻 이 편의 코드는 github.com/kahnco/go-ddd-shop 의
part-17태그에 있습니다.
두 겹의 방어
중복 발행을 두 층위에서 막습니다.
- 발행측 dedup(브로커가 걸러냄) — 이벤트에 안정적인 ID를 붙여 발행하면, JetStream이 중복을 제거합니다.
- 행 잠금(애초에 두 번 안 집음) — 릴레이가 아웃박스 행을 트랜잭션으로 잠그고 처리해, 다른 릴레이가 같은 행을 집지 못하게 합니다.
둘은 상호 보완입니다. 행 잠금이 평상시의 이중 폴링을 막고, 발행측 dedup이 그 사이 틈(크래시 등)을 메웁니다.
발행측 dedup — Nats-Msg-Id
JetStream에는 발행 중복 제거가 내장돼 있습니다. 발행 메시지에 Nats-Msg-Id 헤더를 실으면, 브로커가 중복 윈도우(우리는 2분) 안에서 같은 ID의 메시지를 하나만 받아들입니다. 우리에겐 마침 완벽한 ID가 있죠 — 아웃박스 행 ID. 재전송돼도 바뀌지 않으니까요.
// 봉투 ID(=아웃박스 행 ID)를 Nats-Msg-Id 로 실는다
func (b *Bus) Publish(subject string, env Envelope) error {
raw, _ := json.Marshal(env)
if b.js != nil {
var opts []nats.PubOpt
if env.ID != "" {
opts = append(opts, nats.MsgId(env.ID)) // ← 브로커가 이 ID 로 중복 제거
}
_, err := b.js.Publish(subject, raw, opts...)
return err
}
// ...
}
// 스트림에 중복 윈도우를 설정
b.js.AddStream(&nats.StreamConfig{
Name: s.Name, Subjects: s.Subjects, Storage: nats.FileStorage,
Duplicates: 2 * time.Minute, // 이 안에서 같은 Nats-Msg-Id 는 한 번만
})
이제 두 릴레이가 같은 아웃박스 행(같은 ID)을 발행해도, 브로커가 두 번째를 조용히 버립니다. 테스트로 못 박습니다 — 같은 ID로 두 번 발행하면 소비자는 한 번만 받습니다.
행 잠금 — FOR UPDATE SKIP LOCKED
발행측 dedup은 안전망이지만, 애초에 두 번 발행하지 않는 게 낫습니다. PostgreSQL의 FOR UPDATE SKIP LOCKED 가 우아하게 해결합니다. 릴레이가 미발행 행을 트랜잭션으로 잠그되, 이미 잠긴 행은 건너뜁니다. 그러면 릴레이 A가 1~100번을 잠그는 동안, 릴레이 B의 조회는 그 행들을 건너뛰고 101번부터 집습니다. 겹치지 않습니다.
func (r *PostgresOrderRepository) DispatchOutbox(ctx context.Context, publish func(OutboxMessage) error) (int, error) {
tx, _ := r.pool.Begin(ctx)
defer tx.Rollback(ctx)
rows, _ := tx.Query(ctx, `
SELECT id, subject, event_name, payload, ...
FROM outbox WHERE published_at IS NULL
ORDER BY id LIMIT 100
FOR UPDATE SKIP LOCKED`) // ← 잠긴 행은 건너뛴다
// ... 행들을 읽어 slice 로 ...
var published []int64
for _, m := range msgs {
if err := publish(m); err != nil { break } // 브로커로 발행
published = append(published, m.ID)
}
tx.Exec(ctx, `UPDATE outbox SET published_at = now() WHERE id = ANY($1)`, published)
return len(published), tx.Commit(ctx) // 커밋해야 잠금이 풀린다
}
핵심은 잠금이 커밋될 때까지 유지 된다는 겁니다. 발행하는 동안 행이 잠겨 있으니, 다른 릴레이가 끼어들 수 없습니다. 처리가 끝나 커밋하면(발행 표시 포함) 잠금이 풀리죠.
리더 선출은 왜 안 썼나
"릴레이가 여럿이니 하나만 일하게 리더를 뽑자"고 할 수도 있습니다(리더 선출). 하지만 그건 병목(리더 하나가 다 처리)이자 취약점(리더가 죽으면 새 리더 뽑힐 때까지 공백)입니다. SKIP LOCKED 는 리더가 없습니다 — 모든 replica가 서로 다른 행을 나눠 동시에 처리하니, 병목도 공백도 없이 확장됩니다. 아웃박스엔 이쪽이 더 맞습니다.
그 과정에서 만난 진짜 버그 — 동시 스키마 생성
replica 2개로 실제로 돌려보니, 두 번째 인스턴스가 기동하다 죽었습니다.
ERROR: 스키마 생성: duplicate key value violates unique constraint "pg_type_typname_nsp_index" (SQLSTATE 23505)
원인은 뜻밖이었습니다 — 두 인스턴스가 동시에 CREATE TABLE IF NOT EXISTS 를 돌렸는데, PostgreSQL의 이 구문은 동시 실행에 완전히 안전하지 않습니다. 내부 카탈로그(pg_type)에서 경합해 하나가 23505로 실패하죠. 흔히 "IF NOT EXISTS니까 괜찮겠지" 하고 넘어가는 함정입니다.
해법은 스키마 생성을 자문 잠금(advisory lock)으로 직렬화 하는 것입니다.
conn, _ := r.pool.Acquire(ctx)
defer conn.Release()
conn.Exec(ctx, `SELECT pg_advisory_lock(727274)`) // 한 번에 하나만
defer conn.Exec(context.Background(), `SELECT pg_advisory_unlock(727274)`)
conn.Exec(ctx, ddl) // 안전하게 DDL
이 버그는 라이브로 두 인스턴스를 돌려보지 않았으면 못 잡았을 것입니다 — 단위 테스트나 단일 인스턴스에선 절대 안 나니까요. 분산 시스템은 "여럿을 동시에" 돌려봐야 진짜 문제가 드러납니다.
진짜로 중복이 없는지 — 경쟁하는 두 릴레이
주문 서비스 두 인스턴스 를 같은 Postgres·JetStream에 붙여(둘 다 릴레이 가동), 주문 6건을 번갈아 넣습니다.
=== 결과 ===
주문 상태: SHIPPED | 6
아웃박스(이벤트별): order.placed 적재 6 발행 6
order.paid 적재 6 발행 6
order.confirmed 적재 6 발행 6
order.shipped 적재 6 발행 6
재고 소비 처리: 6건 (중복 소비 0)
상태 전이 에러: 0건
각 이벤트가 정확히 6번씩 — 주문 수만큼만 — 발행됐습니다. 릴레이가 둘이 경쟁했는데도 이중 발행이 없습니다. 재고 서비스도 6건만 처리했고(중복 소비 0), 상태 전이 에러도 없습니다. SKIP LOCKED(안 겹치게) + Nats-Msg-Id(브로커 dedup) + 소비자 멱등성(최후 방어)이 함께, 다중 replica 아웃박스를 정확히 한 번 에 수렴시켰습니다.
정직하게 — 방어의 깊이
- 중복 윈도우는 시간 제한 입니다(우리는 2분). 어떤 이유로 그보다 늦게 재전송된 중복은 발행측 dedup을 빠져나갑니다 — 그래서 소비자 멱등성 이 여전히 최후의 방어로 남습니다. 세 겹(SKIP LOCKED → Nats-Msg-Id → 멱등 소비)의 방어 심도(defense in depth)죠.
- 잠금 보유 시간: 릴레이는 발행하는 동안 행을 잠급니다. 발행이 아주 느리면 잠금이 길어질 수 있으니, 배치 크기(LIMIT 100)로 조절합니다.
- 이 모든 건 주문 서비스가 Postgres를 쓸 때(아웃박스 모드)의 이야기입니다. 인메모리 단독 모드는 애초에 replica가 상태를 안 나누니 해당 없습니다.
정리 — 여럿이 돌아도 한 번
- JetStream 발행측 dedup(Nats-Msg-Id)으로, 같은 이벤트가 두 번 발행돼도 브로커가 하나만 받습니다.
- FOR UPDATE SKIP LOCKED로 릴레이가 행을 나눠 잠가, 여러 replica가 리더 없이 경쟁하며 겹치지 않게 처리합니다.
- 자문 잠금 으로 동시 스키마 생성 경합(pg_type 23505)까지 없앴습니다 — 라이브 다중 인스턴스에서만 드러난 버그였죠.
- 세 겹 방어(잠금 → 브로커 dedup → 소비자 멱등)로, 다중 replica 아웃박스가 정확히 한 번 에 수렴함을 6건 라이브로 확인했습니다.
이제 주문 서비스는 몇 개로 늘려도 이벤트를 중복 없이 흘려보냅니다. 이벤트 기반 시스템의 발행 경로가 진짜 운영급으로 단단해졌습니다.
이번 편 전체 코드는 리포의
part-17태그에 있습니다.