정확히 1000번째 — 이벤트 당첨자를 어떻게 정할까

2025-11-28

쇼핑몰에 마케팅 요청이 하나 들어왔다고 합시다.

오픈 이벤트 — 특정 시각부터 응모를 받아, 정확히 1000번째로 응모한 사람에게 10만 원을 드립니다.

기획서 한 줄입니다. 그런데 이걸 코드로 옮기려고 앉으면, 생각보다 만만치 않습니다. "선착순 1000명"이 아니라 "정확히 1000번째 그 한 사람" 이고, 상금은 실제 돈 이며, 이벤트 시작 순간엔 요청이 한꺼번에 몰립니다. 정확성·동시성·정의(定義)가 한 지점에서 정면충돌하는, 실무에서 꼭 한 번은 데는 문제입니다.

이번 편에서는 여러 방안을 늘어놓고 하나씩 장단점을 따진 뒤, 우리 쇼핑몰에 실제로 붙여 봅니다.

💻 이 편의 코드는 github.com/kahnco/go-ddd-shoppart-31 태그에 있습니다. promotion 이라는 새 컨텍스트로 넣었습니다.

먼저 — "1000번째"가 대체 뭔가

코드를 짜기 전에 멈춰야 합니다. 무엇을 1000번째로 셀 것인가? 후보가 여럿입니다.

  • 도착 순서 — 로드밸런서에 먼저 닿은 순서? 여러 인스턴스·여러 커넥션이라 전역 순서가 없습니다.
  • 처리 순서 — 서버가 먼저 처리하기 시작한 순서? 병렬이라 이것도 애매합니다.
  • 커밋 순서응모가 성공적으로 기록으로 남은 순서.

앞의 둘은 관측이 어렵고 다투기 쉽습니다. 우리가 택할 정의는 세 번째 — "유효한 응모가 저장소에 커밋된 순서" 입니다. 이게 유일하게 하나의 권위 있는 값 으로 못박히고, 나중에 "왜 저 사람이 당첨이냐"를 감사(audit) 할 수 있습니다. 돈이 걸린 이벤트라면 이 감사 가능성이 특히 중요합니다.

정의를 정하고 나면 요구사항이 또렷해집니다.

  1. 시작 시각 이전 응모는 무효.
  2. 유효한 응모마다 빈틈없는(gapless) 순번 을 매긴다 — 1, 2, 3, … 중간에 빠진 번호가 없어야 합니다.
  3. 순번 1000번 을 받은 사람이 당첨. 정확히 한 명.
  4. 같은 사람이 재시도(중복 요청) 해도 순번을 두 번 먹으면 안 됩니다(멱등).

특히 2번 — 빈틈이 없어야 한다 — 가 이 문제의 핵심이자 함정입니다. 여기부터 순진한 시도들이 무너집니다.

순진한 시도 1 — DB 시퀀스(nextval)

가장 먼저 떠오르는 건 데이터베이스 시퀀스입니다.

CREATE SEQUENCE entry_seq;
-- 응모할 때마다
INSERT INTO entry (user_id, seq) VALUES ($1, nextval('entry_seq'));

nextval 은 원자적이고 매우 빠릅니다. 동시에 1000명이 불러도 서로 다른 번호를 겹치지 않게 나눠 줍니다. 완벽해 보이죠. 그런데 치명적인 성질 이 하나 있습니다.

시퀀스는 롤백해도 되돌아가지 않습니다. nextval 로 뽑은 번호는, 그 트랜잭션이 실패해 롤백돼도 소비된 채로 버려집니다.

이건 시퀀스의 의도된 설계입니다 — 되감으면 동시성이 깨지니까요. 하지만 우리 요구사항엔 독입니다. 응모 트랜잭션이 어떤 이유로든 롤백되면(제약 위반, 타임아웃, 앱 크래시) 그 번호는 허공으로 사라집니다. 그 사라진 번호가 하필 1000번이면?

997  998  999  [1000 ← 롤백돼 증발]  1001  1002 …
당첨자: 아무도 없음.

빈틈(gap) 이 생기고, "정확히 1000번째"라는 약속이 깨집니다. 선착순 개념이라면 "1000 이하면 당첨"으로 넘어가겠지만, 우리는 딱 그 번호 하나 를 줘야 합니다. 시퀀스는 빠르지만 gapless 가 아니라서 이 문제에는 애초에 맞지 않습니다. 이 한 가지가 뒤의 모든 설계를 끌고 갑니다.

순진한 시도 2 — 낙관적 동시성(read-modify-CAS)

그럼 카운터를 직접 세되, 잠그지 말고 낙관적으로 가 봅니다.

