티스토리 뷰

728x90

TL;DR — 트래픽 폭증은 한 층으로 못 막는다. 누구를 들일지(Rate Limit) → 얼마나 빨리 들일지(대기열) → 기다리는 사람을 어떻게 붙잡을지(폴링 피드백), 세 질문에 각각 한 층씩. DB 커넥션 40개라는 바닥에서 거꾸로 설계했고, 부하 테스트로 검증했다.

1. 블랙 프라이데이의 산수  - 서버를 늘려도 안 되는 이유

시나리오는 이렇다. 평소 초당 100건이던 주문이 행사 시작 직후 초당 10,000건으로 폭증한다.

 

우리 서버가 가진 것

자원 크기 출처
Tomcat 워커 스레드 200 (설정값) application.yml
DB 커넥션 풀 40 (설정값) jpa.yml
PG 호출 read-timeout 2초 (재시도 포함 최대 약 7.5초) (설정값) openfeign read-timeout · resilience4j retry

 

10,000 req/s가 들어오면 스레드 200개가 즉시 점유된다.

그중 DB 커넥션을 잡은 40개 말고는 전부 커넥션 풀 앞에서 대기하고, 유저 화면은 로딩만 돌다가 타임아웃 난다.

그리고 유저는 새로고침을 누른다. 트래픽이 더 는다.

 

 

"서버를 10배 늘리면 되지 않나?"가 안 통하는 이유

앱 서버는 늘릴 수 있어도 DB와 PG는 스케일이 제한적이다. 피크가 10초짜리라 오토스케일링이 반응하기 전에 터진다.

결국 하류가 감당하는 속도만큼만 상류를 흘려보내는 것(back-pressure)이 본질이고, 대기열은 그 구현체다.

2. 세 개의 질문, 세 개의 층

처음엔 "Rate Limiting vs Queuing 중 뭘 쓸까"로 고민을 시작했다. 그런데 설계를 끝내고 보니 이 둘은 대결이 아니었다.

답하는 질문이 다른, 서로 다른 층이었다.

질문 수단
1층 Rate Limit 누구를 들일 것인가 봇·비정상은 429로 거부
2층 대기열 얼마나 빨리 들일 것인가 하류 처리량만큼만 입장
3층 폴링 피드백 기다리는 사람을 어떻게 붙잡을 것인가 순번·예상 대기 시간

 

거부가 맞는 트래픽이 있고(줄 세워줄 가치가 없는 봇), 거부하면 안 되는 트래픽이 있다(블프에 429를 받은 유저는 떠나거나 더 세게 새로고침한다). 그리고 줄을 세웠다면, 줄이 보여야 유저가 떠나지 않는다.

 

 

이번 라운드에 구현한 것은 2층과 3층이다. 1층은 위치만 설계했다.

3. 2층 대기열 — 바닥에서 거꾸로 산정하기

3-1. 커넥션 40개에서 시작하는 산수

학습 자료의 예시는 "풀 50 → 250 TPS → 안전마진 175 TPS"였다. 처음엔 이 숫자를 그대로 가져다 쓰려 했는데, 우리 프로젝트 설정을 열어보니 풀부터 40으로 달랐다. 예시가 아니라 우리 설정으로 다시 계산했다.

