TroubleShooting & Study/Architecture & Design Pattern

[WIL] - Round8. 대기열 유입 제어, 순서 보장, 상태 전이

DH_0518 2026. 4. 5. 19:05

대기열은 트래픽을 줄이기 위한 도구가 아니라, 시스템이 감당할 수 있는 속도로 요청을 흘려보내는 도구다.
그래서 대기열은 기능이 아니라 '유입 제어', '순서 보장', '상태 전이'의 세 축으로 보는 편이 안전하다.


1. 유입 제어

유입 제어의 핵심은 시스템이 감당할 수 있는 처리량을 먼저 정하고, 그 속도에 맞춰 요청을 흘려보내는 일이다.

1-1. 처리량 상한은 병목 자원에서 정한다

유입 제어를 설계할 때 가장 먼저 할 일은 아래다.

  • 시스템에서 가장 먼저 한계에 도달하는 자원이 무엇인가
  • 그 자원의 이론적 최대 처리량은 얼마인가
  • 안전 마진을 빼면 실제 목표 처리량은 얼마인가

보통 병목은 커넥션 풀, 외부 API 호출, 디스크 쓰기 중 하나다.

예시 코드

// 병목: 커넥션 풀 50개, 건당 처리 시간 200ms
int maxPool = 50;
double avgLatency = 0.2;
double theoreticalTps = maxPool / avgLatency;  // 250

double safetyMargin = 0.7;
double targetTps = theoreticalTps * safetyMargin;  // 175

1-2. 발급 간격을 나누면 순간 부하가 줄어든다

목표 처리량이 정해지면, 그 양을 어떻게 시간에 분배하는가가 다음 문제다.

1초에 175건을 한 번에 풀면 순간 부하가 몰린다.
100밀리초에 18건씩 나눠 풀면 같은 양이 더 고르게 분산된다.

이 차이가 중요한 이유는 아래와 같다.

  • 한 번에 풀면 커넥션 풀이 순간적으로 포화된다
  • 나눠 풀면 각 배치 사이에 커넥션이 반환될 여지가 생긴다
  • 외부 API나 메시지 브로커에도 같은 원리가 적용된다

스케줄러가 여러 인스턴스에서 동시에 실행되면 발급량이 배로 늘어난다.
분산 환경에서는 단일 실행을 보장하는 잠금이 필요하다.

예시 코드

int batchSize = (int) Math.ceil(targetTps / 10);  // 18

@Scheduled(fixedRate = 100)
@SchedulerLock(name = "queue-token-issuer", lockAtLeastFor = "PT0.05S", lockAtMostFor = "PT5S")
void issueTokens() {
    queueStore.popAndIssue(batchSize);
}

1-3. 조회 부하도 제어 대상이다

대기열에 사용자가 들어오면 순번을 확인하기 위한 반복 조회가 발생한다.
이 조회 부하를 무시하면, 대기열 자체가 시스템을 압박하는 모순이 생긴다.

조회 주기를 동적으로 조절하면 부하를 크게 줄일 수 있다.

  • 순번이 낮을수록 곧 진입하므로 짧은 주기가 필요하다
  • 순번이 높을수록 진입까지 시간이 남으므로 긴 주기로 충분하다
  • 같은 구간의 사용자가 동시에 요청하지 않도록 무작위 지연을 섞는다

예시 코드

int getInterval(long position) {
    if (position <= 100) return 1_000;
    if (position <= 1_000) return 2_000;
    if (position <= 10_000) return 5_000;
    if (position <= 100_000) return 10_000;
    return 30_000;
}

int getJitter(long position) {
    if (position <= 100) return 0;
    if (position <= 1_000) return 300;
    if (position <= 10_000) return 500;
    if (position <= 100_000) return 1_000;
    return 4_000;
}

int retryAfterMs = getInterval(position)
    + ThreadLocalRandom.current().nextInt(-getJitter(position), getJitter(position) + 1);

1-4. 대기열 자체에도 상한이 필요하다

대기열에 무제한으로 사용자를 넣으면 아래 문제가 생긴다.

  • 메모리 사용량이 계속 늘어난다
  • 마지막 사용자의 예상 대기 시간이 현실적이지 않다
  • 시스템 장애 시 복구 범위가 지나치게 커진다

따라서 대기열에도 상한을 두고, 초과 시에는 진입을 거부하는 편이 낫다.
상한은 보통 아래를 기준으로 정한다.

  • 메모리 예산
  • 마지막 사용자의 최대 허용 대기 시간
  • 서비스 정책

예시 코드

