대기열에서 참여열로, 적정 배치 사이즈는 얼마일까?
# 서론
가족의 명절 기차 예매와 한국사능력검정시험 예매를 도우며 대기열을 마주하였다. 최근 대기열에 대해 관심이 있었기에 길고 긴 대기열을 기다리며 API 응답과 같이 대기열의 흐름이나 변화 양상을 집중해서 지켜보았다. 두 경우 모두 최소 몇 십만 번째의 순위를 부여받았기 때문에 가족의 예매는 힘들었지만, 필자가 실제 적용된 대기열을 분석할 수 있는 시간은 넉넉하였다.
대용량 트래픽 관련 기술 및 지식을 학습하며 대기열을 접해본 적이 있다. 이전에는 단순히 ‘실제 예매 등의 페이지에 접속하여서 API 호출이 가능하도록 하는 동시 사용자수를 제어하는 기술’ 정도로만 이해했었다. 기존 진행했던 프로젝트는 규모가 그렇게 크지 않았기 때문에 대기열 도입을 고려하지 않는 경우가 많았다. 그러나, 최근 실무에서 사용되는 기술에 대한 관심이 많아지면서 대기열에 대한 여러가지 자료들을 찾아보고 개인적인 학습을 진행하였다.
대기열에 대한 본격적 학습을 계획하고 있었기에, 실제 서비스에 적용된 대기열을 보면서 다양한 의문이 들었다. 대기 순위가 낮아질수록(앞당겨질수록) 갱신 주기가 빨라지고, 대기열을 통과하더라도 예약 페이지에 머무를 수 있는 시간은 3분이었다. 또한, 대기열을 통과한다 하더라도 좌석 선택이나 결제를 즉시 진행하지는 않았다. 이러한 동작들과 결과에 의문이 들었기 때문에 이참에 토이 프로젝트를 통해 대기열에 대해 직접 학습해보며 들었던 의문점들을 해결해보자는 생각을 하였다.
이번 포스팅에서는 대기열 시스템을 학습하면서 “대기열 → 참여열로 이동하는 적절한 배치 사이즈나 스케줄링 주기는 얼마인가?”라는 질문에 대답을 찾아가는 과정을 공유하고자 한다.
# 본론
Background 1 - 대기열을 통과한 사용자를 검증하는 방법
예를 들어 대기열을 통과한 사용자들은 실제 예매 페이지로 이동한다하더라도, 궁극적으로는 “해당 사용자가 실제로 대기열을 통과한 사용자인지”를 검증한 수단이 있어야한다.
만약, 사용자가 대기열을 통과한 사용자, 즉 현재 활성 상태인 사용자임을 확인할 방법이 없다면 예약 페이지 URL을 통해 바로 접근을 하거나 바로 API를 호출하여 대기열 시스템을 무너뜨릴 수 있을 것이다.
따라서 대기열을 도입하기 위해서 전제되어야할 것은 해당 사용자의 상태를 구분하기 위한 수단이 있어야한다는 것이다.
대기열은 주로 Redis Sorted Set에 사용자 정보를 점수(score)와 함께 삽입한다. 그러나, 이후 대기열을 통과한 사용자를 구분하는 방법은 다양하게 존재한다.
- 접근 토큰(Access Token) 발급 방식
- 참여열(Active Queue/Set)에 등록하는 방식
- 세션/쿠키 기반 플래그 방식
- …
위 방식 중 2번째 방식인 참여열에 등록하는 방식을 사용할 것이다.
다른 방법은 고려 대상이 아니며, 접근 토큰 발급 방식의 경우에는 현재 서버 내 활성화 된 사용자 수를 확인하기가 어려워 활성 상태인 사용자 수 제어에 어려움이 존재한다.
따라서, 대기열을 통과한 사용자들을 참여열에 등록된다.
Background 2 - 배치 사이즈와 스케줄링을 구하는 방법·과정의 학습 목표
이 글의 목적은 “배치 사이즈는 n이 정답이다”라는 절대적 수치를 제시하는 것이 아니다. 각 서비스마다 커넥션 풀 크기, 처리 가능한 TPS, 사용자 규모, 기능의 수행시간 등이 모두 다르기 때문에 절대적 수치가 모든 상황에 적용될 수는 없다. 이 글에서 얻고자 하는 것은 주어진 조건(커넥션 풀 크기, 처리 시간 등)이 무엇이든 그에 맞는 배치 사이즈와 스케줄링 주기를 도출해내는 방법과 사고 과정이며, 이후 실제 서비스에 대기열을 도입하게 되었을 때 이 과정을 그대로 적용해 각자의 환경에 맞는 값을 구할 수 있도록 학습하는 것이 목적이다.
Background 3 - 리틀의 법칙(Little’s Law)
배치 사이즈와 스케줄링 계산하기에 앞서, 이 계산의 이론적 근거가 되는 리틀의 법칙(Little’s Law)을 먼저 짚고 넘어가려 한다.
리틀의 법칙 이란?
리틀의 법칙은 대기 행렬 이론(Queueing Theory)에서 도출된 공식으로, 어떤 시스템이 안정 상태(steady state)에 있을 때 다음 세 가지 값 사이의 관계를 나타낸다.
- L : 시스템 내 평균 존재 개체 수 (예: 대기열 또는 참여열에 머무르고 있는 평균 사용자 수)
- λ : 단위 시간당 시스템에 유입되는 개체의 평균 유입률 (예: 초당 유입되는 사용자 수)
- W : 개체 하나가 시스템에 머무르는 평균 시간 (예: 사용자가 참여열/시스템에 머무는 평균 체류 시간)
즉, “시스템 안에 몇 명이 있는가”는 “얼마나 자주 들어오는가”와 “한 명이 얼마나 오래 머무는가”의 곱으로 결정된다는 법칙이다.
리틀의 법칙을 대기열/참여열 문제에 적용할 수 있는 이유
참여열도 결국 “사용자가 유입되고(L), 일정 시간 머무르다가(W), 빠져나가는” 하나의 대기 행렬 시스템으로 볼 수 있다. 참여열 시스템이 안정적이게 동작하기 위한 관점에서 세 변수를 다음과 같이 대응시킬 수 있다.
- L : 참여열에 동시에 머무를 수 있는(=서버가 감당 가능한) 최대 동시 사용자 수 (동시성, 참여열의 크기)
- λ : 참여열로 유입시켜야 하는 초당(or 배치 주기당) 사용자 수 (시간 당 처리량)
- W : 사용자 한 명이 참여열에 진입해서 이탈하기까지 머무는 평균 시간 (TTL)
따라서, 서버가 참여열 내 인원에 대한 처리량(λ)과 참여열 내 사용자들이 머무는 시간인 TTL(W)을 안다면, 리틀의 법칙을 통해 평균적으로 활성 상태인 사용자의 수(L)를 구할 수 있고, 이는 곧 참여열의 최대 크기로 제한할 수 있다.
Toy Project - 콘서트 좌석 예약
대기열을 학습하기 위한 토이 프로젝트로 ‘콘서트 좌석 예약’이라는 주제를 선정하였다.
해당 주제를 선택한 이유는 특정 시간대만 선택하는 것이 아니라, 대기열을 통과한 사용자들이 좌석을 선택하는 시나리오까지 확장하여 여러 관점에서 학습을 진행할 수 있다고 생각하였기 때문이다.
해당 프로젝트의 주요 엔티티는 콘서트(concert) - 콘서트 스케줄(concert_schedule) - 좌석(seat)으로 구성이 되어있다.
- 각 콘서트는 여러 콘서트 스케줄을 가질 수 있다.
- 콘서트 스케줄마다 여러 좌석을 가질 수 있다.
이 정도의 관계만 정의하여 토이 프로젝트를 진행하였다. 물론 엔티티 간 더 복잡한 관계나 실질적 의미, 필요한 컬럼 등을 더 생각해볼 수는 있었지만 현재 학습하고자하는 것은 결국 ‘대기열 시스템’이기에 복잡한 엔티티를 사용하기보다는 간단한 엔티티로부터 시작해서 필요 시 이를 더욱 고도화해가는 방향으로 결정하였다.
대기열
대기열은 Redis SortedSet(ZSet)을 통해 구현한다.
MQ와 같이 Queue구조를 통해 대기열을 구성하는 등 다양한 방법이 존재한다. 그러나 Redis는 인메모리 단일 쓰레드 기반으로 동작하기 때문에 빠르게 처리가 가능하며, 모든 명령어를 단일 쓰레드가 처리하기 때문에 대기열 입장에 있어 동시성 문제에 대한 고민을 줄일 수 있다. 이외에도 사용자의 순위를 알 수 있고, 점수 기반의 간단한 동작 방식과 전체 대기열의 사이즈를 구할 수 있는 등 다양한 이점이 존재하기 때문에 Redis Sorted Set을 사용하기로 하였다.
또한, 대기열은 {key} {score} {member} {score} {member} ...와 같이 특정 키에 대해 {socre} - {member} 쌍으로 순서가 매겨진다. 이번 프로젝트에서는 score는 대기열 입장 시간, member는 사용자 고유 식별자(user_id)이다.
대기열의 key는 waiting-queue:concert_shcedule:{concert_schedule_id}와 같이 각 콘서트 스케줄을 기준이다.
대기열을 구성하는 각 값은 구현에 맞게 다르게 설정할 수도 있을 것이다. 그러나 해당 토이 프로젝트에서는 각 콘서트 스케줄을 기준으로 좌석 예매(예약)을 진행하고 대기열 입장 시간을 기준으로 순위를 매기며, 각 사용자의 고유 식별자를 저장한다.
참여열의 도입과 고려 사항
대기열을 도입하기까지는 매우 간단하였다. Redis Sorted Set에 대기열 입장 시간과 사용자 id를 삽입하고, 순번 조회 로직만 구성하면 되었기에 생각보다 간단한 작업이었다. 그러나, 대기열 → 참여열로 사용자들을 이동해야하는 기능을 도입하며 몇 가지 의문 사항이 생겼다.
- 적절한 배치 사이즈는?
- 적절한 스케줄링 주기는?
- 참여열의 최대 크기는?
배치 사이즈와 스케줄링 주기는 “대기열에서 몇 명을, 얼마의 주기마다 옮겨야하는지”에 관한 간단한 질문에서부터 시작하였다. 그러나, 배치 사이즈와 스케줄링 주기를 고려하다보니 참여열에 무한정 사용자를 넣을 수는 없는 부분이었다. 물론 TTL을 두어 참여열에 존재할 수 있는 최대 사용자의 수를 어느정도 구할 수는 있기야 하지만 참여열의 크기가 너무 작거나 크다면 역시 문제가 존재한다고 생각하는 부분이었다.
처음에는 위 3가지 질문들이 서로 혼동되어 어떤 것부터 구해야하는지, 하나에 대해 고민하면 다른 하나가 또 발목을 잡았다. 그래서 내가 가장 먼저 구해야하는 것은 무엇인가, 다른 질문과의 연관 관계는 어떠한가를 다시 한 번 정리하였다.
3가지 질문을 각각 독립적으로 생각하기엔 서로가 서로를 참조하기 때문에 이 질문들에 대한 교집합이 존재한다고 생각하여 그 교집합을 찾으려고 하였다. 결국 이 3가지 질문들을 관통하는 공통된 값이 있는지 살펴보았고, 그 결과 모두 참여열 통과 후 서버가 안정적으로 처리할 수 있는 초당 처리량(TPS)이라는 하나의 값에서 구해진다는 것을 알게되었다.
결국 대기열을 도입하는 근본적인 이유로 되돌아가면 "서버가 안정적이게 동작하기 위함"이다. 그렇다면, 서버가 안정적으로 처리할 수 있는 처리량을 측정해서 이 처리량을 기준으로 각 질문에 대한 답을 해나가기로 하였다.
- 참여열의 최대 크기 = TPS * TTL (리틀의 법칙)
- 배치 사이즈 = TPS * 스케줄링 주기
따라서, TPS를 구할 수 있다면 리틀의 법칙에 의해 안정적이게 동작할 수 있는 참여열의 최대 크기와 배치 사이즈 & 스케줄링 주기를 결정할 수 있다. 물론 이 공식들에 의해 나온 결과는 이론적인 값이며 실제로 서버가 안정적이게 동작을 하기 위해서는 추가적인 검증이 필요하다. 이는 아래에서 더 자세하게 설명하고자 한다.
테스트를 통한 참여열의 크기, 배치 사이즈·스케줄링 주기 계산
본 토이 프로젝트는 단순한 좌석 예약 시나리오를 가정해 테스트를 진행한다. 이 테스트는 예약 시나리오를 진행해는데 소요되는 시간과 서버 감당 가능 부하량을 측정하는데 기준이 된다. 별도의 결제 기능 등은 존재하지 않으며, 단순 예약 기능(API)을 수행하는데 걸리는 시간을 기준으로 테스트를 진행한다.
테스트 진행 환경
테스트는 모두 k6를 통해 진행한다. 테스트 진행 결과 메트릭은 html 파일로 출력하거나 influxdb로 수집하고, grafana로 시각화하였다. 또한 AWS EC2 내 테스트 환경을 구성한다.
자세한 테스트 환경 설명은 아래와 같다.
- AWS EC2 t3.micro 사용
- 2 vCPU
- 1 GiB Memory (2GB Swap Memory 설정)
- EC2 내 docker-compose를 통한 테스트 인프라 구성
- MySQL
- Redis
- influxdb (hikaricp, GC 메트릭 수집)
- 로컬에서는 k6를 통하여 EC2에서 실행 중인 서버 애플리케이션에 예약 API 요청
- 결과는 html 파일로 출력 + 결과로 수집되는 메트릭은 influxdb에 저장
- Grafana에서는 influxdb에 저장된 메트릭을 시각화
단, k6 → influxdb로 결과를 출력(flush)하는 과정에서 일부 데이터 누락이 존재하여 Grafana로 확인한 지표는 평균값 등을 시각화하는 것이 목적이며, 자세한 상세값 수치는 실제와 완전히 일치하지는 않다.
테스트 진행 순서
테스트 진행 순서는 다음과 같다.
- TPS 측정 (DB Connection Pool 기준 역산)
- TPS를 통한 참여열의 최대 크기를 계산
- TPS를 통한 스케줄링 주기와 배치 사이즈를 산정 (테스트를 통한 배치 사이즈 결정)
1. TPS 측정
앞서 정리한대로, 참여열의 크기와 배치 사이즈·스케줄링 주기를 구하기 위해서는 먼저 서버가 안정적으로 처리 가능한 TPS를 알아야한다. 이 TPS는 대기열 통과 후 가장 중요하게 수행해야 할 로직인 예약 API를 처리하는데 걸리는 순수 처리 시간으로부터 역산할 수 있다.
TPS = 처리량 / 처리시간
이때 처리 시간을 측정하는 조건에 2가지 전제 조건이 필요하다.
첫째, 락 경합이 없는 상태에서 측정해야 한다. 예약 로직에는 동일 좌석에 대한 동시 요청을 막기 위한 Lock이 걸려있는데, 만약 여러 요청이 같은 좌석을 동시에 예약하려 하면 뒤이은 요청은 앞선 요청이 락을 해제할 때까지 대기하게 된다. 같은 좌석 예매 여부에 따라 락 충돌 횟수가 달라져 대기 시간에 영향을 주게 된다. 따라서, 락 경합이 존재한다면 락을 기다리는 시간까지 포함이 되어 순수 처리 시간을 알 수 없게 된다.
둘째, 동시성(vu)은 DB 커넥션 풀 크기보다 작게 유지해야 한다. SpringBoot는 기본적으로 HikariCP를 커넥션 풀로 사용한다. 동시에 들어오는 요청 수가 풀의 크기를 넘어서면 초과된 요청은 커넥션을 얻기 위해 대기 상태에 놓이게 된다. 이 상태에서 측정한 처리 시간은 순수한 예약 API 처리 시간이 아니다. 따라서, 테스트 시 동시성은 HikariCP 커넥션 풀 사이즈보다 작게 설정한 상태에서 진행해야 한다.
이 두 조건을 만족한 상태에서 테스트를 진행해야, 락 대기나 커넥션 대기의 영향을 받지 않은 예약 로직 자체를 수행하는데 서버의 순수 처리 시간을 구할 수 있다.