동시 처리 가능 수 = 커넥션 풀 크기
이론 최대 TPS    = 풀 ÷ 커넥션 점유 시간
  • 풀 40 (설정값) ÷ 주문 1건 점유 200ms = 이론 200 TPS
  • 이 나눗셈의 분모는 상수가 아니다. 주문 트랜잭션 안에는 재고 차감(행 락)이 있어서, 경합이 몰리는 순간 점유 시간이 200ms에 머물러 준다는 보장이 없다. 점유가 2배로 늘면 처리량은 절반 이론 200은 "가장 좋은 날"의 숫자다.
  • 그럼 PG 결제 호출은? 지난 라운드에서 일부러 트랜잭션 밖으로 분리해뒀다. 결제건 저장(짧은 TX) → PG 호출(TX 밖) → 거래 키 반영(짧은 TX). 덕분에 PG가 느려져도 커넥션 40개는 무사하다. 대신 스레드가 잡힌다. read-timeout 2초에 재시도 최대 3회가 겹치면 요청 하나가 스레드를 약 7.5초 (계산: 시도 2초×3 + 백오프 0.5초+1초)까지 붙든다. 커넥션 풀과 스레드 풀은 서로 다른 이유로 마르는 별개의 자원이고, 커넥션은 대기열이, 스레드는 타임아웃과 서킷브레이커가 지킨다.
  • 그래서 이론 200을 믿지 않고 동시 활성 상한(targetActive)을 풀의 절반인 20으로 잡았다. 나머지 20개는 상품 조회, 결제 콜백의 몫이다.
  • 유효 처리율 ≈ 20 ÷ 0.2s ≈ 100 TPS

 

200ms는 설계 시점의 가정이었고, 부하 테스트로 검증했다. 

커넥션 점유는 평균 200ms, 최대 1.14초 평균이 가정과 거의 정확히 맞았고, 대신 꼬리가 길었다. 

최대가 평균의 5.7배라는 것이 "분모는 상수가 아니다"의 실물이다. 주문 요청만 보면 서버 평균 300ms 인데, 여기엔 트랜잭션이 파사드 전체를 감싸는 바람에 BCrypt 인증(4-2에서 계속)까지 커넥션을 문 채 도는 비용이 포함돼 있다.

실측 평균 200ms로 계산하면 유효 처리율은 20 ÷ 0.20 ≈ 100 TPS로 설정해둔 값과 맞아떨어진다.

정답 수치를 박제하는 것보다 라이브로 조정할 수 있는 구조가 중요하다고 판단해서, targetActive·tick 주기·처리율은 전부 설정 프로퍼티로 빼뒀다.

3-2. 공정함은 자료구조 한 글자에서 나온다

대기열은 Redis Sorted Set 하나다. score = 진입 시각, member = userId.

override fun enter(userId: Long, at: Instant) {
    // ZADD NX : 이미 대기 중인 member 면 score 를 갱신하지 않아 최초 순번이 유지된다.
    redis.opsForZSet().addIfAbsent(KEY, userId.toString(), at.toEpochMilli().toDouble())
}

 

add(ZADD)와 addIfAbsent(ZADD NX) 이 한 글자 차이가 공정성 정책이다.

add였다면 유저가 불안해서 새로고침(재진입)할 때마다 score가 갱신돼 줄 맨 뒤로 밀린다.

NX라서 "이미 줄 선 사람은 자리를 지킨다". 중복 진입 방지는 Set의 member 유일성이 공짜로 따라온다.

 

순번 조회(ZRANK)는 master에서 읽는다.

기본 RedisTemplate이 REPLICA_PREFERRED로 잡혀 있어서, 방금 ZADD한 값이 복제 지연으로 replica에 아직 없으면 내 순번이 있다가 없다가 널뛴다(read-your-write 문제).

3-3. 한 번에 100명? 아니, 100ms마다 부족분만

가장 단순한 스케줄러는 "1초마다 100명 입장" 같은 고정 발급이다. 그러면 매초 100명이 동시에 주문 API를 때린다 — 원래 문제의 축소판이다(Thundering Herd). 게다가 하류가 소화 중인지는 보지도 않는다. 그래서 발급을 "보충" 방식으로 바꿨다.

fun admitOnce() {
    val capacity = targetActive - entryTokenService.activeCount()
    if (capacity <= 0) return
    waitingQueueService.poll(capacity).forEach { userId -> /* 토큰 발급 */ }
}

100ms(설정값)마다 targetActive − 현재 활성만큼만 꺼낸다. 새는(만료·소비되는) 만큼만 다시 채우는 리키버킷 — 유입이 아니라 배출 속도가 일정해진다. 발급량이 하류 상태(활성 토큰 수)에 물려 있는 폐쇄 루프이기도 하다.

3-4. 토큰은 주문의 시작에 죽는다

