Skill

[대기열] Redis만 빠르면 해결될까? - 부하 테스트가 알려준 진짜 병목

소범범 2026. 4. 3. 17:54

 

 

k6로 1,000명을 줄 세워봤다
 대기열의 진짜 병목은 대기열이 아니었다

175 TPS를 설계했는데 10.7이 나왔습니다. 그런데 대기열은 잘 하고 있었습니다.

 

TL;DR
이론 175 TPS vs 실측 10.7 TPS, 16배 괴리의 원인은 대기열(Redis)이 아니라 주문 처리(DB)였다.
주문 병목(1,298ms)이 Tomcat 스레드를 잡아먹으면서 대기열 API까지 느려지는 캐스케이드가 발생했다.
가정을 세우고, 실측으로 깨뜨리고, 괴리를 분석하는 사이클이 성능 설계의 본질이다.

175 TPS — 이 숫자는 어디서 나왔나

대기열 시스템을 설계할 때 가장 먼저 한 건, “서버가 초당 몇 명을 처리할 수 있는가?”를 산출하는 것이었습니다.

DB 커넥션 풀: 50 (HikariCP 설정)
주문 1건 처리 시간: 200ms (가정)
이론 최대 TPS: 50 / 0.2 = 250
안전 마진 70%: 175 TPS

이 숫자로 모든 걸 설계했습니다.

  • 스케줄러: 100ms마다 18명씩 토큰 발급 (초당 ~180명 = 175 TPS 매칭)
  • 예상 대기시간: position / 175
  • 배치 크기: 175 / 10 batches = 18

깔끔합니다. 이론적으로는요.

문제는, 이 숫자들이 전부 “가정”이었다는 겁니다. 200ms는 실측이 아닌 추정이고, 커넥션 풀 50도 설정이 아닌 계획이었습니다. 이 가정을 검증하려면 실제 부하를 걸어봐야 합니다.


k6 시나리오 — 1,000명을 줄 세우는 법

k6로 3가지 시나리오를 설계했습니다.

시나리오 VU 동작 목적
blackfriday_enter 1,000 5초 ramp-up → 30초 유지 대기열 진입 대량 부하
full_flow 100 진입 → Polling → 토큰 → 주문 실제 유저 경험 시뮬레이션
spike 1,000 1초 만에 1,000명 폭증 스파이크 안정성

핵심은 full_flow입니다. 실제 유저가 하는 것과 동일한 흐름을 시뮬레이션합니다.

javascript — k6/queue-load-test.js
// 1. 대기열 진입
const enterRes = http.post(`${BASE_URL}/api/v1/queue/enter`, null, { headers });

// 2. Polling으로 토큰 대기
for (let i = 0; i < 20; i++) {
    const posRes = http.get(`${BASE_URL}/api/v1/queue/position`, { headers });
    if (body.data && body.data.token) {
        token = body.data.token;
        break;
    }
    sleep(2); // 2초마다 Polling
}

// 3. 토큰이 있으면 주문
if (token) {
    const orderRes = http.post(`${BASE_URL}/api/v1/orders`, orderBody, { headers });
}

setup에서 1,000명의 테스트 유저, 브랜드, 상품(재고 999,999)을 생성합니다. 재고를 충분히 줘서 재고 부족이 아닌 순수한 처리량을 측정하기 위해서입니다.


실측 결과 — 이론이 산산조각 나는 순간

항목 이론값 실측값
DB 커넥션 풀 50 10 (HikariCP 기본값)
주문 처리 시간 200ms 1,298ms
최대 TPS 175 10.7
주문 성공 건수 2,238건
주문 에러율 0%

175 TPS를 기대했는데 10.7 TPS. 16배 괴리.

처음엔 “뭘 잘못한 거지?”라고 당황했습니다. 대기열 코드에 버그가 있나? Redis 설정이 잘못됐나?


병목 추적 — 대기열이 아니었다, 그런데 대기열도 느렸다?

원인을 찾기 위해 각 구간의 응답 시간을 뜯어봤습니다.

주문 처리 (POST /orders)

메트릭
avg 1,298ms
P95 1,700ms
에러율 0%

주문 처리가 1,298ms. 200ms를 가정했는데 6.5배 느립니다. 재고 비관적 락, 쿠폰 적용, 트랜잭션 오버헤드가 복합적으로 작용한 결과입니다.

여기까지는 예상 가능했습니다. 그런데 의외의 결과가 하나 더 있었습니다.

대기열 진입 (POST /queue/enter)

메트릭
avg 4,607ms
P95 8,798ms
에러율 0%

Polling 순번 조회 (GET /queue/position)

메트릭
avg 3,681ms
P95 8,829ms
에러율 0%