spring-actuator를 통해서 HikariCP 사이즈를 확인해보면 총 10개의 커넥션이 존재하는 것을 알 수 있다. 따라서, 순수한 예약 API 처리 시간을 측정하기 위해서 동시 사용자(vu)를 10보다 작게 설정하여 테스트를 진행해야한다. 또한, 락 경합을 이루어지지 않도록 테스트는 모두 다른 좌석을 대상으로 예약을 진행하였다. 그리고, 테스트를 진행하는 DB 내 콘서트 스케줄과 연관된 총 좌석 수는 50,000석이다.
예약 API 처리 시간 측정
import http from 'k6/http';
import { check } from 'k6';
import exec from 'k6/execution';
import { Trend } from 'k6/metrics';
const BASE_URL = __ENV.BASE_URL;
const BASE_VUS = Number(__ENV.BASE_VUS) || 5;
const TOTAL_SEATS = Number(__ENV.TOTAL_SEATS) || 50;
const reserveSuccessDuration = new Trend('reserve_success_duration');
http.setResponseCallback(http.expectedStatuses(200, 404, 409));
export const options = {
scenarios: {
baseline_service_time: {
executor: 'shared-iterations', // 정해진 iterations만큼만 실행하고 종료
vus: BASE_VUS,
iterations: TOTAL_SEATS,
maxDuration: '2m',
},
},
}
export default function() {
const seatId = exec.scenario.iterationInTest % TOTAL_SEATS + 1;
const start = Date.now();
const res = http.post(
`${BASE_URL}/api/v1/reserve/lock`,
JSON.stringify({ seatId, userId: __VU }),
{ headers: { 'Content-Type': 'application/json' } }
);
check(res, {
'Reservation Success': (r) => r.status === 200,
'Seat Not Found': (r) => r.status === 404,
'Seat Already Reserved': (r) => r.status === 409,
});
const duration = Date.now() - start;
if (res.status === 200) {
reserveSuccessDuration.add(duration);
}
}

