예매 시작 1초에 다 들어오게 두지 않습니다 (ZSET 대기열과 입장 제어)

티켓 100장에 만 명이 몰리면 만 명이 다 DB까지 갑니다. 줄을 세우고 초당 몇 명씩만 통과시키는 구조와, popMin이 되돌릴 수 없다는 문제.

[배경 - 100장을 사려고 만 명이 들어온다]

선착순 예매는 트래픽 모양이 특이합니다. 평소에는 아무도 안 오다가 예매 시작 시각에 전부 한꺼번에 들어와요.

문제는 그 사람들이 전부 실패한다는 겁니다. 티켓이 100장이면 100명만 성공하고 나머지는 매진 응답을 받아요. 그런데 그 실패를 판정하기 위해 만 개의 요청이 전부 서버와 DB까지 도달합니다.

성공할 100명을 위해 만 명분의 자원을 쓰는 구조예요. 그리고 그 부하 때문에 성공해야 할 100명도 느려집니다.

[문제 상황 분석 - 어디서 걸러낼 것인가]

재고 확인만으로는 부족합니다

“재고가 0이면 바로 거절하면 되지 않나” 싶었어요. 그런데 이건 매진 이후에만 통합니다.

시작 직후 1초 동안은 재고가 남아 있습니다. 그 순간에 만 명이 동시에 재고를 확인하고 차감을 시도해요. Redis는 빠르지만 애플리케이션 스레드는 그만큼 안 됩니다.

즉 걸러내야 할 지점이 재고 확인보다 앞이어야 했어요.

순서를 정해야 합니다

선착순이라는 말은 순서가 있다는 뜻입니다. 그런데 동시에 도착한 요청에 순서를 매기는 건 간단하지 않아요.

필요한 건 이겁니다.

  • 도착 순서대로 줄을 세운다
  • 각자 자기가 몇 번째인지 알 수 있다
  • 앞에서부터 정해진 수만큼 꺼낸다
  • 같은 사람이 두 번 줄 서면 안 된다

Redis의 Sorted Set이 이 네 가지를 다 합니다.

[해결 방법 - 줄을 세우고 조금씩 들여보낸다]

등록은 addIfAbsent 로 합니다

public QueueEntry register(String screeningId, String token, long score) {
    String key = queueKey(screeningId);
    boolean inserted = queueStore.addIfAbsent(key, token, score);
    Double storedScore = queueStore.score(key, token);
    long effectiveScore = storedScore == null ? score : storedScore.longValue();

    return new QueueEntry(screeningId, token, effectiveScore, true, inserted);
}

addIfAbsent 는 Redis의 ZADD NX 입니다. 이미 있으면 점수를 안 바꿔요.

이게 중요합니다. 그냥 ZADD 를 쓰면 사용자가 새로고침할 때마다 점수가 갱신되고, 그러면 줄 뒤로 밀려납니다. 새로고침했다고 순번이 밀리면 사용자는 계속 새로고침을 안 하게 되고, 그게 또 다른 문제를 만들어요.

그리고 등록 후에 저장된 점수를 다시 읽습니다.

Double storedScore = queueStore.score(key, token);
long effectiveScore = storedScore == null ? score : storedScore.longValue();

내가 넣으려던 점수가 아니라 실제로 저장된 점수를 돌려줘요. 이미 줄을 선 사람이 다시 요청하면 원래 순번을 그대로 알려줍니다. 응답에 inserted 플래그를 따로 두는 것도 신규인지 재조회인지 구분하기 위해서예요.

순번은 rank로 봅니다

public Long rank(String screeningId, String token) {
    return queueStore.rank(queueKey(screeningId), token);
}

ZRANK 는 점수 순서에서 몇 번째인지 돌려줍니다. 0부터 시작해요.

Long zeroBasedRank = queueRepository.rank(screeningId, token);

변수 이름에 zeroBased 를 넣어둔 게 좋았습니다. 사용자에게 보여줄 때는 1을 더해야 하는데, 이름이 그냥 rank 면 어디서 더했는지 헷갈려요.

전체 인원은 ZCARD 로 봅니다. 순번과 전체 수가 있으면 “1,234번째 / 총 9,876명” 을 보여줄 수 있어요. 기다리는 사람에게 진행 상황을 보여주는 게 대기열의 절반입니다. 아무 정보 없이 기다리게 하면 새로고침을 누릅니다.

입장은 스케줄러가 시킵니다

줄에서 꺼내는 건 요청이 아니라 스케줄러가 합니다.

@Scheduled(fixedDelayString = "${waiting.queue.admission.scheduler-interval:5000}")
public void run() {
    for (String screeningId : properties.getScreeningIds()) {
        try {
            admissionService.admit(screeningId);
        } catch (RuntimeException exception) {
            log.error("Queue admission scheduler failed screeningId={}", screeningId, exception);
        }
    }
}

5초마다 돌면서 입장시킵니다. 유입 속도와 무관하게 처리 속도를 서버가 정하는 구조예요.

이게 대기열의 핵심입니다. 요청이 밀려들어도 뒷단으로 나가는 양은 일정해요. 만 명이 동시에 들어와도 예매 API는 정해진 수만큼만 받습니다.

