감당 가능한 처리량은 서버가 버티는 최대치가 아니라 목표 지연을 지키면서 유지되는 도착률입니다.
최대 처리량 한 줄이 위험한 이유
부하 테스트 보고서에 "최대 50,000 TPS"만 남아 있으면 운영에 쓸 수 없습니다. 그 값은 대개 지연이 이미 무너진 지점에서 측정되기 때문입니다. 포화점은 감당 가능한 한계선과 다른 숫자입니다.
도착률이 처리량을 넘어서는 순간 초과분은 매초 큐에 적체됩니다. 도착률 15,000/초를 처리량 10,000/초로 받으면 매초 5,000건이 밀립니다.
60초 뒤면 30만 건입니다. CPU와 메모리에 여유가 있고 HTTP 200이 나가더라도, 백로그가 계속 자라는 시스템은 이미 장애 상태입니다.
Kafka나 SQS 같은 버퍼가 앞단에 있으면 이 위험을 알아차리기 어렵습니다. producer가 20,000/초를 넣는데 consumer가 15,000/초만 처리하면 애플리케이션 지표는 건강해 보입니다.
그동안 lag은 시간당 1,800만 건씩 쌓입니다. 겉보기 정상이 대응 시점을 늦춰 오히려 더 위험합니다.
그래서 처리량을 하나의 숫자로 뭉뚱그리지 않고 계층별로 나눠 측정해야 합니다. 들어온 양, 처리를 시작한 양, 완료되어 확정된 양, 실패한 양, 재시도로 되돌아온 양, 아직 처리하지 못한 적체량을 따로 봅니다. 특히 적체량의 기울기는 CPU 사용률보다 훨씬 먼저 움직이는 신호이므로 오토스케일과 부하 차단의 1차 트리거로 쓸 만합니다.
수용 가능 처리량의 정의
수용 가능 처리량은 정해진 목표를 모두 만족하면서 지속 상태로 처리할 수 있는 최대 도착률입니다. 지연 조건 하나가 아니라 제약식의 집합으로 묶어야 의미가 생깁니다.
처리량만 적힌 서비스 수준 목표(Service Level Objective, SLO)는 쓸모가 없습니다. 서버가 큐잉으로 버티기만 하면 처리량은 채워도 사용자 경험은 무너지기 때문입니다.
아래 임계값은 설명용 예시이며 서비스의 목표로 바꿔야 합니다.
수용 가능 처리량 = max throughput
subject to:
p95 latency <= 100 ms
p99 latency <= 300 ms
error rate < 0.1 %
CPU < 75 %
DB pool saturation < 80 %
queue lag < 10 sec부하가 올라갈수록 평균 지연은 완만히 증가하지만 꼬리 지연은 먼저 급증합니다. 한계선은 p99가 목표 임계를 넘기 직전의 처리량으로 정해야 합니다. 이 기준이 평균 지연 기준보다 실제 사용자 경험에 가깝습니다.
보고서에도 조건과 지속 시간을 함께 적어야 운영 데이터가 됩니다. "지속 가능 18,000 TPS, p95 80ms 미만, p99 200ms 미만, 오류율 0.01% 미만, 30분 지속 확인"처럼 적습니다.
숫자를 적기 전에 단위부터 합의해야 합니다. 세 지표는 실무에서 자주 혼용되지만 측정 계층이 다릅니다.
| 지표 | 풀네임 | 세는 단위 | 계층 |
|---|---|---|---|
| RPS | Requests Per Second | 개별 HTTP 요청 수 | 가장 원시적. 목적 무관 |
| QPS | Queries Per Second | 쿼리·조회 요청 수 | RPS와 거의 동의어. DB·검색 문맥에서 선호 |
| TPS | Transactions Per Second | 완결된 비즈니스 트랜잭션 수 | 최상위. 1건이 요청 여러 개일 수 있음 |
차이는 트랜잭션 1건이 요청 몇 개로 구성되는가입니다. 사용자 액션 하나가 요청 하나면 세 지표가 같습니다. 주문 한 번이 재고 확인과 결제와 알림 3개 API를 부르면 1 TPS가 3 RPS가 됩니다. 어느 계층으로 목표를 잡았는지 합의하지 않으면 아무리 정밀하게 측정해도 서로 다른 것을 세게 됩니다.
리틀의 법칙과 피크 환산
리틀의 법칙은 안정 상태에서 처리량, 평균 체류 시간, 평균 동시 요청 수를 연결합니다. 아래 계산은 설명용 가정이며, 지연과 동시성을 같은 시스템 경계에서 측정해야 합니다.
L = λ × W
L : 시스템 안에 동시에 존재하는 요청 수 (concurrency, in-flight)
λ : 처리량 (throughput, RPS/TPS)
W : 요청 하나가 시스템에 머무는 시간 (latency, 초)
예) λ = 500 RPS, W = 0.2 s → L = 평균 100건이 시스템 안에 존재
역산 λ_max = L_max / W
워커 200개, 서비스 시간 50ms → 200 / 0.05 = 4,000 TPS/인스턴스지연은 동시성의 승수입니다. 슬로우 쿼리로 W가 2배가 되면 처리량이 그대로여도 in-flight가 2배로 불어납니다. 그러면 CPU가 아니라 스레드 풀과 커넥션 풀이 먼저 고갈됩니다.
목표 처리량은 평균이 아니라 피크로 잡습니다. 트래픽은 하루 중 고르지 않기 때문에, 아래 예시에서는 트래픽의 80퍼센트가 20퍼센트 시간대에 몰린다고 가정합니다. 실제 비율은 서비스의 관측값으로 바꿔야 합니다.
DAU = 1,000,000
사용자당 요청/일 = 50
일 총요청 = 50,000,000
평균 RPS = 50,000,000 / 86,400s ≈ 580 RPS
피크 윈도우 = 0.2 × 86,400s = 17,280s
피크 트래픽 = 0.8 × 50,000,000 = 40,000,000
피크 RPS = 40,000,000 / 17,280s ≈ 2,315 RPS
피크 계수 ≈ 2,315 / 580 ≈ 4배
순간 스파이크 ×1.5~2 → 설계 목표 ≈ 3,500~4,600 RPS피크 계수는 서비스 성격과 이벤트 일정에 따라 달라집니다. 업종별 고정 배수를 적용하지 않고 과거 트래픽으로 추정해야 합니다. 티켓팅처럼 일시에 몰리는 서비스는 별도 스파이크 시나리오로 산정합니다.
서버 대수도 가정에 따라 추정할 수 있습니다. 다음 예시의 70% 목표와 장애 여유는 설명을 위해 선택한 값입니다. 피크 3,000 RPS에 평균 지연 200ms면 in-flight는 600건입니다.
인스턴스당 워커 200개를 70퍼센트만 채우면 유효 동시성이 140이므로 올림하여 5대가 필요합니다. 여기에 장애와 배포 여유로 23대를 더하는 시나리오라면 총 78대입니다.
서버를 더 늘린다고 처리량이 선형으로 늘지는 않습니다. Neil Gunther의 범용 확장성 법칙(Universal Scalability Law, USL)은 노드 수 N에 대한 상대 처리량을 모델링합니다. 식은 C(N) = N / (1 + α(N−1) + βN(N−1))이고, α는 직렬화 구간의 경합, β는 노드 간 데이터 동기화 비용입니다.
β > 0이고 0 ≤ α < 1이면 모델의 연속 최댓값은 N* = √((1−α)/β)에서 나옵니다. 그 이후에는 모델상 처리량이 감소하므로 실측 데이터로 계수를 추정해야 합니다. 락과 분산 합의를 줄이지 않은 채 대수만 늘리면 확장 비용이 처리량 증가분을 상쇄할 수 있습니다.
지연 목표로 정하는 이용률 상한
M/M/1은 포아송 도착, 지수분포 서비스 시간, 서버 하나를 가정하는 대기행렬입니다. 평균 응답시간은 R = S / (1 − ρ)이며 S는 평균 서비스 시간, ρ는 이용률입니다. 이 식은 ρ < 1인 안정 상태에 적용됩니다(MIT 강의 자료).
| 이용률 ρ | 평균 응답시간 R |
|---|---|
| 0.50 | 2.0 S |
| 0.70 | 약 3.3 S |
| 0.80 | 5.0 S |
| 0.90 | 10 S |
| 0.95 | 20 S |
| 1.00 | 유한한 정상 상태 평균 없음 |
표는 위 수식에서 계산한 값입니다. 이용률 70%에서도 평균 응답시간은 서비스 시간의 약 3.3배이므로 지연이 평평하다고 볼 수 없습니다. 어디를 운영 상한으로 삼을지는 목표 지연에 달려 있습니다.
예를 들어 평균 서비스 시간 S = 10ms, 평균 응답시간 목표 R ≤ 40ms라고 가정합니다. 식을 풀면 ρ ≤ 0.75가 되지만, 이는 이 모델과 평균 목표에서만 나온 결과입니다. p99 목표나 여러 워커가 있는 실제 시스템에는 부하 테스트가 필요합니다.
포화 처리량의 70~80%를 초기 후보로 시험할 수는 있지만 큐잉 이론이 정한 보편 상한은 아닙니다. 서비스 수준 목표와 장애 시 여유를 만족하는지 측정한 뒤 한계선을 정해야 합니다. 요청별 자원 비용도 다르므로 Google SRE는 QPS만으로 용량을 판단하는 위험을 설명합니다.
다음 표는 한계선을 고르는 방법을 보여주는 가상 예시입니다. 실제 서비스의 측정 결과가 아닙니다.
| 처리량 | p95 | CPU |
|---|---|---|
| 5K | 20 ms | 30% |
| 10K | 25 ms | 45% |
| 15K | 35 ms | 60% |
| 18K | 60 ms | 72% |
| 20K | 200 ms | 82% |
| 22K | 1.5 s | 95% |
이 예시에서 p95 목표가 100ms라면 18K는 통과하고 20K는 실패합니다. 18K에서 20K로 처리량은 약 11.1% 늘지만 p95는 약 233% 증가합니다. 실제 운영 상한은 통과한 부하를 오래 유지하고 장애·재시도 조건에서도 검증한 뒤 정합니다.
CPU 외에도 커넥션 풀 크기가 병목을 만들 수 있습니다. HikariCP 풀 사이징 문서는 PostgreSQL에서 제시된 추정식을 소개합니다. connections = (core_count × 2) + effective_spindle_count 형태입니다.
core_count는 하이퍼스레딩을 제외한 물리 코어이고, effective_spindle_count는 데이터가 완전히 캐시되면 0에 가깝습니다. 4코어에 유효 스핀들 수 1을 대입하면 9커넥션입니다. 같은 문서에는 이 정도 커넥션으로 프론트 사용자 약 3,000명과 6,000 TPS를 처리한 사례가 기록돼 있습니다.
풀은 키울수록 느려지기도 합니다. 같은 위키가 인용한 Oracle 벤치마크에서는 다른 변경 없이 커넥션 풀만 줄였더니 응답시간이 약 100ms에서 약 2ms로 떨어졌습니다.
커넥션이 많을수록 DB 내부의 컨텍스트 스위칭과 락 경합이 심해지기 때문입니다. 원칙은 소수의 커넥션을 포화 상태로 유지하고 나머지 애플리케이션 스레드는 큐에서 대기시키는 것입니다.
풀 크기를 잡을 때 두 가지를 더 확인합니다. 데드락을 피하는 최소값은 pool size = Tn × (Cm − 1) + 1입니다. Tn은 동시 스레드 수, Cm은 스레드 하나가 동시에 필요로 하는 커넥션 수이며, 이 값은 최적값이 아니라 하한입니다.
그리고 DB 최대 커넥션은 전역 자원이므로 인스턴스 수로 나눠야 합니다. 인스턴스 10대가 각자 풀 20을 잡으면 DB에는 200 커넥션이 몰립니다.
스레드 풀은 리틀의 법칙으로 역산합니다. 목표 2,000 TPS에 요청당 서비스 시간 40ms면 80 스레드가 필요하고, 예시로 이용률 목표 70퍼센트를 적용해 약 115 스레드로 잡습니다.
부하 테스트에서 놓치는 것
종이 계산은 병목을 놓치므로 산정한 숫자는 실측으로 검증해야 합니다. 부하의 모양은 세 가지를 조합합니다. 램프업은 부하를 서서히 올려 처리량이 더 늘지 않고 지연만 치솟는 포화점을 찾습니다.
스파이크는 계단식 급증으로 오토스케일링과 리미터의 반응 속도를 봅니다. 소크는 목표 부하를 수 시간 유지해 커넥션과 힙과 파일 디스크립터가 서서히 새는지 잡습니다.
부하 테스트가 실제보다 좋은 숫자를 주는 대표 함정은 Gil Tene이 명명한 조정 누락(Coordinated Omission)입니다. 부하 생성기가 이전 요청이 끝나야 다음 요청을 보내는 폐쇄 모델이면, 서버가 5초 멈춰 있는 동안 보냈어야 할 요청들이 통째로 누락됩니다.
그 결과 나쁜 지연 샘플이 1건만 기록되고 p99가 실제보다 훨씬 좋게 나옵니다. 운영에서 터지는데 테스트 p99는 멀쩡하다면 이것부터 의심합니다.
해결은 요청을 일정 속도로 계속 보내는 개방 모델을 쓰는 것입니다. Gil Tene의 wrk2가 이 방식을 선구했고 Vegeta와 autocannon이 채택했습니다. k6와 Gatling도 도착률 기반 실행기를 제공하므로, 가상 사용자 수를 고정하는 대신 도착률로 부하를 정의해야 조정 누락을 피할 수 있습니다.
| 도구 | 언어·스크립트 | 특징 | 적합 |
|---|---|---|---|
| k6 | Go / JavaScript | 코드 기반, CI 친화적, WebSocket 내장 | 개발자 주도 성능 회귀 테스트 |
| Gatling | Scala·Java DSL | 비동기 엔진, 고품질 HTML 리포트 | 상세 리포트가 필요한 시나리오 |
| Locust | Python | 분산 지원, 낮은 러닝커브, GIL 제약 | 복잡한 사용자 행위 모델 |
| nGrinder | Jython·Groovy | 컨트롤러와 에이전트 구조, 웹 UI | 여러 팀이 공유하는 사내 부하 플랫폼 |
초과분을 흘려보내는 3층 방어선
한계선을 정했으면 초과분을 거절하거나 지연시키거나 완충하는 층을 둡니다. 첫 층은 속도 제한입니다.
| 알고리즘 | 동작 | 버스트 | 용도 |
|---|---|---|---|
| Token Bucket | 토큰이 일정 속도로 충전되고 요청이 소비 | 허용 | 일반 API 기본값 |
| Leaky Bucket | 입력 모양과 무관하게 일정 속도로 유출 | 불허 | 다운스트림 보호, 트래픽 셰이핑 |
| Fixed Window | 고정 시간창 카운터 | 창 경계에서 2배 폭발 | 러프한 제한 |
| Sliding Window Log | 요청 타임스탬프를 모두 기록 | 정밀 제어 | 보안 민감 구간 |
| Sliding Window Counter | 인접 두 창을 가중 보간 | 근사 평활 | 대규모 API 권장 |
분산 환경에서는 Redis의 원자적 카운터로 구현하는 것이 정석입니다. 두 번째 층은 배압과 부하 차단입니다. 배압은 하류가 못 따라올 때 상류에 천천히 보내라는 신호를 전파하는 방식이고, 부하 차단은 감당 못 할 요청을 빠르게 거절해 자신과 하류를 지키는 방식입니다.
오토스케일 환경에서는 고정 RPS 한도가 실제 용량과 금방 어긋납니다. Netflix의 concurrency-limits는 이 문제에 대응하기 위해 지연으로 큐잉을 감지하고 한도를 적응적으로 조절합니다. 여기에 우선순위 기반 부하 차단을 얹어, 재생과 결제 같은 필수 트래픽보다 로깅과 프리페치 같은 비필수 트래픽을 먼저 버립니다. 과부하 중에도 핵심 기능의 지연을 지키는 것이 목적입니다.
세 번째 층은 대기열입니다. 순간 트래픽이 한계선의 수십 배인 이벤트에서는 입장을 직렬화해 하류로 가는 부하를 일정 범위로 잘라 보냅니다.
우아한형제들 기술블로그가 공개한 빼빼로데이 선착순 이벤트는 1,111명 쿠폰이 1.89초에 마감됐고 평시의 100배 트래픽을 받았습니다. Node.js 인스턴스 5대와 Redis, AWS SQS로 참여 기록을 완충하고 쿠폰 발급은 워커로 비동기 분리했습니다.
같은 사례는 복제 지연이 만드는 정합성 문제도 보여줍니다. 마스터의 증가 카운터가 리플리카에 복제되기 전에 초과 참여가 들어와 1,111명을 넘겨 당첨됐습니다. 고속 카운팅은 마스터 한 지점에서 원자적으로 수행하고 판정도 마스터에서 읽어야 정합성이 깨지지 않습니다.
토스 기술블로그의 사례도 결이 같습니다. 피크에 초당 수십만 건이 들어오는 포인트 지급 API는 Redis Increment로 선착순 상한을 관리합니다. 분산 락으로 중복 지급을 막고, Kafka 비동기 입력과 컨슈머 스로틀링으로 DB 부하를 눌렀습니다.
여기에 3개 API를 1개로 통합해 피크 트래픽 규모 자체를 50퍼센트 줄였습니다. 요청량을 줄이는 것도 유효한 초과 대응입니다.
오토스케일은 이 방어선을 대체하지 못합니다. 지연이 급증하기 시작하는 knee 이전 지표에 스케일 트리거를 걸어야 합니다. 그래야 부팅과 워밍업에 걸리는 수십 초에서 수 분 동안에도 여유가 남습니다. 이용률 100퍼센트까지 기다리면 이 여유가 없습니다.
필요한 여유는 트래픽 변동과 기동 시간, 허용할 동시 장애 수로 정합니다. 무엇보다 DB나 외부 결제 같은 상태 있는 하류는 즉시 늘어나지 않으므로, 앞단 웹서버만 늘리면 병목이 하류에서 그대로 재현됩니다.
flowchart LR
A[산정<br/>리틀의 법칙, DAU 환산] --> B[검증<br/>부하 테스트로 포화점 탐색]
B --> C[한계선<br/>지연·오류 목표를 통과한 부하]
C --> D[목표<br/>p99 X ms 이내 N TPS]
D --> E[운영<br/>목표 위반 전 증설, 장애 여유 검증]
E --> F[초과분<br/>속도 제한, 배압, 부하 차단, 대기열]
정리하면 운영 체크리스트는 다음과 같습니다.
- 1건이 무엇인지 먼저 합의합니다. 단위를 못 박지 않으면 팀마다 다른 것을 셉니다.
- 목표를 처리량 단독이 아니라 지연과 오류율 조건에 묶어 적습니다.
- 피크는 평균에 계수를 곱해 잡되, 이벤트성 서비스는 별도 스파이크 시나리오로 산정합니다.
- 부하 생성기는 도착률 기반 실행기로 돌려 조정 누락을 피합니다.
- 한계선은 포화점이 아니라 knee 직전으로 잡고 30분 이상 유지되는지 확인합니다.
- 오토스케일 트리거를 CPU 포화가 아니라 knee 이전 지표와 적체량 기울기에 겁니다.
- 상태 있는 하류로 가는 부하는 속도 제한과 대기열로 상한 안에 묶습니다.
정리
수용 가능 처리량은 목표 지연과 오류율을 지키면서 지속되는 도착률입니다. 리틀의 법칙으로 동시성을 추정하되, 운영 상한은 도착률 기반 부하 테스트와 장애 시나리오로 확인해야 합니다.
포화점의 일정 비율은 시험할 후보일 뿐이며 서비스의 지연 목표를 대신하지 못합니다. CPU와 풀 사용량, 적체량을 함께 관측하고 초과 요청은 속도 제한과 배압, 부하 차단으로 제어합니다.