과제 문구는 "주문 완료 후 토큰 삭제"였고, 처음엔 문구 그대로 구현할 생각이었다.

그런데 같은 토큰으로 동시에 두 요청이 오는 경우를 그려보니, GET → 비교 → DEL 세 연산 사이의 틈으로 둘 다 통과했다.

1토큰 = 1주문이 깨지면 targetActive 계산 전체가 무너진다.

-- KEYS[1] = entry-token:{userId}, KEYS[2] = active-tokens
if redis.call('GET', KEYS[1]) == ARGV[1] then
    redis.call('DEL', KEYS[1])
    redis.call('ZREM', KEYS[2], ARGV[2])
    return 1
else
    return 0
end

 

Lua로 검증과 소비를 원자화했다.

대가도 있다. 주문 시작 시점에 토큰이 소비되니, 재고 부족으로 주문이 실패해도 토큰은 이미 죽었다(DB 트랜잭션은 롤백돼도 Redis는 롤백되지 않는다).

유저는 다시 줄을 서야 한다. back-pressure 정합성과 유저 편의가 충돌하는 지점인데, 정합성 쪽을 택했다. 실패한 주문도 하류 자원을 쓴 건 사실이니까.

 

GETDEL을 안 쓴 이유. 값을 비교하기 전에 무조건 지우기 때문에, 틀린 토큰을 들고 온 요청이 남의 유효한 토큰을 지울 수 있다.

4. 3층 폴링 피드백 — 기다리는 사람을 붙잡기

4-1. 왜 SSE가 아니라 폴링부터인가

순번 알림은 서버→클라이언트 단방향이면 충분하다. WebSocket은 애초에 과했고, 후보는 폴링과 SSE.

SSE는 순번이 바뀔 때만 push하니 낭비가 없다.

대신 대기 인원 × 1 커넥션을 유지해야 하고, 로드밸런서 뒤라면 연결 유지 설정도 필요하다.

10,000명이 기다리면 커넥션 10,000개다.

우리 톰캣의 max-connections 8192 부터 넘는다. 폴링은 투박하지만 인프라 변경이 0이다.

그래서 폴링으로 시작하고, 폴링 부하가 문제로 측정되면 그때 SSE로 전환하기로 했다.

4-2. 폴링의 산수 — Redis는 버틴다, 문제는 다른 곳

  • 10,000명 × (1회 / 2초) = 5,000 QPS
  • 순번 조회는 ZRANK, skiplist 구조라 O(log N). 이 정도로 Redis는 죽지 않는다.

진짜 문제는 우리 인증 구조에서 나왔다. 이 프로젝트는 매 요청 로그인 헤더를 BCrypt로 검증한다.

BCrypt는 설계상 일부러 느린 해시다.

재보니 1회 75ms 부하를 걸자 순번 조회 요청은 평균 193ms까지 늘었다.

ZRANK 자체는 마이크로초 단위인데, 요청 시간의 대부분이 인증이다.

5,000 QPS로 환산하면 초당 375 CPU-초 CPU 375코어어치 일 이다.

대기열이 줄이려던 부하를, 대기열의 피드백이 만들어내는 모순.

 

실측이 하나 더 알려줬다. 우리 authenticate는 읽기 트랜잭션 안에 있어서(클래스 레벨 @Transactional), 유저를 SELECT한 커넥션이 BCrypt가 도는 75ms 동안 반납되지 않는다.

폴링이 CPU만 태우는 게 아니라, 대기열이 지키려던 커넥션 풀까지 문다.

그래서 대기열 도메인 서비스는 userId만 받도록 경계를 잘랐다(인증은 파사드에만 둔다).

 

다만 솔직하게 현재 구현도 여전히 폴링마다 BCrypt가 돈다.

구조만 분리해둔 상태고, 폴링 전용 경량 인증(큐 세션 토큰 등)으로 바꾸는 게 다음 개선이다. 10,000명 규모라면 Redis보다 이쪽이 먼저 병목이 된다.

4-3. position = null 의 두 가지 의미

입장 처리는 ZPOPMIN이라, 입장한 유저는 큐에서 사라진다.