남은 자리만큼만 꺼냅니다

AdmissionBatch selectBatch(String screeningId) {
    long activeCount = activeAdmissionRepository.countActive(screeningId);
    int remainingCapacity = (int) Math.max(0L, properties.getMaxActiveUsers() - activeCount);
    int selectionCount = Math.min(properties.getBatchSize(), remainingCapacity);
    List<QueueEntry> entries = selectionCount <= 0
            ? List.of()
            : queueRepository.popEarliest(screeningId, selectionCount);
    return new AdmissionBatch(screeningId, properties.getBatchSize(), remainingCapacity, entries);
}

두 값이 함께 작동합니다.

private int batchSize = 10;
private Duration activeTtl = Duration.ofMinutes(5);
private int maxActiveUsers = 100;
private Duration lockTtl = Duration.ofSeconds(10);
  • maxActiveUsers 100: 동시에 예매를 진행 중일 수 있는 최대 인원
  • batchSize 10: 한 번에 최대 몇 명을 들여보낼지
  • activeTtl 5분: 입장한 사람이 얼마나 오래 유효한지

남은 자리를 먼저 계산하고 그만큼만 꺼냅니다. 자리가 다 찼으면 아무도 안 꺼내요.

activeTtl 이 자리를 비우는 장치입니다. 입장했는데 예매를 안 하고 사라진 사람이 있어도 5분 뒤에 자리가 반납돼요. 명시적인 퇴장 신호에 기대지 않습니다. 브라우저를 닫는 건 서버에 알려지지 않으니 시간으로 회수하는 게 맞습니다.

입장 중인 사람은 만료 시각을 점수로 하는 ZSET에 넣습니다.

redisTemplate.opsForZSet().add(indexKey, token, expiresAtMillis);

이러면 ZCARD 로 인원을 세고 rangeByScore 로 만료된 사람을 골라낼 수 있어요. 같은 ZSET을 대기열은 순서용으로, 입장자 목록은 만료용으로 쓰는 셈입니다.

인스턴스가 여럿이어도 한 번만 돕니다

boolean lockAcquired = lockRepository.acquire(screeningId, instanceId, properties.getLockTtl());
if (!lockAcquired) {
    lockMissCounter.increment();
    return result(screeningId, false, 0, 0, SkipReason.LOCK_NOT_ACQUIRED, startedAt);
}

try {
    // ... 입장 처리
} finally {
    lockRepository.release(screeningId, instanceId);
}

상영 단위로 락을 잡습니다. 서버가 여러 대여도 한 인스턴스만 입장을 처리해요.

락이 없으면 두 인스턴스가 각각 countActive 를 읽고 각각 10명을 꺼냅니다. 그러면 maxActiveUsers 를 넘어요.

락에 instanceId 를 같이 넘기는 게 중요합니다. 해제할 때 내가 잡은 락인지 확인하기 위해서예요. TTL이 만료돼서 다른 인스턴스가 락을 가져갔는데 내가 해제하면 남의 락을 푸는 게 됩니다.

lockTtl 이 10초인데 스케줄러 주기가 5초예요. 처리가 5초 넘게 걸리면 다음 주기가 돌아오는데, 그때는 아직 락이 살아 있어서 건너뜁니다.

실패한 이유를 남깁니다

public enum SkipReason {
    NO_VALID_SCREENING, LOCK_NOT_ACQUIRED, ACTIVE_CAPACITY_REACHED, QUEUE_EMPTY
}

입장을 못 시킨 이유를 네 가지로 나눠 기록합니다. 그리고 지표로도 셉니다.

this.runCounter = meterRegistry.counter("waiting.admission.runs");
this.successCounter = meterRegistry.counter("waiting.admission.success");
this.failureCounter = meterRegistry.counter("waiting.admission.failure");
this.lockMissCounter = meterRegistry.counter("waiting.admission.lock.miss");
this.capacityHitCounter = meterRegistry.counter("waiting.admission.capacity.hit");

“입장이 안 되고 있다” 는 현상은 같은데 원인이 다릅니다. 자리가 없어서인지, 줄이 비어서인지, 락을 못 잡아서인지를 구분해야 대응이 달라요. 대기열은 겉으로는 아무 일도 안 일어나는 것처럼 보이는 시스템이라 지표가 없으면 진단이 불가능합니다.

[성과 - 개선 전후 비교]

항목대기열 없음대기열 있음
예매 API에 도달하는 요청유입량 전체최대 maxActiveUsers (100)
순서 보장없음 (경쟁)등록 시각 기준
새로고침 시 순번해당 없음유지 (ZADD NX)
사용자에게 보이는 정보성공 또는 실패내 순번과 전체 인원
이탈자 자리 회수해당 없음activeTtl 5분
다중 인스턴스해당 없음상영별 락으로 직렬화

저장소의 테스트는 돌렸습니다.

./gradlew test

waiting-api   테스트 51건, 실패 0건
  ├─ QueueAdmissionServiceTest                 7건
  ├─ QueueAdmissionSchedulerTest               3건
  ├─ QueueRegistrationConcurrencyTest          1건
  └─ QueueRegistrationControllerConcurrencyTest 1건

