콘서트 티켓 예매 시스템 설계: 수백만 사용자를 위한 가상 대기열

1. 개요

어떤 대형 콘서트의 예매가 오전 10시에 오픈됩니다. 불과 몇 초 후, 수백만 명이 동시에 “티켓 구매” 버튼을 눌러 50,000석을 놓고 경쟁합니다. 모든 요청을 데이터베이스로 보낸다면 시스템이 마비됩니다. 잘못 처리하면 두 사람이 같은 좌석을 구매하는 문제도 생깁니다.

이 글에서는 설계의 각 단계를 살펴봅니다: 요구사항, 데이터 모델, API 및 처리 흐름. 핵심은 세 가지 어려운 부분입니다: 급작스러운 트래픽 증가, 좌석 경쟁, 결제 시 일관성 보장. 읽고 나면 Redis로 가상 대기열을 만들고, 중복 판매가 없는 좌석 보유 메커니즘을 구현하며, 반복 호출에 안전한 주문 확인 흐름을 구축할 수 있습니다.

2. 요구사항

2.1. 기능 요구사항

  • 사용자가 이벤트 페이지에 들어오면 판매 개시 전에 가상 대기열에 배치됩니다.
  • 시스템은 사용자를 배치 단위로 좌석 선택 영역에 입장시키며, 선착순 순서를 따릅니다.
  • 사용자는 최대 4개의 좌석을 선택할 수 있습니다. 좌석은 결제를 위해 5분간 보유(hold) 됩니다.
  • 결제가 성공하면 좌석은 판매됨 상태로 변경되고 티켓이 발급됩니다.
  • 기한을 초과하면 좌석은 보유 상태에서 해제됩니다.

2.2. 규모 및 제약사항

지표 추정 값
판매 개시 시 동시 사용자 2,000,000
좌석 수 50,000
이벤트 페이지 피크 요청 ~500,000 RPS
동시 좌석 선택 영역 사용자 5,000

가장 중요한 제약은 절대로 같은 좌석을 중복 판매하면 안 된다는 것입니다. 여기서 일관성은 강한 일관성(strong consistency)이어야 합니다. 반대로 대기열 내 위치는 근사치로 충분합니다: “앞에 약 12,000명이 있습니다”라고 표시하는 것으로 충분합니다.

위의 두 숫자에서 핵심을 파악할 수 있습니다: 사용자의 단 2.5%만 티켓을 구매합니다. 따라서 시스템은 대부분의 요청을 가능한 한 저렴한 계층에서 거부하거나 지연시켜야 하며, 데이터베이스에 도달하기 전에 처리해야 합니다.

3. 아키텍처 및 처리 흐름

Client ──> CDN (정적 페이지, 대기 페이지)
   │
   ├─> Queue Service ──> Redis (ZSET 대기열, access token)
   │        ▲
   │        └── Admitter (스케줄 작업, 초당 N명 입장)
   │
   └─> Booking Service (access token 필요)
            ├─> PostgreSQL (seat, order, outbox)
            └─> Kafka (ticket-issued) ──> Ticket/Email Service

주요 흐름은 다음과 같습니다:

  1. 클라이언트가 POST /events/{id}/queue를 호출하고 queueId를 받습니다.
  2. 클라이언트가 GET /events/{id}/queue/{queueId}를 몇 초마다 폴링하여 위치를 확인합니다. 차례가 되면 클라이언트는 accessToken을 받습니다.
  3. 클라이언트가 토큰을 가지고 POST /events/{id}/holds를 호출하며 좌석 목록을 전달합니다.
  4. 클라이언트가 결제합니다. 결제 제공자가 웹훅을 호출하고, 시스템은 멱등성 키와 함께 POST /orders/{id}/confirm을 호출합니다.
  5. Booking Service는 단일 트랜잭션 내에서 주문과 outbox 이벤트를 기록합니다. 그 후 이벤트는 티켓 발급을 위해 Kafka로 전송됩니다.