void addToQueue(Long userId, long sequence) {
    queue.add(userId, sequence);

    if (queue.size() > maxCapacity) {
        queue.remove(userId);
        markRejected(userId);
    }
}

2. 순서 보장

순서 보장의 핵심은 누가 먼저 왔는지를 분산 환경에서도 일관되게 판단하는 일이다.

2-1. 시각 기반 순번은 분산 환경에서 깨지기 쉽다

여러 인스턴스가 동시에 요청을 받으면, 각 인스턴스의 시계가 미세하게 다를 수 있다.
이 차이는 밀리초 수준이지만, 트래픽이 몰리는 상황에서는 순서 역전이 발생한다.

시각 기반 순번의 문제는 아래와 같다.

  • 인스턴스 간 시계 오차가 순서에 영향을 준다
  • 같은 밀리초에 들어온 요청의 순서를 구분할 수 없다
  • 시계 동기화에 의존하면 인프라 복잡도가 올라간다

2-2. 원자적 카운터가 더 안전하다

외부 저장소의 원자적 증가 연산을 쓰면 위 문제가 사라진다.

  • 모든 인스턴스가 같은 카운터를 공유한다
  • 카운터는 단조 증가하므로 순서가 보장된다
  • 같은 시점에 들어와도 각자 고유한 번호를 받는다

예시 코드

long sequence = redis.increment("queue-seq");
redis.addToSortedSet("queue", userId, sequence);

2-3. 정렬 집합은 순번 조회와 선두 추출을 함께 처리하기 좋다

대기열에서 가장 자주 필요한 연산은 두 가지다.

  • 특정 사용자가 몇 번째인가
  • 앞에서 N명을 꺼내라

정렬 집합은 이 두 연산을 모두 효율적으로 지원한다.

  • 순위 조회: 점수 기준 정렬에서 특정 멤버의 위치를 반환한다
  • 선두 추출: 가장 낮은 점수부터 N개를 꺼낸다
  • 같은 멤버를 다시 넣으면 엔트리가 누적되지 않고 점수만 갱신된다

예시 코드

Long rank = redis.rank("queue", userId);
int displayPosition = rank != null ? rank.intValue() + 1 : -1;

Set<Long> popped = redis.popMin("queue", batchSize);

이 특성은 재진입 정책과 바로 연결된다.

  • 기존 순번을 유지하려면 재진입 자체를 차단한다
  • 새 순번으로 밀리게 하려면 점수 갱신을 허용한다

점수 갱신을 허용하는 방식을 택하면, 같은 사용자가 다시 진입할 때 엔트리는 하나만 유지되고 더 큰 점수를 받아 뒤로 밀린다.

2-4. 진입 접수와 순번 반영은 같은 단계가 아닐 수 있다

사용자의 진입 요청을 곧바로 저장소에 적재하면 아래 문제가 생길 수 있다.

  • 저장소가 순간적으로 과부하에 빠진다
  • 저장소 장애 시 요청이 유실된다

메시지 브로커를 중간에 두면, 요청을 먼저 접수하고 뒤에서 순차적으로 반영할 수 있다.
이때 중요한 것은 '접수 성공''순번 반영 완료' 를 같은 상태로 취급하지 않는 일이다.

  • 'ACCEPTED': 진입 요청은 접수됐다
  • 'PROCESSING': 접수는 됐지만 아직 순번 저장소에 반영되지 않았다
  • 'WAITING': 순번이 확정되어 대기 중이다

이 상태 구분을 안전하게 하려면, 접수 시점에 '진입 증명' 같은 상태 없는 영수증을 함께 내려주는 편이 좋다.
그래야 아직 Redis에 반영되지 않은 사용자를 단순 미진입 사용자와 구분할 수 있다.

단, 순서 보장이 필요하면 아래 조건을 만족해야 한다.

  • 단일 파티션에 모든 메시지를 보낸다
  • 소비자는 하나만 둔다
  • 배치 처리 시 순서대로 소비한다

예시 코드

// enter API: 접수만 하고 진입 증명과 함께 바로 응답
producer.send(topic, userId.toString(), userId.toString());
return ACCEPTED;

// consumer: 순차 처리 후 최종 순번 부여
long sequence = redis.increment("waiting-queue-seq");
redis.addToSortedSet("waiting-queue", userId, sequence);

메시지 브로커가 최종 순번을 직접 결정하는 것은 아니다.
메시지 브로커는 유입을 흡수하고 소비 순서를 안정화하며, 최종 순번은 외부 저장소의 단조 증가 카운터가 만든다.

2-5. 재진입 정책은 명시적으로 정한다

같은 사용자가 대기열에 다시 진입하면 두 가지 선택이 있다.

  • 기존 순번을 유지한다
  • 새 순번으로 밀린다

