주문 폭주를 막는 대기열, 배치 사이즈는 어떻게 정해지나

2026. 7. 10. 15:47·루퍼스 4기

블랙프라이데이, 선착순 이벤트, 한정 수량 판매와 같은 상황에서는 수천~수만 건의 주문 요청이 동시에 하나의 재고를 대상으로 몰리는데 이때 적절한 동시성 제어나 방파제 역할을 하는 대기열이 없다면 DB Lock 경합과 커넥션 풀 고갈이 발생하고, 서버 리소스 사용량이 급증한다.

 

이러한 구조는 응답 지연, 타임아웃과 재시도를 유발하고, 사용자 경험에서도 좋지 않으며 이는 다시 시스템 부하를 키우는 악순환으로 이어진다.

 

결국 수만건의 주문 요청을 완화하기 위해선 모든 요청을 한 번에 처리하려고 하는 것이 아니라, 서버가 감당할 수 있는 만큼만 순차적으로 처리해야 한다.

 

이를 위해 사용자 요청을 즉시 처리하는 것이 아닌 대기실에서 기다리게끔 하는 대기열이 필요하다.

Redis 대기열 선택 

대기열은 모든 요청이 DB에 도달하기 전에 가장 먼저 거치는 관문이다. 따라서 이 관문 자체가 병목이 되어서는 안 된다. 이런 이유로 메모리 기반으로 빠르게 처리할 수 있는 Redis를 선택했다.

 

대기열에서는 초당 수천~수만 건의 순번 등록과 조회가 발생한다. 이를 RDB로 처리하면 디스크 I/O와 트랜잭션 비용이 누적되어 대기열 자체가 새로운 병목이 될 수 있다. 반면 Redis는 메모리 기반으로 동작해 이러한 대량의 요청을 빠르게 처리할 수 있다.

 

또한 Redis는 명령을 원자적으로 순차 처리하므로 별도의 Lock 경합 없이 동시 요청을 안정적으로 처리할 수 있다. 여기에 Sorted Set(ZSET) 자료구조를 활용하면 사용자에게 자신의 대기 순번을 즉시 조회해 보여주고, 스케줄러는 입장 대상자를 순서대로 꺼내 처리할 수 있다.

 

이러한 특성 덕분에 Redis는 대량의 요청을 제어하고 DB를 보호하는 대기열의 방파제 역할을 수행하기에 적합했다.

주문 시스템 대기열 구조

주문 대기열 구조

입장이 가능해진 사용자에게는 주문을 진행할 수 있는 입장 토큰을 발급하고, 아직 대기 중인 사용자는 주기적인 폴링을 통해 자신의 순번과 입장 가능 여부를 확인할 수 있도록 했다.

 

대기열을 관리하는 스케줄러는 일정 주기마다 정해진 수의 사용자를 대기열에서 꺼내 입장 가능한 상태로 전환한다.

 

하지만 한 번에 너무 많은 사용자를 입장시키면 주문 서버에 다시 부하가 집중되고, 반대로 너무 적게 입장시키면 대기 시간이 불필요하게 길어진다.

 

결국 한 번에 몇 명을 입장시킬 것인가, 즉 스케줄러의 배치 사이즈(Batch Size)를 결정하는 것이 시스템 안정성과 사용자 경험을 좌우하는 핵심 요소였다.

 

그렇다면 스케줄러는 얼마나 많은 사용자를 한 번에 입장시켜야 할까? 

 

주문 API의 최대 요청 처리량 확인

배치 사이즈를 결정하기 위해서는 먼저 주문 서버가 실제로 초당 얼마나 많은 요청을 안정적으로 처리할 수 있는지를 확인해야 했다. 이를 기준으로 스케줄러가 한 번에 입장시킬 사용자 수를 결정할 수 있기 때문이다.

 