-- 1) 현재 카운트를 읽고
SELECT cnt, version FROM counter WHERE id = 'ev';
-- 2) 내가 읽은 버전 그대로일 때만 +1 (아니면 재시도)
UPDATE counter SET cnt = cnt+1, version = version+1
 WHERE id = 'ev' AND version = $readVersion;

경합이 낮을 때 는 잠금보다 빠릅니다. 문제는 이벤트 시작 순간입니다. 수천 요청이 같은 한 행 에 몰리면, 대부분의 CAS 가 "버전이 바뀌었네" 하고 실패→재시도 를 반복합니다. 한 번에 하나만 성공하는데 나머지가 계속 되돌아오니, 재시도 폭주(livelock) 로 처리량이 바닥을 칩니다. 낙관적 락은 경합이 드물다는 가정 위에서만 좋은데, 이벤트는 정반대 — 의도적으로 한 지점에 몰리는 상황이라 최악의 궁합입니다.

방안들을 늘어놓고 따져보기

핵심 성질(gapless·정확히 하나)을 지키면서 스파이크를 견디는 방법을 정리하면 이렇습니다.

A. 원자 카운터 행 — SELECT … FOR UPDATE (채택)

카운터를 한 행에 두고, 그 행을 잠근 채 읽고→증가시키고→응모를 기록합니다. 같은 이벤트의 모든 응모가 그 행 위에서 한 줄로 직렬화 됩니다.

  • 장점 — 빈틈이 없고(gapless), 정확히 한 명이 N번째가 되며, 결과가 DB에 남아 감사 가능. 12편에서 재고에 쓴 행 잠금과 같은 도구라 우리 스택에 이미 익숙합니다.
  • 단점핫 로우(hot row). 모든 요청이 한 행에서 줄을 서니 그 행의 처리 속도가 상한입니다. 트랜잭션을 짧게 유지해야 하고(그 안에서 외부 호출 금지), 극단적 스파이크는 앞단에서 완충해야 합니다.

B. Redis INCR

카운터를 Redis 에 두고 INCR event:counter 로 셉니다. 싱글 스레드라 원자적이고 초당 수십만 도 거뜬합니다.

  • 장점 — 압도적 처리량, 원자적 증가, 핫 로우 경합이 사실상 없음.
  • 단점내구성과 정확히-한번. Redis 가 장애 나면 카운터가 유실되거나 되돌아갈 수 있고(AOF/복제 설정에 따라), INCR 로 1000을 받은 요청이 그 직후 크래시 하면 "당첨 번호를 받았지만 기록은 못 남긴" 상태가 됩니다. 돈이 걸린 값을 DB 밖 휘발성 저장소에 두는 건 위험합니다. 쓰려면 Redis 는 빠른 게이트 로만 쓰고, 실제 당첨 확정은 DB 진실과 정합 을 맞춰야 하는데 — 그 정합 로직이 결국 A만큼 복잡해집니다.

C. 큐로 직렬화 — JetStream 단일 순차 소비자

응모를 전부 큐에 넣고(5편의 JetStream), 단일 순차 소비자 하나가 꺼내며 번호를 매깁니다. 스파이크는 큐가 완충 하고, 소비자가 하나라 순서가 자연히 직렬화됩니다.

  • 장점 — 스파이크를 버퍼가 흡수, 순서 보장, 내구성 있는 저장(JetStream), 우리 이벤트 기반 구조와 결이 같음. 응모 접수(빠름)와 순번 확정(순차)을 분리 할 수 있습니다.
  • 단점 — 단일 소비자라 처리량 상한 이 있고, 여기서 "1000번째"는 도착 순서가 아니라 소비(처리) 순서 입니다. 사용자가 먼저 눌렀는데 큐에서 뒤로 밀릴 수 있어, 공정성의 정의 가 바뀝니다. 접수→확정 사이 지연 도 생깁니다.

D. 샤딩 카운터 — 여기선 안 됩니다

핫 로우가 무서우니 카운터를 N개로 쪼개 부하를 분산하는 기법이 있습니다. "선착순 1000명" 같은 임계값 문제엔 훌륭합니다. 그런데 우리 요구는 "정확히 1000번째" — 전역에서 몇 번째인지 를 알아야 합니다. 샤드로 나누면 전역 순번을 알려면 결국 전역 합의/집계 가 필요해, 분산의 이점이 사라집니다. 요구사항이 임계값이냐 정확한 순번이냐 에 따라 갈리는, 좋은 구분점입니다. 우리는 정확한 순번이므로 샤딩은 접습니다.

정리하면, 돈이 걸린 정확한 순번 이라는 조건에서는 A(원자 카운터 행) 가 정확성·감사 가능성에서 가장 단단합니다. 처리량이 정말 극단적이면 C(큐)를 앞단 완충 으로 A 앞에 두는 조합이 답입니다. 우리는 먼저 A로 정확성을 확실히 잡겠습니다.

택한 것 — 빈틈없는 카운터 행