어떤 선택이든 정책으로 정하고 일관되게 적용하는 것이 중요하다.

정렬 집합에서 같은 멤버를 다시 넣으면 점수가 갱신된다.
이 특성을 이용하면 재진입 시 자동으로 뒤로 밀리는 정책을 자연스럽게 구현할 수 있다.
이 정책은 사용자가 반복 새로고침을 자제하게 만드는 부수적 효과도 있다.

다만 이 선택에는 전제가 있다.

  • 대기열 진입 요청 API는 자동 재시도를 막아야 한다
  • 순번 확인은 별도의 조회 API로 분리해야 한다
  • 의도치 않은 재전송까지 뒤로 밀릴 수 있다는 비용을 감수해야 한다

예시 코드

void enter(Long userId) {
    long sequence = redis.increment("waiting-queue-seq");
    redis.addToSortedSet("waiting-queue", userId, sequence);  // 기존 score 갱신
}

3. 상태 전이

상태 전이의 핵심은 대기에서 처리까지 각 단계를 안전하게 연결하고, 이탈이나 실패에도 시스템이 일관된 상태를 유지하는 일이다.

3-1. 상태 전이는 실제 응답 상태와 함께 정의한다

대기열의 상태 전이는 내부 처리 단계와 외부 응답 상태가 함께 정의돼야 한다.
현재 구조는 보통 아래 흐름으로 나뉜다.

  • 인증: 로그인 정보를 한 번 확인하고 서명 토큰을 발급한다
  • 접수: 대기열 진입 요청을 받고 'ACCEPTED' 와 진입 증명을 반환한다
  • 처리 중: 메시지는 들어갔지만 아직 순번 저장소에 반영되지 않아, 진입 증명을 바탕으로 'PROCESSING' 이 반환될 수 있다
  • 대기: 순번이 확정되면 'WAITING' 상태에서 순번과 예상 대기 시간을 조회한다
  • 입장: 스케줄러가 입장 토큰을 발급하면 'TOKEN_ISSUED' 로 전이된다
  • 처리: 실제 주문 API가 입장 토큰을 검증하고 작업을 수행한다
  • 정리: 성공과 실패에 따라 토큰과 락을 정리한다

각 전이에는 검증이 필요하고, 실패 시 이전 상태로 돌아갈 수 있어야 한다.

3-2. 서명 토큰은 반복 인증을 없애고, 갱신으로 긴 대기를 버틴다

대기열에 있는 동안 사용자는 반복적으로 순번을 조회한다.
매 요청마다 저장소에서 인증 정보를 읽으면 불필요한 부하가 생긴다.

서명 기반 토큰을 쓰면 저장소 조회 없이 신원을 확인할 수 있다.

  • 최초 인증 시에만 사용자 정보를 확인한다
  • 이후 진입 요청과 순번 조회 요청에서는 서명만 검증한다
  • Polling 응답마다 갱신된 토큰을 내려 긴 대기 시간 동안 만료를 뒤로 민다
  • 만료 시각이 지나면 토큰은 자동으로 무효가 된다

예시 코드

ResolvedQueueToken resolveAndRefresh(String token) {
    Long userId = parseValidUserId(token);   // HMAC 검증 + 만료 확인
    String refreshedToken = generateToken(userId);
    return new ResolvedQueueToken(userId, refreshedToken);
}

서명 비교 시 일정 시간 비교 함수를 쓰면 타이밍 공격을 방어할 수 있다.

3-3. 입장 토큰 TTL은 발급 후 미사용 사용자를 정리한다

대기열에서 순번이 되면 사용자에게 입장 토큰을 발급한다.
이 토큰은 아래 역할을 한다.

  • 대기열을 통과했다는 증명
  • 실제 처리 API에 접근할 수 있는 권한
  • 유효 시간이 지나면 자동 소멸

여기서 중요한 것은 '무엇이 TTL로 정리되는가' 를 분리해서 보는 일이다.

  • 정리되는 것: 입장 토큰을 받았지만 실제 처리를 시작하지 않은 사용자
  • 정리되지 않는 것: 아직 대기열 안에서 기다리는 사용자

즉 TTL은 '입장권의 수명' 을 정리하는 장치이지, 대기열 전체를 자동 청소하는 장치는 아니다.

또한 토큰 발급은 대기열에서 꺼내는 것과 저장하는 것을 원자적으로 묶어야 한다.
대기열에서 꺼냈는데 토큰 저장이 실패하면, 사용자는 대기열에서도 사라지고 토큰도 없는 상태가 된다.

예시 코드