전부 통과하지만 이건 입장 로직이 의도대로 동작하는지를 본 것이고, 대기열이 실제 부하에서 무엇을 막아주는지는 아닙니다.

부하 쪽은 load-test/ 에 k6 시나리오와 결과 요약이 남아 있었어요. 대기열 등록부터 폴링, 티켓 발급까지 전 과정을 도는 시나리오입니다.

executor: shared-iterations, VU 5,000, iterations 30,000, maxDuration 15m

http_reqs              4,592,687건 (초당 4,938건)
http_req_duration      avg 10.33ms, p95 18.68ms, max 4,093ms
http_req_failed        0.03%
tickets_issued         1,000
tickets_sold_out       1,277
iterations (완료)      7,000
dropped_iterations     19,000

제일 중요한 줄은 tickets_issued 가 정확히 1,000이라는 겁니다. 재고만큼만 나갔어요. 시나리오의 임계값도 여기에 걸려 있습니다.

thresholds: {
  // 핵심: 1000장이 모두 발급되어야 한다
  'tickets_issued':        ['count>=1000'],

실패율 0.03%도 뜯어보면 실패가 아니에요. 실패로 잡힌 1,277건과 tickets_sold_out 1,277건이 정확히 같습니다. 매진 응답을 k6가 HTTP 실패로 센 것이지 오류가 아닙니다.

다만 정정할 게 있어요. 이력서에는 “초당 30,000건 규모의 요청 상황에서도 재고 정합성 유지 검증” 이라고 적었는데, 이 결과와 맞지 않습니다.

  • 30,000은 초당 요청 수가 아니라 목표 iteration 수입니다
  • 그중 실제로 완료된 건 7,000건이고 19,000건은 드롭됐어요 (15분 안에 못 끝남)
  • 실측 처리량은 초당 약 4,938 요청입니다

즉 “30,000 VU가 몰려도 버텼다” 가 아니라 “30,000건을 목표로 걸었고 7,000건이 완료되는 동안 재고는 정확히 지켜졌다” 가 맞습니다. 검증된 건 처리량이 아니라 정합성이에요.

계산으로만 적으면, 현재 설정에서 입장 처리량은 5초에 10명, 초당 2명입니다. 티켓 100장을 다 소진하는 데 최소 50초가 걸려요. 이게 적절한지는 실제 예매 소요 시간을 재봐야 알 수 있습니다.

[결론]

정리하면 이렇습니다.

  • 걸러내는 지점은 자원을 쓰기 전이어야 한다. 재고 확인은 이미 늦다
  • 유입 속도와 처리 속도를 분리하는 게 대기열의 본질이다
  • 퇴장은 신호가 아니라 시간으로 회수해야 한다
  • 대기열은 겉으로 조용해서, 지표가 없으면 고장을 모른다

한계를 적어둘게요. 첫 번째가 제일 큽니다.

첫째, popMin 은 되돌릴 수 없습니다.

List<QueueEntry> entries = queueRepository.popEarliest(screeningId, selectionCount);
// ...
for (QueueEntry entry : batch.selectedEntries()) {
    try {
        if (activeAdmissionRepository.activate(screeningId, entry.token(), properties.getActiveTtl())) {
            successCount++;
        } else {
            failureCount++;
        }
    } catch (RuntimeException exception) {
        failureCount++;
        // ...
    }
}

줄에서 꺼낸 다음에 입장 등록을 합니다. 그런데 꺼내는 순간 그 사람은 대기열에서 사라져요. 등록이 실패하면 그 사용자는 줄에도 없고 입장자 목록에도 없습니다.

failureCount 를 세고 로그를 남기지만 줄에 되돌려놓지 않습니다. 그 사용자는 조회할 때 순번도 없고 입장도 안 된 상태로 남아요.

꺼내기와 등록을 Lua로 묶거나, 실패 시 원래 점수로 다시 넣는 처리가 필요합니다. 원래 점수는 QueueEntry 에 들고 있으니 복구할 재료는 있어요.

둘째, 상영 ID를 설정에 하드코딩합니다.

@NotEmpty
private Set<String> screeningIds = new LinkedHashSet<>(Set.of("1"));

스케줄러가 설정에 적힌 상영만 처리해요. 새 상영이 열릴 때마다 배포해야 합니다.

셋째, 모든 상영을 한 스케줄러가 순회합니다. 상영이 늘어나면 한 주기 안에 다 못 돌 수 있고, 앞의 상영에서 오래 걸리면 뒤가 밀려요.

넷째, 입장 후 실제로 예매했는지 추적하지 않습니다. activeTtl 5분이 지나면 자리가 반납되는데, 3초 만에 예매를 끝낸 사람의 자리도 5분 동안 잡혀 있어요. 예매 완료 시 즉시 반납하면 처리량이 올라갑니다.

다섯째, load-test 를 만들어두고 결과를 안 남겼습니다. 대기열은 부하 상황을 위해 만든 구조인데 정작 부하를 걸어본 기록이 없어요. 이건 다음에 제일 먼저 할 일입니다.