카운터를 한 행에 두고, 그 행을 FOR UPDATE 로 잠근 채 순번을 배정합니다. 핵심 트릭은 "카운터를 언제 전진시키느냐" 입니다.

// 카운터 행을 잠근다 — 여기서부터 이 이벤트의 응모는 한 줄로 직렬화된다.
var cnt int
tx.QueryRow(ctx,
    `SELECT cnt FROM promotion_counter WHERE event_id=$1 FOR UPDATE`, eventID).Scan(&cnt)
next := cnt + 1

// 응모를 먼저 기록한다. (event,user) 유니크라 같은 사용자 중복은 여기서 걸린다.
ct, _ := tx.Exec(ctx,
    `INSERT INTO promotion_entry (event_id, user_id, seq, created_at) VALUES ($1,$2,$3,$4)
     ON CONFLICT (event_id, user_id) DO NOTHING`, eventID, userID, next, now)

// 카운터 전진은 응모 INSERT 가 성공한 뒤에만 한다.
// → 롤백/거부되면 이 증가도 함께 사라져, 번호에 빈틈이 없다.
tx.Exec(ctx, `UPDATE promotion_counter SET cnt=$1 WHERE event_id=$2`, next, eventID)

시퀀스가 틀렸던 지점을 정확히 뒤집습니다. 번호를 먼저 뽑고 실패하면 버리는 게 아니라, 성공한 응모에 대해서만 카운터를 전진 시킵니다. 트랜잭션이 롤백되면 UPDATE counter 도 함께 되돌아가니, 빈틈이 생길 수 없습니다. 시작 전(무효)·이미 응모(멱등)인 경우엔 카운터를 잠그기도 전에 빠져나가 순번을 건드리지 않습니다.

그리고 1000번이 배정되는 그 트랜잭션 안에서 당첨을 이벤트 행에 못박습니다.

winner := next == target
if winner {
    tx.Exec(ctx,
        `UPDATE promotion_event SET winner_user_id=$1
           WHERE event_id=$2 AND winner_user_id IS NULL`, userID, eventID)
}

당첨자가 DB에 정확히 하나 로 확정되고, 나중에 얼마든지 조회·감사할 수 있습니다. 인메모리 구현은 같은 일을 뮤텍스로 합니다 — 포트(Repository)는 같고, 도메인·유스케이스는 저장소가 무엇이든 한 줄도 안 바뀝니다(12편에서 재고에 했던 것과 같은 방식입니다).

멱등성 — 재시도가 순번을 두 번 먹지 않게

네트워크는 거짓말을 합니다. 사용자가 응모 버튼을 눌렀는데 응답이 늦으면, 클라이언트(또는 사용자)는 다시 누릅니다. 이때 순번을 두 번 소비하면 카운터가 부풀고, 심하면 한 사람이 1000번을 밀어내 버립니다.

그래서 한 사람은 한 응모 로 못박습니다. promotion_entry 의 기본키를 (event_id, user_id) 로 두고, 재요청이 오면 기존 순번을 그대로 돌려줍니다.

// 이미 응모했으면 기존 순번을 그대로(순번 미소비) → 멱등
var seq int
err := tx.QueryRow(ctx,
    `SELECT seq FROM promotion_entry WHERE event_id=$1 AND user_id=$2`, eventID, userID).Scan(&seq)
if err == nil {
    return app.Result{Seq: seq, Winner: seq == target, Already: true}, nil
}

동시에 같은 사용자가 두 번 밀고 들어오는 자기 자신과의 레이스 도 있습니다. 이건 INSERT … ON CONFLICT DO NOTHING 의 결과(RowsAffected == 0)로 잡아, 카운터를 전진시키지 않고 기존 순번을 반환합니다. 4편의 멱등성 원칙 그대로 — at-least-once 세상에서 안전하려면 소비자가 멱등해야 합니다.

당첨 통지 — 정확히 한 번 주려면

당첨이 확정되면 상금을 줘야 합니다. 우리 구조답게 이벤트를 발행 해, 결제·알림이 이어받게 합니다.

if res.Winner && !res.Already {              // 이번 응모로 처음 확정됐을 때만
    pub.Publish(ctx, domain.WinnerDetermined{ EventID: eventID, UserID: userID, Seq: res.Seq })
}

여기서 정직해야 할 한계 가 있습니다. 응모는 커밋됐는데 발행이 실패 하면(브로커 순간 장애 등), 당첨은 DB에 남았지만 통지가 안 나갈 수 있습니다. 반대로 브로커의 at-least-once 전달로 같은 당첨 이벤트가 두 번 올 수도 있습니다 — 그럼 상금을 두 번 줄 위험이 생깁니다.