테스트 진행 결과, http 응답을 받는데까지 걸린 평균 응답 시간은 51ms, p95 응답 시간은 76ms이다.
안정적인 처리가 목적이기에 예약 API를 수행하는데 소요되는 처리 시간은 보수적으로 76ms(p95)로 측정하였다. 즉, 대부분의 사용자가 예약 API를 수행하는데 76ms가 소요된다고 측정하였다.
리틀의 법칙을 이용한 TPS 계산
앞서 측정한 예약 API의 처리 시간(p95 = 76ms)을 바탕으로, 리틀의 법칙을 적용하여 안정 상태일 때의 서버의 TPS를 계산한다.
리틀의 법칙 L = λ × W에서, L은 시스템 내 동시에 존재하는 개체 수를 의미한다. 이번 시스템에서 “동시에 처리 중인 예약 요청 수”는 결국 “동시에 사용 중인 DB 커넥션 수”와 같다. 예약 로직은 DB 커넥션을 확보하는 순간부터 쿼리를 실행하고 반환할 때까지 하나의 “처리 중인 상태”로 볼 수 있고, 이 상태에서 서버가 안정적이게 동작하기 위한 최대 요청의 갯수는 HikariCP 커넥션 풀에 설정된 크기를 넘을 수가 없기 때문이다.
즉, 동시에 처리 가능한 예약 요청의 최댓값은 애플리케이션 로직보다 DB 커넥션 풀 크기로부터 역산하였다.
이제 지금까지의 측정 결과를 대입해보면 다음과 같다.
- L: 10 (커넥션 풀의 크기)
- W: 76ms = 0.076s (예약 API 처리 시간, p95)
즉, 이 시스템은 안정 상태일 때 초당 약 131건의 예약 요청을 처리할 수 있다는 결론을 얻는다. 이 TPS 값은 앞으로 배치 사이즈와 스케줄링 주기, 그리고 참여열의 최대 크기를 계산하는데 기준이 되는 TPS이다.
그러나, 실제 측정 RPS는 96/s ?
기준이 되는 TPS는 약 130/s로 계산되었다. 그러나, 테스트 결과 사진을 보면 http_reqs의 rate는 95.96/s로 나타난다.
이는 잘못 측정된 값이 아니라 http_reqs 계산은 p95 응답 시간이 아닌, 평균 응답 시간을 기준으로 계산한 결과이다. 되돌아가 응답 시간의 평균(avg) 값은 51ms이다. 따라서 이를 리틀의 법칙에 적용시키면 다음과 같다.
- L: 5 (테스트에서 설정한 vu)
- W: 51ms = 0.051s (예약 API 처리 시간, avg)
위 결과에서 알 수 있듯이 실제 측정 RPS와 리틀의 법칙을 통해 계산한 RPS가 거의 일치하는 것을 확인할 수 있다. 이는 락이나 커넥션 풀 대기 시간 없이 순수한 예약 API 처리를 위한 서버의 처리 시간임을 나타낸다.
따라서, 이 말은 응답 시간(p95)인 76ms가 예약 API 처리를 위해 걸리는 순수 서버의 처리 시간임을 의미하며, 해당 값을 통해 도출해낸 TPS를 이후 역산 과정에 사용해도 괜찮다는 증거이다.
과연 130/s의 TPS는 정말 안정적인가?
현재 계산한 TPS인 130/s는 HikariCP 사이즈만큼의 동시성과 예약 API 처리 시간이 76m가 보장되는 상황에서 리틀의 법칙을 통해 계산한 결과이다.
이렇듯 “상황을 제어한 상태에서 계산한 TPS를 이후 계산에 사용해도 적절한가?”라는 의문이 들었다. 왜냐하면, 실제 운영시에는 동시성(사용자) 수도 더욱 많을 것이며, DB 커넥션 풀만 아니라 WAS의 쓰레드풀, OS/네트워크 환경 등 다양한 원인에서 병목이 발생하게 된다. 이는 계산한 이론치와는 상관없이 처리량 등 서버 성능의 저하로 이어지게 된다.
따라서, “130/s가 과연 적절한 값인가?”는 질문에 답을 하기 위해서 실측 테스트를 진행해보고자 하였다.
테스트는 k6의 ramping-arrival-rate executor를 통해 일정 TPS 만큼 상승(ramp-up) - 유지 - 정리의 단계로 나누어, 해당 TPS를 유지하였을 때 서버의 성능을 측정하여 비교해보기로 하였다.
import http from 'k6/http';
import { check } from 'k6';
import exec from 'k6/execution';
import { Trend, Rate } from 'k6/metrics';
const BASE_URL = __ENV.BASE_URL;
const TOTAL_SEATS = Number(__ENV.TOTAL_SEATS) || 50000;
const TARGET_TPS = Number(__ENV.TARGET_TPS) || 90;
const RAMP_UP_DURATION = __ENV.RAMP_UP_DURATION || '60s';
const HOLD_DURATION = __ENV.HOLD_DURATION || '3m';
const PRE_ALLOCATED_VUS = Number(__ENV.PRE_ALLOCATED_VUS) || TARGET_TPS * 2;
const MAX_VUS = Number(__ENV.MAX_VUS) || TARGET_TPS * 5;
const reserveSuccessDuration = new Trend('reserve_success_duration');
const reserveErrorRate = new Rate('reserve_error_rate');
http.setResponseCallback(http.expectedStatuses(200, 404, 409));
export const options = {
scenarios: {
stability_check: {
executor: 'ramping-arrival-rate',
startRate: 0,
timeUnit: '1s',
preAllocatedVUs: PRE_ALLOCATED_VUS,
maxVUs: MAX_VUS,
stages: [
// 상승(ramp-up): 목표 TPS까지 램프업
{ target: TARGET_TPS, duration: RAMP_UP_DURATION },
// 유지: 목표 TPS를 길게 유지하며 안정성을 확인
{ target: TARGET_TPS, duration: HOLD_DURATION },
// 정리: 종료 전 정리
{ target: 0, duration: '10s' },
],
},
},
thresholds: {
// 기존 측정한 응답 시간(p95) 대비 과도하게 높은 응답 시간을 확인
reserve_success_duration: ['p(95)<150'],
// 예약 성공/실패(404, 409 제외)와 무관하게, http 요청 자체의 실패율(타임아웃, 5xx 등)
http_req_failed: ['rate<0.01'],
// 커스텀 에러율(락 예외 등 예상치 못한 상태)
reserve_error_rate: ['rate<0.01'],
},
};
export default function () {
const seatId = exec.scenario.iterationInTest % TOTAL_SEATS + 1;
const start = Date.now();
const res = http.post(
`${BASE_URL}/api/v1/reserve/lock`,
JSON.stringify({ seatId, userId: exec.scenario.iterationInTest }),
{ headers: { 'Content-Type': 'application/json' } }
);
const passed = check(res, {
'Reservation Success': (r) => r.status === 200,
'Seat Not Found': (r) => r.status === 404,
'Seat Already Reserved': (r) => r.status === 409,
});
const duration = Date.now() - start;
if (res.status === 200) {
reserveSuccessDuration.add(duration);
}
reserveErrorRate.add(!passed);
}
테스트 스크립트는 위와 같으며, ec2 인스턴스에서는 spring-actuator로 수집된 메트릭(hikaricp, gc 등)과 k6 테스트 수행 결과를 비교해보며 검증해보았다.
1) TPS 90/s 테스트 결과