이를 확인하기 위해 다음과 같은 환경에서 부하 테스트를 진행했다.

  • k6 스크립트를 이용해 입장 토큰을 발급받은 사용자가 실제 주문 API를 호출하도록 구성했다.
  • 디비 커넥션 수는 최대 maximum-pool-size: 40로 고정했다. 
  • 대기열 토큰을 한 번에 받은 직후 30초 동안 RATE(초당 요청 수)를 20부터 80까지 단계적으로 증가시키며 테스트를 수행했다.
  • 각 단계가 끝날 때마다 주문, 재고, 대기열 상태를 초기화하여 항상 동일한 조건(주문 0건, 재고 초기 상태, 빈 대기열)에서 테스트를 시작했다.
  • 서버 재시작 직후 발생하는 콜드 스타트(JIT 컴파일, 커넥션 워밍업) 구간은 측정 대상에서 제외했다.
실제 결과
RATE(목표 TPS) 실제 TPS p95 지연 실패율(status≠201 or timeout)
20 19.8 104ms 0.60%
30 29.6 356ms 0.55%
40 39.5 140ms 0.56%
50 49.4 140ms 0.57%
60 58.1 13.9s 0.48%
70 65.7 17.6s 0.48%

테스트 결과 50 TPS까지는 p95 응답 시간이 약 140ms 수준으로 안정적으로 유지되었다. 또한 실패율도 0.5% 내외로 큰 변화 없이 유지되어 주문 서버가 해당 수준의 트래픽을 무리 없이 처리할 수 있음을 확인했다.

 

하지만 60 TPS부터는 p95 응답 시간이 13초 이상으로 급격히 증가했고, 70 TPS에서는 17초를 넘어섰다. 반면 실패율은 여전히 0.5% 내외를 유지했다. 이는 요청이 실패한 것은 아니고 서버가 처리 한계에 도달해 디비 요청이 대기하게 되면서 응답 시간이 급격히 증가했기 때문으로 판단했다.

 

즉, 서버는 요청을 처리하지 못하는 상태가 아니라 처리 속도가 유입 속도를 따라가지 못하는 포화 상태에 진입했다.

따라서 스케줄러는 이러한 임계점을 넘지 않도록 안정적으로 처리 가능한 TPS는 50인 것으로 확인된다.

 

나는 이 값을 그냥 사용할 경우 주문 처리 외 결제와 상품 조회 같은 다른 작업을 수행할 여유가 없을 것 같아 10 TPS의 마진을 두고 초당 40건을 대기열에서 처리하기로 결정했다.

 

입장 후의 주문 폭주 문제 

대기열을 통해 동시에 주문 페이지로 진입하는 사용자 수는 제어할 수 있었다. 하지만 입장했다고 해서 모든 문제가 해결된 것은 아니었다.

 

배치 사이즈만큼의 사용자가 한 번에 입장 가능한 상태가 되는 만큼, 그 인원이 동시에 주문을 요청하면 여전히 순간적인 트래픽 쏠림이 발생할 수 있었다.

 

앞단 대기열이 완화해 주는 건 전체 유입 속도일 뿐, 배치 단위로 함께 입장한 사용자들이 같은 순간 몰리는 문제까지는 막아주지 못했다.

REDIS 입장 토큰 TTL 5분 설정 

입장한 사용자가 주문을 진행하지 않은 채 계속 머무르면 다음 사용자가 입장하지 못해 대기열의 의미가 사라진다. 따라서 입장 권한은 영구적으로 유지되지 않도록 만료 시간이 필요했다.

 

TTL은 5분으로 설정했다. 이는 사용자가 상품을 확인하고 주문을 완료하기에 충분한 시간이면서도, 장시간 자리를 점유하는 것을 방지해 선착순 시스템의 공정성과 대기열의 흐름을 유지할 수 있는 시간이라고 판단했다.

 

하지만 토큰의 TTL만으로는 모든 문제를 해결할 수는 없었다. 사용자는 토큰이 유효한 5분 동안 주문 버튼을 반복해서 누르거나 동일한 주문 요청을 여러 번 전송할 수 있다. 결국 주문 API 자체에도 중복 요청을 제어하는 장치가 필요했다.

주문 API Rate Limit (feat. Lua Script)

입장한 사용자는 5분 동안 유효한 입장 토큰을 가지게 되는데, 이 시간 안에 언제 주문 버튼을 누를지는 제어할 수 없다.

 