두 방향으로 막습니다.

  • 중복 수신결정적 DedupID 로. 한 이벤트의 당첨자는 하나뿐이니 promotion-winner:{event_id} 하나면 충분합니다. 다운스트림이 이 키로 멱등 처리하면, 몇 번을 받아도 상금은 한 번입니다.
  • 발행 유실 의 진짜 해법은 아웃박스(4·9편)입니다. 당첨 이벤트를 응모와 같은 트랜잭션에서 아웃박스 테이블에 남기고, 릴레이가 재전송하면 "커밋됐으면 반드시 발행된다"가 보장됩니다. 이번 구현은 커밋 후 발행 + DedupID 까지만 갖췄고, 아웃박스로의 승격은 그 위에 얹는 다음 단계로 남겨 둡니다 — 당첨 확정 자체는 이미 DB에 정확히 하나로 남아 있으니, 최악의 경우에도 "누가 당첨인지"는 잃지 않습니다.

눈으로 확인 — 레이스 감지기와 진짜 Postgres

동시성 코드는 "될 것 같다"로 믿으면 안 됩니다. 12편처럼 감지기와 테스트 로 못박습니다. 서로 다른 2000명이 동시에 응모합니다.

// -race 로 돌린다. 2000명이 동시에 응모.
for i := 0; i < 2000; i++ {
    go func(i int) { svc.Enter(ctx, "ev", "u-"+strconv.Itoa(i)) }(i)
}
// 검증: 순번이 1..2000 의 순열(빈틈·중복 없음), 당첨(1000번)은 정확히 하나.

인메모리는 -race 로, Postgres 는 testcontainers(진짜 컨테이너) 로 돌려 다음을 확인했습니다.

  • 순번이 1..N 의 순열 — 빈틈도 중복도 없음.
  • 당첨은 정확히 한 명, 그 순번은 정확히 target.
  • 시작 전 응모 를 여러 번 거부해도, 시작 후 첫 응모는 여전히 1번 — 거부가 순번을 새게 하지 않음.
  • 같은 사용자가 동시에 500번 밀어도 순번은 하나만 소비.

특히 세 번째 — 거부·롤백이 빈틈을 만들지 않는다는 것 — 이 시퀀스와 갈리는 지점이고, 이 이벤트를 "정확히 N번째"로 만들어 주는 성질입니다.

정직하게 — 공짜는 없습니다

  • 핫 로우는 그대로 상한 입니다. 카운터 한 행이 직렬화 지점이라, 처리량이 그 행의 속도에 묶입니다. 다만 이벤트당 카운터가 하나뿐이고 트랜잭션이 짧아(외부 호출 없음), Postgres 한 대로도 초당 수천~수만 응모는 감당합니다. 그 이상이면 C(큐)로 앞단을 완충 해 접수와 확정을 분리하세요.
  • "1000번째"는 커밋 순서 입니다. 사용자 체감(버튼 누른 순간)과 다를 수 있고, 이건 정의의 문제라 기획과 합의 해야 합니다. "무엇이 공정한 1000번째인가"는 기술이 아니라 약속입니다.
  • 어뷰징 은 별도 방어가 필요합니다 — 1인 1응모(지금 구조가 강제), 레이트 리밋, 봇 차단. 상금이 걸리면 반드시 몰려옵니다.
  • 통지의 정확히-한번 은 DedupID 로 중복을, 아웃박스로 유실을 막습니다. 당첨 확정 은 이미 정확히-한번이지만, 통지 는 그 위에 한 겹 더 필요합니다.

정리 — 정의부터, 그다음 빈틈

  • "정확히 1000번째"는 먼저 무엇이 1000번째인지(우리는 커밋 순서) 정의하는 데서 시작합니다.
  • DB 시퀀스는 빠르지만 롤백 gap 때문에 이 문제엔 틀립니다. 낙관적 락은 스파이크에서 재시도 폭주. 샤딩은 임계값엔 좋지만 정확한 순번엔 부적합. Redis 는 빠르지만 돈 앞에서 내구성이 불안. 큐는 좋은 완충 이되 순서 정의가 바뀝니다.
  • 우리는 빈틈없는 카운터 행(FOR UPDATE) 으로 정확성과 감사 가능성을 잡았습니다 — 카운터 전진은 응모 성공 뒤에만. 여기에 멱등(1인 1응모)당첨 통지(DedupID·아웃박스) 를 얹었습니다.

핵심 한 줄 — "정확히 N번째"의 정확성은 속도가 아니라 빈틈없음에서 나옵니다. 가장 빨라 보이는 시퀀스가 gap 때문에 탈락하고, 조금 느려 보이는 카운터 행이 정답이 되는 것 — 요구사항을 정확히 읽는 일이 늘 먼저입니다.

이번 편 전체 코드·테스트(-race·testcontainers)는 리포의 part-31 태그, internal/promotion 에 있습니다.