수용 가능 처리량 산정과 검증
목표 지연을 지키면서 감당할 수 있는 초당 처리량을 리틀의 법칙으로 산정하고, 부하 테스트로 찾은 포화점의 70~80퍼센트를 운영 한계선으로 잡는 절차를 정리합니다.
감당 가능한 처리량은 서버가 버티는 최대치가 아니라 목표 지연을 지키면서 유지되는 도착률입니다.
최대 처리량 한 줄이 위험한 이유
부하 테스트 보고서에 "최대 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피크 계수는 서비스 성격에 따라 크게 벌어집니다. 소셜과 메시징은 평균의 25배, 티켓팅이나 선착순 같은 이벤트성은 2050배까지 갑니다. 두 범위 모두 업계 관례에 가까운 경험칙이므로 자기 서비스의 과거 트래픽으로 다시 재야 합니다. 이벤트성 서비스는 평균에 계수를 곱하는 대신 별도 스파이크 시나리오로 산정하는 편이 안전합니다.
서버 대수는 리틀의 법칙과 결합해 나옵니다. 피크 3,000 RPS에 평균 지연 200ms면 in-flight는 600건입니다. 인스턴스당 워커 200개를 70퍼센트만 채우면 유효 동시성이 140이므로 약 5대가 필요하고, 장애와 배포 여유까지 얹으면 7~8대를 준비합니다.
서버를 더 늘린다고 처리량이 선형으로 늘지는 않습니다. Neil Gunther의 범용 확장성 법칙(Universal Scalability Law, USL)은 노드 수 N에 대한 상대 처리량을 모델링합니다. 식은 C(N) = N / (1 + α(N−1) + βN(N−1))이고, α는 직렬화 구간의 경합, β는 노드 간 데이터 동기화 비용입니다.
β 항 때문에 실제 시스템은 특정 지점을 넘으면 처리량이 오히려 감소하며, 그 꼭짓점은 N* = √((1−α)/β)입니다. 락과 분산 합의를 줄이지 않은 채 대수만 늘리면 확장 비용이 이득을 잡아먹습니다.
포화점의 70퍼센트가 한계선인 이유
M/M/1 대기행렬에서 응답시간 R은 이용률 ρ에 대해 R = S / (1 − ρ)로 커집니다. S는 서비스 시간입니다. 이 식은 이용률이 올라갈 때 지연이 선형이 아니라 비선형으로 폭증한다는 사실을 보여줍니다.
| 이용률 ρ | 응답시간 R | 상태 |
|---|---|---|
| 0.50 | 2.0 S | 여유 |
| 0.70 | 3.3 S | 권장 상한 근처 |
| 0.80 | 5.0 S | knee 진입 |
| 0.90 | 10 S | 위험 |
| 0.95 | 20 S | 임계 |
| 1.00 | 발산 | 붕괴 |
지연은 70퍼센트까지 거의 평평하다가 8085퍼센트를 넘으면 큐가 직관보다 훨씬 빠르게 자랍니다. 이 무릎(knee) 구간 때문에 실무는 평상시 이용률 6070퍼센트를 목표로 잡습니다. 남긴 여유가 스파이크와 페일오버와 재시도 폭주를 흡수하는 완충재입니다. 큐잉 이론의 결론은 수용 가능 처리량을 포화 처리량의 0.7~0.8배로 두라는 것입니다.
실측에서도 knee는 표로 드러납니다. 아래는 어떤 서비스의 부하 테스트 결과입니다.
| 처리량 | 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% |
여기서 22K를 최대 처리량이라고 부르면 안 됩니다. 18K에서 20K 사이에 이미 처리량은 10퍼센트 늘었는데 p95는 60ms에서 200ms로 233퍼센트 뛰었습니다. 실질적 한계선은 18~20K이고 그 위는 큐잉과 락 경합과 커넥션 대기와 재시도가 서로를 증폭시키는 구간입니다.
한계선을 먼저 만드는 것은 대개 CPU가 아니라 풀 크기입니다. HikariCP 위키의 풀 사이징 문서는 PostgreSQL 권장 공식을 제시합니다. connections = (core_count × 2) + effective_spindle_count 형태입니다. core_count는 하이퍼스레딩을 제외한 물리 코어이고, effective_spindle_count는 데이터가 완전히 캐시되면 0에 가깝습니다. 4코어에 HDD 1개면 9~10 커넥션이며, 같은 문서에는 이 정도 커넥션으로 프론트 사용자 약 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의 원자적 카운터로 구현하는 것이 정석입니다. 두 번째 층은 배압과 부하 차단입니다. 배압은 하류가 못 따라올 때 상류에 천천히 보내라는 신호를 전파하는 방식이고, 부하 차단은 감당 못 할 요청을 빠르게 거절해 자신과 하류를 지키는 방식입니다.
Netflix의 concurrency-limits는 고정 RPS 한도가 오토스케일 환경에서 금방 낡는 문제 때문에 지연으로 큐잉을 감지해 한도를 적응적으로 조절합니다. 여기에 우선순위 기반 부하 차단을 얹어, 재생과 결제 같은 필수 트래픽보다 로깅과 프리페치 같은 비필수 트래픽을 먼저 버립니다. 과부하 중에도 핵심 기능의 지연을 지키는 것이 목적입니다.
세 번째 층은 대기열입니다. 순간 트래픽이 한계선의 수십 배인 이벤트에서는 입장을 직렬화해 하류로 가는 부하를 일정 범위로 잘라 보냅니다. 우아한형제들 기술블로그가 공개한 빼빼로데이 선착순 이벤트는 1,111명 쿠폰이 1.89초에 마감됐고 평시의 100배 트래픽을 받았습니다. Node.js 인스턴스 5대와 Redis, AWS SQS로 참여 기록을 완충하고 쿠폰 발급은 워커로 비동기 분리했습니다.
같은 사례는 복제 지연이 만드는 정합성 문제도 보여줍니다. 마스터의 증가 카운터가 리플리카에 복제되기 전에 초과 참여가 들어와 1,111명을 넘겨 당첨됐습니다. 고속 카운팅은 마스터 한 지점에서 원자적으로 수행하고 판정도 마스터에서 읽어야 정합성이 깨지지 않습니다.
토스 기술블로그의 사례도 결이 같습니다. 피크에 초당 수십만 건이 들어오는 포인트 지급 API는 Redis Increment로 선착순 상한을 관리합니다. 분산 락으로 중복 지급을 막고, Kafka 비동기 입력과 컨슈머 스로틀링으로 DB 부하를 눌렀습니다. 여기에 3개 API를 1개로 통합해 피크 트래픽 규모 자체를 50퍼센트 줄였습니다. 요청량을 줄이는 것도 유효한 초과 대응입니다.
오토스케일은 이 방어선을 대체하지 못합니다. 스케일 트리거는 이용률 100퍼센트가 아니라 knee 이전 지표에 걸어야 부팅과 워밍업에 걸리는 수십 초에서 수 분 동안에도 여유가 남습니다. 여유율은 피크 예측치의 1.5~3배를 잡는 것이 관례이며, 트래픽 변동성이 클수록 크게 잡습니다. 무엇보다 DB나 외부 결제 같은 상태 있는 하류는 즉시 늘어나지 않으므로, 앞단 웹서버만 늘리면 병목이 하류에서 그대로 재현됩니다.
정리하면 운영 체크리스트는 다음과 같습니다.
- 1건이 무엇인지 먼저 합의합니다. 단위를 못 박지 않으면 팀마다 다른 것을 셉니다.
- 목표를 처리량 단독이 아니라 지연과 오류율 조건에 묶어 적습니다.
- 피크는 평균에 계수를 곱해 잡되, 이벤트성 서비스는 별도 스파이크 시나리오로 산정합니다.
- 부하 생성기는 도착률 기반 실행기로 돌려 조정 누락을 피합니다.
- 한계선은 포화점이 아니라 knee 직전으로 잡고 30분 이상 유지되는지 확인합니다.
- 오토스케일 트리거를 CPU 포화가 아니라 knee 이전 지표와 적체량 기울기에 겁니다.
- 상태 있는 하류로 가는 부하는 속도 제한과 대기열로 상한 안에 묶습니다.
정리
수용 가능 처리량은 서버가 죽기 직전의 최대치가 아니라 목표 지연과 오류율을 지키면서 지속되는 도착률입니다. 산정은 리틀의 법칙으로 시작해 피크 계수로 환산하고, 검증은 도착률 기반 부하 테스트로 포화점을 찾아 그 70~80퍼센트를 한계선으로 삼습니다. 병목은 대개 CPU가 아니라 풀 크기에서 먼저 생기므로 커넥션 풀은 (core × 2) + spindles 근처에서 시작해 실측으로 튜닝합니다. 초과분은 속도 제한과 배압과 부하 차단과 대기열로 흘려보내고, 오토스케일 트리거는 knee 이전에 겁니다. 마지막으로 적체량의 기울기를 감시 지표에 넣어야 큐 뒤에서 조용히 쌓이는 장애를 놓치지 않습니다.