-- Lua: ZPOPMIN + SET EX를 한 번에 실행
local users = redis.call('ZPOPMIN', KEYS[1], ARGV[1])
local idx = 0
for i = 1, #users, 2 do
    local userId = users[i]
    idx = idx + 1
    local token = ARGV[2 + idx]
    redis.call('SET', 'entry-token:' .. userId, token, 'EX', ARGV[2])
end

3-4. 동시 실행 방지는 분산 락으로 처리한다

입장 토큰을 받은 사용자가 같은 요청을 동시에 보내면 이중 처리가 발생할 수 있다.
이를 막으려면 처리 시작 시점에 분산 락을 건다.

예시 코드

String lockValue = UUID.randomUUID().toString();
boolean acquired = redis.setIfAbsent(
    "order-lock:" + userId, lockValue, Duration.ofSeconds(300));

if (!acquired) {
    throw new BusinessException(ORDER_ALREADY_IN_PROGRESS);
}

락 해제 시에는 자신이 건 락만 해제해야 한다.
다른 요청이 건 락을 해제하면 보호 효과가 사라진다.

예시 코드

// 원자적 비교 후 삭제
String script = "if redis.call('GET', KEYS[1]) == ARGV[1] "
    + "then return redis.call('DEL', KEYS[1]) else return 0 end";
redis.execute(script, "order-lock:" + userId, lockValue);

3-5. 성공과 실패의 정리 정책은 먼저 단순하게 고정한다

처리 중 실패가 발생하면, 어디까지 되돌릴 것인가를 정해야 한다.
정책을 너무 많이 나누기 전에, 먼저 성공과 실패를 일관되게 나누는 편이 안전하다.

처리 성공

  • 입장 토큰을 삭제한다
  • 처리 락을 해제한다

처리 실패

  • 입장 토큰은 유지한다
  • 처리 락만 해제한다
  • 사용자가 토큰 유효 시간 안에 다시 시도할 수 있다

이 선택이 중요한 이유는, 모든 실패에서 토큰을 삭제하면 일시적 오류에도 사용자가 대기열 처음부터 다시 시작해야 하기 때문이다.

대신 이 구조를 쓰려면 성공 후 재시도도 안전해야 한다.
그래서 주문 API는 입장 토큰을 다시 보기 전에 멱등 키를 먼저 확인해, 이미 성공한 요청이면 기존 결과를 반환할 수 있어야 한다.

예시 코드

Optional<Result> existing = findByUserIdAndRequestId(userId, requestId);
if (existing.isPresent()) {
    return existing.get();
}

String lockValue = entryValidator.validateAndLock(userId, token);
try {
    Result result = processOrder(userId, request);
    entryValidator.completeOrder(userId, lockValue);
    return result;
} catch (Exception e) {
    entryValidator.releaseOrderLock(userId, lockValue);
    throw e;
}

3-6. 경계 컨텍스트 간 연동은 포트로 격리한다

대기열과 처리 도메인은 서로 다른 책임을 가진다.
처리 도메인이 대기열의 내부 구현에 직접 의존하면 변경이 전파된다.

포트 인터페이스를 두면 아래 장점이 생긴다.

  • 처리 도메인은 토큰 검증과 락 관리만 알면 된다
  • 대기열의 저장소나 인증 방식이 바뀌어도 포트 뒤에서만 바뀐다
  • 테스트 시 대기열을 가짜 구현으로 대체할 수 있다

즉 처리 도메인은 Redis, Kafka, HMAC 구현을 몰라도 된다.
알아야 하는 것은 아래 세 가지 책임뿐이다.

  • 지금 이 요청이 유효한 입장권을 가졌는가
  • 같은 사용자의 동시 처리를 막았는가
  • 성공 또는 실패 후 어떤 정리를 해야 하는가

예시 코드

interface EntryTokenValidator {
    String validateAndLock(Long userId, String entryToken);
    void completeOrder(Long userId, String lockValue);
    void releaseOrderLock(Long userId, String lockValue);
}

정리

대기열은 결국 세 가지로 정리된다.

  • 유입 제어: 시스템이 감당할 수 있는 속도로 요청을 흘려보내는가
  • 순서 보장: 분산 환경에서도 공정한 순서가 유지되는가
  • 상태 전이: 대기에서 처리까지 각 단계가 안전하게 연결되는가

이 세 축만 분리해서 봐도 대기열 설계는 훨씬 빨리 구조가 잡힌다.

대기열 설계를 좁히는 마지막 질문은 아래와 같다.

'병목은 어디에 있고, 어떤 속도로, 어떤 순서로, 어떤 상태를 거쳐야 하는가'