부하테스트로 커넥션 상한 찾기: 중앙값이 움직이면 이미 늦었습니다
동시 사용자를 고정해 부하를 걸면 상한이 보이지 않습니다. 요청률을 고정하고 버린 요청을 세어 커넥션 1개당 REST 상한을 찾은 기록입니다. 포화 신호는 중앙값이 아니라 꼬리 지연에 먼저 나타났습니다.
TL;DR
커넥션 상한은 요청률을 고정해 부하를 걸고 버린 요청을 세면 나옵니다. 커넥션 1개당 REST 상한은 약 9,000 req/s 였습니다.
포화는 꼬리 지연에서 먼저 나타납니다. 중앙값이 움직이는 시점에는 이미 상한을 넘은 뒤입니다.
1. 문제: 상한이 어디인지 나오지 않았습니다
부하를 견디는 구조를 공부하면서 부하가 왔을 때 어디가 먼저 막히는지를 지점별로 보고 있습니다. 이번 지점은 커넥션입니다.
서비스 간 호출을 측정하면서 커넥션 수를 줄여 봤는데 처리량도 지연도 거의 그대로였습니다. 풀이 200개인데 동시 요청이 50건이라 커넥션이 병목에서 멀었기 때문입니다. 그래서 커넥션 예산을 1개로 낮추고 다시 측정했습니다. 이번에는 상한 자체가 나오지 않았습니다.
원인은 부하를 거는 방식에 있었습니다.
| 실행 모델 | 부하를 정하는 값 | 응답이 늦어지면 |
|---|---|---|
| 동시 사용자 고정 | 가상 사용자 수 | 다음 요청도 늦게 발송돼 부하가 스스로 줄어듭니다 |
| 요청률 고정 | 초당 요청 수 | 요청은 그대로 도착하고 처리하지 못한 만큼 밀립니다 |
동시 사용자 50명으로 걸면 응답이 100 ms 로 늘어난 순간 초당 요청 수도 함께 떨어집니다. 서버가 감당하는 만큼만 부하가 도착하므로 그래프는 평형에 머무릅니다. 상한을 찾으려는 측정에서 부하가 스스로 조절되면 찾을 대상이 없어집니다.
2. 측정
요청률을 고정하고 버린 요청을 셌습니다
k6 의 실행 모델을 constant-arrival-rate 로 바꿨습니다.
scenarios: {
openLoop: {
executor: 'constant-arrival-rate', // 응답과 무관하게 초당 rate 만큼 요청
rate: RATE,
timeUnit: '1s',
duration: '15s',
preAllocatedVUs: 50,
// 응답이 밀려 VU 가 모두 점유되면 k6 가 요청을 보내지 않고
// dropped_iterations 로 센다. 그 값이 과부하 지표가 된다.
maxVUs: 400,
},
}
가상 사용자를 400개까지 확보해 두고 그것이 모두 점유되면 k6 는 요청을 보내지 않고 dropped_iterations 로 셉니다. 서버가 감당하지 못한 양이 부하 도구 쪽에 숫자로 남습니다. 회차가 15초이므로 버린 요청의 모수는 요청률 × 15 입니다.
커넥션만 줄였습니다
커넥션을 병목으로 만드는 방법은 둘입니다. 부하를 올리거나 커넥션을 줄이는 것입니다. 앞을 택하면 CPU 포화와 부하 도구 경합이 함께 들어와 무엇이 막았는지 판정할 수 없습니다. 그래서 요청률 사다리는 그대로 두고 커넥션 예산만 1개로 낮췄습니다.
| 항목 | 값 |
|---|---|
| 런타임 | Java 21, Spring Boot 3.5 |
| 부하 | k6, 요청률 고정, 회차당 15초 |
| 요청률 사다리 | 400 부터 12,800 까지 두 배씩 |
| 커넥션 예산 | 풀 1개 (비교 회차만 4개) |
| 응답 크기 | 1건 고정 |
| 데이터 계층 | 인메모리 고정 (DB 없음) |
| 호스트 | 단일 호스트 (M2 Pro 12코어, 16GB) |
| 측정 지점 | 호출 측이 측정한 피호출 서비스 왕복 시간 |
회차는 구성마다 하나입니다
반복 측정은 하지 않았습니다. 회차마다 서버를 새로 띄우고 워밍업을 본 회차와 분리했지만, 앞선 측정에서 같은 조건이 12% 차이로 나온 적이 있습니다. 그 크기 안쪽 차이는 뒤에서 신호로 읽지 않습니다.
3. 결과
달성 요청률이 목표를 따라가지 못하는 지점이 상한입니다
REST, 커넥션 풀 1개로 요청률을 두 배씩 올린 결과입니다.
| 목표 요청률 | 달성 요청률 | 버린 요청 | 상류 p50 | 상류 p95 |
|---|---|---|---|---|
| 400 | 400 req/s | 0 (0%) | 309 µs | 680 µs |
| 800 | 800 req/s | 0 (0%) | 198 µs | 660 µs |
| 1,600 | 1,591 req/s | 130 (0.5%) | 113 µs | 307 µs |
| 3,200 | 3,190 req/s | 152 (0.3%) | 79 µs | 954 µs |
| 6,400 | 6,316 req/s | 1,259 (1.3%) | 72 µs | 1,795 µs |
| 9,600 | 8,812 req/s | 11,631 (8.1%) | 33,394 µs | 61,987 µs |
| 12,800 | 9,043 req/s | 56,008 (29%) | 40,396 µs | 54,690 µs |
6,400 까지는 목표를 거의 그대로 달성합니다. 9,600 에서 8,812 로 미달하고 12,800 을 줘도 9,043 에서 멈춥니다. 커넥션 1개당 REST 상한은 약 9,000 req/s 입니다.
버린 요청이 같은 판정을 뒷받침합니다. 6,400 까지 1% 안쪽이고 9,600 에서 8.1%, 12,800 에서 29% 입니다. 목표를 두 배로 올렸는데 달성은 231 req/s 만 늘고 버린 양은 다섯 배 가까이 됐습니다.
저부하 구간의 p50 이 오히려 큰 것(400 에서 309 µs, 6,400 에서 72 µs)은 회차가 15초로 짧아 워밍업 영향이 남은 것으로 봅니다. 상한 판정에는 쓰지 않았습니다.
꼬리부터 무너집니다
6,400 은 REST 가 목표를 달성한 구간입니다. 같은 회차를 gRPC 와 나란히 놓으면 백분위마다 그림이 달라집니다.
| 지표 | REST | gRPC | 배수 |
|---|---|---|---|
| 상류 p50 | 72 µs | 79 µs | 0.9배 |
| 상류 p90 | 390 µs | 156 µs | 2.5배 |
| 상류 p95 | 1,795 µs | 245 µs | 7.3배 |
중앙값으로는 두 전송이 같고 REST 가 오히려 조금 빠릅니다. 그런데 p90 에서 2.5배, p95 에서 7.3배로 뒤로 갈수록 벌어집니다.
커넥션이 모자라지는 신호는 꼬리에만 나타납니다. 커넥션 1개에 요청이 순서를 기다리면 대부분은 기다리지 않고 통과하고 일부만 앞의 요청이 끝날 때까지 대기합니다. 그 일부가 백분위 뒤쪽에 쌓입니다.
중앙값이 움직이면 이미 늦었습니다
| 목표 요청률 | 목표 달성 | 상류 p50 | 상류 p95 |
|---|---|---|---|
| 6,400 | 달성 | 72 µs | 1,795 µs |
| 9,600 | 미달 | 33,394 µs | 61,987 µs |
사다리를 한 칸 올리자 p50 이 72 µs 에서 33.4 ms 로 460배가 됐습니다. 중앙값까지 밀린 상태입니다.
그리고 이 구간에서 REST 는 목표를 달성하지 못했습니다. 중앙값이 나빠졌다면 상한을 이미 넘은 뒤입니다. 상한을 찾으려면 중앙값이 아니라 꼬리와 버린 요청을 봐야 합니다.
풀을 늘리면 처리량은 돌아오고 꼬리는 남습니다
커넥션이 상한이라면 커넥션을 늘리는 것이 첫 선택입니다. 풀을 4개로 올려 12,800 목표를 다시 걸었습니다.
| 풀 | 달성 요청률 | 상류 p50 | 상류 p90 | 버린 요청 |
|---|---|---|---|---|
| 1 | 9,043 req/s | 40,396 µs | 48,088 µs | 29% |
| 4 | 12,328 req/s | 118 µs | 10,274 µs | 3.5% |
처리량은 돌아옵니다. 12,800 목표에서 12,328 req/s 를 달성하고 중앙값도 118 µs 로 정상입니다. 그런데 p90 이 10.3 ms 로 중앙값의 87배입니다. 중앙값만 걸린 대시보드에서는 정상으로 읽히는 상태입니다.
풀 4개로 9,600 목표를 건 회차도 있습니다. 그쪽 p90 은 18.3 ms 로 요청률이 더 낮은데 12,800 회차보다 나쁩니다. 회차가 하나씩이라 이 역전은 신호로 읽지 않았습니다.
같은 목표를 gRPC 는 커넥션 1개로 처리했습니다. 12,800 에서 12,625 req/s 를 달성하고 버린 요청이 1.4% 입니다. HTTP/2 는 한 커넥션 안에서 스트림을 나눠 동시에 실어 보내므로 동시 요청 수만큼 커넥션이 필요하지 않습니다.
| 선택 | 얻는 것 | 남는 것 |
|---|---|---|
| 풀 확대 | 처리량 회복. 설정값 하나로 끝납니다 | 꼬리 지연. 호출자 수만큼 곱해지는 커넥션 |
| 다중화 프로토콜 | 커넥션 1개로 같은 처리량 | 도입 비용. 계약과 도구를 함께 교체합니다 |
4. 마치며
커넥션을 첫 지점으로 둔 이유는 이 값이 호출자 수만큼 곱해지기 때문입니다. 여러 서비스가 한 서비스로 몰리는 구조에서 호출자가 200개면 풀 4개는 피호출 쪽에서 커넥션 800개입니다. 파일 디스크립터와 소켓 메모리와 로드밸런서 커넥션 한계에 그대로 걸립니다. 단일 인스턴스에서 「풀을 늘리면 된다」로 끝나는 판단이 규모에서 뒤집히는 자리입니다.
측정에서 남는 것은 값이 아니라 판정 순서입니다. 9,000 req/s 는 이 노트북의 이 구성에서 나온 값이고 다른 환경에 그대로 쓰이지 않습니다. 요청률을 고정해 부하를 걸고 달성 요청률과 버린 요청으로 상한을 찾은 뒤 꼬리에서 신호를 읽는 순서는 환경이 바뀌어도 남습니다. 같은 순서를 DB 커넥션이나 스레드 풀에 그대로 적용할 수 있습니다.
범위를 적습니다. 단일 호스트라 왕복 지연이 거의 0이고 데이터 계층이 인메모리라 대기 시간이 짧습니다. 풀 1개는 실무 설정이 아니므로 절대 처리량보다 배수를 봐야 합니다. 회차는 구성마다 하나이고 반복 측정이 없습니다.
전체 측정 설계와 gRPC 를 포함한 9개 지표 비교는 MSA 는 왜 gRPC 를 쓰는가 에 있습니다. 측정 코드는 grpc-vs-rest-lab 에 정리해 두었습니다.
이 글은 Claude와 함께 작업했습니다.