TPS 90/s에 대한 테스트 결과 GC로 인한 오버헤드 외에는 큰 p95 응답 시간의 변화는 없다.
2) TPS 130/s 테스트 결과

TPS 130/s에 대해서 GC로 인한 스파이크(spike) 외에 p95 응답 시간의 큰 변화는 없다. 그러나, 동시 사용자(vu)의 일시적 증가로 인하여 DB 커넥션을 대기하는 쓰레드 수가 일시적으로 증가하는 경우 응답 시간도 증가하는 경향을 보이고 있다. 커넥션 대기 시간이 존재하기 때문에 응답 시간이 늘어난 것이다.
그리고 130/s 테스트 부터 IllegalMonitorStateException이 발생하였다. 이는 Lock을 가지고 있는 쓰레드가 점유한 채로 처리 시간이 길어져 결국 lease time을 초과하여 unlock()을 호출하는 시점에서는 이미 Lock을 해당 쓰레드가 가지고 있지 않다면 발생하는 예외이다. 이는 k6 테스트 실행 결과 메트릭과 서버 성능 메트릭을 대조하며 확인한 결과 GC 시점에서 Stop-the-World 현상에 따라 모든 쓰레드가 대기를 하게 되면서 발생한 예외임을 알 수 있었다. 따라서, 이 테스트 결과를 통해 Lock의 leaseTime을 늘리거나 WatchDog 방식의 개선점도 생각해볼 수 있다.
3) TPS 200/s 테스트 결과