그러면 순번 조회는 "없음"을 돌려주는데 이게 "입장하셨습니다"인지 "줄 선 적이 없는데요"인지 구분되지 않는다.

실제로 중간 단계까지는 입장 처리된 유저도 404를 받았다.

 

그래서 응답을 상태 기반으로 바꿨다.

상태 조건 응답
WAITING 큐에 있음 순번(1-based) + 전체 대기 + 예상 대기 시간
READY 큐에 없음 + 토큰 있음 입장 토큰
404 큐에도 토큰도 없음 미진입 또는 입장 시간 만료

 

예상 대기 시간은 순번 ÷ 처리율(초당 100) 어디까지나 추정값이라 "약 N초"로 보여준다.

폴링 응답에 토큰을 실어주면, 클라이언트는 같은 API 하나만 반복 호출하다가 READY를 받는 순간 주문으로 넘어가면 된다.

 

스케줄러는 큐에서 꺼낸 다음 토큰을 발급하는데(pop-then-issue), 그 틈에 발급이 실패하면 유저는 큐에도 토큰에도 없는 상태. 정확히 404로 떨어진다.

클라이언트는 404를 받으면 재진입하면 된다.

유실 장애의 복구 경로가 상태 설계에서 공짜로 나온 셈이고, 대신 줄은 끝에서 다시 선다. 부하 테스트에서 이 복구가 실제로 작동하는 걸 봤다.

진입 직후 스케줄러가 먼저 꺼내가는 레이스로 전체 요청의 약 7%가 순번을 찾지 못했지만, 전부 다음 폴링에서 READY로 복구됐고 주문 실패는 0건이었다.

 

4-4. SSE로 넘어가는 신호

  • 순번 조회 QPS가 앱 서버를 압박할 때 위에서 본 대로 Redis보다 앱(인증)이 먼저 죽는다
  • 그 전 중간 단계로 순번 구간별 동적 폴링이 있다: 앞 100명은 1초, 1,000번대는 3초, 그 뒤는 5초. 폴링을 유지한 채 부하를 낮추는 선택지

5. 1층 Rate Limit — 위치만 설계했다

솔직하게, 이번 라운드에서 1층은 구현하지 않았다. 하지만 어디에 서야 하는지는 정해뒀다.

ZADD NX가 막는 건 "같은 userId의 중복 진입"뿐이다. 봇, 비정상 폭주, 새로고침 폭풍은 대기열 진입 API 자체를 때린다. 줄에 세워줄 가치가 있는 요청만 줄에 들어와야 줄이 공정하니, 이건 대기열 앞단에서 429로 거부할 트래픽이다.

그리고 마지막 안전장치 — 주문 API 자체의 rate limit. 대기열이 뚫리거나(버그, 장애) 토큰 발급이 과해져도 하류를 지키는 최후의 층. 대기열은 피크를 평탄화할 뿐, 부하를 없애주지 않는다.

6. 회고 — 층은 서로를 지킨다

  • 3층(피드백)이 없으면 유저가 새로고침을 누르고, 그 폭풍이 1층을 때린다
  • 1층(거부)이 없으면 봇이 2층의 줄을 오염시키고, 정상 유저의 대기가 늘어난다
  • 2층(대기열)이 없으면 DB 커넥션 40개가 마르고, 전부 죽는다

"Rate Limiting vs Queuing", "Polling vs SSE"를 뭐가 더 나은가로 물었을 때는 교과서 같은 답밖에 안 나왔다. "우리 시스템에서 이 층이 답해야 하는 질문이 무엇인가" 로 바꾸고 나서야 각자의 자리가 보였다.

 


*실측은 이렇게 했다

 

본문의 (실측) 수치가 어디서 나왔는지, 검산할 수 있게 날것을 남긴다.

