Skip to content
xxwon.log
Go back

현재 대기순번 56898번째, 나는 왜 항상 밀렸을까

왜 나는 항상 가지 못할까

작년 응원하는 팀이 가을 야구에 가게됐다. 당연히 현장에서 그 열기를 느끼고 싶었다. 야구 직관 티켓을 예매하려고 예매 시작 시간에 맞춰 들어갔더니 대기 순번 578,834번째 이라는 문구를 마주했다. 숫자만 달라질뿐 시즌 내내 마주 했던 문구다. 운 좋게 겨우 대기열을 뚫고 들어가도 좌석을 선택하면 “이미 선택된 좌석입니다”라는 문구가 떴다. 매번 같은 패턴이었다. 도대체 어떻게 되어 있길래 나는 항상 바로 들어가지 못하고 대기열에 덩그러니 놓여 있는건지 궁금해졌다.


핵심 구현 과제

인기 콘서트/공연 티켓 예매 상황을 시뮬레이션한 고동시성 시스템이다. 핵심 과제는 두 가지였다. 1)천 명이 동시에 같은 좌석을 예매할 때 딱 1명만 성공시키는 동시성 제어, 그리고 그 전에 2)사용자를 줄 세우는 대기열 처리. Spring Boot 3.5 + Java 21 가상 스레드, Redis, RabbitMQ로 구성했다. 여기서는 구현했던 대기열 처리에 관해서 이야기 해보고자 한다.


설계: 대기열은 어떻게 동작하는가

전체 흐름은 다음과 같다.

사용자
  ├─ ① GET /api/subscribe  →  SSE 연결 (에미터 등록)
  └─ ② POST /api/queue/join  →  Redis ZSet 등록
                                  (score: 입장 타임스탬프 — 선착순 보장)

QueueScheduler (500ms마다)
  └─ ZSet 상위 N명 조회
  └─ 각 유저: active_user:{userId} 토큰 발급 (TTL 10분)
  └─ ③ SSE로 GO_BOOKING 신호 전송

사용자 (GO_BOOKING 수신)
  └─ 예매 요청 가능