TPS 200/s에 대해서는 GC 시점 이후에도 지속적으로 p95 응답 시간이 증가한 채로 유지되는 현상을 보인다. 이후에는 다시 안정적인 상태로 회복되기는 하지만, 이러한 현상이 발생한 것 자체가 200/s 시점에서는 여유가 거의 없는 서버가 포화 상태가 될 가능성이 높다는 것이다.
따라서, 200/s의 TPS는 다양한 병목으로 인하여 서버가 포화 상태에 이르러 심각한 성능 저하가 발생될 확률이 높다.
위 수치외에도 50, 90, 130, 160, 200 TPS를 기준으로 테스트를 진행하였다.
| TPS | avg(ms) | p95(ms) | 비고 |
| 50 | 56 | 98 | |
| 90 | 152 | 260 | |
| 130 | 122 | 366 | IllegalMonitorStateException 발생 |
| 160 | 395 | 2 (s) | IllegalMonitorStateException 발생 |
| 200 | 770 | 2 (s) | IllegalMonitorStateException 발생 |
위 테스트 결과에서 이론적으로 계산한 130/s의 TPS에서도 평균 응답시간은 122ms, p95 응답시간은 366ms로 준수한 성능을 보이고 있다. IllegalMonitorStateException이 발생하였지만 이는 앞서 언급하였듯이 GC의 Stop-the-World로 인한 Lock 획득 쓰레드가 멈추어 leaseTime이 초과한 문제로 leaseTime을 증가시키거나, WatchDog 방식으로 바꾸면 해결될 문제이다.
그러나, 130/s도 준수한 성능을 보이고 있지만 실제 서버에서는 더 많은 트래픽과 예약 외에도 다양한 로직을 수행한다. 따라서, 향후 계산에 130/s를 TPS의 값으로 사용하기보다는 안전 마진을 적용하여 보수적으로 값을 결정하고자 한다.
TPS에 대해 안전 마진을 적용
예약 API 응답 시간을 구할 때 보수적으로 p95를 기준으로 값을 측정한 것 처럼, 계산한 TPS에도 안전마진을 적용한다.
TPS에 대한 안전 마진은 약 70%를 적용하여 130/s * 70% = 약 90/s를 TPS로 사용한다.
2. TPS를 통한 참여열의 최대 크기를 계산
서버가 안정 상태일 때 TPS를 구했으니, 이제 이 값을 통해 참여열이 가질 수 있는 최대 크기를 계산해야 한다.
계산에 들어가기 전에 한 가지 짚고 넘어갈 부분이 있다. 참여열의 최대 크기를 구한다고 하더라도 서버의 부하가 완전히 해결되는 것은 아니라는 점이다.
이는 학습 과정에서 참여열의 크기를 계산한 이후 “그런데 참여열 내 모든 사용자들이 동시에 요청을 보낸다면 DB 커넥션 풀 크기보다 큰 요청들이 동시에 들어오는데 괜찮은가?”에 든 의문이다. 이러한 의문 속에 참여열의 의미와 역할을 되돌아 봤을 때, 참여열의 최대 크기(L)는 어디까지나 '참여열에 있는 사용자들이 TPS 속도로 순차적으로 요청을 보낸다'는 이상적인 가정 하에서의 값일 뿐이다. 참여열에 진입한 다수의 사용자가 동시에 예약 버튼을 눌러버리는 상황 자체를 막을 수는 없다.
즉, 참여열의 최대 크기는 동시에 요청이 몰리는 상황 자체를 막아주는 장치는 아니라는 것이다.
따라서, 대기열과 참여열은 순간적으로 급격하게 몰리는 트래픽(유입량)을 제어하는 1차적인 장치의 의미가 우선이다. 그렇다면 참여열 내 존재하는 사용자들이 동시에 요청을 보내는 상황에서 대처법 등에 대해서는 이후 자세히 다룰 예정이다.
이러한 전제를 두고, 앞서 구한 TPS를 기준으로 참여열이 안정적으로 유지할 수 있는 최대 크기를 계산해본다.
리틀의 법칙을 통한 참여열의 최대 크기 계산
앞서, TPS를 구할 때도 리틀의 법칙을 사용했었다. 참여열 역시 “사용자가 유입되고(λ), 일정 시간 머무르다가(W) 이탈하는 하나의 대기 시스템”의 구조로 생각할 수 있다.
앞선 계산에서는 동시성(L)과 처리 시간(W)를 알고 있는 상태에서 미지수인 TPS(처리량, λ)를 구했다면, 이번에는 반대로 TPS(λ)와 TTL(W)를 알고 있는 상태에서 미지수인 참여열의 최대 크기(L)을 구한다. 리틀의 법칙 L = λ * W에서 구하고자 하는 변수만 바뀌었을 뿐 적용 개념 자체는 동일하다.
TTL
TTL(Time-To-Leave)은 사용자가 참여열에 진입한 뒤 좌석 선택·예약을 완료하는 데 필요한 충분한 시간 + 여유 시간을 기준으로 설정한다. TTL이 너무 짧으면 사용자가 좌석을 고르는 도중에 참여열에서 만료되는 상황이 발생하고, 반대로 너무 길면 이미 예약을 완료한 사용자가 여전히 참여열 내 자리를 차지하고 있기 때문에 참여열 내 유입량이 낮아 사용자 경험이 저해될 것이다. 또한, TTL(W)이 많아진다면 서버가 안정 상태에 머무르기 위한 동시성(L)의 크기도 커지기 때문에 이를 충족하기 위해 활성(참여) 상태의 사용자의 수가 많아지면서 락 충돌 및 서버 부하 등의 문제로 이어질 수 있다.
우선, 해당 토이 프로젝트에서는 콘서트 좌석 예약이라는 목적으로 진행되는 프로젝트이므로 예약 과정이 단순하고 직관적이다. 따라서, 실제 처리 시간을 TTL을 결정하는데 그리 큰 요소로 작용하지는 않는다. 따라서, 명절 기차 예매처럼 3분의 TTL을 사용하기로 결정하였다.
- λ: 90/s (TPS)
- W: 3분 (TTL)
따라서 서버의 처리량이 90/s이고 참여열에 참가한 사용자가 머무르는 시간이 3분일 때, 서버가 안정 상태일 때 활성화된 사용자는 16,200이고, 이는 곧 서버가 안정적으로 운영되기 위한 참여열의 최대 크기(이론적 상한선)이다.
3. TPS를 통한 스케줄링 주기와 배치 사이즈를 산정 (테스트를 통한 배치 사이즈 결정)
배치 사이즈와 스케줄링 주기를 구하기 위해 사용하는 공식은 다음과 같다.
이는 단순히 처리 갯수(count) = 처리량(rate) x 시간(duration)라는 단순한 관계에서 나온 것이다. TPS는 서버가 초당 안정적으로 처리할 수 있는 요청 수를 의미하므로, 여기에 시간을 곱하면 그 시간 동안 처리 가능한 총 요청의 개수를 구할 수 있다. TPS가 90/s일 때, 5초 동안은 450건까지 처리할 수 있다는 것이다.
이 관계를 배치 사이즈와 스케줄링 주기에도 적용할 수 있다. 앞서 우리가 구한 TPS는 안정 상태의 서버의 초당 처리량이므로 값을 90/s로 고정한다. 위 공식에서 TPS를 90/s로 고정하면 공식은 다음과 같다.
따라서, 하나의 변수의 값이 결정되었으므로 이 공식을 만족하기 위해 (배치 사이즈, 스케줄링 주기) 쌍이 지어지게 된다.
이제 여러 (배치 사이즈, 스케줄링 주기) 조합 사이에서 적절한 값을 찾아야한다.
배치 사이즈가 너무 크다면?
한 번에 참여열로 진입 시키는 인원(배치 사이즈)가 많아질 수록 많은 사용자가 동시에 예약 API를 호출할 확률도 높아진다. 순간적으로 가해지는 부하량을 높이고, 이는 서버 쓰레드풀, DB 커넥션 풀, 락 타임아웃 등 응답 지연이나 타임아웃으로 이어질 위험이 커진다. 결국 배치 사이즈를 지나치게 크게 설정하면 유입량(트래픽)을 제어하자는 대기열의 의미 자체가 퇴색된다.
배치 사이즈가 너무 작다면?
반대로 배치 사이즈가 지나치게 작으면, 한 번에 활성화되는 인원이 적어 서버가 여유롭게 처리할 수 있는 처리량을 충분히 활용하지 못하게 된다. 이는 서버 자원의 idle time이 늘어난다는 뜻이고, 동시에 대기열에 남아있는 사용자들의 대기 시간이 불필요하게 늘어나 사용자 경험이 저하된다.
스케줄링 주기가 너무 길다면?
스케줄링 주기가 너무 길다면 그 주기동안 나머지 대기자들은 대기열에서 머물러야한다. 이는 곧 대기 시간의 증가로 이어지며, 서버의 감당 가능한 처리량을 충분히 활용하지 못하고 대기 시간이 불필요하게 늘어나 사용자 경험이 저하된다.
스케줄링 주기가 너무 짧다면?
스케줄링 주기가 지나치게 짧으면 배치 실행 자체가 매우 잦아져, Redis 조회·참여열 등록과 같은 배치 작업 오버헤드가 자주 발생해 오버헤드가 누적된다. 뚜한, 주기가 짧을 수록 한 번에 이동하는 인원도 적을 것이기에 처리 효율이 떨어질 수 있다.
정리하자면, 배치 사이즈와 스케줄링 주기는 각각 독립적으로 “크다/작다”의 장단점은 존재하지만, 결국 앞서 세운 공식(배치 사이즈 = TPS(90/s) * 스케줄링 주기) 덕분에 TPS가 고정되어 이 두 값은 서로 상대적이다. 결국, 하나의 변수 값만 정하면 다른 변수의 값은 TPS(90/s)를 맞추기 위해 자동으로 결정할 수 있는 것이다.
필자는 이 두 값 중, 배치 사이즈를 실험을 통해 먼저 확정하기로 하였다. 스케줄링 주기는 값 자체를 조정해도 서버에 순간적으로 가해지는 부하량과는 직접적인 관련이 없지만, 배치 사이즈는 “참여열에 한 번에 몇 명이 참여하는가?”를 결정하는 값이기 때문에 서버 부하량과 직결된다고 생각하여 배치 사이즈를 우선적으로 구하기로 하였다.
배치 사이즈에 따른 부하 테스트
배치 사이즈에 관한 테스트는 예약 API를 대상으로 진행한다.
이 배치 사이즈는 한 번에 참여열에 입장하는 사용자의 수를 의미하기에, 해당 사용자들은 동시에 예약 API를 호출할 확률이 높다. 따라서, 한 번의 배치 이후 해당 배치로 인해 참여열에 참가하게 된 사용자가 동시에 예약 버튼을 누를 최악의 상황을 가정해서 서버의 처리량을 측정하고 적절한 배치 사이즈 값을 선택하는 목적이다.
HikariCP 사이즈는 10, 서버의 안정적 처리량은 90/s 이지만 실제 부하테스트를 통해 최대한 사용자 경험을 극도로 저하시키지 않으면서, 서버가 안정적으로 처리할 수 있는 배치 사이즈(동시성)을 구한다. 커넥션풀의 수는 제한적이더라도 큐를 통해 나머지 요청을 대기시키는 등의 작업이 이루어지기에, 락 타임아웃이나 커넥션 획득 실패 타임아웃 등 서버가 감당하지 못할 동시성의 한계량을 측정하고, 사용자 경험을 저해시키지는 않은 최대한의 배치 사이즈를 구하는 것이 목적이다.
실제 서비스는 단순히 DB 커넥션 풀 외에도 애플리케이션 쓰레드 수, 대기 큐, OS의 자원 등 각 환경과 조건마다 이론 계산에 반영되지 않은 다른 오버헤드들이 존재하기 때문에, 부하 테스트를 통해 적절한 값을 실측으로 확인해야 한다.
import http from 'k6/http';
import { check } from 'k6';
import exec from 'k6/execution';
import { Trend } from 'k6/metrics';
const BASE_URL = __ENV.BASE_URL;
const TOTAL_SEATS = Number(__ENV.TOTAL_SEATS) || 50;
const BATCH_SIZE = Number(__ENV.BATCH_SIZE) || 20; // 이번에 테스트할 배치 크기
const reserveSuccessDuration = new Trend('reserve_success_duration');
http.setResponseCallback(http.expectedStatuses(200, 404, 409));
export const options = {
scenarios: {
batch_burst: {
executor: 'per-vu-iterations', // 모든 VU가 동시에 시작
vus: BATCH_SIZE, // 배치 사이즈 = 한 번에 참여열로 입장할 사용자 수
iterations: 1, // 모든 VU가 단 1번만 반복
maxDuration: '30s',
},
},
}
export default function() {
const seatId = exec.scenario.iterationInTest % TOTAL_SEATS + 1;
const start = Date.now();
const res = http.post(
`${BASE_URL}/api/v1/reserve/lock`,
JSON.stringify({ seatId, userId: __VU }),
{ headers: { 'Content-Type': 'application/json' } }
);
check(res, {
'Reservation Success': (r) => r.status === 200,
'Seat Not Found': (r) => r.status === 404,
'Seat Already Reserved': (r) => r.status === 409,
});
const duration = Date.now() - start;
if (res.status === 200) {
reserveSuccessDuration.add(duration);
}
}
위 테스트에서 BATCH_SIZE 값을 환경 변수로 전달하여, BATCH_SIZE를 늘려가면서 응답 시간을 비교한다.
| 배치 사이즈 | avg(ms) | p95(ms) | 비고 |
| 10 | 137 | 150 | |
| 20 | 194 | 246 | |
| 50 | 384 | 574 | |
| 100 | 678 | 1,007 | |
| 150 | 942 | 1,520 | |
| 200 | 1,240 | 2,090 | |
| 300 | 3,630 | 5,190 | IllegalMonitorStateException 발생 |
| 400 | 4,430 | 6,500 | IllegalMonitorStateException 발생 |
테스트 결과, 배치 사이즈(동시성)이 300인 지점에 이르렀을 때 IllegalMonitorStateException이 발생하였다. 이는 동시에 보낸 요청 수가 많이 HikariCP 커넥션 풀의 한계로 커넥션 획득 대기 시간이 길어졌고, 이에 따라 leaseTime이 초과한 것으로 보인다. 물론, 앞서 TPS를 정할 때와 비슷하게 GC의 영향도 있을 수 있다.
또한, 필자는 사용자 응답을 고려하면 500ms 내외의 응답 시간을 가지는 것이 좋다고 생각하였다. 따라서, 배치 사이즈 = 50연 경우를 선택하였다. 배치 사이즈(동시성)에서도 안전 마진을 적용하여 50 * 70% = 35 (명)을 배치 사이즈로 결정하였다.
이제, TPS와 배치 사이즈를 공식에 대입할 수 있게 되었다.
즉, 90/s TPS를 기준으로 배치 사이즈를 35로 잡았을 때, 스케줄링 주기는 389ms가 된다. 이 값 역시 안전 마진을 확보하고 값을 다루기가 편하도록 500ms로 반올림하여 최종 스케줄링 주기로 결정하였다.
지금까지 테스트와 계산을 거쳐 결정한 값들은 다음과 같다.
- TPS: 90/s (130/s의 안전마진)
- 참여열의 최대 크기: 16,200
- 배치 사이즈: 35
- 스케줄링 주기: 500ms
실제 적용 전에 추가로 확인해야 할 것들
지금까지의 과정으로 배치 사이즈, 스케줄링 주기, 참여열의 최대 크기까지 값을 모두 결정하였다. 그러나 이 값들은 각각을 개별적으로 검증했을 뿐, 이 값들을 동시에 적용한 시스템 전체를 검증한 것은 아니다. 실제 서버 운영과 대기열 시스템을 운영하기 위해서는 다양한 관점들을 고려하여야 하였기에 여러 가지 고민을 해보았다. 추가적으로 놓치지 말아야할 주의점들은 무엇이 있는지 아래에서 정리해보았다.
적용 전 추가로 진행해야 할 테스트 및 분석
지금까지의 테스트는 “예약 API 하나”를 대상으로 한 부하 테스트였다. 그러나 실제로 값들을 적용했을 때 동작하는 흐름은 “스케줄러가 대기열에서 35명씩 500ms마다 참여열로 옮기고, 그렇게 참여열에 들어온 사용자들이 실제로 예약 API를 호출하는” 훨씬 복잡한 시스템이다. 따라서 아래와 같은 추가 검증이 필요하다고 생각한다.
- 엔드투엔드(End-to-End) 시나리오 테스트: k6로 예약 API만 직접 두드리는 것이 아니라, 실제 대기열 → 참여열 이동 스케줄러를 동작시킨 상태에서, 대기열에 대량의 사용자를 채워넣고 시간이 지남에 따라 참여열로 이동한 사용자들이 실제로 예약을 시도하는 전체 흐름을 재현하는 테스트. 지금까지의 테스트는 “배치로 유입된 사용자가 전부 동시에 요청을 보낸다”는 최악의 가정을 개별적으로 검증한 것이므로, 실제 스케줄러가 주기적으로 돌아가는 상태에서도 동일한 결론이 유지되는지 확인이 필요하다.
- 참여열 크기 자체에 대한 부하 테스트: 참여열의 최대 크기(16,200)에 근접한 수준으로 Redis Sorted Set에 데이터를 채운 상태에서, 순위 조회·삽입·삭제 등의 연산 성능이 저하되지 않는지 확인이 필요하다. 지금까지의 테스트는 예약 API 관점의 부하만 다루었을 뿐, 대기열/참여열 자체의 데이터 규모가 커졌을 때의 Redis 성능은 별도로 검증하지 않았다.
- 장시간 소크(Soak) 테스트: 지금까지의 테스트는 길어야 수 분 단위였다. 그러나 실제 서비스의 예매 오픈 상황은 수십 분에서 수 시간 지속될 수 있다. 짧은 테스트에서는 드러나지 않는 메모리 누수, 커넥션 누수, GC 패턴의 누적 변화(예: Old Generation 점유율이 서서히 늘어나며 Full GC 빈도가 증가하는 경우) 등은 장시간 테스트를 통해서만 확인할 수 있다.
- 스케줄러 자체의 오버헤드 측정: 500ms마다 대기열을 조회하고 참여열로 옮기는 배치 작업 자체도 Redis에 부하를 주는 연산이다. 이 배치 작업이 지속적으로 반복될 때 Redis와 애플리케이션에 누적되는 오버헤드가 예약 API 처리 성능에 영향을 주지 않는지도 별도로 확인이 필요하다.
- 퍼널 분석을 통한 실제 예약 과정 참여 시간 측정: 퍼널 분석은 “실제 운영 서비스에서는 사용자들이 바로 예약을 하는 것이 아니라, 대기열 입장 후 로그인을 하거나 좌석을 찾고, 시간대를 찾는 등의 시간도 포함될텐데 TTL을 너무 추상적으로 잡는 것 아닌가?”라는 생각이 들어서, 이는 실제로 어떻게 계산을 하는가에 대해 찾아보니 ‘퍼널 분석’이라는 방법을 알게되었다. 퍼널 분석을 통해 실제로 사용자가 해당 플로우를 실행하는데 걸리는 시간을 분석하고, 사람마다 각기 다른 행동 패턴을 분석해 적절한 TTL을 설정할 수 있다고 생각한다. 참고로 대기열 시스템을 실제로 운영 중인 서비스들에서는 ‘넷퍼널’이라는 대기열과 같이 트래픽을 제어하는 솔루션을 이미 사용하고 있다는 것도 확인하였다.
이 값들을 사용하더라도 잊지 말아야 할 주의점
- 이 값은 지금 이 토이 프로젝트의 특정 인프라(t3.micro, HikariCP 풀 크기 10, GC 미세 튜닝이 되지 않은 상태 등)에 종속된 결과값이다. 인프라 사양이나 애플리케이션 설정이 달라지면 당연히 값도 달라져야 하므로, 이 수치 자체를 그대로 가져다 쓰기보다는 이 값을 도출한 과정(리틀의 법칙을 통한 이론적 추정 → 부하 테스트를 통한 실측 검증 → 안전마진 적용)을 재사용해야 한다.
- 참여열의 최대 크기(16,200)는 “참여열 내 모든 사용자가 TPS 속도로 순차적으로 요청을 보낸다”는 이상적인 가정 하의 이론적 상한선이다. 실제로는 참여열에 진입한 사용자들이 특정 순간에 몰려서 요청을 보낼 수 있으며, 이 값 자체는 그런 버스트 상황을 막아주는 장치가 아니다. 이에 대한 구체적인 대응은 바로 아래에서 다룬다.
- 안전마진(70%)은 모든 상황을 보장하는 것은 아니다. 트래픽 패턴이 크게 바뀌거나(예: 특정 인기 좌석에 요청이 쏠리는 경우), 인프라 성능이 저하되거나, 애플리케이션 로직이 변경되는 경우에는 기존에 산정한 값이 더 이상 유효하지 않을 수 있다. 따라서 이 값들은 한 번 정해지고 끝나는 상수가 아니라, 주기적인 재측정과 모니터링을 통해 지속적으로 검증되어야 하는 값이다.
배치·스케줄링만으로는 부족한 부분 - 순간적인 버스트에 대한 방어
앞서 짚었듯, 배치 사이즈와 스케줄링 주기를 아무리 정교하게 조절하더라도 참여열에 존재하는 사용자들이 동시에 예약 버튼을 누르는 상황 자체를 막을 수는 없다. 해당 고민이 해당 토이 프로젝트와 포스팅을 작성하는데 오랜 시간이 걸리게 한 고민이다. 배치/스케줄링은 어디까지나 “참여열로 얼마나 많은 사용자를 얼마나 자주 들여보낼 것인가”를 제어하는 것일 뿐, 참여열에 이미 들어와 있는 사용자들의 행동 패턴까지 통제하지는 못하기 때문이다. 따라서 대기열 시스템만으로 서버를 완전히 보호할 수 있다고 생각해서는 안 되며, 그 이후 단계에서도 추가적인 방어 장치가 필요하다.
1. MQ 도입 - DB에 가해지는 부하 자체를 다시 한번 제어
참여열에 있는 사용자들이 동시에 예약 요청을 보내더라도, 이 요청을 곧바로 DB에 반영하지 않고 MQ(Kafka, RabbitMQ 등)에 우선 적재한 뒤, Consumer가 DB 커넥션 풀이 감당 가능한 속도로만 순차적으로 소비하도록 구성하는 방법이다.
- API 서버는 예약 “요청”을 받는 즉시 MQ에 적재만 하고, 사용자에게는 “예약을 접수했다” 정도의 응답을 우선 반환한다.
- Consumer는 DB 커넥션 풀 크기에 맞춘 정해진 동시성으로만 메시지를 소비하며 실제 예약 처리(락 획득, DB 반영)를 수행한다.
- 사용자에게는 실제 예약 성공/실패 여부를 폴링이나 웹소켓, 서버센트이벤트(SSE) 등으로 비동기 전달한다.
이 방식의 핵심은, 참여열에 있는 모든 사용자가 동시에 요청을 보내는 최악의 상황이 오더라도, 그 요청들이 DB에 도달하는 속도는 Consumer가 강제로 제어하기 때문에 DB 커넥션 풀이 감당 가능한 한도를 절대 넘지 않는다는 것이다. 다만 예약이 동기적으로 즉시 처리되지 않고 비동기로 전환되므로, 사용자에게 “처리 중”이라는 상태를 명확히 안내하는 UX 설계와, MQ 자체의 운영 복잡도(파티션 설계, 장애 시 재처리 전략 등)를 함께 고려해야 한다.
2. Redis Soft Lock을 통한 인기 좌석 락 경합 완화
콘서트 좌석 예약처럼 특정 좌석(혹은 특정 항목)에 사용자가 몰리는 경우, 다수의 사용자가 동시에 같은 좌석에 대한 DB 락(비관적 락)을 두고 경합하게 된다. 이 경합이 심해지면 락 대기 시간이 늘어나고, 이는 앞서 살펴본 것처럼 leaseTime 초과로 인한 예외로도 이어질 수 있다.
이를 완화하기 위해, DB 락으로 바로 넘어가기 전에 Redis를 이용한 소프트 락(Soft Lock)으로 짧은 시간 동안 좌석을 임시 선점시키는 방법을 고려할 수 있다.
- 사용자가 좌석 선택을 요청하면, 우선 Redis에
SET seat:{seatId} {userId} NX EX {ttl}과 같은 원자적 연산으로 해당 좌석을 짧은 시간(예: 5분) 동안 임시 선점한다. - 이 Redis 연산에 실패한(이미 다른 사용자가 선점한) 나머지 사용자들은 DB까지 요청이 도달하지 않고 즉시 “이미 선점된 좌석”이라는 응답을 받는다.
- Redis 소프트 락 획득에 성공한 사용자만 실제 DB 예약(결제 등 최종 확정 처리)로 넘어간다.
Redis는 단일 스레드로 명령어를 순차 처리하기 때문에, 다수의 동시 요청이 몰리더라도 원자적으로 한 명의 승자만 가려낼 수 있다. 이 방식의 핵심은 경합 자체를 무거운 DB 락이 아니라 훨씬 가벼운 Redis 연산 선에서 걸러낸다는 것으로, 인기 좌석에 요청이 몰리는 상황에서 DB에 도달하는 요청 수 자체를 크게 줄여준다.
정리하면, 배치 사이즈와 스케줄링 주기는 “참여열에 얼마나 많은 사용자를 들여보낼 것인가”를 제어하는 1차 방어선이고, MQ를 통한 완충과 Redis 소프트 락은 그 이후에도 발생할 수 있는 순간적인 버스트와 락 경합에 대한 2차, 3차 방어선이라고 볼 수 있다. 대기열 시스템은 이 여러 겹의 방어 장치가 함께 맞물려야 비로소 온전히 제 역할을 할 수 있다.
# 결론
언제나 대규모 트래픽에 관한 공부를 하다보면 머리가 정말 아프다. 하나의 관점은 다른 여러 관점과 맞물려있기 때문에 동시에 여러가지를 고민하는 것은 고통이지만, 마치 퍼즐 맞추듯이 일련의 지식들이 함께 맞춰질 때 큰 성취감을 느끼기도 하는 것 같다.
필자도 사실 대기열을 처음 듣고, 유튜브에서 대기열의 구현에 대해 찾아보며 생각보다 어려운 방식은 아니라고 생각하였다. 토이 프로젝트를 통해 구현도 해보았지만 구현 자체는 그리 어렵지는 않았다. 그러나, 가장 핵심적인 것은 주어진 인프라와 환경에 따른 제약을 고려해야하고, 동시성과 정합성 등 다양한 문제를 해결하기 위한 값 설정이나 대기열/참여열 이후 구조 설계 등이었다. 이는 이 포스팅 작성 중 백스페이스를 수없이 누르게 만들었던 고민들이다. 해당 포스팅에서는 DB CP로부터 역산하여 배치 사이즈와 스케줄링 주기, 참여열의 최대 크기를 산정하여 이를 테스트를 통해 실제로 검증하였다. 이 모든 내용은 향후 인프라나 환경 등이 달라지더라도 해당 제약에 맞추어 이번 경험을 바탕으로 거기에 맞는 적용 능력과 관점을 키우기 위함이다.
단순히 대기열이라는 시스템을 넘어 운영되는 서비스에서 예약·예매 기능을 생각한다면 위에서 언급한 더 많은 문제들을 고민해야한다. 해당 포스팅은 그 시작점에 있으며, 앞으로도 지속적인 공부를 통하여 향후 내용들을 이해하고 해결 방법을 알아갈 것이다.