요즘 대기열을 공부하고 있다. 아이폰 발매일이나 콘서트 티켓팅 때 뜨는 "현재 대기 인원 3,241명" 화면 뒤에 있는 그 시스템이다.
근데 공부를 시작하자마자 삐딱한 생각부터 들었다. 이게 왜 필요하지? 트래픽이 몰리면 서버를 늘리면 되는 거 아닌가. 클라우드 시대에 스케일아웃은 버튼 하나인데, 굳이 유저를 줄 세워서 기다리게 하는 건 뭔가 지는 것 같았다.
그래서 시나리오를 하나 놓고 따져보기로 했다. 블랙 프라이데이, 평소 초당 100건이던 주문이 초당 10,000건으로 뛴다. 서버를 100배로 늘리면 버틸 수 있나? 계산기를 두드려보고 나서 이 감각이 어디서부터 잘못됐는지 알게 됐다. 그리고 대기열을 만들어서 해결했다고 생각한 순간, 원래 문제가 축소판으로 되돌아오는 것도 봤다.
이 글은 그 과정의 기록이다. 스케일아웃이 왜 답이 아닌지, 거부(Rate Limiting)와 줄 세우기(Queuing)는 뭐가 다른지, Redis 대기열을 만들고 나서 어떤 문제가 새로 생겼는지를 다룬다. 만들면서 스스로 쓴 문장을 의심하게 된 지점들도 함께 기록해봤다.
미리 밝혀둘 게 있다. 이 글의 수치들은 대부분 계산값이다. 커넥션 풀 50, 평균 처리 200ms 같은 전제를 놓고 손으로 계산한 거지, 실제 부하 테스트로 실측한 게 아니다. 계산이 실전과 어긋나는 지점은 분명 있을 거니 참고해 이 글을 읽기 바란다.
1. 뭘 알고 싶었나
"트래픽이 몰리면 서버를 늘린다"는 명제를 나는 별 의심 없이 믿고 있었다.
근거도 있다고 생각했다. 웹 서버는 stateless니까 복제하면 되고, 로드밸런서가 나눠주면 되고, 오토스케일링이 알아서 해준다. 실제로 이 방식으로 잘 돌아가는 서비스가 많다.
근데 대기열 자료들을 읽다 보니 같은 말이 반복해서 나왔다. 서버를 아무리 늘려도 DB와 PG는 스케일이 제한적이라는 것. 이 문장이 왜 성립하는지 나는 설명할 수 없었다. DB도 결국 서버인데 왜 못 늘리지? 리드 레플리카도 있고 샤딩도 있잖아. 남의 결론을 그대로 받아 적기는 싫어서 직접 확인하기로 했다.
정리하면 질문은 이거였다. 트래픽 100배는 서버 100배로 해결되는 문제인가. 아니라면, 정확히 어디서 막히는가.
2. 서버를 100배로 늘려서 계산해보다
병목이 어딘지부터 계산
막연히 "DB가 병목"이라고 말하고 넘어가기 싫어서, 흔한 구성을 하나 가정해놓고 처리량을 직접 계산했다.
DB 커넥션 풀: 50개
주문 1건 평균 처리 시간: 200ms
커넥션 1개가 초당 처리 가능한 주문: 1초 / 0.2초 = 5건
전체 처리량: 50개 × 5건 = 250 TPS
주문 시스템이 낼 수 있는 이론상 최대치가 250 TPS였다.
다만 우리가 가정한 TPS는 10,000 TPS다. 40배 차이가 난다.. ㅜ
여기서 애플리케이션 서버를 100배로 늘리면 어떻게 되나. 요청을 받아주는 입구는 100배로 넓어진다. 근데 그 요청들이 전부 DB 커넥션을 기다리게 된다. 커넥션은 여전히 50개다. 서버를 늘린 건 대기실을 넓힌 거지, 창구를 늘린 게 아니었다.

