티스토리 뷰

한국에서 흑백 요리사라는 프로그램이 굉장히 인기였다. 요리 서바이벌 프로그램인데, 프로그램이 끝나고 나면 서바이벌에 참가한 요리사들의 식당은 항상 사람들로 넘쳐났다. 식당을 방문하기 위해서 한참 기다려야 했다. 좌석이 한정적이었기 때문이다. 식당들은 넘쳐나는 손님들을 수용하기 위해 대기자 명단을 작성해두고, 손님들이 빠져나간 만큼 수용 가능한 인원들을 입장시킨다. 우리는 시스템에서 이것을 대기열이라 부른다.

블랙프라이데이를 앞두고 주문 시스템에도 대기열이 필요했다. 트래픽이 폭발적으로 증가할 것으로 예상되는데, DB는 스케일 아웃이 제한적이고 오토스케일링은 극단적으로 짧고 높은 트래픽 앞에서 반응이 늦다. 시스템을 키우는 데 한계가 있다면, 방향을 바꿔야 했다. 들어오는 요청을 시스템이 처리할 수 있는 속도로 조절하는 것이다. 주문 요청을 대기열에 세워두고, 처리 가능한 만큼만 흘려보내기로 했다.

1. 대기열은 어떻게 구현하나?

대기열을 위해서는 대기자를 관리할 저장소가 필요했다. 대기열은 대기자들을 입장한 순서대로 관리하고 대기 순번을 지속적으로 알려줘야 한다. 대기 데이터는 주문이 완료되면 사라지는 일시적인 데이터이고, 순번 조회가 빈번하게 발생하므로 속도가 중요했다. Redis는 인메모리 기반으로 읽기/쓰기가 빠르다. Redis를 사용하기로 했다.

Redis Sorted Set을 사용하면 대기자들을 입장 순서대로 관리할 수 있다. Redis Sorted Set은 score 기반으로 데이터를 정렬 저장하고, 특정 멤버의 순위를 O(log N)으로 조회할 수 있다. 주문 요청 시각을 score로 지정하면 자연스럽게 선착순 정렬이 된다.

ZADD queue:waiting <timestamp> <userId>    -- 대기열 진입
ZRANK queue:waiting <userId>               -- 순번 조회 (O(log N))

여기서 주의해야 하는 점은, 사용자가 불안해서 주문 버튼을 한 번 더 누르는 경우다. ZADD는 기본적으로 member가 이미 있으면 score를 갱신한다. 즉, 100번째로 대기 중이던 사용자가 다시 요청하면 timestamp가 갱신되면서 순번이 맨 뒤로 밀린다. 수많은 요청을 제어하기 위해 대기열을 둔 건데, 실수로 다시 요청했다고 한참을 더 기다려야 한다면 사용자 경험에 최악이다.

NX(Not Exists) 옵션을 지정해 이미 대기자 명단에 추가된 경우 무시하도록 설정한다.

ZADD queue:waiting NX <timestamp> <userId>

2. 이제 입장하셔도 됩니다.

대기열에 진입했다고 바로 주문이 되는 건 아니다. 대기열은 주문 접수이고, 실제 주문은 입장권을 받은 후에야 가능하다. 입장권은 "이제 주문해도 됩니다"라는 허가증이다.

@Scheduled(fixedDelay = 1000) // 1초마다 실행
public void processQueue() {

    // 1. 대기큐에서 사용자를 꺼내고 대기자 명단에서 삭제한다.
    List<Long> userIds = queueRepository.popFromQueue(batchSize); //50

    // 2. 입장권을 생성한 후 저장한다.
    for (Long userId : userIds) {
        String token = UUID.randomUUID().toString();
        queueRepository.issueToken(userId, token);
    }
}

스케줄러가 주기적으로 대기열에서 사용자를 꺼내 입장권을 발급한다. 입장권은 입장 토큰이라고 한다. 토큰은 entry-token:{userId} 형태로 Redis에 저장되며, TTL을 두어 일정 시간 내에 주문하지 않으면 자동 파기된다. 입장권을 발급 받은 클라이언트는 발급받은 토큰을 갖고 실제 주문을 처리하는 주문 API를 호출하게 된다.

3. 원자적 처리

위 코드에서 이상한 점을 발견하지 못했는가? 다시 한 번 살펴보자. 만약, 대기자 명단에서 사용자를 제거한 뒤 입장권을 생성하기 전에 애플리케이션에 문제가 발생하거나, 입장권을 저장할 때 네트워크 통신 오류로 저장하지 못하면 어떻게 될까? 대기자 명단에서는 사라졌는데 입장권은 발급되지 않는다. 사용자는 대기열에도 없고, 입장권도 없는 상태가 된다.