대기 페이지는 CDN을 통해 정적으로 제공됩니다. join과 poll API만 백엔드로 이동하며, 둘 다 Redis에만 접근합니다.

4. 데이터 모델

CREATE TABLE seat (
    event_id        BIGINT       NOT NULL,
    seat_id         VARCHAR(16)  NOT NULL,
    section         VARCHAR(16)  NOT NULL,
    price           NUMERIC(12,2) NOT NULL,
    status          VARCHAR(16)  NOT NULL DEFAULT 'AVAILABLE', -- AVAILABLE | HELD | SOLD
    held_by         BIGINT,
    hold_expires_at TIMESTAMP,
    PRIMARY KEY (event_id, seat_id)
);

CREATE TABLE ticket_order (
    id              BIGSERIAL PRIMARY KEY,
    customer_id     BIGINT      NOT NULL,
    event_id        BIGINT      NOT NULL,
    status          VARCHAR(16) NOT NULL, -- PENDING | PAID | EXPIRED
    idempotency_key VARCHAR(64) NOT NULL UNIQUE
);

CREATE TABLE outbox (
    id         BIGSERIAL PRIMARY KEY,
    topic      VARCHAR(64) NOT NULL,
    payload    TEXT        NOT NULL,
    created_at TIMESTAMP   NOT NULL DEFAULT now()
);

좌석 상태는 seat 테이블에 직접 저장됩니다. 덕분에 좌석 보유는 조건이 있는 UPDATE 하나만 필요합니다. idempotency_key에 대한 UNIQUE 제약은 중복 주문 방지를 위한 최후의 방어선입니다.

5. Redis를 사용한 가상 대기열

의존성 추가:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

5.1. 대기열 입장

대기열은 Redis Sorted Set입니다. Score는 증가하는 일련번호이며, INCR에서 가져옵니다:

@Service
public class QueueService {

    private final StringRedisTemplate redis;

    public QueueService(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public long join(long eventId, long customerId) {
        String queueKey = "queue:" + eventId;
        String member = String.valueOf(customerId);

        Long existing = redis.opsForZSet().rank(queueKey, member);
        if (existing != null) {
            return existing;
        }
        Long seq = redis.opsForValue().increment("queue:seq:" + eventId);
        redis.opsForZSet().addIfAbsent(queueKey, member, seq);
        return redis.opsForZSet().rank(queueKey, member);
    }

    public Long position(long eventId, long customerId) {
        return redis.opsForZSet().rank("queue:" + eventId, String.valueOf(customerId));
    }
}

설명:

  • increment는 전역 일련번호를 반환합니다. 많은 요청이 같은 밀리초에 도달할 수 있으므로 타임스탐프를 score로 사용하지 않습니다.
  • addIfAbsent는 ZADD NX에 해당합니다. 사용자가 F5를 계속 눌러도 대기열 끝으로 밀려나지 않습니다.
  • rank (ZRANK)의 시간 복잡도는 O(log N)입니다. 200만 개 요소가 있어도 이 명령은 몇 마이크로초만 소비합니다.

세 사람이 순서대로 대기열에 입장하면 다음과 같은 결과를 얻습니다:

join(1, 101) -> 0
join(1, 202) -> 1
join(1, 101) -> 0   (다시 입장, 위치 유지)

주의: 봇을 차단해야 한다면 join하기 전에 사용자를 인증하십시오 (로그인, captcha). ZSET에 한 번 들어가면 봇도 실제 사람처럼 대기열에서 한 자리를 차지합니다.

5.2. Admitter: 배치로 사용자 입장 허용

초마다 실행되는 작업입니다. 이 작업은 좌석 선택 영역에 남은 자리를 확인한 후, 대기열 앞에서 정확히 그만큼의 사람을 가져옵니다:

@Component
public class Admitter {

    private static final int MAX_ACTIVE = 5_000;
    private static final Duration TOKEN_TTL = Duration.ofMinutes(10);

    private final StringRedisTemplate redis;

    public Admitter(StringRedisTemplate redis) {
        this.redis = redis;
    }