그러면 DB를 늘리면 되는 거 아냐?
여기서 자연스럽게 다음 반문이 나온다. 병목이 DB라면 DB를 늘리면 되는 거 아닌가. 서버 늘리듯이. 선택지를 하나씩 따져봤다.
커넥션 풀을 늘리면? 커넥션 풀은 애플리케이션 설정이니 숫자만 바꾸면 된다. 근데 그 커넥션을 받아주는 건 DB 서버다. 커넥션마다 DB 쪽에 메모리와 스레드가 붙고, 커넥션 수가 코어 수를 한참 넘어가면 컨텍스트 스위칭과 락 경합 때문에 오히려 전체 처리량이 떨어진다.
HikariCP 문서에도 이 내용이 서술되어있다. 커넥션 수와 처리량은 비례하지 않고, 적정 커넥션은 코어 수 기반으로 계산하면 생각보다 훨씬 적다.1 풀 사이즈를 500으로 올리는 건 창구를 늘리는 게 아니라, 창구 하나에 직원 열 명을 욱여넣는 것에 가깝다.
스케일업은? 더 좋은 DB 머신으로 바꾸는 건 실제로 통하는 방법이다. 근데 세 가지가 걸렸다. 첫째, 천장이 있다. 아무리 비싼 인스턴스도 최고 사양이 정해져 있고, 40배 격차를 수직으로만 메꾸기는 어렵다. 둘째, 비용이 선형이 아니다. 성능 2배짜리 머신이 가격 2배가 아니라 그 이상으로 뛴다. 그리고 그 비용은 10초짜리 피크를 위해 24시간 지불된다..
read 복제(스케일 아웃)는? 읽기만 분산한다. 주문은 재고 차감, 결제, 주문 저장 —> 전부 쓰기다. 레플리카를 열 대 붙여도 쓰기는 여전히 마스터 한 대가 받는다.
그럼 쓰기를 분산하는 샤딩은? 여기서 좀 머쓱해졌다. 샤딩은 서버 버튼 하나 누르는 일이 아니었다. 샤드 키를 뭘로 잡을지부터가 설계고, 유저 기준으로 쪼개면 같은 상품의 재고가 여러 샤드의 주문과 얽히면서 크로스 샤드 트랜잭션 문제가 생긴다. 강한 일관성이 필요한 재고 차감을 샤드 경계 너머로 보장하려면, 분산 트랜잭션이라는 더 어려운 문제를 새로 사는 셈이다. 데이터 모델과 트랜잭션 경계를 다시 설계하는 일이라, 행사 전날 할 수 있는 게 아니다.
마지막 의문. DB는 왜 오토스케일이 안 되지? 웹 서버가 버튼 하나로 복제되는 건 상태가 없어서다. 새 인스턴스는 뜨자마자 일할 수 있다. DB는 상태 그 자체다. 새 노드를 띄우면 데이터를 복제받아 따라잡는 시간이 필요하고, 그동안 원본 DB는 복제 부하까지 추가로 진다. 10초 피크에 맞춰 탄력적으로 늘었다 줄었다 할 수 있는 물건이 아니었다.
PG는 더 단순하다. 외부 시스템이라 우리가 늘릴 수 있는 대상 자체가 아니다.
정리하면 이렇다. 스케일아웃은 복제 가능한 자원만 늘린다. stateless한 웹 서버는 복제가 된다. 강한 일관성이 필요한 DB 쓰기, 외부 PG는 복제가 안 되거나 우리 권한 밖이다. 그리고 병목은 항상 복제 안 되는 쪽에서 생긴다. "서버 늘리면 되지"라는 내 감각은 복제 가능한 자원만 보고 있었던 거다.
자원뿐만 아니라 시간 측면에서도 한계가 있다.
행사 트래픽의 피크는 극단적으로 짧고 높다. 행사 시작 직후 10초 같은 식이다. 오토스케일링은 지표를 관찰하고, 판단하고, 인스턴스를 띄우고, 웜업하는 데 분 단위가 걸린다. 피크가 10초면, 새 서버가 뜨는 시점엔 이미 장애가 나고 트래픽도 빠진 뒤다. 불난 뒤에 도착하는 소방차다.
여기까지 계산하고 나니 문제를 바라보는 관점이 바뀌었다.
처리 능력을 사용자 요청의 증가 속도에 맞추는 것은 현실적으로 어렵다.
그렇다면 남은 방향은 하나였다. 처리 가능한 속도에 맞춰 들어오는 요청의 양을 조절하는 것.
즉, DB나 PG가 감당할 수 있는 만큼만 요청이 전달되도록 만드는 것.
이 개념을 Back Pressure라고 한다.
3. 요청량을 제어하는 방법 두 가지.. 거부냐, 보관이냐
요청량을 깎는 방법은 두 가지가 있었다. 초과분을 거부하거나(Rate Limiting), 초과분을 보관하거나(Queuing).
TCP의 back-pressure
back-pressure라는 개념을 찾아보다가 재밌는 걸 발견했다. 이게 새로운 아이디어가 아니었다. TCP가 처음부터 하고 있던 일이다.2
TCP에서 수신자는 "지금 이만큼만 받을 수 있어"라고 수신 윈도우를 알려주고, 송신자는 그 크기만큼만 보낸다. 수신자가 버거우면 윈도우를 줄이고, 송신자는 얌전히 속도를 낮춘다. 하류의 속도가 상류의 속도를 정하는 구조가 전송 계층에 이미 있었다.
이게 가능한 이유는 송신자가 TCP라는 규칙을 반드시 따르기 때문이다. 수신자가 "천천히 보내"라고 하면 송신자는 그대로 속도를 줄인다.
사람은 프로토콜을 지키지 않는다
문제는 어플리케이션이다.
429 Too Many Requests도 같은 발상이다. "지금 못 받으니 천천히 와"라는 신호다. 근데 이 신호의 수신자가 사람이면 얘기가 달라진다.
블랙 프라이데이에 한정 수량을 노리던 유저가 "나중에 다시 시도하세요"를 받으면 어떻게 하나. 천천히 오지 않는다. 새로고침을 연타한다. 요청 하나 거부했더니 재시도 요청 다섯 개가 돌아온다. 실패한 클라이언트의 재시도가 부하를 배수로 증폭시키는 이 패턴은 분산 시스템에서 잘 알려진 장애 유형이고, 재시도에 backoff와 jitter를 거는 게 표준 처방으로 자리 잡은 이유이기도 하다.3
여기서 좀 웃겼다. 거부는 유입을 줄이려는 수단인데, 사람을 상대로 쓰면 유입을 늘린다. 신호를 보냈는데 상대가 프로토콜을 안 지키는 거다.
그래서 줄을 세운다. 대기열은 사람에게 back-pressure를 적용하는 방법이었다. "천천히 와"라고 부탁하는 대신, 일단 다 받아두고 나가는 속도를 시스템이 쥔다. 유저에게는 거부 대신 순번을 준다. "현재 512번째"는 새로고침할 이유를 없앤다. 새로고침해도 512번째니까.
정리하면 이렇다.
| Rate Limiting (거부) | Queuing (보관) | |
|---|---|---|
| 초과 요청 | 429로 쳐낸다 | 대기열에 쌓는다 |
| 유저가 받는 것 | "나중에 다시 오세요" | "현재 512번째입니다" |
| 사람의 반응 | 새로고침 → 재시도 폭풍 | 순번을 보며 대기 |
| 맞는 상황 | 봇 차단, 일상적 보호 | 기다릴 이유가 있는 행사 |
물론 둘은 양자택일이 아니다. 봇이나 비정상 트래픽은 Rate Limiting으로 앞에서 쳐내고, 정상 유저만 대기열에 태우는 조합이 현실적이다. 거부는 기다릴 의사가 없는 대상에게, 대기열은 기다려서라도 얻고 싶은 게 있는 사람에게.
4. 대기열을 설계하다
Kafka 있는데 왜 또 만드나
설계 전에 걸리는 게 하나 있었다. 얼마 전에 선착순 쿠폰을 Kafka로 버퍼링하는 구조를 만들어봤다. 요청을 큐에 쌓고 Consumer가 순차 처리하는 구조. 이미 줄 세우기를 하고 있는 것 아닌가? 왜 또 만들지?
비교해보니 다른 물건이었다. 쿠폰은 fire & forget이었다. 유저는 신청을 던지고 화면을 떠나도 되고, 결과는 나중에 확인하면 된다. 주문은 다르다. 유저가 화면 앞에서 기다리고 있다. 지금 몇 번째인지, 얼마나 남았는지를 알고 싶어 한다.
제어하려는 대상도 다르다. Kafka 버퍼링에서 내가 신경 쓴 건 처리 순서와 유실 방지였다. 대기열에서 제어하려는 건 처리 속도다. 하류가 감당하는 속도로 입장을 조절하는 것, 그리고 기다리는 사람에게 피드백을 주는 것. 같은 "줄"이어도 풀려는 문제가 달랐다.
만들려는 것의 전체 그림은 이렇다.
[유저] ──① POST /queue/enter──▶ [대기열 · Redis Sorted Set]
│ │
│◀─② GET /queue/position (polling)─────┤
│ "512번째, 약 3초" │
│ [스케줄러] ──③ 주기적으로 N명 pop
│ │
│◀────④ 입장 토큰 발급 (TTL 5분)────────┘
│
└─⑤ POST /orders + 토큰──▶ [주문 API] ──▶ [DB / PG]
이 그림의 부품 하나하나가 그냥 정해진 게 아니었다. 부품마다 붙은 의심은 다음 장에 따로 모았고, 여기서는 설계 자체를 먼저 훑는다.
Redis Sorted Set
대기열 구현체로는 Redis Sorted Set을 썼다. 이름은 대기열인데 자료구조는 큐가 아니다.
처음엔 진짜 큐를 생각했다. Redis List에 LPUSH로 넣고 RPOP으로 빼면 FIFO가 공짜니까. 근데 이 시스템에서 제일 많이 불리는 연산이 뭔지 생각해보니 넣고 빼기가 아니었다. 수만 명이 몇 초마다 던지는 "내가 몇 번째냐"는 조회다. List에서 특정 유저의 위치를 찾으려면 앞에서부터 전부 훑어야 해서 O(N)이고, 대기 인원이 늘수록 조회 자체가 병목이 된다. Sorted Set의 ZRANK는 O(log N)이다. 자료구조는 가장 빈번한 연산에 맞춰 고르는 거였다.
ZADD waiting-queue {timestamp} {userId} // score = timestamp(진입 시각)
ZRANK waiting-queue {userId} // 내 순번
ZCARD waiting-queue // 전체 대기 인원
ZPOPMIN waiting-queue {N} // score가 가장 작은 N명 입장
score를 진입 시각으로 두면 먼저 온 사람이 앞 순번이 되고, 각 연산이 원자적이라 동시 진입에도 순서가 꼬이지 않는다. 예상 못 한 덤도 있었다. member가 userId라서, Set의 특성상 같은 유저가 두 번 진입하면 자동으로 덮어써진다. 중복 진입 방지를 따로 구현하려고 했는데 자료구조가 공짜로 해결해줬다. 이런 건 기분이 좋다.
입장 — 스케줄러와 토큰
입장은 스케줄러가 맡는다. 일정 주기로 ZPOPMIN으로 앞에서 N명을 꺼내 입장 토큰을 발급하고, 토큰에 TTL을 걸어 안 쓰면 만료시킨다. 토큰이 있는 유저만 주문 API를 통과한다. 하류 처리량 250 TPS에 안전 마진 70%를 잡아 175 TPS를 입장 속도로 정했다.
예상 대기 시간 알려주기
유저는 단순히 "대기 중입니다."보다 "현재 512번째, 약 3분 남았습니다."를 훨씬 궁금해한다.
기다리는 것 자체보다, 얼마나 기다려야 하는지 모르는 것이 더 답답하기 때문이다.
그래서 예상 대기 시간을 계산하는 식이 필요했다.
예상 대기 시간 = 내 순번 / 초당 처리량
순번 512에 초당 175명이면 약 3초. 만들고 보니 너무 당연한 식이라 시시했는데, 나중에 자료를 뒤적이다가 이 식에 이름이 있다는 걸 알았다.
Little's Law. 시스템 안의 평균 개체 수 L, 유입률 λ, 평균 체류 시간 W 사이에 L = λW가 성립한다는 정리다.4 내 식은 이걸 W = L/λ로 뒤집은 것과 같았다. 1961년에 증명된 법칙을 계산기 두드리다 재발명한 셈이다. 머쓱하면서도, 손으로 만든 식이 이론과 만나는 순간은 꽤 즐거웠다.
이 식으로 적체도 계산해봤다. 유입 10,000 TPS, 입장 175 TPS면 초당 9,825명이 쌓인다.
피크가 10초만 유지돼도 대기열에 약 10만 명. 이걸 175 TPS로 다 빼려면 약 570초, 9분 30초다.
이 숫자를 보고 잠깐 고민했다. 대기열은 시스템을 지켜준다. 근데 마지막 유저는 9분 30초를 기다린다. 유저가 그만큼 기다려줄 것인가는 기술이 답할 수 있는 질문이 아니었다.
한정판 스니커즈면 기다린다. 생수 한 박스면 떠난다. 대기열을 쓸 수 있느냐는 결국 유저에게 기다릴 이유가 있느냐는 비즈니스 질문에 달려 있다.
5. 설계하며 생긴 고민들
설계는 위처럼 진행했는데, 설계하는 동안 머릿속은 늘 고민의 연속이었다. 설계 하나하나마다 "이게 맞나" 싶은 지점이 하나씩 생겼다.
이 순서, 진짜 순서 맞나
진입 시각으로 순서를 정하면, 먼저 들어온 사람이 앞 순번을 받는다. 그렇게 구현했다고 쓰고 나서 문득 고민이 생겼다.
먼저 들어온 사람이라는 게 정확히 누구지. 먼저 버튼을 누른 사람인가, 요청이 먼저 도착한 사람인가.
둘은 다를 수 있다. 먼저 누른 사람의 패킷이 유실돼 재전송이 걸리면, 나중에 누른 사람의 요청이 먼저 도착한다.
서버 안에서도 뒤집힐 수 있다. 먼저 도착한 요청이 스레드를 먼저 배정받아도, CPU 코어가 어떤 상태냐에 따라 나중 요청이 먼저 처리를 마칠 수 있다.
그럼 클라이언트가 버튼 누른 시각을 찍어서 보내면? 클라이언트가 보내는 값은 전부 조작 가능하다. 결국 서버가 신뢰할 수 있는 시각은 자기한테 도착한 시각뿐이었다.
이게 내 구현이 어설퍼서 생기는 문제인 줄 알았는데, 찾아보니 근본 문제였다. Lamport가 1978년에 정리한 게 정확히 이 얘기다.5 분산 시스템에는 전역 시계가 없어서, 서로 다른 지점에서 일어난 사건들의 선후 관계는 부분적으로만 정의된다. "누가 먼저 눌렀는가"는 시스템이 관측할 수 없는 사실이다. Lamport가 이 아이디어를 특수상대성이론에서 가져왔다는 대목이 특히 재밌었다. 관측자에 따라 사건의 순서가 다르게 보일 수 있다는 것 — 물리학이 먼저 겪은 문제를 분산 시스템이 다시 겪고 있었다.
그래서 인정해야 했다. 내가 만든 건 공정한 대기열이 아니라 도착 순서 대기열이다. 이 간극은 없앨 수 없고, 줄일 수는 있다. 실시간을 포기하면 된다. 요청을 일단 다 받아두고, 일정 타임 윈도우 안에 들어온 요청들을 나중에 모아서 정렬하면 네트워크 지연 수준의 역전은 흡수된다.
공정성과 실시간성이 트레이드오프 관계였던 거다. 커머스 대기열은 실시간 쪽을 택하고 간극을 감수한다.
이 토큰, 어디서 본 구조 같은데
토큰 쪽을 설계하다가 기시감이 들었다. "동시에 N명까지만 통과시키고, 빠져나온 만큼 다시 들여보낸다" - 이거 어디서 봤더라.
세마포어였다. 다익스트라가 1965년에 정리한 그 동시성 도구.6 토큰 발급이 P(획득)고, 주문 완료 후 삭제가 V(반납)다. 스레드 자리에 유저가, 임계 구역 자리에 주문 API가 들어갔을 뿐 구조가 같다. 이벤트를 공부할 때 인터럽트의 비결정론으로 만났던 다익스트라를 대기열에서 또 만난 게 좀 웃겼다.
근데 같은 구조를 네트워크 너머로 옮기면 새 문제가 생긴다. 프로세스 안의 세마포어는 획득한 스레드가 반드시 반납한다. 스레드가 죽으면 프로세스도 같이 죽으니, "획득만 하고 사라지는" 경우가 관리 범위 안에 있다. 유저는 아니다. 토큰을 받아놓고 브라우저를 닫아버리면 반납은 영원히 안 온다. 게다가 앞에서 봤듯 HTTP는 무상태라서, 서버는 그 유저가 떠났다는 사실조차 모른다. 반납을 기다리기만 하는 세마포어는 분산 환경에서 자리가 하나씩 영구 증발하는 구조가 된다.
해법은 소유권에 기한을 다는 거였다. 자원을 "줄게"가 아니라 "5분 동안만 줄게"로 바꾸는 것. 분산 시스템에서는 이걸 lease라고 부르고, 1989년에 정리된 아이디어다.7 내가 토큰에 건 TTL이 정확히 이거였다. 처음엔 TTL을 그냥 만료 옵션 정도로 생각했는데, 다시 보니 죽었는지 살았는지 물어볼 수 없는 소유자에게서 자원을 회수하는 유일한 방법이었다.
왜 250을 다 안 쓰지
앞에서 계산한 대로 시스템은 약 250 TPS를 처리할 수 있었다. 그런데 실제 입장 속도는 175 TPS로 잡았다. 최대 성능보다 30%나 낮다.
처음에는 아까웠다. 240 TPS쯤 넣으면 대기열도 훨씬 빨리 빠질 텐데 왜 일부러 남겨둘까.
찾아보니 이유는 단순했다. 요청은 일정하게 들어오지 않는다. 평소에는 한가하다가도 어느 순간 우르르 몰린다. 처리량을 한계까지 꽉 채워두면 이런 순간의 요청을 흡수하지 못하고 대기열이 빠르게 쌓인다.
반대로 여유를 남겨두면 순간적으로 몰린 요청을 바로 흡수할 수 있다. 놀고 있는 것처럼 보이던 75 TPS는 사실 버려지는 처리량이 아니라, 급격한 트래픽을 흡수하는 완충재였다.
그제야 생각이 바뀌었다. 놀고 있는 것처럼 보이던 75 TPS는 버려지는 처리량이 아니었다. 순간적으로 몰리는 요청을 흡수하기 위해 남겨둔 여유이다.
물론 이 숫자는 어디까지나 추정이다. 실제 운영에서는 예상과 다른 트래픽이 들어올 수도 있고, 시스템 상태도 계속 변한다. 결국 중요한 건 175 TPS를 맞히는 것이 아니라, 운영 중에도 입장 속도를 유연하게 조정할 수 있는 설계가 아닐까?
왜 빈자리만큼 정확히 안 넣지
설계하면서 이상했던 게 하나 더 있다. 게임 서버의 대기열은 채널 정원이 정확하게 관리된다. 나간 사람 수만큼 들여보내면 하류의 동시성이 딱 유지될 텐데, 왜 웹 대기열은 "일정량을 꾸준히 붓는" 방식으로 갈까. 처음엔 그냥 구현이 편해서라고 생각했다.
파보니 프로토콜 문제였다. 빈자리만큼 정확히 넣으려면 "누가 나갔는지"를 알아야 한다. 게임은 소켓이 계속 열려 있어서 연결이 끊기는 순간 이탈을 즉시 안다. HTTP는 요청-응답 한 번으로 끝나는 무상태 프로토콜이다. 응답을 보내고 나면 그 유저가 브라우저를 껐는지, 아직 화면 앞에 있는지 서버는 알 길이 없다. 빈자리를 셀 수가 없는 거다. 이탈을 알아내겠다고 유저마다 연결을 유지하면, 그 연결 유지 자체가 대기 인원 수만큼의 부하가 된다. 알 수 없는 것을 알아내는 비용이 너무 비싸서, 아예 모른 채로 동작하는 전략, 일정량 붓기를 택하는 거였다.
HTTP가 무상태로 설계된 건 1990년대의 결정이다.
Fielding은 REST를 정리하면서, 서버가 클라이언트 상태를 들고 있지 않아야 확장 가능하다는 내용인데, 궁금하면 따로 확인해보시길...8
6. 대기열이 만든 새로운 문제들
여기까지 만들고 끝난 줄 알았다.
대기열도 만들었고, Redis도 붙였고, DB도 살렸다. 이제 해결된 줄 알았다.
그런데 운영 관점에서 보니 새로운 문제가 하나씩 보이기 시작했다. 대기열은 문제를 없애는 장치가 아니라, 문제를 다른 형태로 바꾸는 장치였다.
그 과정에서 세 가지를 다시 고민하게 됐다.
문제 1. 출구에서 다시 몰린다
스케줄러는 1초마다 대기열에서 175명에게 토큰을 발급한다.
문제는 그 다음이었다.
매 1초 → 175명 동시 토큰 발급
→ 175명 동시 POST /orders
→ DB 커넥션 175개 동시 요청 (풀은 50개)
대기하던 사람들은 토큰을 받는 순간 거의 동시에 주문 버튼을 누른다.
결국 입구에서 막았던 트래픽이 출구에서 다시 만들어졌다.
처음에는 10,000명이 한꺼번에 몰렸고, 지금은 175명이 몰린다. 규모는 작아졌지만 구조는 같다. 이 현상을 Thundering Herd라고 부른다.
조금 허무했다.
해결했다고 생각했는데, 부하를 없앤 게 아니라 작게 접어서 출구로 옮겨놓은 것뿐이었다.
해법도 단순했다.
175명을 한 번에 풀지 않고 100ms마다 17~18명씩 나눠 토큰을 발급한다. 그러면 순간적인 DB 커넥션 수요가 커넥션 풀이 감당할 수 있는 수준으로 낮아진다.
여기에 토큰 활성화 시점을 0~2초 정도 무작위(jitter)로 흩어 주면 요청이 더욱 자연스럽게 분산된다. 재시도 폭풍에 jitter를 적용하는 것과 같은 원리다.
마지막으로 주문 API에도 Rate Limit을 둔다. 토큰이 있더라도 초당 N건까지만 처리하도록 하면, 대기열에 문제가 생기더라도 주문 서버까지 과부하가 전파되지 않는다.
문제 2. 부하는 사라지지 않는다
두 번째 문제는 더 눈에 띄지 않았다.
대기 중인 사용자는 계속 자신의 순번을 확인한다.
대기 인원이 10,000명이고 2초마다 Polling하면 초당 5,000건의 조회가 발생한다.
숫자만 보면 다시 원점으로 돌아간 것처럼 보인다.
하지만 자세히 보면 전혀 다른 부하다.
원래의 초당 10,000건은 DB 트랜잭션과 결제가 포함된 무거운 요청이었다.
지금의 초당 5,000건은 Redis에서 순번 하나 조회하는 가벼운 요청이다.
그때 깨달았다. 대기열은 부하를 없애는 기술이 아니다.
비싼 부하를 싼 부하로 바꾸는 기술이다.
물론 싼 부하도 계속 쌓이면 문제가 된다.
그래서 순번에 따라 Polling 주기를 다르게 했다.
- 순번 1~100: 1초
- 순번 1000 이후: 5초
8,000번째 사람이 매초 자신의 순번을 확인할 이유는 없으니까.
SSE도 고려했으나,, 너무 과한거 같아서 패스..
문제 3. 너무 오래 기다리는 것도 장애다
마지막 문제는 시간이 만든다.
4장에서 계산한 대기 시간에는 하나의 가정이 있었다.
피크가 10초 정도라는 가정이다.
그런데 유입이 한 시간 동안 계속되면 어떻게 될까?
대기열에 100만 명이 쌓이면 마지막 사용자의 예상 대기 시간은 약 95분이 된다.
시스템은 정상이다.
순번도 정확하다. 하지만 95분을 기다려야 하는 서비스가 정말 정상일까?
사용자 입장에서는 그것도 장애다.
네트워크도 같은 문제를 먼저 겪었다.
버퍼를 계속 늘리자 패킷은 버려지지 않았지만, 큐 안에서 오래 머물며 지연만 커졌다. 이를 Bufferbloat라고 한다.
이후 등장한 CoDel은 큐의 길이가 아니라 얼마나 오래 기다렸는지를 기준으로 일부를 버리기 시작했다.
큐도 건강하려면 버릴 줄 알아야 했던 것이다.
대기열도 마찬가지다.
상한 없는 대기열은 부하를 지연으로 바꾸기만 하다가, 결국 그 지연 자체가 장애가 된다. 그래서 대기열에도 상한이 필요하다.
최대 인원이든, 최대 예상 대기 시간이든 기준을 넘으면 진입 자체를 거부해야 한다. 여기까지 오고 나니 3장에서 그렸던 그림이 뒤집혔다.
처음에는 거부(Rate Limiting)와 보관(Queue)을 서로 다른 선택지라고 생각했다. 하지만 끝까지 가보니 둘은 연결되어 있었다.
보관의 끝에는 반드시 거부가 있어야 한다.
대기열은 Rate Limiting의 대체재가 아니라, 거부선을 조금 뒤로 미뤄 주는 장치였다. 결국 중요한 건 대기열을 만들지 말지가 아니라, 어디까지 기다리게 하고 어디서부터 거부할 것인지를 결정하는 일이었다.
7. 지금 정리되는 것들
처음 질문으로 돌아가면 — 트래픽 100배는 서버 100배로 해결되는 문제가 아니었다. 이유도 이제 말할 수 있다.
스케일아웃은 복제 가능한 자원만 늘린다. stateless 서버는 복제된다. 강한 일관성이 필요한 DB 쓰기와 외부 PG는 복제가 안 되거나 권한 밖이다. 병목은 복제 안 되는 자원에서 생기고, 그래서 서버를 아무리 늘려도 250 TPS라는 천장은 그대로다. 짧고 높은 피크 앞에서는 오토스케일링의 반응 속도도 못 따라간다.
그래서 방향을 뒤집는다. 처리 능력을 유입에 맞추는 게 아니라, 유입을 처리 능력에 맞춘다. 하류의 속도가 상류의 속도를 정하는 것 —> back-pressure. TCP가 전송 계층에서 이미 하던 일을 서비스 계층에서 다시 하는 거다. 다만 상대가 사람이라 거부(429) 대신 줄 세우기를 쓴다. 사람은 거부당하면 재시도로 답하니까.
이 아이디어의 계보를 늘어놓으면, 전에 이벤트를 공부할 때 봤던 것과 같은 그림이 또 나온다. 같은 아이디어가 계층을 타고 올라온다.
| 시기 | 계층 | 형태 | 신호 |
|---|---|---|---|
| 1981 | 전송 계층 | TCP 수신 윈도우2 | "이만큼만 보내" |
| 1986 | 통신망 | 리키 버킷9 | "일정 속도로만 흘려" |
| 2015 | 애플리케이션 | Reactive Streams back-pressure10 | "요청한 만큼만 줘" |
| 지금 | 사람 | 대기열 | "현재 512번째입니다" |
전송 계층에서 통신망으로, 애플리케이션으로 올라온 back-pressure가 마지막에 사람을 만났다. 기계는 신호를 따르는데 사람은 안 따르니까, 신호 대신 줄을 세운다. 대기열은 이 계보의 맨 끝에 있는, 프로토콜을 안 지키는 수신자를 위한 back-pressure였다.
대기열은 부하를 없애지 않는다. 형태를 바꾼다. 이번 공부에서 얻은 가장 큰 문장이다. 시간 축으로 보면 피크를 깎아 옆으로 펼치는 거라 총량은 그대로다.
유입: ▁▁▁████████▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 10,000 TPS × 10초
처리: ▁▁▁▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂ 175 TPS × 570초
높이는 낮아지고 옆으로 길어질 뿐, 면적(총 부하)은 같다
종류 축으로 보면 비싼 주문 부하가 싼 조회 부하로 교환된다. 그리고 출구에서는 Thundering Herd라는 원래 문제의 축소판이 태어난다. 없애는 게 아니라 감당 가능한 형태로 계속 바꿔치기하는 것 — 그게 대기열이 하는 일의 전부였다.
보관은 거부의 대체재가 아니었다. 상한 없는 대기열은 부하를 지연으로 바꾸다가 그 지연 자체가 장애가 된다. 대기열이 하는 일은 거부를 없애는 게 아니라 거부선을 뒤로 미루는 것이고, 선 너머에서는 결국 거부해야 한다. 3장에서 갈라놨던 두 전략이 끝에서 다시 만났다.
여유는 낭비가 아니었다. 사용률과 대기 시간은 선형이 아니라 쌍곡선 관계고, 100%에 다가갈수록 대기는 발산한다. 마진 30%는 버리는 처리량이 아니라 랜덤한 몰림을 흡수하는 완충재이자, 대기 시간을 사는 비용이다.
공정성도 마찬가지로, 보장되는 게 아니라 정의되는 것이었다. "먼저 온 사람"은 시스템이 관측할 수 없는 사실이고, 우리가 보장할 수 있는 건 도착 순서까지다. 그 이상의 공정성을 원하면 실시간성을 내주고 사야 한다.
그리고 이 시스템에 정답인 숫자는 없다. 250도 175도 "평균 처리 시간 200ms"라는 가정 위의 값이고, 그 가정은 행사 당일 가장 먼저 무너진다. 값을 정확히 맞추는 것보다 값을 라이브로 바꿀 수 있는 구조가 먼저다. 계산은 초기값이고, 설계는 조절 손잡이를 다는 일이었다.
참고 문헌
Footnotes
-
Brett Wooldridge, "About Pool Sizing," HikariCP Wiki. 커넥션 수와 처리량이 비례하지 않는 이유와 코어 수 기반 적정 풀 사이즈 공식. https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing ↩
-
RFC 9293, "Transmission Control Protocol (TCP)," IETF, 2022. 수신 윈도우 기반 흐름 제어(flow control) 명세. https://www.rfc-editor.org/rfc/rfc9293 ↩ ↩2
-
Marc Brooker, "Timeouts, retries, and backoff with jitter," Amazon Builders' Library. 재시도가 부하를 증폭시키는 문제와 backoff/jitter 처방. https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/ ↩
-
J.D.C. Little, "A Proof for the Queuing Formula: L = λW," Operations Research, vol. 9, no. 3, 1961, pp. 383–387. ↩
-
Leslie Lamport, "Time, Clocks, and the Ordering of Events in a Distributed System," Communications of the ACM, vol. 21, no. 7, 1978, pp. 558–565. 전역 시계 없는 분산 시스템에서 사건의 선후는 부분 순서로만 정의됨을 정립한 논문. ↩
-
E.W. Dijkstra, "Cooperating Sequential Processes" (EWD 123), 1965. 세마포어와 P/V 연산의 원전. ↩
-
Cary G. Gray and David R. Cheriton, "Leases: An Efficient Fault-Tolerant Mechanism for Distributed File Cache Consistency," Proceedings of SOSP, 1989. 기한부 소유권(lease) 개념. ↩
-
Roy T. Fielding, "Architectural Styles and the Design of Network-based Software Architectures," Doctoral dissertation, UC Irvine, 2000. REST의 stateless 제약과 확장성의 관계. ↩
-
J.S. Turner, "New Directions in Communications (or Which Way to the Information Age?)," IEEE Communications Magazine, vol. 24, no. 10, 1986. 통신망 트래픽 제어를 위한 리키 버킷 알고리즘 제안. ↩
-
Reactive Streams Specification 1.0, 2015. 비동기 스트림 처리에서 수요 기반(back-pressure) 흐름 제어를 표준화. https://www.reactive-streams.org ↩