환경과 방법

  • 머신: 맥북(로컬), 인프라는 Docker — MySQL 8.0, Redis(master/replica), 앱은 bootRun
  • 부하: k6 — VU 20 × 60초. 각 VU가 실제 유저 플로우를 그대로 돈다: POST /queue/enterGET /queue/position 폴링(0.5초 간격) → READY 받으면 POST /orders
  • 왜 VU 20인가: targetActive와 같은 값. 대기열 게이트 자체가 병목이 되지 않는, "20명이 계속 주문을 밀어넣는" 정상 통과 상태를 재현하려는 것
  • 재고: 상품 1개에 1억 개 — 재고 부족이 변수가 되지 않게

직접 재현하기

이 글의 숫자는 박제된 캡처가 아니다. 아래 한 줄로 다시 돌릴 수 있다.

# 사전 준비: docker, k6, 그리고 redis(6379)
docker-compose -f docker/infra-compose.yml up -d redis-master redis-readonly

# 원커맨드: 임시 MySQL → 앱 기동 → 유저20+상품 시드
#          → 커넥션 before 스냅샷 → k6 60초 → after 스냅샷
./round8-load-test.sh

 

중요한 건, 돌릴 때마다 숫자가 다르다는 점이다. 같은 스크립트를 두 번 돌린 결과:

지표 1차 2차
주문 완료 1,076건 847건
커넥션 점유 평균 240ms 439ms
커넥션 점유 최대 1230ms 2788ms
주문 p95 700ms 1030ms

 

로컬 단일 노드라 그 순간 머신 사정에 따라 절대치가 갈린다. 하지만 두 번 다 같은 결론이 나왔다 — order 꼬리가 position보다 길고, 커넥션 점유 최대가 평균의 5~6배였고, 주문 실패는 0건이었다. 재현되는 건 특정 숫자가 아니라 이 성질이다. 그래서 아래 수치는 "정답"이 아니라 "한 번의 관측"으로 읽어야 맞다(1차 실행 기준).

커넥션 점유는 이렇게 유도했다

주목할 지표는 API 응답 시간이 아니라 커넥션 점유 시간이다(3-1의 분모). HikariCP가 hikaricp_connections_usage_seconds로 정확히 이걸 내보낸다 — 커넥션을 풀에서 빌린 순간부터 반납까지. 부하 전후로 /actuator/prometheus(관리 포트 8081)를 두 번 찍어 그 차이를 계산했다.

 

 

이 두 스냅샷의 뺄셈이 곧 이번 부하 구간의 실측이다.

점유 건수 = 4190 − 25     = 4,165 회
점유 총합 = 833.191 − 2.45 = 830.741 초
평균 점유 = 830.741 / 4,165 = 0.200 초 = 200ms   ← 본문 3-1
최대 점유 = 1.137 초 = 1137ms                     ← 꼬리

 

sum ÷ count는 내가 손댈 수 없는 카운터 두 개의 나눗셈이다. 200ms는 그렇게 나왔다.

요청 레이턴시 — k6 요약 원문

 

주문(order)과 순번조회(position)의 백분위를 나란히 그리면 꼬리 길이 차이가 드러난다. order의 최대치(1130ms)가 중앙값(275ms)의 4.1배 이 변동이 "커넥션 점유는 상수가 아니다"의 실물이다.

 

진입/조회 상태 코드 분해 (4-3의 404 복구 실증)

POST /queue/enter     200: 973건   500: 103건
GET  /queue/position  200: 1358건  404: 149건
POST /orders          200: 1076건  (실패 0건)

 

진입 500(103건)과 조회 404(149건)는 전부 "enter 직후 스케줄러가 먼저 ZPOPMIN으로 꺼내간" 레이스다. 전체 요청의 약 7%가 순번을 못 찾았지만 주문 실패는 0건 전부 다음 폴링에서 READY로 복구됐다. 404가 에러가 아니라 복구 경로라는 것의 실측 증거다.

BCrypt

BCrypt matches: 평균 75ms/회  (strength 10, 50회 측정, 무부하)

 

기본 BCryptPasswordEncoder()의 strength가 10이라, 운영과 같은 조건으로 쟀다. 무부하에서 75ms인데, 4-2에서 본 대로 부하가 걸리면 순번 조회 요청 전체가 평균 227ms까지 늘어난다.

728x90