    @Scheduled(fixedRate = 1000)
    public void admit() {
        long eventId = 1L;
        String activeKey = "active:" + eventId;
        long now = System.currentTimeMillis();

        redis.opsForZSet().removeRangeByScore(activeKey, 0, now);
        long active = Optional.ofNullable(redis.opsForZSet().zCard(activeKey)).orElse(0L);
        long slots = MAX_ACTIVE - active;
        if (slots <= 0) return;

        var batch = redis.opsForZSet().popMin("queue:" + eventId, slots);
        if (batch == null) return;

        for (var tuple : batch) {
            String customerId = tuple.getValue();
            String token = UUID.randomUUID().toString();
            redis.opsForValue().set("access:" + token, customerId, TOKEN_TTL);
            redis.opsForValue().set("token-of:" + eventId + ":" + customerId, token, TOKEN_TTL);
            redis.opsForZSet().add(activeKey, customerId, now + TOKEN_TTL.toMillis());
        }
    }
}

설명:

  • active:{eventId}는 score가 만료 시간인 ZSET입니다. removeRangeByScore는 세션이 끝난 사람들을 정리하고, zCard는 좌석 선택 영역에 있는 사람의 수를 보여줍니다.
  • popMin (ZPOPMIN)은 대기열 앞의 N명을 가져오고 제거합니다.
  • 폴링할 때 클라이언트는 token-of:...을 읽습니다. 키가 존재하면 차례가 된 것이고, 클라이언트는 토큰을 받습니다.

@EnableScheduling을 활성화해야 하고 이 작업을 정확히 한 인스턴스에서만 실행해야 합니다 (예: ShedLock 또는 leader election 사용). 두 인스턴스가 동시에 실행되면 좌석 선택 영역이 MAX_ACTIVE를 초과할 수 있습니다.

200만 명을 처리하기 위해 여러 Admitter를 실행할 필요는 없습니다. 사용자 입장 속도는 Redis가 아닌 Booking Service의 처리량에 의해 제한됩니다.

5.3. 토큰 없는 요청 차단

@Component
public class AccessTokenFilter extends OncePerRequestFilter {

    private final StringRedisTemplate redis;

    public AccessTokenFilter(StringRedisTemplate redis) {
        this.redis = redis;
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        return !request.getRequestURI().contains("/holds");
    }

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
                                    FilterChain chain) throws ServletException, IOException {
        String token = req.getHeader("X-Queue-Token");
        String customerId = token == null ? null : redis.opsForValue().get("access:" + token);
        if (customerId == null) {
            res.sendError(HttpServletResponse.SC_FORBIDDEN, "Not admitted");
            return;
        }
        req.setAttribute("customerId", Long.valueOf(customerId));
        chain.doFilter(req, res);
    }
}

이 필터는 토큰을 조회 키로 사용하며, 클라이언트가 보낸 customerId를 신뢰하지 않습니다. 사용자는 API /holds를 직접 호출하여 대기열을 뛰어넘을 수 없습니다.

6. 좌석 보유: 경쟁 처리

여러 사람이 동시에 좌석 A-12를 선택할 수 있습니다. 가장 간단하고 정확한 방법은 조건이 있는 UPDATE 문으로 데이터베이스가 결정하도록 하는 것입니다:

@Service
public class SeatHoldService {

    private static final Duration HOLD_TTL = Duration.ofMinutes(5);
    private final JdbcTemplate jdbc;

    public SeatHoldService(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    @Transactional
    public void hold(long eventId, long customerId, List<String> seatIds) {
        if (seatIds.size() > 4) throw new IllegalArgumentException("Max 4 seats");
        Timestamp now = Timestamp.from(Instant.now());
        Timestamp expires = Timestamp.from(Instant.now().plus(HOLD_TTL));

        for (String seatId : seatIds.stream().sorted().toList()) {
            int updated = jdbc.update("""
                UPDATE seat SET status = 'HELD', held_by = ?, hold_expires_at = ?
                WHERE event_id = ? AND seat_id = ?
                  AND (status = 'AVAILABLE' OR (status = 'HELD' AND hold_expires_at < ?))
                """, customerId, expires, eventId, seatId, now);
            if (updated == 0) {
                throw new SeatUnavailableException(seatId);
            }
        }
    }
}

class SeatUnavailableException extends RuntimeException {
    SeatUnavailableException(String seatId) { super("Seat taken: " + seatId); }
}

설명:

  • WHERE 절은 단일 원자적 명령에서 확인과 업데이트를 동시에 수행합니다. 데이터베이스가 그 행을 잠금합니다. 나중에 도착한 요청은 대기한 후 조건을 다시 평가하고 status = 'HELD'를 보고 updated = 0을 받습니다.
  • hold_expires_at < ? 조건은 정리 작업을 기다릴 필요 없이 만료된 보유를 다시 획득할 수 있습니다.
  • 그룹의 한 좌석이 실패하면 예외로 인해 전체 트랜잭션이 롤백됩니다. 사용자가 “반 그룹”의 좌석만 보유하는 경우는 절대 없습니다.
  • 업데이트 전에 seatIds를 정렬하면 모든 트랜잭션이 같은 순서로 잠금을 획득하므로 교차 선택 (예: {A-1, A-2}와 {A-2, A-1})으로 인한 데드락을 피할 수 있습니다.

주의: “SELECT로 상태 확인 후 UPDATE” 방식을 잠금 없이 수행하지 마세요. 이것이 바로 중복 판매로 이어지는 전형적인 경쟁 조건입니다.

6.1. 중복 판매 방지 동작 테스트

50개의 스레드가 동시에 같은 좌석을 놓고 경쟁하도록 합니다. 예상 결과는 정확히 한 스레드만 성공하는 것입니다.

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>test</scope>
</dependency>
@JdbcTest
@Import(SeatHoldService.class)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class SeatHoldServiceTest {

    @Autowired JdbcTemplate jdbc;
    @Autowired SeatHoldService service;

    @Test
    void onlyOneCustomerCanHoldTheSameSeat() throws Exception {
        jdbc.update("DELETE FROM seat");
        jdbc.update("INSERT INTO seat(event_id, seat_id, section, price) VALUES (1, 'A-12', 'VIP', 3000000)");

        AtomicInteger success = new AtomicInteger();
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (long customer = 1; customer <= 50; customer++) {
                long id = customer;
                executor.submit(() -> {
                    try {
                        service.hold(1L, id, List.of("A-12"));
                        success.incrementAndGet();
                    } catch (SeatUnavailableException ignored) { }
                });
            }
        }

        assertThat(success.get()).isEqualTo(1);
        assertThat(jdbc.queryForObject(
            "SELECT status FROM seat WHERE seat_id = 'A-12'", String.class)).isEqualTo("HELD");
    }
}

seat 테이블은 src/test/resources/schema.sql에서 생성됩니다. 섹션 4의 CREATE TABLE seat 문을 정확히 사용하고, 나머지 테이블을 추가로 생성하는 경우 BIGSERIAL을 BIGINT AUTO_INCREMENT로 변경하세요. @Transactional(NOT_SUPPORTED)는 테스트 주변 트랜잭션을 비활성화하여 데이터가 커밋되고 다른 스레드가 볼 수 있도록 합니다.

7. 결제 및 일관성

결제 제공자의 웹훅은 여러 번 호출될 수 있습니다. 따라서 주문 확인 단계는 멱등해야 합니다:

@Transactional
public void confirm(long orderId, long customerId, long eventId, String idempotencyKey) {
    int inserted = jdbc.update("""
        INSERT INTO ticket_order(id, customer_id, event_id, status, idempotency_key)
        VALUES (?, ?, ?, 'PAID', ?) ON CONFLICT (idempotency_key) DO NOTHING
        """, orderId, customerId, eventId, idempotencyKey);
    if (inserted == 0) return; // 이전에 이미 처리됨

    int sold = jdbc.update("""
        UPDATE seat SET status = 'SOLD'
        WHERE event_id = ? AND held_by = ? AND status = 'HELD'
        """, eventId, customerId);
    if (sold == 0) throw new IllegalStateException("Hold expired, refund required");

    jdbc.update("INSERT INTO outbox(topic, payload) VALUES ('ticket-issued', ?)",
        "{\"orderId\":" + orderId + "}");
}

