springboot

대규모 트래픽 경험기 - 대기열

2026-05-15
6분 분량
JAVATraffic

이전 글에서 Pessimistic Lock으로 동시성 문제를 해결했다. 이번에는 한 단계 앞으로 가서, "애초에 서버에 동시에 들어오는 요청 수를 제어하면 어떨까?" 라는 질문에서 출발한다.


1. 문제 상황

콘서트 티켓 오픈 시간에 2000명이 동시에 좌석 조회를 요청한다고 가정하자.

서버는 요청마다 DB 커넥션을 잡고, 쓰레드를 할당하고, 쿼리를 실행해야 한다. 하지만 Tomcat의 기본 쓰레드 풀은 200개, HikariCP의 기본 커넥션 풀은 10개다. 2000명이 동시에 몰리면 어떻게 될까?

대부분의 요청이 쓰레드/커넥션 풀에서 자기 차례를 기다리게 된다.

실제 쿼리 실행 시간은 수십 ms에 불과하지만, 앞에 쌓인 요청들이 빠질 때까지 기다리느라 응답이 수 초씩 밀린다. 사용자 입장에서는 "좌석 조회" 버튼을 눌렀는데 10초 가까이 로딩만 돌아가는 상황이다.

Locust로 2000명 동시 접속을 재현한 결과가 이를 증명한다.

plain text
[No Queue] 좌석 조회 결과
  총 요청수       : 21,882
  성공            : 21,882  (100.0%)
  평균 응답 (ms)  : 8,695
  p95  응답 (ms)  : 10,000
  p99  응답 (ms)  : 11,000

평균 응답 8.7초, p95가 10초다. 요청의 절반 이상이 9.5초 이상 걸렸다. 서버가 죽지는 않았지만, 사용자 경험은 사실상 장애 수준이다.

핵심은 실제 처리 시간이 아니라 대기 시간이 병목이라는 점이다. 최소 응답 시간이 101ms인 것을 보면, 서버가 여유 있을 때의 처리 성능은 충분하다. 문제는 2000개의 요청이 한꺼번에 밀려들면서 쓰레드 풀과 커넥션 풀이 포화되고, 뒤에 온 요청들이 줄을 서느라 전체 응답 시간이 급등한 것이다.


2. 해결 방향 — "서버 앞에 줄을 세우자"

이 문제를 코드 레벨에서 최적화하는 것은 한계가 있다. 쿼리를 아무리 빠르게 만들어도 2000개의 동시 요청이 200개의 쓰레드를 놓고 경쟁하는 구조 자체가 바뀌지 않기 때문이다.

발상을 전환하면, 서버가 감당할 수 있는 만큼만 요청을 통과시키고 나머지는 서버 바깥에서 대기시키면 된다. 인터파크나 YES24에서 티켓 오픈 때 보이는 "앞에 N명이 대기 중입니다" 화면이 바로 이 패턴이다.

이 대기열이 해결하는 것은 두 가지다.

  • 서버 보호: 동시 접근 인원을 제한해서 쓰레드/커넥션 풀이 포화되지 않도록 한다.
  • 공정성 보장: 먼저 온 사용자가 먼저 입장하는 FIFO 순서를 지킨다.

3. 왜 Redis Sorted Set인가

대기열의 자료구조로 Redis Sorted Set을 선택한 이유는 명확하다.

일반 Queue가 아닌 이유 — 사용자가 현재 몇 번째인지 조회하려면 O(N)O(N) 탐색이 필요하다. 대기열에 만 명이 있으면 매 폴링마다 만 건을 순회해야 한다.

μsSorted Set을 쓰는 이유 — score에 요청 시각을 넣으면 자연스럽게 FIFO 순서가 보장된다. ZRANK로 현재 순번을 O(logN)O(log N)에 조회할 수 있고, ZRANGE로 선두 N명을 /인라인수학에 꺼낼 수 있다. 대기열 10만 명이어도 순번 조회가 수십 μs\mu s 수준이다.

Redis를 쓰는 이유 — 대기열은 휘발성 데이터다. 서버가 재시작되면 다시 줄을 서면 된다. RDBMS에 저장할 필요가 없고, 인메모리 저장소인 Redis가 속도 면에서 압도적이다. 또한 서버가 여러 대일 때도 Redis 한 대가 중앙 대기열 역할을 하므로 분산 환경에서도 일관된 순서를 유지할 수 있다.


4. 전체 구조

post image

동시에 좌석을 조회할 수 있는 인원을 MAX_ACTIVE_USERS(1000명)로 제한하고, 스케줄러가 "승격"과 "만료"를 반복하면서 그 수를 유지하는 구조다.