이를 해결하려면 "대기열에서 꺼내기"와 "입장권 발급"이 하나의 단위로 실행되어야 한다. Redis는 Lua 스크립트 인터프리터를 내장하고 있어, 여러 커맨드를 하나의 스크립트로 묶어 실행할 수 있다. Redis가 싱글 스레드로 동작하기 때문에, Lua 스크립트 실행 중에는 다른 커맨드가 끼어들 수 없다.

-- 1. 대기열에서 가장 먼저 온 50명을 꺼낸다 (꺼내면서 대기열에서 제거)
local count = 50
local popped = redis.call('ZPOPMIN', 'queue:waiting', count)

local results = {}

for i = 1, #popped, 2 do
    local userId = popped[i]
    local token = '미리-생성된-UUID'

    -- 2. 입장권을 저장한다 (300초 후 자동 파기)
    redis.call('SET', 'entry-token:' .. userId, token, 'EX', 300)

    table.insert(results, userId)
end

-- 3. 입장권이 발급된 사용자 목록을 반환한다
return results

ZPOPMIN으로 대기열에서 사용자를 꺼내고, 바로 SET으로 입장권을 저장한다. 이 과정이 하나의 Lua Script 안에서 완결되므로, 애플리케이션 장애가 중간에 개입할 수 없다.

다만 DB 트랜잭션과는 다르다. commit/rollback 개념이 없어서, 스크립트 중간에 오류가 나면 이미 실행된 커맨드는 롤백되지 않는다. 그래도 세 작업이 Redis 안에서 한 호흡에 끝나므로, 애플리케이션 장애가 중간에 끼어드는 시나리오는 방지된다.

Lua Script를 적용하면 스케줄러 코드는 다음과 같이 변경된다.

@Scheduled(fixedDelay = 1000)
public void processQueue() {
    // 대기열 조회 → 제거 → 입장권 발급을 Lua Script로 원자적 처리
    List<Long> issuedUserIds = queueRepository.popAndIssueTokens(batchSize);
}

기존에는 popFromQueue()issueToken()을 각각 호출했지만, 이제 popAndIssueTokens() 하나로 Lua Script를 실행한다. 애플리케이션 코드에서는 한 번의 호출로 끝나고, 원자성은 Redis가 보장한다.

4. 배치 사이즈 산출 기준

2번째 단계에서 일정 주기마다 대기자를 조회해 입장권을 생성한다고 했다. 그렇다면 한 번에 몇 명씩, 얼마나 자주 꺼낼지를 정해야 한다. 대기열의 존재 이유는 시스템이 감당할 수 있는 만큼만 흘려보내는 것이다. "감당할 수 있는 만큼"은 어떻게 결정할까?

이는 평상시 운영 지표를 통해 확인할 수 있다. 아니면, 부하 테스트를 통해 산출된 지표를 참조하면 된다. 필자는 주문 시스템의 안정적인 처리량을 TPS 50으로 지정했다. 여기서 배치 사이즈 50, 스케줄 주기 1,000ms라는 설정이 나왔다. 초당 50건의 처리량을 흘려보내는 것이다.

5. 대기열 적용 효과

여기까지 구현한 뒤, 정말 효과가 있는지 확인하고 싶었다. k6를 사용하여 500명의 동시 접속자가 각 20회씩 총 10,000건의 주문을 요청하는 부하 테스트를 수행했다.

지표 대기열 없이 대기열 포함
성공 10,000건 10,000건
실패 43건 0건
에러율 0.43% 0.00%
주문 API 응답 시간 대기열 없이 대기열 포함
평균 922ms 90ms
p95 3,864ms 129ms
최대 9,959ms 6,184ms

대기열 없이 500명이 동시에 주문하면 43건이 실패하고, p95 응답 시간이 3.8초까지 치솟는다. 대기열을 적용하면 에러율 0%, p95가 129ms로 안정적이다. 물론 입장 토큰을 받기 전까지 대기시간이 포함되진 않아 수치가 극단적으로 다르지만 에러율을 봐야한다. 대기열이 유입량을 50명/초로 조절하면서 DB 커넥션 등 경합이 사라졌다.

사용자는 평균 9.8초를 대기하지만, 주문 자체는 90ms에 처리되며 에러는 0건이다. "기다리게 하되, 실패하지 않게 한다" — 대기열의 본질을 숫자로 확인할 수 있었다.


여기까지가 대기열의 기본 구조다. Redis Sorted Set으로 대기열을 생성하고, Lua Script로 원자성을 확보하고, 부하 테스트로 효과를 검증했다. 하지만 이 구조에는 아직 풀지 않은 숙제들이 있다. 대기 인원 50명이 동시에 입장하면서 발생하는 부하, 대기 순번 확인을 위한 polling 부하, 그리고 Redis 자체가 죽었을 때의 문제. 이 문제들은 대기열, 시스템을 보호하라 (2) 에서 다룬다.

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2026/05   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
글 보관함