이 코드는 세 가지를 처리합니다:

  • ON CONFLICT DO NOTHING (PostgreSQL 구문)은 웹훅 반복 호출이 중복 주문을 생성하지 않도록 보장합니다.
  • 좌석은 여전히 해당 고객이 보유하고 있을 때만 SOLD로 전환됩니다. 보유가 만료되었고 좌석이 다른 사람에게 이미 속한 경우 트랜잭션이 롤백됩니다. 이 경우 주문은 환불 흐름으로 이동해야 합니다.
  • Transactional outbox: 이벤트는 주문과 동일한 트랜잭션에 기록됩니다. 별도의 프로세스 (또는 Debezium)가 outbox 테이블을 읽고 Kafka로 푸시합니다. 덕분에 주문은 저장되었지만 티켓이 발급되지 않는 경우 또는 그 반대의 경우는 없습니다.

주의: 보유의 TTL을 결제 페이지 타임아웃보다 조금 길게 설정하세요 (예: 보유 5분, 결제 페이지 4분). 이 버퍼는 결제가 이루어졌지만 보유가 방금 만료되는 경우의 수를 줄입니다.

8. 어려운 점과 흔한 오류

  • 프론트엔드의 급작스러운 트래픽: 대기 페이지는 CDN의 정적 페이지여야 합니다. poll API는 위치에 따라 poll 주기를 제안하는 헤더를 반환해야 합니다 (예: 5–15초). 클라이언트가 초마다 poll하면 200만 명이 Redis에 200만 RPS를 생성합니다.
  • Redis는 단일 실패 지점: Redis Cluster 또는 Sentinel을 사용하세요. 단일 대형 이벤트만 있는 경우 전체 ZSET은 한 key에 있으므로 한 shard에 있을 것입니다. 단일 Redis 노드도 수백만 ops/s를 처리할 수 있지만 미리 측정해야 합니다.
  • “남은 좌석” 표시가 부정확: UI의 남은 좌석 수는 캐시에서 읽을 수 있으며 몇 초의 지연을 허용합니다. 섹션 6의 UPDATE 문만이 소스 오브 트루스입니다.
  • 버려진 보유: 사용자가 좌석을 보유한 후 탭을 닫습니다. UPDATE의 만료 조건은 좌석 회수를 처리합니다. 만료된 좌석을 AVAILABLE로 반환하는 정기 작업을 추가하기만 하면 UI가 올바르게 표시됩니다.
  • 가상 대기열이 필요 없을 때: 수백 좌석의 이벤트와 중간 정도의 트래픽이 있는 경우 rate limiting과 조건부 UPDATE만으로 충분합니다. 가상 대기열은 운영 복잡도를 추가하고 사용자 경험을 더 느리게 합니다. 구매 계층의 수용 능력을 크게 초과하는 인원이 있을 때만 사용해야 합니다.

9. 결론

티켓 판매 시스템은 두 가지 관심사를 분리하여 높은 부하를 견딥니다. Redis의 가상 대기열은 급작스러운 트래픽 증가를 흡수하고 공정성을 유지합니다. 데이터베이스는 입장이 허가된 소수의 사용자만 처리하고 조건부 UPDATE로 중복 판매를 방지합니다.

ZADD NX와 ZPOPMIN으로 대기열을 관리하고 배치로 사용자를 입장시키는 방법, 원자적으로 좌석을 보유하고 데드락을 피하는 방법, 그리고 멱등한 주문 확인과 transactional outbox를 결합하여 Kafka로 이벤트를 전송하는 방법을 배웠습니다. 이 프레임워크에서 봇 방지, 회원 우선 대기열, 또는 여러 이벤트의 병렬 실행으로 확장할 수 있습니다.

system-designredisspring-bootconcurrencykafka