QueueInterceptor (모든 /api/** 요청)
  └─ active_user:{userId} 토큰 없으면 → 403 Forbidden

처음 구현은 하나의 스케줄러가 두 가지를 순차적으로 처리했다.

QueueScheduler (1000ms마다)

  ├─ [1단계] 입장 처리 — N명 반복
  │    유저 1명당 개별 호출:
  │      SET  active_user:{userId}     ← 네트워크 1회
  │      ZREM waiting_queue            ← 네트워크 1회
  │      SSE  sendMoveSignal() 직접 호출
  │    → N명이면 N × 3 네트워크 왕복

  └─ [2단계] 순번 업데이트
       대기자 전체 조회 → 각자에게 현재 순번 SSE 전송
       → 대기자 수에 비례해 실행 시간 선형 증가

트러블슈팅: 병목은 어디서 왔나

처음엔 SSE 직접 호출이 병목이라고 생각했다. 대기자가 늘수록 스케줄러가 전체 유저에게 개별적으로 SSE를 쏘니까 실행 시간이 선형으로 늘어나는 게 문제처럼 보였다.

그런데 측정해보니 근본 원인은 더 넓었다. SSE만의 문제가 아니었다. 유저 1명을 처리할 때 SET, ZREM, SSE 전송을 개별 호출로 처리했기 때문에, N명을 처리하면 N × 3번의 네트워크 왕복이 발생했다. SSE를 의심한 방향은 맞았지만, 측정 전에 원인을 단정한 게 문제였다. 설정은 ALLOW_COUNT=100, fixedDelay=1000ms — 즉 초당 최대 100명 처리가 한계였다.

이 두 가지 문제는 실무에서도 동일하게 고민하는 지점이다. 대규모 Redis 작업을 개별 호출로 처리하면 네트워크 왕복 비용이 선형으로 누적되고, SSE 에미터를 서버 인스턴스 메모리에 직접 들고 있으면 서버를 여러 대로 늘릴 때 다른 인스턴스의 클라이언트에게 신호를 보낼 방법이 없다. 내가 대기열에서 순번이 좀처럼 줄지 않았던 이유 중 하나가 이런 구조적 병목이었을 수 있겠다는 생각이 들었다.


해결

첫째, 스케줄러를 분리했다.

입장 처리와 순번 업데이트를 별도 스케줄러로 나눴다. 각자의 주기와 책임을 명확히 분리하니 서로가 서로를 블로킹하지 않았다.

둘째, Redis Pipeline을 도입했다.

입장 처리 스케줄러에서 executePipelined()를 사용해 유저 N명의 SETEX + ZREM + PUBLISH단일 네트워크 왕복으로 처리했다.

redisTemplate.executePipelined((RedisCallback<Object>) conn -> {
    for (Long userId : toPromote) {
        conn.stringCommands().setEx(activeKey, TTL, token);
        conn.zSetCommands().zRem(waitingKey, userId);
        conn.pubSubCommands().publish(channel, userId);
    }
    return null;
});

SSE도 직접 호출 대신 PUBLISH로 교체했다. QueuePromotionListener가 구독하고 있다가 해당 userId의 SSE 연결로 신호를 보낸다. 덕분에 서버를 여러 대로 늘려도 각 인스턴스가 자신에게 연결된 클라이언트에게만 전달하는 구조가 됐다. Pipeline과 Pub/Sub, 두 가지 변경이 앞서 발견한 두 가지 문제를 각각 해결한 셈이다.


결과

ALLOW_COUNT=2000, fixedDelay=500ms로 조정했다. 이론상 초당 4,000명 처리가 가능해졌다. k6로 2,000 VUs, 4분 부하 테스트를 돌렸다.

지표결과
p95 대기열 진입11.71ms
p95 예매 응답14.44ms
처리량1,526 req/s
인프라 오류율0.00%

HTTP 실패 16%는 전부 409 Conflict, 즉 이중 예매 정상 거부였다.


회고

대기열 입장 처리에 스케줄러를 선택한 건 단일 서버를 전제로 한 결정이다. fixedDelay는 같은 메서드가 겹쳐 실행되지 않음을 보장하고, Redis 작업은 Lettuce(논블로킹 I/O)를 사용하기 때문에 가상 스레드 환경에서도 핀닝 문제가 없다. 다만 서버가 여러 대로 늘어나면 각 인스턴스의 스케줄러가 같은 ZSet을 동시에 읽어 중복 프로모션이 발생할 수 있다. 실무라면 ZPOPMIN(원자적 팝)이나 Redis Stream의 Consumer Group으로 자연스럽게 분산 처리하는 것이 맞을 것이다.

구현의 한계도 있다. 현재는 잔여석과 무관하게 ALLOW_COUNT만큼 무조건 프로모션한다. 좌석이 1개 남은 상황에서 2,000명을 입장시키면 1,999명은 예매 시도 후 거부된다. 대기열 자체에 집중하다보니 전체 정원 관리 로직이 필요하다는 것을 놓쳤다.

대기열 이탈 처리도 마찬가지다. active_user: 토큰이 TTL 만료로 사라져도 ZSet의 대기 항목은 그대로 남는다. 실제 서비스라면 잔여석 기반 동적 프로모션과 이탈 유저 ZSet 정리가 필요하다.

매번 오픈 시간에 맞춰 입장을 하면 내 앞에 적게는 몇 천 명 많게는 몇 만 명씩 있고, 결국 입장이 가능했을 때에는 내 티켓은 존재하지 않았다. 그 대기열을 하염없이 노려보기만 하다가 이번에 어떻게 구성되는가를 알게 되었고, 언젠가는 한 번에 입장해 예매할 수 있을 날을 기다려본다.



Share this post on:

Previous Post
'이미 선택된 자리입니다', 그 많던 좌석은 다 어디로 갔을까