5. 구현

5-1. 대기열 진입

사용자가 좌석 조회를 요청하면 먼저 대기열에 등록된다.

java
public QueueEntryResponse enterQueue(Long eventId, Long userId) {
    String waitKey = String.format(WAIT_QUEUE_KEY, eventId);
    String tokenKey = String.format(TOKEN_KEY, eventId, userId);

    String token = UUID.randomUUID().toString();

    // SET NX — 원자적 중복 방지
    Boolean acquired = redisTemplate.opsForValue()
            .setIfAbsent(tokenKey, token, Duration.ofHours(24));
    if (Boolean.FALSE.equals(acquired)) {
        throw new CustomException(QueueExceptionCode.ALREADY_IN_QUEUE);
    }

    try {
        double score = System.currentTimeMillis();
        redisTemplate.opsForZSet().addIfAbsent(waitKey, String.valueOf(userId), score);

        Long rank = redisTemplate.opsForZSet().rank(waitKey, String.valueOf(userId));
        return new QueueEntryResponse(token, rank + 1, estimateWaitTime(rank));
    } catch (Exception e) {
        redisTemplate.delete(tokenKey);  // 실패 시 토큰 롤백
        throw e;
    }
}

여기서 중복 진입 방지에 SET NX를 사용한 이유가 있다. 처음에는 GET → 존재 확인 → SET 패턴으로 구현했는데, 두 명이 동시에 GET을 호출하면 둘 다 "없음"으로 판단하고 각각 토큰을 생성하는 race condition이 발생했다. SET NX는 "키가 없을 때만 저장"을 하나의 원자적 연산으로 처리하므로 이 문제가 해결된다.

5-2. 대기열 상태 폴링

클라이언트는 주기적으로 자신이 활성 상태가 되었는지 확인한다.

java
public QueueStatusResponse getQueueStatus(Long eventId, Long userId, String token) {
    // 1. 토큰 검증
    String stored = redisTemplate.opsForValue().get(tokenKey);
    if (stored == null || !token.equals(stored)) {
        throw new CustomException(QueueExceptionCode.INVALID_TOKEN);
    }

    // 2. 활성 큐에 있는지 확인 (score = 만료 시각)
    Double expiryScore = redisTemplate.opsForZSet().score(activeKey, String.valueOf(userId));
    if (expiryScore != null && expiryScore >= System.currentTimeMillis()) {
        return QueueStatusResponse.activeToken();  // active=true
    }

    // 3. 대기열 순번 조회
    Long rank = redisTemplate.opsForZSet().rank(waitKey, String.valueOf(userId));
    if (rank == null) {
        throw new CustomException(QueueExceptionCode.SESSION_EXPIRED);
    }

    return new QueueStatusResponse(false, rank + 1, estimateWaitTime(rank));
}

활성 큐의 Sorted Set에서 score를 만료 시각으로 사용한 점이 포인트다. 별도의 TTL 키를 관리하지 않아도 score < 현재 시각이면 만료로 판단할 수 있어서, 만료 확인과 순번 조회를 모두 Sorted Set 연산만으로 처리할 수 있다.

5-3. 스케줄러 — 대기열에서 활성 큐로 승격

10초마다 실행되는 스케줄러가 대기열 선두의 사용자를 활성 큐로 옮긴다.

java
@Scheduled(fixedRate = 10_000)
public void promoteWaitingUsers() {
    Set<String> activeEvents = redisTemplate.opsForSet().members(ACTIVE_EVENTS_KEY);
    if (activeEvents == null) return;

    for (String eventId : activeEvents) {
        promoteForEvent(eventId);
    }
}

승격 로직은 Lua 스크립트로 구현했다.

lua
-- 1. 만료된 활성 사용자 정리
redis.call('ZREMRANGEBYSCORE', activeKey, '-inf', now - 1)

-- 2. 빈 슬롯 계산
local activeCount = redis.call('ZCARD', activeKey)
local available   = maxActive - activeCount
if available <= 0 then return 0 end

-- 3. 대기열 선두에서 승격
local toPromote = math.min(available, batchSize)
local members   = redis.call('ZRANGE', waitKey, 0, toPromote - 1)

for _, uid in ipairs(members) do
    redis.call('ZADD', activeKey, expiry, uid)
    redis.call('ZREM', waitKey, uid)
end