대기열 진입 P95가 8,798ms? Redis ZADDZRANK는 O(log N)인데 왜 8초나 걸리는 걸까요?

병목의 캐스케이드 — Tomcat 스레드가 원인이었다

Redis 자체는 빠릅니다. 문제는 Redis에 도달하기 전 단계에 있었습니다.

1,000명 동시 요청 → Tomcat 워커 스레드 200개
주문 처리(1,298ms)가 스레드를 오래 점유
대기열 API 요청이 Tomcat 스레드 큐에서 대기
Redis 조회 자체는 수 ms인데, 스레드를 받기까지 수 초 대기
대기열 P95 = 8,798ms (대부분이 스레드 대기 시간)

주문 처리가 느리면 Tomcat 스레드를 오래 점유하고, 남는 스레드가 없으니 대기열 API(Redis만 찌르면 되는 가벼운 요청)까지 줄 서서 기다리게 됩니다. DB 병목이 Tomcat 스레드 고갈로 전이되고, 그게 전체 API 응답 지연으로 퍼진 겁니다.

이건 대기열이 느린 게 아니라, 느린 주문 처리가 전체 시스템을 잡아먹은 것입니다. 대기열의 진짜 병목은 대기열이 아니었습니다.

에러율 0% — 대기열은 제 역할을 하고 있었다

응답이 느려졌음에도 에러율은 0%였습니다. 1,000명이 몰려도 단 한 건의 실패 없이 처리했습니다. 대기열이 초당 ~11명만 통과시키면서 시스템을 보호하고 있었던 겁니다.


16배 괴리의 진짜 원인 — 가정 두 개가 틀렸다

가정 1: 커넥션 풀 50 → 실제 10

application.yml에 커넥션 풀을 명시하지 않았습니다. HikariCP 기본값은 10입니다. 이론은 50 기준이었는데 실제로는 1/5만 열려있었습니다.

이건 설계 문서에 “50”이라고 적어놓고 설정에 반영하지 않은 순수한 실수입니다. 부하 테스트를 안 했으면 절대 발견하지 못했을 겁니다.

가정 2: 처리 시간 200ms → 실제 1,298ms

주문 1건에 200ms를 가정했는데, 실제로는 1,298ms였습니다.

  • 재고 비관적 락 (SELECT FOR UPDATE)
  • 쿠폰 낙관적 락 (@Version)
  • 트랜잭션 내 다수 엔티티 조회/저장
  • 로컬 Docker 환경 오버헤드
  • 1,000 VU 동시 부하로 인한 DB 경합 증가

복합 효과 계산

실제 TPS = 커넥션 풀 / 처리 시간
         = 10 / 1.298
         = 7.7
실측: 10.7 (파이프라인 효과로 약간 높음)

가정 두 개가 동시에 틀렸고, 그 괴리가 곱셈으로 작용해서 16배 차이가 난 겁니다. 커넥션 풀 1/5 × 처리 시간 6.5배 = 이론의 1/32. 실측 10.7이 나온 건 스케줄러가 파이프라인 방식으로 토큰을 발급하면서 커넥션을 효율적으로 재사용했기 때문입니다.


대기열이 없었다면 어떻게 됐을까?

커넥션 풀이 10개이고 처리 시간이 1,298ms이면, 동시에 10건만 처리 가능합니다. 나머지 990명은 HikariCP 내부 큐에서 기다리다가, connectionTimeout(기본 30초)을 넘기면 예외가 발생합니다.

1,000명 동시 요청 → 10개 커넥션 점유 → 990명 HikariCP 대기
connectionTimeout 초과 → 대량 500 에러
유저: “결제 됐나? 안 됐나?” → 재시도 → 더 몰림

대기열이 있으면? 스케줄러가 초당 ~11명만 통과시키니, 커넥션 풀이 고갈되지 않습니다. 나머지는 Redis에서 기다리면서 “342번째입니다”를 봅니다.

앞에서 본 Tomcat 스레드 문제로 대기열 API 응답도 느려졌지만, 에러율 0%라는 사실이 핵심입니다. 느려진 것과 실패하는 것은 다릅니다. 1,000명을 받으면서도 단 한 건의 에러 없이 처리했다는 건, 대기열이 시스템을 보호하고 있다는 뜻입니다.

이 Tomcat 스레드 병목은 대기열 API와 주문 API의 스레드 풀을 격리하면 해결할 수 있습니다. 대기열 API 전용 스레드를 확보하면, 주문 처리가 아무리 느려도 대기열 Polling은 Redis 응답 속도(수 ms)에 근접할 겁니다.


Thundering Herd는 실제로 관찰됐는가?