만약 많은 사용자가 같은 순간 주문을 시도한다면, 대기열에서 순차적으로 입장시켰더라도 주문 API에는 순간적으로 요청이 집중될 수 있다.

 

이를 방지하기 위해 주문 API 앞단에 Redis 기반 Token Bucket Rate Limit을 적용했다.

local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refillPerSec = tonumber(ARGV[2])

// 현재 시각 가져오기 
local time = redis.call('TIME')
local nowMs = tonumber(time[1]) * 1000 + tonumber(time[2]) / 1000

// 기존 버킷 상태 불러오기 (없으면 채우기)
local bucket = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(bucket[1])
local ts = tonumber(bucket[2])
if tokens == nil then
tokens = capacity
ts = nowMs
end

// 경과 시간만큼 토큰 리필 ("지난 요청 이후 얼마나 시간이 지났는지"를 계산)
local elapsedSec = math.max(0, (nowMs - ts) / 1000)
tokens = math.min(capacity, tokens + elapsedSec * refillPerSec)

// 요청 허용 여부 결정
local allowed = 0
if tokens >= 1 then
tokens = tokens - 1
allowed = 1
end

// 토큰 상태 저장
redis.call('HMSET', key, 'tokens', tostring(tokens), 'ts', tostring(nowMs))
redis.call('EXPIRE', key, 60)
return allowed

입장 토큰을 검증한 뒤 주문 Rate Limit을 확인하고, 여유가 있을 때만 실제 주문 로직을 수행하도록 구성했다.

 

즉, 입장은 허용되었더라도 현재 주문 처리량이 안전 한도를 초과한 경우에는 주문 요청을 즉시 처리하지 않는다.

 

주문 Rate Limit은 Redis의 Lua Script를 이용한 전역 Token Bucket 방식으로 구현했다. 버킷에는 최대 50개의 토큰이 저장되며, 경과 시간에 따라 초당 50개의 토큰이 다시 채워진다.

 

앞서 대기열의 배치 사이즈는 40명으로 넉넉하게 설정했지만 입장한 사용자가 동시에 주문 버튼을 누르거나, 네트워크 재시도 등으로 주문 요청이 순간적으로 집중될 수 있다.

 

따라서 주문 API는 주문을 기다리지 않고 바로 실패를 리턴하는 부분에서 안정적으로 처리 가능하다고 확인한 50 TPS를 기준으로 별도의 Rate Limit을 적용했다.

 

즉, 대기열은 입장 속도를 제어하고, Token Bucket은 실제 주문 속도를 제어하는 역할을 담당하도록 설계했다.

대기열 구현 중 블로커 

대기열은 스케줄러가 일정 주기마다 실행되며 사용자를 입장시키는 구조로 구현했다. 하지만 테스트를 진행하던 중 스케줄러가 설정한 주기대로 실행되지 않는 현상을 발견했다.

 

처음에는 Redis나 대기열 로직에 문제가 있는 것으로 생각했지만, 로그를 확인해 보니 특정 시점부터 스케줄러가 실행되지 않고 다음 실행이 계속 지연되고 있었다.

 

원인을 분석해 보니 Spring의 기본 TaskScheduler는 단일 스레드(Single Thread)로 동작하고 있었다. 즉, 애플리케이션에서 등록한 모든 @Scheduled 작업이 하나의 스레드를 공유하고 있었다.

 

이미 서비스에는 아웃박스(Outbox) Polling을 위한 스케줄러가 동작하고 있었고, 해당 작업이 실행되는 동안에는 대기열 스케줄러가 실행될 수 없었다. 결국 하나의 스레드를 두 개의 스케줄러가 공유하면서 대기열 처리가 지연되는 문제가 발생했다.

task:
  scheduling:
    pool:
      size: 2

기본값은 1이었고 스케줄러만큼 사이즈를 조절하여 다시 테스트해 본 결과 스케줄러가 정상 주기로 도는 걸로 확인했다. 

 

대기열을 구현하면서 느낀 점 