이 로직을 Java 코드가 아닌 Lua 스크립트로 작성한 이유가 있다. Java에서 ZCARD → ZRANGE → ZADD → ZREM을 순차적으로 호출하면 각 명령 사이에 다른 요청이 끼어들 수 있다. 예를 들어 두 개의 스케줄러 인스턴스가 동시에 ZCARD로 빈 슬롯을 확인하고, 둘 다 200명씩 승격시키면 MAX_ACTIVE_USERS를 초과하게 된다. Lua 스크립트는 Redis에서 원자적으로 실행되므로 이런 race condition이 발생하지 않는다.

5-4. 좌석 조회 — 활성 사용자 검증

좌석 조회 API는 요청이 들어오면 먼저 해당 사용자가 활성 상태인지 검증한다.

java
@GetMapping("/{scheduleId}/seat-status/snapshot/{userId}")
public ResponseEntity<SeatStatusSnapshotRes> snapshot(
        @PathVariable long scheduleId,
        @PathVariable long userId,
        @RequestHeader("X-Queue-Token") String token) {

    queueService.validateActiveUser(scheduleId, userId, token);
    return ResponseEntity.ok(seatStatusLiveService.snapshot(scheduleId));
}
java
public void validateActiveUser(long eventId, Long userId, String token) {
    // 토큰 검증
    String stored = redisTemplate.opsForValue().get(tokenKey);
    if (stored == null || !token.equals(stored)) {
        throw new CustomException(QueueExceptionCode.INVALID_TOKEN);
    }

    // 활성 상태 + 만료 확인
    Double expiryScore = redisTemplate.opsForZSet().score(activeKey, String.valueOf(userId));
    if (expiryScore == null || expiryScore < System.currentTimeMillis()) {
        throw new CustomException(QueueExceptionCode.NOT_ACTIVE_USER);
    }

    // 활성 상태 갱신 (TTL 연장)
    long newExpiry = System.currentTimeMillis() + Duration.ofMinutes(5).toMillis();
    redisTemplate.opsForZSet().add(activeKey, String.valueOf(userId), newExpiry);
}

활성 사용자가 좌석을 조회할 때마다 만료 시각이 5분 뒤로 갱신된다. 반대로 브라우저를 닫거나 아무 동작도 하지 않으면 5분 후 자동으로 슬롯이 회수되고, 대기열의 다음 사용자가 승격된다.


6. 부하 테스트 — Before / After

동일한 로컬 환경에서 Locust를 사용해 2000명 동시 접속, 2분간 테스트를 진행했다.

Before — 대기열 없이 좌석 직접 조회

plain text
locust -f no_queue.py --users 2000 --spawn-rate 100 --run-time 2m
plain text
  총 요청수       : 21,882
  성공            : 100.0%
  평균 응답 (ms)  : 8,695
  p95  응답 (ms)  : 10,000
  p99  응답 (ms)  : 11,000
  처리량 (req/s)  : 182

After — Redis 대기열 적용 후 좌석 조회

plain text
locust -f with_queue.py --users 2000 --spawn-rate 100 --run-time 2m
plain text
  총 요청수       : 18,587
  성공            : 100.0%
  평균 응답 (ms)  : 1,070
  p95  응답 (ms)  : 1,500
  p99  응답 (ms)  : 1,600
  처리량 (req/s)  : 155

비교

Before에서 최소 응답이 101ms였던 것을 생각하면, After의 평균 1,070ms는 아직 개선 여지가 있다. 하지만 8.7초 → 1.1초로 약 8배 개선된 것은, 대기열이 서버의 처리 능력 범위 내로 트래픽을 조절해 준 효과가 확실하다는 것을 보여준다.

처리량이 182 → 155 req/s로 줄어든 것은 대기열 폴링에 리소스가 분산되었기 때문이다. 하지만 이는 트레이드오프로서 합리적인데, 실제 사용자 경험을 결정하는 것은 처리량이 아니라 응답 시간이기 때문이다. 초당 27건을 더 처리하는 대신 모든 사용자가 10초씩 기다리는 것보다, 활성 사용자 1000명이 안정적으로 1초 내에 응답받는 것이 훨씬 낫다.


7. 정리

이전 글에서 Pessimistic Lock으로 "같은 좌석을 두 명이 예매하는 문제"를 해결했다면, 이번에는 Redis 대기열로 "2000명이 동시에 몰려서 서버가 느려지는 문제"를 해결했다.

두 문제의 성격은 다르지만 공통점이 있다. 둘 다 동시에 접근하는 수를 제어하는 것이 핵심이다. Pessimistic Lock은 DB 레벨에서 하나의 row에 대한 동시 접근을 직렬화하고, 대기열은 애플리케이션 레벨에서 서버 전체에 대한 동시 접근을 제한한다. 제어하는 계층이 다를 뿐, "동시성을 관리한다"는 본질은 같다.

함께 읽으면 좋은 글