들어가며
“TPS 5,000 나왔습니다”라는 문장에는 정보가 거의 없다. 무엇을 고정하고 무엇을 밀어 넣었는지가 빠져 있기 때문이다.
같은 시스템, 같은 코드로 측정해도 아래 조건에 따라 결과가 자릿수 단위로 갈린다.
- 동시 사용자 수를 고정했는가, 초당 유입량을 고정했는가
- 부하를 한 번에 몰아넣었는가, 몇 분에 걸쳐 꾸준히 넣었는가
- 요청 키가 한곳에 몰렸는가, 흩어졌는가
- 지연을 언제부터 쟀는가
- 몇 초를 버리고 몇 초를 측정 구간으로 잡았는가
이 글은 그 조건들을 정리한 것이다. 다루는 순서는 다섯 질문이다.
| 질문 | 해당 절 |
|---|---|
| 용어가 정확히 무엇을 뜻하는가 | 용어부터 |
| 부하 도구에 무엇을 입력하고, 무엇이 측정되는가 | 설정하는 값과 측정되는 값 |
| 어떻게 밀어 넣는가 | closed-loop과 open-loop / coordinated omission |
| 어떤 이름으로, 어떤 모양으로 넣는가 | 부하 테스트와 스트레스 테스트 / 부하의 모양 |
| 결과를 어떻게 읽는가 | 환경 고정 / 워밍업 / 결과 읽기 |
이 글은 개념 정리이고, 본문에 나오는 숫자는 설명을 위해 가정한 값이다. 여기 정리한 것을 실제 API에 적용해 측정한 결과는 다음 글에서 다룬다.
용어부터
TPS, RPS, QPS, 지연, 처리량, 동시성. 뜻이 겹쳐 보이지만 서로 다른 축이고, 섞어 쓰면 이후 논의가 전부 어긋난다.
TPS와 RPS는 사실상 같은 말이다
| 약어 | 풀이 | 세는 단위 |
|---|---|---|
| TPS | Transactions Per Second | 트랜잭션(업무 처리 단위) |
| RPS | Requests Per Second | HTTP 요청 |
| QPS | Queries Per Second | 쿼리(주로 DB·검색엔진) |
세는 대상만 다르고 구조는 같다. 단위 시간에 완료된 건수다. HTTP API 하나를 재는 상황에서 요청 1건 = 업무 1건이면 TPS와 RPS는 같은 값이 된다. 이 글도 그 전제로 TPS라고 쓴다.
다만 구분이 필요해지는 경우가 있다.
- 한 번의 사용자 행동이 API를 5번 부른다면, 사용자 관점 TPS는 RPS의 1/5이다
- 요청 1건이 DB 쿼리를 8번 날린다면, DB의 QPS는 RPS의 8배다
용량을 이야기할 때 어느 층의 수치인지 밝히지 않으면 8배 차이가 나는 대화를 하게 된다.
“100 TPS”가 정확히 뜻하는 것
그 1초 동안 응답이 완료된 요청이 100건이라는 뜻이다. 도착한 요청 수가 아니다.
1
2
3
4
├──────────── 1초 ────────────┤
요청 A [───완료───] ← 센다
요청 B [────완료────] ← 센다
요청 C [──── 진행 중 ... ← 안 센다 (아직 안 끝남)
여기서 두 가지가 따라 나온다.
도착과 완료는 다르다. 초당 200건이 도착해도 완료가 50건이면 TPS는 50이다. 나머지 150건은 사라지지 않고 큐에 쌓인다. 이 구분이 뒤에 나올 open-loop 논의의 전부다.
완료 시점을 기준으로 세므로 구간 경계 문제가 생긴다. 응답에 3초 걸리는 요청은 1초 구간 어디에도 온전히 들어가지 않는다. 지연이 측정 구간에 비해 길어지면 “TPS를 무엇으로 나눌 것인가”가 따로 정해야 할 문제가 된다(뒤에서 다시 다룬다).
지연과 처리량은 별개의 축이다
| 지표 | 보는 관점 | 단위 |
|---|---|---|
| 지연 (latency, response time) | 사용자 한 명이 얼마나 기다리는가 | ms |
| 처리량 (throughput, TPS) | 시스템이 얼마나 많이 처리하는가 | 건/초 |
둘은 독립적으로 움직인다. 자주 나오는 조합이 이렇다.
| 지연 | 처리량 | 무슨 상황인가 |
|---|---|---|
| 낮음 | 낮음 | 부하가 적다. 시스템이 놀고 있다 |
| 낮음 | 높음 | 이상적인 상태 |
| 높음 | 높음 | 천장 근처. 큐가 차기 시작했다 |
| 높음 | 낮음 | 병목에 걸렸다. 대부분이 뭔가를 기다리고 있다 |
마지막 줄이 부하 테스트에서 찾으려는 상태다. 처리량은 안 느는데 지연만 오르는 지점이 병목 도달점이고, 그 뒤로 늘어난 시간은 전부 어딘가의 대기열에서 보낸 시간이다.
동시성 — 초당이 아니라 “동시에 몇 건”
세 번째 축이다. 어느 순간에 요청이 들어왔고 아직 응답이 안 나간 건수를 뜻한다. in-flight, 동시 처리 건수, 미완료 요청 수라고도 부르고, 부하 도구에서는 스레드 수나 VU(virtual user) 수로 지정한다. 초당 건수가 아니다.
동시성이 8이라는 것은 서버 입장에서 이렇게 물려 있다는 뜻이다.
1
2
3
4
HTTP 커넥션 8개
→ 서버 워커 스레드 8개 점유
→ DB 커넥션 8개 점유
→ DB 세션 8개가 각자의 쿼리 대기
동시성이 자원 점유량을 정한다. 이 값이 커지면 스레드 풀, 커넥션 풀 같은 한계에 먼저 부딪힌다.
이 셋은 독립이 아니라 하나의 식으로 묶인다. 다음 절이 그 얘기다.
설정하는 값과 측정되는 값
부하 도구를 실행할 때 사람이 입력하는 값은 하나뿐이다. 동시성이거나 유입률이고, 둘 다 지정할 수는 없다. 나머지는 전부 측정 결과로 나온다.
앞 절에서 TPS·지연·동시성이 서로 다른 축이라고 했다. 그런데 셋 다 내 마음대로 정할 수 있는 건 아니다. 부하 도구의 실행 명령을 보면 무엇을 입력하고 있는지가 드러난다.
1
2
3
4
5
6
7
k6 run --vus 8 --duration 20s script.js
# ↑
# 입력: 동시 8건을 유지해달라
# 실행 결과
# http_reqs ............ 1,094 (54.7/s) ← 측정된 TPS
# http_req_duration .... p(50)=146ms ← 측정된 지연
TPS 54.7은 내가 지정한 값이 아니다. 동시성 8을 넣었더니 나온 값이다. 더 큰 TPS를 원한다고 이 자리에 다른 숫자를 적을 방법이 없다. 지정할 수 있는 건 동시성뿐이다.
--vus 8(도구에 따라 --threads=8)은 초당 8건이 아니라 어느 순간에도 8건이 처리 중이라는 뜻이다.
부하 스레드 하나가 하는 일은 이것뿐이다.
1
2
3
while (측정 중) {
요청 전송 ──▶ 응답 올 때까지 블로킹 ──▶ 응답 받음 ──▶ 곧바로 다음 요청
}
sleep이 없다. 응답을 받는 그 순간 다음 요청을 던지므로, 스레드 하나는 쉬는 시간 없이 요청 1건을 계속 물고 있는 상태가 된다. 응답이 30ms 걸린다고 가정하면 이렇게 된다.
1
2
3
4
5
6
7
8
threads=1
스레드A [--30ms--][--30ms--][--30ms--][--30ms--]
→ 1초에 약 33건
threads=2
스레드A [---37ms---][---37ms---][---37ms---]
스레드B [---37ms---][---37ms---][---37ms---]
→ 항상 2건이 겹쳐 있다. 1초에 약 54건
두 번째 줄이 생기자 요청 하나에 걸리는 시간이 30 → 37ms로 늘었다. 두 요청이 같은 자원(락, 커넥션, CPU)을 두고 다투기 시작했기 때문이다.
그래서 처리량은 2배인 66이 아니라 54가 된다. 스레드를 2배로 넣어도 처리량은 2배가 되지 않는다. 늘린 만큼 지연이 같이 늘어나는 몫이 있고, 그 몫은 경합이 심할수록 커진다. 동시성을 계속 올리면 어느 지점부터는 지연이 늘어난 만큼만 상쇄되어 처리량이 아예 평평해진다.
TPS는 정하는 값이 아니라 나눗셈의 결과다
여기서 리틀의 법칙 L = λW가 그대로 성립한다. L은 시스템 안에 있는 건수(동시성), λ는 처리율(TPS), W는 체류 시간(지연)이다.
1
동시성 = TPS × 지연
셋 중 둘이 정해지면 나머지 하나는 자동으로 결정된다. 동시성을 지정하면 지연은 시스템이 정하고, TPS는 그 둘의 나눗셈으로 나온다. 그래서 동시성을 지정하는 방식으로는 “초당 500건을 쏴봐”를 할 수 없다.
내가 입력할 수 있는 값은 두 가지뿐이고, 어느 쪽을 입력하느냐에 따라 나머지가 결정된다.
| 입력하는 값 | 측정되는 값 | 부르는 이름 |
|---|---|---|
동시성 (vus, threads) | TPS, 지연 | closed-loop |
유입률 (rate) | 동시성, 지연 | open-loop |
두 줄에 나온 동시성은 같은 값이다. 시스템 안에 들어와 있고 아직 응답이 안 나간 요청 수, 즉 리틀의 법칙의 L이다. 같은 값인데 한쪽에서는 내가 지정하고, 다른 쪽에서는 결과로 관측된다.
1
2
closed-loop : 동시성을 8로 고정 → 유입률은 시스템이 정한다
open-loop : 유입률을 70/s로 고정 → 동시성은 결과로 나온다 (70 × 지연)
유입률은 평균이라는 점도 짚어둔다. 70/s로 지정했을 때 그게 등간격으로 오는지, 불규칙하게 몰렸다 비는지는 별개의 변수이고 결과를 크게 바꾼다. 이건 뒤의 「부하를 어떤 모양으로 줄 것인가」에서 다룬다.
open-loop에서 동시성이 결과라는 점이 중요하다. 지연이 늘면 L = λW에 따라 동시에 떠 있는 요청 수가 같이 부푼다. 유입 70/s에 지연이 5초면 동시에 350건이 시스템 안에 떠 있다는 뜻이고, 이건 톰캣 스레드나 커넥션 같은 자원 한계에 그대로 부딪힌다. closed-loop에서는 이 수가 내가 정한 값에 묶여 있어 그런 일이 일어나지 않는다.
“초당 500건”을 정하고 싶다면 두 번째 줄을 써야 한다는 뜻이다. 이 차이가 다음 절의 주제다.
이 식은 측정 결과를 검산하는 데도 쓴다. 동시성 32로 돌렸는데 TPS 50 · 지연 200ms가 나왔다면 50 × 0.2 = 10이라 32와 맞지 않는다. 어딘가에서 요청을 빠뜨리고 세고 있다는 신호다.
closed-loop과 open-loop
차이는 한 줄이다. 응답을 기다렸다가 다음 요청을 보내는가, 기다리지 않고 보내는가.
1
2
closed-loop : 요청 → 응답 대기 → 요청 → 응답 대기 → ... (동시성 고정)
open-loop : 요청 → 요청 → 요청 → ... (유입률 고정, 응답과 무관)
closed-loop에서는 서버가 느려지면 유입도 같이 느려진다. 응답을 기다리는 동안 다음 요청이 만들어지지 않으니 자동으로 브레이크가 걸린다. open-loop에서는 서버가 느려져도 유입이 그대로다. 초당 200건으로 설정했으면 서버가 2초씩 걸리든 말든 계속 200건이 들어온다.
유입률을 정하면 동시성이 결과로 나온다
유입률 50/s를 지정했다고 하자. 도구는 20ms마다 한 건씩 발사한다. 앞 요청이 끝났는지는 보지 않는다. 응답이 100ms 걸린다면 시간축에서 이렇게 겹친다.
1
2
3
4
5
6
7
8
t=0ms 요청1 ●━━━━━━━━━━━━━━━━━━━━┫
t=20ms 요청2 ●━━━━━━━━━━━━━━━━━━━━┫
t=40ms 요청3 ●━━━━━━━━━━━━━━━━━━━━┫
t=60ms 요청4 ●━━━━━━━━━━━━━━━━━━━━┫
t=80ms 요청5 ●━━━━━━━━━━━━━━━━━━━━┫
t=100ms 요청6 ●━━━━━ ... (이 순간 요청1이 끝난다)
↑
t=100ms 시점의 세로 단면: 5건이 동시에 떠 있다
t=100ms 이후로는 20ms마다 한 건이 들어오고 한 건이 나가므로 떠 있는 건수가 5로 유지된다. 동시성 = 50/s × 0.1s = 5, 리틀의 법칙 그대로다.
여기서 내가 입력한 값은 50/s 하나다. 5라는 숫자는 어디에도 입력하지 않았다. 지연이 100ms였기 때문에 나온 결과값이다. 서버가 느려져 지연이 400ms가 되면 아무것도 바꾸지 않아도 이렇게 된다.
1
2
3
지연 100ms → 동시성 50 × 0.1 = 5
지연 400ms → 동시성 50 × 0.4 = 20
지연 2s → 동시성 50 × 2 = 100
같은 상황을 closed-loop으로 재면 이 변화가 안 보인다. 동시성 5로 고정해 두면 지연이 400ms가 되는 순간 유입이 12.5/s로 저절로 줄어들 뿐, 떠 있는 건수는 계속 5다.
도구에서도 이 차이가 드러난다. k6의 arrival-rate executor에는 preAllocatedVUs/maxVUs를 적는데, 이건 목표 동시성이 아니라 필요해지면 여기까지 써도 된다는 상한이다. 실제로 몇 개가 바빴는지는 실행 후 vus 지표로 관측한다. 상한이 부족하면 정한 유입률을 못 채우고 dropped_iterations가 찍힌다.
유입률과 TPS는 갈라질 수 있다
유입률은 내가 정한 도착 건수이고, TPS는 측정된 완료 건수다. 시스템이 따라오는 동안에는 두 값이 같고, 상한을 넘기는 순간 갈라진다.
1
2
3
4
처리 상한이 60/s인 시스템
유입 50/s → TPS 50/s 쌓이는 게 없다. 유입률 = TPS
유입 200/s → TPS 60/s 초과분 140/s는 큐에 쌓인다. 유입률 ≠ TPS
그래서 “200 TPS로 테스트했다”는 말은 설정값과 결과값을 구분하지 않은 표현이다. 정확히는 유입률 200/s로 쐈다는 뜻이고, 실제 TPS가 200이었는지는 결과를 봐야 안다. 시스템이 60/s밖에 못 냈다면 그 테스트의 TPS는 60이다.
측정 자체를 틀리는 경우도 같은 지점에서 나온다. 보낸 건수를 시간으로 나눠 TPS라고 적으면 초과 구간에서도 200/s가 찍히지만, 그건 처리량이 아니라 내가 설정한 값을 다시 읽은 것뿐이다. TPS는 응답이 나온 건수로 세야 한다.
실제 워크로드는 어느 쪽인가
| 상황 | 성격 | 이유 |
|---|---|---|
| 사내 배치, 커넥션 수가 고정된 연동 | closed-loop | 클라이언트 수가 물리적으로 고정돼 있다 |
| 앱 사용자 트래픽 (평시) | closed-loop에 가깝다 | 느려지면 사용자도 덜 누른다 |
| 선착순 오픈, 티켓팅, 푸시 발송 직후 | open-loop | 서버 속도와 무관하게 사람이 몰린다 |
| 큐 컨슈머 | 상황에 따라 | 프로듀서가 open-loop이면 컨슈머도 그렇다 |
closed-loop만으로 측정하면 놓치는 것
closed-loop에서는 동시성을 아무리 올려도 큐가 넘치지 않는다. 동시성 128로 걸어두면 128건이 전부 대기 중인 동안 129번째 요청 자체가 생기지 않기 때문이다.
그래서 이런 결과가 나온다.
1
2
동시성 128, 평균 지연 2.5초
→ 타임아웃 0건, 커넥션 거절 0건, 큐 오버플로 0건
지연이 2.5초까지 늘었는데도 실패가 하나도 없다. 시스템이 튼튼해서가 아니라 부하를 주는 방식이 그런 것이다. closed-loop은 유입이 처리 속도를 넘어서는 상태를 만들 수 없다. 늘 자기 처리 속도만큼만 부하가 들어간다.
동시성을 아주 높게 걸면 스레드나 커넥션 풀이 모자라 실패가 나기 시작하지만, 그건 자원 점유량이 한계를 넘은 것이지 큐가 넘친 것이 아니다. 둘의 차이는 뒤의 「부하 테스트와 스트레스 테스트」에서 다룬다.
open-loop에서 천장을 넘기면
비교할 값은 둘이다. 내가 넣는 유입률(초당 들어오는 건수)과 시스템의 처리 상한(초당 처리해낼 수 있는 최대 건수)이다.
| 구간 | 무슨 일이 | 지연 |
|---|---|---|
| 유입률 < 처리 상한 | 유입이 곧 처리량이 된다. 시스템은 놀고 있다 | 평평하다 |
| 유입률 ≈ 처리 상한 | 큐가 생기기 시작한다 | 오르기 시작한다 |
| 유입률 > 처리 상한 | 처리량은 상한에 고정되고, 초과분이 전부 큐에 쌓인다 | 시간에 비례해 계속 자란다 |
대기행렬 이론 문헌에서는 유입률을
λ(람다), 처리 상한을μ(뮤)로 쓴다. 다른 자료를 볼 때를 위해 적어둔다.
세 번째 줄이 핵심이다. 천장을 넘긴 뒤로는 유입을 아무리 늘려도 처리량이 안 늘고 지연만 늘어난다. 그리고 지연은 수렴하지 않는다. 초과 상태가 유지되는 한 큐가 계속 자라므로 지연도 계속 자란다.
이 구간은 동시성을 올려가며 재는 closed-loop 측정에서는 관측되지 않는다. 유입이 알아서 줄어들며 그 지점을 피해 가기 때문이다.
closed-loop은 “이 시스템이 낼 수 있는 처리량”을 재고, open-loop은 “천장을 넘겼을 때 어떻게 무너지는가”를 잰다. 답하려는 질문이 후자인데 전자로 측정하면, 아무 문제가 없다는 결론이 나온다.
같은 시스템을 두 방식으로 재보면
무엇을 입력하고 무엇을 결론으로 얻는지 예시로 보는 편이 빠르다. 하나의 API에 두 방식을 차례로 적용한다고 하자. 아래 수치는 설명용 가상값이지만, 표 안의 값들은 서로 앞뒤가 맞게 잡았다.
1) closed-loop — 동시성을 단계적으로 올린다
각 단계를 20초씩 유지하며 잰다. 입력하는 값은 동시성뿐이다.
| 입력: 동시성 | TPS | 평균 지연 | p99 | 실패 |
|---|---|---|---|---|
| 1 | 33 | 30ms | 45ms | 0 |
| 2 | 54 | 37ms | 60ms | 0 |
| 4 | 78 | 51ms | 90ms | 0 |
| 8 | 96 | 83ms | 150ms | 0 |
| 16 | 102 | 157ms | 280ms | 0 |
| 32 | 104 | 308ms | 620ms | 0 |
| 64 | 103 | 621ms | 1,400ms | 0 |
동시성 16을 넘어서면 TPS가 102 → 104 → 103으로 멈춘다. 반면 지연은 계속 2배씩 늘어난다. 동시성을 더 넣어봐야 대기줄만 길어지고 있다는 뜻이고, 이 지점이 처리 상한이다. (각 줄이 동시성 = TPS × 평균 지연을 만족한다. 64 = 103 × 0.621처럼 검산된다.)
여기서 얻는 결론 — 이 서버 한 대의 처리 상한은 약 100 TPS다. 지연 목표가 100ms 이내라면 동시성 8 부근까지가 쓸 만한 구간이다. 그리고 실패가 한 건도 나지 않았다. 이 방식으로는 무엇이 먼저 터지는지를 알 수 없다.
2) open-loop — 유입률을 지정한다
이제 상한이 100 근처라는 걸 알았으니, 그 언저리와 그 위를 유입률로 지정해 30초씩 쏜다.
| 입력: 유입률 | 달성 TPS | 평균 지연 | p99 | 관측된 동시성 | 실패 |
|---|---|---|---|---|---|
| 60/s | 60 | 45ms | 90ms | 3 | 0 |
| 80/s | 80 | 75ms | 260ms | 6 | 0 |
| 95/s | 95 | 210ms | 900ms | 20 | 0 |
| 130/s | 100 | 4.5s | 8.9s | 450 | 톰캣 스레드 200개 전부 점유, 커넥션 획득 타임아웃 620건 |
앞 세 줄은 유입률과 달성 TPS가 같다. 시스템이 따라오고 있다는 뜻이다. 마지막 줄에서 갈라진다. 130을 넣었는데 100밖에 안 나온다.
넘친 30/s가 30초 동안 쌓여 900건이 밀렸고, 밀린 요청이 대기하는 만큼 지연이 초 단위로 뛴다. 그러자 동시성 = 100 × 4.5초 = 450이 되어 내가 지정한 적 없는 450이라는 동시성이 만들어진다. 톰캣 스레드가 200개뿐이니 여기서 먼저 막힌다.
여기서 얻는 결론 — 상한의 95%(95/s)만 되어도 p99가 이미 900ms다. 여유 있게 보이던 이용률이 실제로는 위험하다. 상한을 넘기면 가장 먼저 터지는 곳은 톰캣 스레드 풀이다. 커넥션 풀이나 DB보다 앞이다. 130/s가 30초 지속되는 스파이크를 맞으면, 유입이 멈춘 뒤에도 밀린 900건을 빼내느라 9초를 더 느리다.
두 실험이 답한 질문이 다르다. 1번은 “얼마나 낼 수 있는가”, 2번은 “넘기면 어디가 어떻게 터지는가”다. 그래서 순서도 이 순서가 자연스럽다. closed-loop으로 천장을 먼저 찾고, 그 값을 기준으로 open-loop 유입률을 정한다. open-loop만으로 천장을 찾으려 하면 절벽 근처에서 지연이 발산해 탐색이 오래 걸린다.
그래서 이 숫자로 무엇을 하는가
동시성 8이라는 값이 쓰이는 곳
이 스윕에서 나온 건 “상한이 100 TPS”만이 아니다. 지연 목표를 만족하는 최대 동시성이 8이라는 값도 같이 나왔고, 이쪽이 설정에 더 직접 쓰인다.
- 동시성 제한을 건다면 그 임계값이 이 숫자다. 유입이 아무리 튀어도 동시에 처리하는 건수를 8로 묶으면 받아들인 요청은 83ms 안에 끝난다. 초당 몇 건으로 거는 속도 제한보다 이쪽이 직접적인데, 지연을 실제로 정하는 게 동시성이기 때문이다
- 커넥션 풀의 하한이 정해진다. 동시성 8을 풀 대기 없이 받으려면 풀이 최소 8은 되어야 한다. 그리고 톰캣 스레드 ≥ 커넥션 풀 ≥ 목표 동시성 순서가 지켜져야 앞단에서 먼저 막히지 않는다
- 대당 목표치가 된다. 목표 피크 300/s를 4대로 나누면 대당 75/s이고,
75 × 0.083초 ≈ 6이라 각 인스턴스가 유지할 in-flight는 6~8이다. 이 값을 모니터링 지표로 걸어두면 “지금 위험한가”를 TPS보다 빨리 알 수 있다
첫 번째 항목의 “동시성을 8로 묶는다”는 건 구체적으로 어느 값을 건드리는 얘기인지 정리하면 이렇다. 거는 위치에 따라 거절되는 지점과 이미 쓴 자원의 양이 달라진다.
| 거는 위치 | 설정 | 초과분이 어떻게 되나 |
|---|---|---|
| 애플리케이션 코드 | Resilience4j Bulkhead(세마포어 방식), 또는 Semaphore 직접 | 대기하다 초과하면 예외. 요청은 이미 서버 스레드를 잡은 뒤다 |
| DB 접근 구간 | HikariCP maximumPoolSize | 풀 대기 후 타임아웃. 요청 전체가 아니라 커넥션을 쥐는 구간만 제한된다 |
| 서버 전체 | 톰캣 maxThreads | accept 큐에서 대기, 큐도 차면 거절. 요청 전체를 제한하며 범위가 가장 넓다 |
| 앞단 프록시 | nginx limit_conn, 게이트웨이의 동시 요청 제한 | 애플리케이션에 도달하기 전에 거절. 서버 자원을 안 쓴다 |
세마포어 방식은 이런 모양이다.
1
2
3
4
5
6
resilience4j:
bulkhead:
instances:
couponIssue:
maxConcurrentCalls: 8 # 동시에 8건까지만
maxWaitDuration: 100ms # 자리 안 나면 100ms 기다렸다 거절
값의 성격이 둘로 갈린다는 점을 봐야 한다. maximumPoolSize는 처리 상한을 정하는 값이라 앞의 풀 크기 ÷ 점유 시간 식에 그대로 들어간다. 반면 Bulkhead나 maxThreads는 처리량을 늘리지 않는다. 넘치는 것을 어디서 어떻게 거절할지를 정할 뿐이다. 그래서 후자는 측정값보다 조금 위에 두고, 진짜 제한은 풀에서 거는 구성이 다루기 쉽다.
그리고 8이 낮다는 것 자체가 개선 대상을 가리킨다. 동시성 1에서 33 TPS가 나왔으니, 완전히 병렬로 처리된다면 8에서는 264가 나와야 한다.
| 동시성 | 실제 TPS | 완전 병렬이라면 | 효율 |
|---|---|---|---|
| 1 | 33 | 33 | 100% |
| 2 | 54 | 66 | 82% |
| 4 | 78 | 132 | 59% |
| 8 | 96 | 264 | 36% |
| 16 | 102 | 528 | 19% |
8에서 이미 효율이 36%다. 동시에 8건만 들어와도 그중 상당 부분이 서로를 기다리고 있다는 뜻이다. 각 줄에서 역산하면 이 감소가 “요청의 약 25%가 겹칠 수 없는 구간”이라는 하나의 값으로 설명된다. 겹칠 수 없는 구간이 25%면 아무리 동시성을 올려도 최대 4배(약 132 TPS)가 천장이다. 실측 상한 104가 그보다 낮은 건 경합 자체의 비용도 있기 때문이다.
그래서 다음 질문이 정해진다. 그 25%가 어디인가. 락, 단일 커넥션, 동기화 블록, 외부 호출 중 하나다. 여기를 줄이지 않는 한 풀을 키우든 대수를 늘리든 1대의 상한은 그대로다.
그럼 무엇을 조정하는가
처리 상한을 올리려면 그 상한을 정하고 있는 것을 건드려야 한다. 대개 DB 커넥션이 그 자리에 있다. 요청이 DB에서 대부분의 시간을 쓰는 API라면 상한은 이 식으로 정해진다.
1
처리 상한 ≈ 커넥션 풀 크기 ÷ 커넥션을 쥐고 있는 시간
리틀의 법칙을 커넥션 풀에 적용한 것이다. 풀이 32개고 요청 하나가 커넥션을 320ms 쥐고 있으면 32 ÷ 0.32 = 100 TPS가 된다. 그래서 상한을 올리는 길이 두 개다. 분자를 키우거나, 분모를 줄이거나.
| 조정 대상 | 언제 이걸 건드리나 | 확인하는 법 |
|---|---|---|
커넥션 풀 크기 (maximumPoolSize) | 지연 중 풀 대기가 차지하는 비중이 클 때 | 풀만 10 → 20 → 32로 바꿔 재본다. 상한이 따라 오르면 풀이 병목이었다 |
| 커넥션 점유 시간 (트랜잭션 범위, 쿼리 수, 인덱스) | 풀을 키워도 상한이 안 오를 때 | 풀을 키웠는데 그대로면 병목은 DB나 CPU다. 여기부터가 애플리케이션 개선 영역이다 |
톰캣 maxThreads | 스레드는 전부 점유돼 있는데 CPU도 DB도 놀 때 | 평시 처리량은 안 바뀐다. 과부하 때의 거동만 바뀐다 |
acceptCount (대기 큐 깊이) | 과부하 뒤 회복이 느릴 때 줄인다 | 큐가 깊으면 다 같이 느려지고, 얕으면 빨리 거절된다 |
| 타임아웃 | 재시도가 부하를 키우는 게 보일 때 | 정상 구간 p99의 몇 배 지점으로 잡는다 |
순서가 있다. 풀을 먼저 키워보고, 안 오르면 점유 시간을 줄인다. 점유 시간을 반으로 줄이는 것은 풀을 2배로 키운 것과 같은 효과인데, DB에 걸리는 부하는 늘지 않는다. @Transactional 범위 안에 외부 API 호출이나 무거운 계산이 들어 있으면 그 시간만큼 커넥션을 붙잡고 있는 것이라, 여기부터 잘라내는 게 보통 가장 크게 먹힌다.
풀을 무작정 키우면 안 되는 이유도 같은 식에서 나온다. 풀 크기는 DB에 동시에 몇 개를 넘길지를 정하는 값이다. DB가 감당할 수 있는 동시 세션을 넘기면 DB 안에서 락·I/O 경합이 심해져 점유 시간(분모)이 같이 늘어난다. 그러면 분자를 키운 만큼 분모도 커져서 상한이 안 오르거나 오히려 떨어진다.
조정할 게 없는 경우도 있다. CPU가 이미 100%면 풀이든 스레드든 무엇을 바꿔도 상한은 그대로다. 그때는 설정이 아니라 로직을 고치거나 대수를 늘리는 문제다.
나머지 값들이 쓰이는 곳
| 알아낸 값 | 쓰이는 곳 |
|---|---|
| 1대의 처리 상한 100 TPS | 대수 산정. 목표 피크가 300/s면 여유를 포함해 4~5대 |
| 이용률 95%에서 이미 p99 900ms | 오토스케일 기준선. 상한의 70~80%에서 증설을 시작한다 |
| 넘기면 톰캣 스레드가 먼저 고갈 | 그 지점 앞에 제한을 건다. 스레드 풀보다 앞단에서 잘라야 한다 |
| 30초 초과 → 회복에 9초 | 타임아웃·재시도 정책, 스파이크 대응 설계 |
유입을 어떻게 다룰 것인가
유입률은 사용자가 정하는 값이라 내가 정할 수 없다. 할 수 있는 일은 셋이다.
- 용량을 유입에 맞춘다 — 스케일 아웃. 측정한 1대 상한이 필요한 대수를 정한다
- 넘치는 유입을 잘라낸다 — 처리량을 늘리는 게 아니라, 못 받을 요청을 빨리 거절해 나머지를 지킨다
- 유입의 모양을 바꾼다 — 동기 처리 대신 큐에 넣고 순차 처리한다. 선착순 오픈처럼 진짜로 몰리는 트래픽에 쓴다
2번이 rate limiter가 들어오는 자리인데, 수단이 하나가 아니다.
- 속도 제한(rate limit) — 초당 N건을 넘으면 429로 거절한다. 게이트웨이, nginx
limit_req, 토큰 버킷 구현 등 - 동시성 제한(bulkhead) — 초당이 아니라 동시에 떠 있는 건수로 제한한다. 유입이 튀어도 자원 점유량이 고정된다
- 부하 흘리기(load shedding) — 큐에서 이미 오래 기다린 요청은 처리하지 않고 버린다. 클라이언트가 이미 타임아웃했을 요청을 처리하는 것은 자원 낭비다
여기서 임계값을 얼마로 잡느냐가 앞의 측정과 이어진다. 측정한 상한을 그대로 임계값으로 쓰면 안 된다. 이용률이 1에 가까울수록 대기가 급격히 커지기 때문이고(뒤의 「평균이 여유로워도 대기가 생기는 이유」), 위 예시에서도 상한의 95%에서 이미 p99가 900ms였다.
타임아웃도 이 측정에서 정해진다. 클라이언트 타임아웃이 서버가 밀렸을 때의 지연보다 짧으면, 서버는 아직 처리 중인데 클라이언트는 포기하고 재시도한다. 그 재시도가 다시 유입이 되어 부하가 원래대로 돌아와도 회복되지 않는 상태가 만들어진다.
지연을 언제부터 잴 것인가 — coordinated omission
open-loop으로 바꾸면 새로 정해야 할 문제가 생긴다. 부하 생성기 자신이 밀렸을 때 그 시간을 지연에 넣을 것인가.
초당 100건을 쏘기로 했는데 서버가 느려 생성기의 커넥션이 전부 묶였다고 하자. 101번째 요청은 예정 시각보다 3초 늦게 발사된다. 이때 지연을 실제 발사 시각부터 재면 3초가 통계에서 사라진다.
1
2
3
4
5
예정 시각 실제 발사 응답
│───── 3초 ─────│──── 0.2초 ────│
실제 발사 기준 : 0.2초 ← 시스템이 만든 대기 3초가 통째로 빠진다
예정 시각 기준 : 3.2초 ← 사용자가 겪은 값
Gil Tene이 coordinated omission이라고 부른 문제다. 이름 그대로 측정기가 대상과 “협조”해서 느린 구간을 빼먹는다. 서버가 느릴 때 요청을 덜 보내고, 덜 보낸 만큼 느린 샘플이 통계에서 빠지므로, 시스템이 느려질수록 측정 결과는 오히려 좋아 보인다.
closed-loop 도구가 구조적으로 이 문제를 안고 있다. 응답을 기다렸다 보내는 것 자체가 “협조”이기 때문이다.
대응은 예정 시각을 절대 고정하는 것이다. 유입률 50/s면 발사 간격이 20ms이므로, i번째 요청의 예정 시각을 시작 시각 + i × 20ms로 미리 못 박고 지연을 그 시각부터 잰다. 실제로 언제 쐈는지와 무관하게 계산되므로, 발사가 밀린 시간이 지연에 그대로 포함된다.
같이 봐야 할 것이 하나 더 있다. 부하 생성기 자신이 밀렸는지를 별도 지표로 출력해야 한다. 예정 시각과 실제 발사 시각의 차이가 그것이다. 이 값이 작아야 측정된 지연을 서버가 만든 값이라고 말할 수 있다. 이걸 안 보면 “서버가 못 버텼다”와 “측정기가 못 쐈다”를 구분할 수 없다.
부하 생성기가 병목인지 먼저 확인하지 않은 측정치는 서버에 대한 정보가 아니다.
부하 테스트와 스트레스 테스트
이름이 여러 개인데 표준 정의가 있는 것은 아니다. 실체는 부하의 모양 × 답하려는 질문의 조합이고, 이름은 자주 쓰는 조합에 붙은 라벨이다.
| 이름 | 부하의 모양 | 답하려는 질문 |
|---|---|---|
| 스모크 테스트 | 아주 작게, 짧게 | 시나리오와 스크립트가 동작하는가 |
| 부하(load) 테스트 | 예상 최대치 수준으로 유지 | 예상 트래픽에서 SLO를 지키는가 |
| 스트레스 테스트 | 천장을 넘겨 계속 올림 | 어디서, 어떻게 무너지는가 |
| 스파이크 테스트 | 짧고 강하게 | 급증에 견디는가, 걷으면 회복하는가 |
| 소크(soak) / 내구 테스트 | 일정 부하로 길게 | 시간이 지나며 나빠지는 것이 있는가 |
| 용량(capacity) / 브레이크포인트 | 단계적으로 상승 | 천장이 정확히 어디인가 |
부하 테스트와 스트레스 테스트의 차이는 부하의 크기가 아니라 천장을 기준으로 어느 쪽에 있느냐다.
1
2
3
4
5
천장(처리량 상한)
│
부하 테스트 ─┤ "정상 범위 안에서 약속을 지키는가"
│
스트레스 ─┼──▶ "넘겼을 때 어떻게 되는가"
같은 시스템에 같은 도구로 유입만 바꿔도 질문이 달라진다. 천장 아래면 부하 테스트고, 넘기면 스트레스 테스트다.
VU를 올리는 것도 스트레스 테스트다 — 다만 다른 축이다
스레드 수나 vus를 계속 올리면 시스템은 실제로 망가진다. 톰캣 워커가 모자라고, 커넥션 풀 대기가 타임아웃으로 바뀌고, 락 경합과 컨텍스트 스위칭이 늘어 어느 지점부터는 처리량이 늘기는커녕 떨어지기 시작한다. 이건 분명한 과부하다.
다만 이때 넘치는 것과 유입률을 올렸을 때 넘치는 것이 서로 다르다. 과부하에는 축이 두 개 있고, 각 방식이 하나씩만 담당한다.
| 동시성을 올린다 (closed-loop) | 유입률을 올린다 (open-loop) | |
|---|---|---|
| 넘치는 것 | 동시에 점유하는 자원 — 스레드, 커넥션, 메모리 | 큐에 쌓이는 밀린 요청 |
| 지연 | 동시성 ÷ 처리량 으로 수렴한다 | 시간에 비례해 계속 자란다 |
| 처리량 | 상한에 머물다가, 경합이 심해지면 오히려 떨어진다 | 상한에 고정된다 |
| 재현하는 현실 | 커넥션이 고정된 클라이언트가 많아진 상황 | 선착순 오픈처럼 사람이 몰리는 상황 |
수렴하느냐 자라느냐가 실질적인 차이다. 동시성 128로 고정하면 시스템 안의 요청은 어느 순간에도 128건을 넘지 않는다. 지연은 128 ÷ 처리량이라는 값으로 수렴하고, 20초를 돌리든 20분을 돌리든 같은 자리에 머문다. 반면 유입 200/s가 상한 50/s를 넘긴 상태는 수렴하지 않는다. 처리하지 못한 150건이 매초 큐에 남으므로 20초를 돌리면 20초치, 20분을 돌리면 20분치가 밀린다. 얼마나 나쁜지가 측정 시간에 따라 달라진다.
이렇게 처리되지 못하고 큐에 쌓여 있는 요청 뭉치를 밀린 요청(backlog)이라고 부른다. 그리고 유입이 멈춘 뒤 이 뭉치를 다 처리해 큐를 비우기까지 걸리는 시간을 해소 시간(drain)이라고 한다. 뒤에서 계속 쓰는 말이다.
그래서 동시성을 올리는 방식으로는 답할 수 없는 질문이 남는다.
- “평소의 10배 트래픽이 오면?” — VU로는 유입률을 지정할 수 없다. 지정할 수 있는 건 동시성뿐이고, 유입은 시스템이 내주는 만큼이다.
- “부하가 걷힌 뒤 얼마 만에 회복되나?” — 밀린 요청이 쌓이지 않으니 해소 시간을 잴 대상 자체가 없다.
무엇을 재려는지에 따라 고른다. “동시 접속이 N까지 늘면 어디가 먼저 터지나”라면
vus를 올리는 게 맞는 방법이다. “초당 유입이 N이 되면”, “얼마 만에 회복되나”라면 open-loop이 아니면 답이 안 나온다.
회복도 결과의 일부다
스트레스 테스트에서 확인할 것은 “언제 무너지는가”만이 아니다. 부하를 걷었을 때 돌아오는가가 같이 나와야 한다.
천장을 넘긴 동안 밀린 요청은 유입이 멈춰도 즉시 사라지지 않는다. 남은 것을 다 처리할 때까지는 부하가 정상으로 돌아온 뒤에도 사용자가 느린 응답을 받는다. 여기에 클라이언트 재시도까지 붙으면 부하가 원래대로 돌아와도 회복되지 않는 상태(metastable failure)에 빠질 수 있다.
그래서 스트레스 시나리오는 초과 구간 뒤에 정상 구간을 붙여 얼마 만에 돌아오는지까지 재는 편이 좋다.
부하를 어떤 모양으로 줄 것인가
총량이 같아도 모양이 다르면 피해가 다르다. 그리고 그 차이는 계산된다.
오해가 많은 것이 스파이크다. “얼마나 세게 때리는가”가 피해를 정한다고 생각하기 쉬운데, 실제로 정하는 건 초과분 × 지속 시간이다.
유입률이 처리 상한을 넘으면, 그 차이만큼이 매초 처리되지 못하고 남는다. 초당 200건이 들어오는데 50건밖에 못 처리하면 나머지 150건이 큐에 남는다는 뜻이다. 이게 초과 구간 내내 반복되므로, 밀린 요청은 초과분에 지속 시간을 곱한 만큼 쌓인다.
1
2
3
4
초당 밀리는 양 = 유입률 − 처리 상한 예) 200 − 50 = 150건/s
밀린 요청 = 초당 밀리는 양 × 초과 구간의 길이
해소 시간 = 밀린 요청 ÷ 처리 상한
해소 시간은 유입이 멈춘 뒤의 얘기다. 쌓인 것을 빼내는 속도도 결국 처리 상한이므로, 밀린 건수를 상한으로 나눈 만큼 걸린다.
처리량 상한이 50/s인 시스템에 두 가지 스파이크를 준다고 하자.
| 시나리오 | 초과분 | 지속 | 밀린 요청 | 해소 시간 |
|---|---|---|---|---|
| A: 200/s를 3초 | 150/s | 3초 | 450건 | 9초 |
| B: 70/s를 30초 | 20/s | 30초 | 600건 | 12초 |
4배 약한 부하가 더 큰 피해를 만든다. 오래 지속됐기 때문이다. 평균 지연도 여기서 나온다. 큐가 선형으로 자라므로 평균 대기는 최대의 절반 근처가 된다.
시나리오를 짤 때 정해야 할 값이 피크 유입만이 아니라는 뜻이다. 그 피크가 얼마나 지속되는가를 같이 정해야 한다.
같은 60/s라도 도착 패턴은 여러 가지다
유입률은 평균이다. “초당 60건”이라는 조건을 만족하는 도착 패턴은 하나가 아니다.
1
2
3
등간격 ● ● ● ● ● ● ● ● ● ● ● ● 16.7ms 마다 정확히 1건
랜덤 ● ●● ● ●● ● ●●● ● ● ● 간격이 제각각, 평균만 16.7ms
버스트 ●●●●●●●●●●●● 한 시점에 60건, 나머지 구간은 정적
셋 다 초당 60건이지만 시스템이 겪는 것은 전혀 다르다. 처리량 상한이 100/s여서 여유가 있다고 해도 마찬가지다.
- 등간격이고 처리 시간도 일정하면, 유입이 상한보다 낮은 한 대기가 아예 없다. 앞 요청이 끝난 뒤에 다음이 도착한다
- 랜덤이면 어떤 구간엔 몰리고 어떤 구간엔 빈다. 몰린 구간에서 큐가 생기고, 그다음 한가한 구간에 서버가 놀아도 이미 기다린 시간은 되돌려지지 않는다
- 버스트면 60건 중 마지막 요청은 앞의 59건이 처리될 때까지 기다린다. 평균 유입은 여유로운데 그 순간의 지연은 최악이다
가운데 줄의 “랜덤”을 정확히 부르는 이름이 포아송 도착이다. 서로 상관없는 사용자들이 각자 알아서 요청을 보내는 상황을 뜻하고, 성질은 이렇다.
- 초당 평균 몇 건인지는 정해져 있다
- 그런데 다음 요청이 언제 올지는 직전에 언제 왔는지와 무관하다. 방금 왔다고 다음이 늦게 오지 않는다
- 그 결과 평균이 60/s여도 어떤 1초엔 75건, 어떤 1초엔 48건이 온다
실제 사용자 트래픽이 대체로 이 모양이다. 서로 약속하고 오는 게 아니기 때문이다.
평균이 여유로워도 대기가 생기는 이유
큐잉 이론이 이걸 정량화한다. Kingman의 근사식이다.
1
2
3
4
5
6
대기시간 ≈ ─────ρ───── × ─(c_a² + c_s²)─ × 평균 처리시간
(1 − ρ) 2
ρ = 이용률 (유입률 ÷ 처리량 상한)
c_a = 도착 간격이 얼마나 들쭉날쭉한가
c_s = 처리 시간이 얼마나 들쭉날쭉한가
c_a, c_s는 변동계수(표준편차 ÷ 평균)다. 값이 얼마나 흩어져 있는지를 평균 대비 비율로 나타낸 것이고, 평균으로 나누기 때문에 단위가 없어 서로 다른 값끼리도 비교할 수 있다.
1
2
3
0 완전히 규칙적 (항상 16.7ms 간격)
1 완전 랜덤 (포아송 도착)
1 초과 포아송보다도 더 몰렸다 배는 패턴 (버스트)
세 항의 곱이라는 점이 핵심이다.
첫째 항 — 이용률이 1에 가까워지면 발산한다. ρ/(1-ρ)는 이렇게 움직인다.
| 이용률 | ρ/(1−ρ) |
|---|---|
| 50% | 1 |
| 70% | 2.3 |
| 90% | 9 |
| 95% | 19 |
용량을 꽉 채워 쓰면 안 되는 이유가 이것이다. 90%에서 95%로 5%p 더 쓰면 대기가 2배가 된다.
둘째 항 — 변동성이 대기를 곱으로 키운다. 등간격 도착에 처리 시간도 일정하면 c_a = c_s = 0이라 대기가 0이다. 완전 랜덤(포아송) 도착이면 c_a = 1이다. 평균이 같아도 도착이 불규칙하다는 것만으로 대기가 생긴다.
대기는 부하가 용량을 넘어서 생기는 것만이 아니다. 평균이 용량 안이어도 변동성이 있으면 생긴다.
도구는 대개 등간격으로 쏜다
여기서 측정상의 함정이 하나 나온다. k6의 constant-arrival-rate는 문서에 이렇게 적혀 있다.
At a
rateof10with atimeUnitof1s, each iteration starts about every tenth of a second (that is, each 100ms).
즉 등간격이다. 위 식의 c_a = 0에 해당하고, 실제 사용자 트래픽보다 얌전한 부하다. 같은 평균 유입률로 재도 실제보다 낙관적인 결과가 나온다.
| 도구 | 도착 패턴 |
|---|---|
k6 constant-arrival-rate | 등간격 |
Gatling constantUsersPerSec | 등간격. .randomized 를 붙이면 간격이 무작위화된다 |
| JMeter + Poisson Random Timer | 도착 간격에 포아송 기반 지연을 섞는다 |
그래서 시나리오를 짤 때 도착 패턴도 같이 정해야 한다.
- 서로 독립적인 사용자가 많이 모인 트래픽이면 포아송에 가깝다. 등간격으로 재고 있다면 결과를 낙관적으로 읽고 있는 것이다
- 배치·스케줄러·푸시 발송처럼 한 신호에 다 같이 움직이는 트래픽은 포아송보다도 더 몰린다. 이건 별도의 스파이크 시나리오로 재야 한다
- 도구가 등간격만 지원한다면, 최소한 “이 수치는 등간격 도착 기준”이라고 밝혀야 한다
어떤 모양을 고를 것인가
부하의 모양에는 이름이 붙어 있다. 시간에 따라 부하를 어떻게 올리고 내리느냐의 차이다.
1
2
3
4
램프업 ╱ 0에서부터 계속 올린다
스텝 스윕 ┌─┐ ┌─┐ ┌─┐ 한 칸 올리고 유지, 또 한 칸 올리고 유지
소크 ┌──────────┐ 한 값으로 길게 유지한다
스파이크 ┌┐ 짧고 강하게 올렸다가 바로 내린다
- 램프업(ramp-up) — 부하를 0에서부터 서서히 올린다. 어느 지점에서 지연이 꺾이는지 본다
- 스텝 스윕(step sweep) — 한 단계 올려 일정 시간 유지하고 재기를 반복한다. “쓸어가며 훑는다”는 뜻의 sweep이다. 단계마다 안정된 값이 나오므로 천장을 찾는 데 쓴다
- 소크(soak) — 한 값으로 길게 유지한다. “담가둔다”는 뜻이고, 시간이 지나며 나빠지는 것(메모리 누수, 커넥션 누수)을 본다
- 스파이크(spike) — 짧고 강하게 올렸다 내린다. 넘긴 뒤 회복되는지를 본다
| 워크로드 성격 | 어울리는 모양 |
|---|---|
| 상시 트래픽이 있는 API | 스텝 스윕으로 천장 확인 → 예상 피크로 소크 |
| 선착순 오픈, 푸시 발송 | 스파이크. 피크 크기와 지속 시간을 둘 다 변수로 |
| 오토스케일이 붙어 있는 서비스 | 램프업. 스케일아웃이 부하 증가를 따라오는지 |
| 배치·연동처럼 동시성이 고정된 경우 | 고정 동시성 closed-loop |
어느 모양을 고르든 용량 산정은 평균이 아니라 변동을 감안해서 해야 한다. “평균 60 TPS니까 100 TPS 시스템이면 충분하다”는 계산은 이용률 60%를 전제로 하는데, 그 60%에서도 도착이 불규칙하면 대기는 이미 생기고 있다.
키 분포도 부하의 모양이다
락이나 캐시가 얽힌 코드에서는 총 유입량보다 같은 키에 몰리는 정도가 결과를 더 크게 흔든다.
같은 100 TPS라도, 요청이 1,000개 키에 고르게 흩어지면 키 하나당 0.1 TPS라 경합이 거의 없다. 반대로 전부 같은 키를 노리면 그 키에 100 TPS가 걸리고, 그 키에 대한 처리는 직렬화된다. 총량은 같은데 결과가 완전히 갈린다.
병목의 위치도 달라진다.
| 키 분포 | 먼저 막히는 곳 | 대응 |
|---|---|---|
| 흩어짐 | 커넥션 풀, CPU, 디스크 | 자원을 늘리거나 요청당 작업량을 줄인다 |
| 한 키에 집중 | 그 행의 락 | 락 구간을 줄인다. 자원을 늘려도 안 변한다 |
“TPS 얼마까지 버티나”라는 질문이 키 분포 없이는 답이 안 되는 이유다. 시나리오를 짤 때는 실제 워크로드의 분포를 흉내 내야 한다. 상위 몇 개 키에 트래픽이 쏠리는 서비스라면 Zipf 분포에 가깝게 주는 편이 균등 분산보다 현실에 가깝다.
부하 생성 도구
도구를 고르는 기준은 기능 개수가 아니라 open-loop을 1급으로 지원하는가다.
| 도구 | 형태 | open-loop | 메모 |
|---|---|---|---|
| k6 | Go + JS 시나리오 | constant-arrival-rate, ramping-arrival-rate | executor만 바꿔 closed/open을 대조할 수 있다 |
| Vegeta | Go CLI/라이브러리 | 설계 자체가 rate 기반 | CLI 한 줄로 open-loop |
| oha | Rust CLI + TUI | -q 로 rate 지정 | 가볍게 확인할 때. 히스토그램 출력이 좋다 |
| wrk | C + Lua | 미지원 (closed-loop) | 가볍고 빠르지만 유입률을 못 정한다 |
| wrk2 | C + Lua | 지원 | Gil Tene이 wrk의 coordinated omission을 고치려 만든 포크 |
| Gatling | Scala/Java DSL | constantUsersPerSec | JVM 팀에 익숙하고 리포트가 강하다 |
| JMeter | Java, GUI | Throughput Shaping Timer 등 플러그인 필요 | 기본이 스레드 = closed-loop이다. 이걸 모르고 “500 스레드니까 500 TPS”로 읽는 경우가 많다 |
| Locust | Python | LoadTestShape를 직접 구현 | 시나리오를 코드로 짤 때 |
| ab | C | 미지원 | HTTP/1.0 기본, 단일 스레드 등 제약이 많다. 지금 새로 쓸 이유는 없다 |
같이 확인할 것 셋.
- 지연을 예정 시각부터 재는가. 앞 절의 문제다. k6의 arrival-rate executor와 Vegeta, wrk2가 여기에 대응한다.
- 백분위를 어떻게 집계하는가. 여러 워커의 p99를 평균 내면 그건 p99가 아니다. HdrHistogram처럼 히스토그램을 합칠 수 있는 구조인지 본다.
- 생성기 자신의 한계. 파일 디스크립터, ephemeral port, 단일 머신 CPU. 목표 유입이 안 나오는 이유가 서버가 아닐 수 있다.
k6로 두 모드를 대조하면 이런 모양이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export const options = {
scenarios: {
// closed-loop : 동시 8명 고정
closed: {
executor: 'constant-vus',
vus: 8,
duration: '20s',
},
// open-loop : 초당 70건 고정, 응답과 무관
open: {
executor: 'constant-arrival-rate',
rate: 70,
timeUnit: '1s',
duration: '20s',
preAllocatedVUs: 100,
maxVUs: 2000,
},
},
};
maxVUs는 넉넉히 잡아야 한다. 부족하면 k6는 예정 시각에 요청을 쏘지 못하고 dropped_iterations로 세는데, 못 쏜 요청은 지연 통계에 안 들어가므로 결과가 오히려 좋아 보인다. 앞 절의 착시가 도구 설정에서도 그대로 재현되는 지점이다.
환경을 어떻게 고정하는가
원칙은 하나다. 보려는 변수 하나만 남기고 나머지는 전부 고정한다. 문제는 무엇이 변수인지 모르는 채로 측정한다는 데 있다.
런을 돌릴수록 데이터가 변한다
가장 놓치기 쉬운 오염이다. 상태를 바꾸는 API를 대상으로 하면 부하 테스트 자체가 데이터를 바꾼다.
- 재고를 차감하는 API는 돌릴수록 남은 재고가 줄어든다
- 행을 추가하는 API는 돌릴수록 테이블이 커지고 인덱스가 깊어진다
- 그 결과 아무것도 안 고쳤는데 런마다 성능이 달라진다
방향이 어느 쪽인지도 미리 알 수 없다. 데이터가 줄어 쿼리가 빨라지기도 하고, 쌓여서 느려지기도 한다. 전자라면 개선하지 않았는데 좋아진 것처럼 보이고, 개선 전후 비교에서 결론이 통째로 뒤집힌다.
런 사이에 데이터 상태를 되돌리는 절차는 편의 기능이 아니라 측정 설계의 일부다.
DB 쪽 변수 고정
무엇을 고정할지는 무엇을 보려는 실험인지가 정한다. 락 경합을 보려는 실험이라면 디스크 I/O를 변수에서 빼는 편이 좋다.
1
2
3
4
5
command:
- --innodb-buffer-pool-size=2G # 데이터가 전부 메모리에 상주 → 디스크 I/O 제거
- --innodb-flush-log-at-trx-commit=2 # 커밋마다 fsync 하지 않음
- --max-connections=500 # 커넥션 풀 실험에서 DB가 먼저 막히지 않도록
- --performance-schema=ON # 락 관측용
반대로 디스크 성능을 보려는 실험이었다면 버퍼 풀을 작게 잡아 일부러 I/O를 유발해야 한다. 그리고 이렇게 고정한 값은 운영과 다르다는 것을 기억해야 한다. flush_log_at_trx_commit=2로 낸 커밋 지연을 운영 예측치로 쓰면 안 된다.
한 번에 하나만 바꾼다
커넥션 풀 사이즈의 영향을 보려면 톰캣 설정도, 워크로드도, 데이터도 그대로 두고 커넥션 수 하나만 바꿔야 한다. 그래야 결과의 차이를 그 변경의 결과라고 말할 수 있다.
여기 함정이 하나 있다. 어떤 값은 부하 도구 인자로 바뀌지만 어떤 값은 서버를 다시 띄워야 바뀐다. 커넥션 풀 사이즈, 톰캣 스레드 수가 후자다. 인자만 바꾸고 서버를 안 내렸다가 “바꿨는데 결과가 같다”는 결론을 낼 수 있다.
측정기와 대상의 자원을 분리한다
부하 생성기와 서버를 한 프로세스에 넣으면 부하 스레드와 서버 워커가 같은 CPU를 두고 다투면서 “무엇이 병목인가”라는 질문 자체가 흐려진다. 최소한 프로세스는 분리하고, 가능하면 머신을 분리한다.
같은 머신에서 돌릴 수밖에 없다면, 최소한 부하 생성기가 밀리지 않았는지를 매 런 확인해야 한다.
절대값이 아니라 비율에서 결론을 낸다
개발 머신에서 재면 같은 조건으로 같은 런을 반복해도 처리량이 수십 % 씩 흔들린다. 다른 프로세스, OS 스케줄링, 터보 부스트와 발열에 따른 클럭 변화가 전부 섞이기 때문이다.
그래서 두 가지 원칙이 필요하다.
- 같은 조건을 최소 2~3회 반복해서 편차의 크기를 먼저 안다. 그래야 “이 차이가 의미 있는 차이인가”를 판단할 수 있다
- 결론은 절대값이 아니라 비율에서 끌어낸다. “TPS 57이 나왔다”보다 “시간의 84%가 락 대기였다”가 훨씬 잘 재현된다
곡선의 미세한 모양(정점이 어디인가 등)을 편차보다 작은 차이로 주장하지 않는 것도 같은 이유다.
워밍업과 측정 구간
부하를 걸자마자 나오는 수치는 시스템의 정상 성능이 아니다. 앞의 몇 초는 버려야 한다.
JVM 기반 서버에서 초반이 느린 이유는 여러 개가 겹친다.
| 대상 | 콜드할 때 무슨 일이 |
|---|---|
| JVM JIT | 인터프리터 → C1 → C2 순으로 컴파일된다. 초반 요청은 최적화 안 된 코드로 돈다 |
| 커넥션 풀 | 커넥션을 lazy하게 만든다. 첫 요청들이 TCP 핸드셰이크와 DB 인증까지 부담한다 |
| DB 버퍼 풀 | 콜드면 디스크에서 페이지를 읽는다. 워밍업 후에는 메모리에서 끝난다 |
| 프레임워크 | 매퍼·프록시·리플렉션 캐시 초기화 |
그래서 측정은 두 구간으로 나눈다.
1
2
[ 워밍업 ] 부하는 걸지만 통계에 넣지 않는다
[ 측정 ] 이 구간만 집계
워밍업을 얼마나 줄지는 무엇이 콜드한지에 달렸다. JIT만 문제면 수 초로 충분하고, 버퍼 풀이 콜드하면 데이터를 한 번 훑을 만큼 필요하다. 버퍼 풀이 변수인지 아닌지는 확인해두는 편이 좋다. 데이터가 전부 메모리에 올라가는 크기라면 재기동 직후가 아닌 한 변수가 아니다. “확인해서 변수가 아니었다”와 “생각해보지 않았다”는 다르다.
측정 구간의 경계를 어떻게 처리할 것인가
지연이 길어지면 새 문제가 생긴다. 측정 구간 안에서 시작해 밖에서 끝난 요청을 어떻게 셀 것인가.
1
2
3
측정 구간 (20초)
├──────────────────────┤
요청 X [───────────────┼─────] ← 구간 안에서 시작, 밖에서 완료
open-loop에서 이 문제가 특히 크다. 천장을 넘긴 상태에서는 완료가 구간 밖으로 대량으로 밀리기 때문이다. 성공 건수 ÷ 측정 구간으로 계산하면 유입률과 똑같은 값이 나와서 그럴듯해 보인다. 평균 지연이 수십 초인데 초당 200건을 처리했다는 결과가 나오면 계산이 틀린 것이다.
완료가 실제로 퍼진 구간(측정 구간 + 밀린 것을 다 처리하기까지의 시간)으로 나눠야 시스템이 낸 처리량이 된다.
closed-loop에서는 이 함정이 작다. 응답을 기다리므로 구간 밖으로 새는 요청이 적다. 그래도 지연이 측정 구간에 비해 길면 오차가 생긴다.
지연이 측정 구간의 10%를 넘어가면 경계 오차를 의심해야 한다. 측정 구간을 늘리는 것이 가장 간단한 대응이다.
결과를 어떻게 읽는가
평균은 부하 테스트에서 가장 쓸모없는 지표에 가깝다. 무너지는 것은 늘 꼬리부터다.
p50 / p95 / p99
응답 시간을 오름차순으로 줄 세웠을 때, 앞에서 그 비율만큼 지점의 값이다.
| 뜻 | |
|---|---|
| p50 = 20ms | 요청의 50%가 20ms 이내에 끝났다 (중앙값) |
| p99 = 40ms | 요청의 99%가 40ms 이내. 뒤집으면 1%는 40ms보다 오래 걸렸다 |
p99는 “평균적으로 이 정도”가 아니라 가장 느린 1%의 경계선이다. 그래서 꼬리(tail) 지표라고 부른다.
평균만 보면 안 되는 이유는 대기가 평균을 크게 흔들지 않으면서 꼬리부터 무너뜨리기 때문이다. 대기 큐가 없을 때와 있을 때의 분포는 대략 이런 모양으로 갈린다.
| 상황 | mean | p50 | p99 |
|---|---|---|---|
| 경합 없음 | 24 ms | 23 | 39 |
| 경합 있음 | 1,300 ms | 1,200 | 3,400 |
위쪽은 mean·p50·p99가 다닥다닥 붙어 있다. 모든 요청이 비슷하게 처리됐다는 뜻이다. 아래쪽은 p50 대비 p99가 3배 가까이 벌어진다. 대기 큐에서 순서가 밀린 요청들이 꼬리를 늘어뜨린 것이다. mean만 보면 p50과 별 차이 없어 보이지만, 실제로는 3초 넘게 걸린 요청이 섞여 있다.
가로축을 지연, 세로축을 그 지연에 해당하는 요청 수로 놓고 분포를 그리면 차이가 눈에 보인다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
경합 없음
요청 수 ▲
│ ▄██▄
│ ██████
│ ████████
│ ██████████▄▄▁
└───┴────┴────┴────┴────────────────────────▶ 지연(ms)
20 30 40 50
▲ ▲
p50=23 p99=39 p99 ÷ p50 = 1.7배
경합 있음
요청 수 ▲
│ ▄██▄
│ ██████
│ ████████
│ ███████████▄▄▄▄▄▂▂▂▂▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
└─┴────┴────┴────┴────┴────┴────┴────┴───▶ 지연(초)
1 1.5 2 2.5 3 3.5 4
▲ ▲
p50=1.2 p99=3.4 p99 ÷ p50 = 2.8배
두 그림의 가로축 단위가 다르다는 점(ms와 초)에 주의한다. 모양만 비교하는 그림이다.
위는 봉우리 하나로 끝나고 오른쪽이 금방 바닥에 닿는다. 아래는 봉우리 위치는 하나인데 오른쪽으로 얇고 긴 꼬리가 계속 이어진다. 이 꼬리에 들어 있는 요청 수는 전체의 몇 %밖에 안 되므로 mean은 봉우리 근처에 머문다. 꼬리는 평균을 거의 움직이지 않으면서 p99만 밀어 올린다.
따라서 봐야 할 것은 p99의 절대값만이 아니라 p50과 p99의 간격이다. 간격이 벌어지기 시작하는 지점이 어딘가 막히기 시작한 지점이다.
1%는 생각보다 크다
p99를 넘는 1%가 왜 문제인지는 두 방향으로 계산된다.
빈도로 보면 — 50 TPS로 1분이면 3,000건이고, 그중 1%면 분당 30건이 p99를 넘는다. 하루면 4만 건이 넘는다.
한 사용자 기준으로 보면 — 한 화면이 이 API를 5번 부른다면 그중 최소 한 번이 p99를 넘을 확률은 이렇다.
1
1 - 0.99⁵ = 4.9%
사용자 20명 중 1명은 느린 화면을 본다. SLO를 평균이 아니라 꼬리로 잡는 이유다.
무엇을 지연 통계에 넣을 것인가
성공한 요청만 지연에 넣고, 실패는 원인별로 따로 센다.
1
락 대기 타임아웃 / 커넥션 풀 타임아웃 / 데드락 / 커넥션 거절 → 각각 따로 집계
실패를 지연 통계에 섞으면 착시가 생긴다. 실패가 빨리 떨어질수록 평균 지연이 좋아지기 때문이다. 서버가 무너져서 즉시 500을 뱉는 상태가 가장 빠른 시스템으로 보인다.
원인별로 나눠 세야 하는 이유는 각각이 다른 층을 가리키기 때문이다.
| 실패 | 가리키는 층 |
|---|---|
| connection refused / reset | 커널 accept 큐, 톰캣 acceptCount |
| 커넥션 풀 타임아웃 | 풀 사이즈 또는 커넥션 보유 시간 |
| 락 대기 타임아웃 / 데드락 | DB 락 경합 |
| 클라이언트 read timeout | 서버는 아직 처리 중일 수 있다 |
여기에 하나 더. 정상적인 비즈니스 실패는 장애와도 구분해야 한다. 재고 부족처럼 시스템이 멀쩡해도 나오는 결과를 에러율에 합치면 SLO 위반으로 오독된다.
어디서 재는가
클라이언트가 재는 값과 서버가 재는 값이 다르면, 그 차이가 정보다.
| 차이 | 해석 |
|---|---|
| 거의 없다 | 네트워크와 앞단 큐는 병목이 아니다 |
| 크게 벌어진다 | 네트워크, 톰캣 accept 큐, 혹은 계측 위치를 의심한다 |
계측 위치는 특히 조심해야 한다. 서비스 메서드 안에서 재면 커넥션 풀 대기가 빠진다. @Transactional 프록시는 메서드 본문에 들어오기 전에 커넥션을 확보하기 때문이다. 풀이 마른 상태에서는 이 대기가 응답 시간의 대부분을 차지할 수 있는데, 서비스 안에서 재면 그게 통째로 안 잡힌다. 결과적으로 “서버는 빠른데 클라이언트가 느리다”는 잘못된 결론으로 이어지고, 네트워크를 의심하며 시간을 쓰게 된다.
@Transactional은 커넥션 획득과 커밋을 애플리케이션 코드 밖으로 밀어낸다. 편해진 대신 코드를 읽어서는 언제 커넥션을 잡고 놓는지 알 수 없게 된다. 계측도 코드 밖으로 따라 내려가야 한다.
분자와 분모를 같은 쪽에서 세는 것도 같은 맥락이다. 단계별 시간은 서버가 쟀는데 요청 수는 클라이언트가 센 값으로 나누면, 구간 경계에 걸린 요청만큼 요청당 시간이 부풀려진다.
정리
부하 테스트에서 결정해야 할 것들을 체크리스트로 압축하면 이렇다.
부하를 주기 전
- 도구에 입력할 값이 동시성인가 유입률인가 정한다. 둘 다 지정할 수는 없다
- 실제 워크로드가 closed-loop인지 open-loop인지 판단한다. 선착순·푸시 직후는 open-loop이다
- 무너뜨려 볼 생각이라면 어느 축인지 정한다. 동시성을 올리면 자원이 먼저 막히고, 유입률을 올려야 요청이 밀려 쌓인다
- 피크 유입뿐 아니라 그 피크가 얼마나 지속되는지를 정한다. 피해는 초과분 × 지속 시간이다
- 유입률은 평균일 뿐이다. 도착 패턴(등간격인가 불규칙한가)을 정하고, 도구가 무엇으로 쏘는지 확인한다
- 키 분포를 정한다. 정하지 않은 측정치는 해석할 수 없다
- 런 사이에 데이터 상태를 되돌리는 절차를 넣는다
- 무엇을 보려는 실험인지에 따라 무엇을 고정할지 정한다
부하를 주는 동안
- 워밍업 구간을 버리고 측정 구간만 집계한다
- 부하 생성기 자신이 밀리지 않았는지 매 런 확인한다
- 한 번에 하나만 바꾼다. 서버 재기동이 필요한 값인지 확인한다
- 같은 조건을 2~3회 반복해 편차의 크기를 먼저 안다
결과를 읽을 때
- 평균이 아니라 p99를 본다. p50과 p99의 간격이 벌어지는 것이 신호다
- 성공만 지연에 넣고 실패는 원인별로 센다. 정상적인 비즈니스 실패는 또 따로 센다
- TPS를 무엇으로 나눴는지 확인한다. 지연이 길면 측정 구간이 분모가 아니다
동시성 = TPS × 지연으로 검산한다. 안 맞으면 어딘가를 빠뜨리고 세고 있다- 절대값은 편차 범위로 읽고, 결론은 시간 배분 비율에서 끌어낸다
다음 글에서는 여기 정리한 것을 실제 API에 적용해 k6로 측정한다.
참고 자료
- k6 — Open and closed models (grafana.com)
- k6 — constant-arrival-rate executor (grafana.com)
- Gatling — Injection profiles: constantUsersPerSec / randomized (docs.gatling.io)
- Apache JMeter — Component Reference: Timers (jmeter.apache.org)
- k6 — Scenarios / Executors (grafana.com)
- How NOT to Measure Latency — Gil Tene (infoq.com)
- wrk2 — a constant throughput, correct latency recording variant of wrk (github.com/giltene)
- HdrHistogram (hdrhistogram.org)
- Vegeta — HTTP load testing tool (github.com/tsenart)
- Gatling — Injection profiles (docs.gatling.io)
- Metastable Failures in Distributed Systems (sigops.org)
- Apache Tomcat 10 — The HTTP Connector: maxThreads / acceptCount / maxConnections (tomcat.apache.org)
- nginx — ngx_http_limit_req_module (nginx.org)
- Resilience4j — Bulkhead (resilience4j.readme.io)
- Universal Scalability Law (perfdynamics.com)
- MySQL 8.0 Reference Manual — innodb_buffer_pool_size (dev.mysql.com)