이번 작업을 통해 Redis ZSET 기반 대기열로 동시 진입 사용자 수를 제어했고, 부하 테스트로 확인한 안정 처리량(50 TPS)에 마진을 둔 배치 사이즈(초당 40명)로 서버를 1차 보호했다. 하지만 배치 단위 입장과 토큰 TTL만으로는 입장 후 순간적인 주문 쏠림을 막을 수 없었고, 이를 Redis Lua Script 기반 Token Bucket Rate Limit(50 TPS)으로 2차 방어했다.

 

대기열이 완벽한 차단이 아니듯, 이 구조 역시 트래픽 폭주를 100% 막는 것이 아니라 현재 서버가 버틸 수 있는 수준으로 완화하는 것이 목표였다.

 

다만 이 수치는 주문 API만을 기준으로 계산한 추정치일 뿐, 실제 트래픽 상황에서는 외부 API 지연이나 네트워크 재시도 같은 변수가 추가로 작용할 수 있다.

 

만약 서버 한대로 버티지 못하는 경우 서버 스케일 아웃이나 Tomcat 요청 처리 스레드 수 조정처럼 인프라 단의 여유도 함께 고려해 볼 생각이다.

 

저작자표시 (새창열림)

'루퍼스 4기' 카테고리의 다른 글

루퍼스 LOOPPAK 백엔드 코스 4기 수료 후기  (0) 2026.08.02
주문상품 랭킹 시스템의 장애 대응 설계기  (0) 2026.07.17
주문 도메인에서 아웃박스 패턴과 polling 스케줄러 도입기  (1) 2026.07.02
주문 상태는 Pending 인데, 사용자는 결제 안내를 받았다.  (0) 2026.06.26
인덱스 적용부터 반정규화까지, 조회 성능 개선 실험  (0) 2026.06.19
'루퍼스 4기' 카테고리의 다른 글
  • 루퍼스 LOOPPAK 백엔드 코스 4기 수료 후기
  • 주문상품 랭킹 시스템의 장애 대응 설계기
  • 주문 도메인에서 아웃박스 패턴과 polling 스케줄러 도입기
  • 주문 상태는 Pending 인데, 사용자는 결제 안내를 받았다.
LIMTAEYANG
LIMTAEYANG
오늘 할 코딩을 내일로 미루지 말자
  • LIMTAEYANG
    lty's Blog
    LIMTAEYANG
  • 전체
    오늘
    어제
    • 분류 전체보기 (80)
      • 웹개발 (23)
        • JAVA (1)
        • Spring (6)
        • JavaScript (7)
        • css (2)
        • Vue (1)
      • 클라우드 (4)
        • AWS (2)
        • MSA (0)
        • Docker (2)
      • 운영 및 배포 (4)
      • Backend (6)
      • Database (2)
      • 명령어 (2)
        • Linux (2)
      • 형상관리 (8)
        • github & git (8)
      • 알고리즘 (1)
      • 개인 프로젝트 (2)
      • CS (11)
        • 소프트웨어 공학 (10)
        • 운영체제 (1)
      • 정보처리기사 (2)
      • 취미활동 (2)
        • 등산 (1)
        • 여행 (0)
        • 글쓰기 (1)
      • 회고 (0)
      • 루퍼스 4기 (13)
  • 블로그 메뉴

    • 홈
    • 개인프로젝트
    • 프로그래밍 언어
    • 운영체제
    • 형상관리
    • 자기개발
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    테스팅
    루프팩백엔드4기
    소프트웨어
    reset
    Jenkins
    되돌리기
    비관적 락
    DDD 주문설계
    아웃박스 테이블 polling 방식
    환경변수
    대기열 TPS 테스트
    CI/CD
    설계ERD
    TDD
    2주차 회고
    루퍼스 4기 수료 후기
    PG 정합성
    Callback 장애 대응
    배포 자동화
    재고 동시성 차감
    루퍼스 백엔드 코스
    대기열 배치사이즈
    prefilight
    CDC 방식
    LOOPPAK
    설계
    랭킹 레디스 장애 대응
    낙관적 락
    4주차 WIL
    원자적 연산
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
LIMTAEYANG
주문 폭주를 막는 대기열, 배치 사이즈는 어떻게 정해지나
상단으로

티스토리툴바