스케줄러가 100ms마다 18명씩 토큰을 발급합니다. 이 18명이 동시에 주문 API를 호출하면 순간적으로 부하가 몰릴 수 있습니다.

실측에서는 주문 P95가 1,700ms로, avg(1,298ms) 대비 1.3배 수준이었습니다. 극단적인 스파이크는 아니었는데, 이유가 있었습니다.

Polling 자체가 자연스러운 Jitter 역할을 하고 있었습니다.

유저마다 대기열 진입 시각이 다르고, Polling 주기도 2초입니다. 스케줄러가 18명에게 동시에 토큰을 발급해도, 유저A는 0.3초 뒤에 Polling하고 유저B는 1.7초 뒤에 Polling합니다. 토큰을 발견하는 시점이 자연스럽게 분산되는 겁니다.

“2초 동안 쌓이면 한꺼번에 터지는 거 아닌가?” 싶었는데, 유저별 Polling 타이밍이 다르다는 걸 깨달으니 납득됐다. 오히려 SSE로 토큰 발급 즉시 Push하면 18명이 정확히 같은 시점에 주문 API를 호출하게 되어 Thundering Herd가 더 심해질 수 있다.

그래도 추가 완화를 위해 4가지 전략을 적용했습니다.

전략 동작 효과
스케줄러 배치 분산 100ms마다 18명 (1초 10회) 피크 부하 10배 평탄화
Jitter 토큰에 0~2초 랜덤 딜레이 같은 배치 내 분산
동적 Polling 순번별 1s/3s/5s 간격 Polling 자체 부하 감소
Graceful Degradation BLOCK/BYPASS 전략 전환 Redis 장애 시 대응

10,000 req/s가 들어오면 뭘 바꿔야 하나?

과제 시나리오는 10,000 req/s 트래픽입니다. 이건 대기열 진입 트래픽이고, TPS(초당 주문 처리 수)는 DB 용량으로 결정됩니다. 유입량이 늘어도 TPS는 변하지 않고, 대기열 길이와 대기 시간만 증가합니다. 핵심은 TPS를 높여 대기 시간을 줄이는 것입니다.

보정 항목 현재 목표 효과
커넥션 풀 10 100 TPS 상한 10배 ↑
주문 처리시간 1,298ms 200ms 이하 같은 커넥션으로 6.5배 ↑
Tomcat 스레드 공유 대기열/주문 격리 대기열 API 응답 정상화
인스턴스 1대 N대 TPS를 곱하기로 확장

커넥션 100 + 처리시간 200ms면 100 / 0.2 = 500 TPS. 10,000 req/s 유입 시 초당 9,500명이 누적되고, 30초 후 예상 대기시간은 약 28분. 2대로 확장하면 14분.

Redis는 10만+ ops/s를 처리하므로 10,000 ZADD/s는 여유입니다. 병목은 항상 DB 쪽이고, 대기열은 그 병목을 보호하는 역할입니다.


이 경험에서 배운 것

1. 가정은 틀린다, 실측이 진실이다

“커넥션 풀 50, 처리시간 200ms”라고 적어두고 검증하지 않으면, 그건 설계가 아니라 소설입니다. 16배 괴리를 경험하고 나니, 숫자를 내놓을 때 “이건 가정이다”를 명시하고, 실측 계획을 함께 세우는 게 당연해졌습니다.

2. 성능 설계는 가정 → 실측 → 괴리 분석 → 보정의 반복이다

한 번에 정확한 설계를 하는 게 아니라, 가정을 세우고 부수고 다시 세우는 반복입니다. k6 같은 도구는 이 사이클을 빠르게 돌리기 위해 존재합니다.

3. 대기열의 가치는 에러율로 증명된다

TPS가 낮고 응답이 느려져도 에러율 0%라면, 대기열은 제 역할을 하고 있는 겁니다. 대기열 없이 1,000명이 직접 주문 API를 호출했다면, 커넥션 풀 고갈 → 500 에러 → 재시도 폭풍이 발생했을 겁니다. Tomcat 스레드 병목으로 대기열 API까지 느려진 건 문제지만, 이건 스레드 풀 격리로 해결 가능한 문제입니다.


마무리

“대기열의 진짜 병목은 대기열이 아니었다.”

주문 처리가 1,298ms로 느려지면서 Tomcat 스레드를 잡아먹었고, 그 여파로 Redis만 찌르면 되는 대기열 API까지 8초대로 밀렸습니다. 병목은 주문 처리(DB)에 있었고, 대기열은 그 병목이 시스템 전체를 무너뜨리지 않도록 보호하고 있었습니다.

설계 문서에 적힌 숫자를 믿지 마세요. k6를 돌리세요. 이론이 산산조각 나는 순간이 진짜 설계가 시작되는 순간입니다.