들어가며
외부 API를 연동하다 보면 여러 네트워크 문제를 만난다. 그중에서도 외부 API 응답 시간 지연에 대응하다가 고민했던 이야기다.
어느 날 API 연동사인 A업체에서 특정 기간 동안 API timeout 60초 이상 설정 권고 메일을 받았다. 타임아웃 설정 자체는 간단하지만, 60초라는 시간이 눈에 띄었다. 하지만 "낮은 트래픽, (트랜잭션 등과 엮여 있지 않은) 단순 조회용, 특정 조건에서만 호출되는 API"였다. (Spring MVC 환경 기준) 다만 timeout 시간을 조정할 수 있었던 건 단순히 운이 좋았을 뿐이다. 트래픽이 높았다면 Tomcat Thread Pool 고갈부터 시작해 여러 문제가 발생했을 것이고, 단순 조회가 아니었다면 DB 트랜잭션 범위 분리 같은 작업도 필요했을 것이다.
그래서 이번에는 트래픽이 높을 때 Tomcat Thread Pool이 고갈되는 문제를 풀어보려 한다. 후보로 떠올린 방식은 세 가지였고, 그중 논블로킹 I/O가 트래픽 대응에 가장 적합해 보였다.
- Thread 수 늘리기, 스케일 아웃(scale-out)
- 장점: 작업 난이도 쉬움
- 단점: 비용
- RateLimit을 이용해서 동시에 N명만 이용하도록 설정
- 장점: 작업 난이도 보통
- 단점: 사용자 안내 필요, 사용자 이탈 가능성 있음, 프론트 작업 필요
- 논블로킹 I/O
- 방법: Tomcat Thread가 외부 API 응답을 기다리지 않고 반납해 다른 사용자의 요청을 처리하고, 외부 API 응답이 완료되면 다른 스레드가 이어서 사용자 응답을 처리한다
- 장점: thread 낭비 X
- 단점: 변경 시 영향 범위 주의
논블로킹 I/O를 적용하는 데 Coroutine과 WebFlux를 실험해보려 한다.
reactor, coroutine을 사용하면서 MDC, ThreadLocal을 처리하는 작업은 이번 글에서 다루지 않는다.
이 글의 목표는 하나다. Tomcat Thread Pool이 고갈되는 상황을 그대로 재현하고, 같은 부하 조건에서 Thread-per-request, Coroutine, WebFlux 세 방식이 실제로 어떤 차이를 만드는지 지표로 확인하는 것이다.
기본 환경
주어진 환경은 다음과 같다. Spring MVC, WebClient을 사용하고 Coroutine은 사용할 예정이다.
dependencies {
// Spring MVC (Tomcat)
implementation("org.springframework.boot:spring-boot-starter-webmvc")
// WebClient (Netty)
implementation("org.springframework.boot:spring-boot-starter-webclient")
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.springframework.boot:spring-boot-starter-webmvc")
// Coroutine
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
}
서버는 2대로 실험한다. 실험에 대한 지표는 api 서버만 본다.
서버는 로컬 컴퓨터에서 각각 컨테이너 환경으로 띄웠고 싱글 인스턴스이다.
| 서버 | port | 목적 |
|---|---|---|
| api | 8080 | car-external-api를 연동한 서버 |
| car-external-api | 8081 | 테스트를 위한 A업체 모킹 서버 |
api, car-external-api의 흐름도다.

간략한 코드 예시
실험 버전별로 코드가 조금씩 변경되지만, V1(Thread-per-request)을 기준으로 작성한 코드 예시이다.
| 버전 | 내용 | 깃허브 레포 링크 |
|---|---|---|
| V1 | Thread-per-request (Spring MVC 환경) | v1 |
| V2 | Kotlin Coroutine (Spring MVC 환경) | v2 |
| V3 | Spring WebFlux (Spring MVC 환경) | v3 |
Controller
@RestController
@RequestMapping("/api")
class CarControllerV1(
private val carService: CarServiceV1,
) {
@PostMapping("/v1/car/verify")
fun verifyCarOwner(
@RequestBody requestModel: CarRequestModel,
): ResponseEntity<CarResponseModel> {
return ResponseEntity.ok(carService.verifyCarOwner(requestModel))
}
}
Service
CarOutbound의 verify 메서드를 호출하여 응답을 연결해주는 정도의 용도로 사용되는 서비스다.
@Service
class CarServiceV1(
private val carOutbound: CarOutboundV1,
) {
fun verifyCarOwner(car: CarRequestModel): CarResponseModel {
val searchResult = carOutbound.searchCarData(
Car(
vehicleNumber = car.vehicleNumber,
userName = car.userName,
)
)
return CarResponseModel(
success = searchResult.success,
data = searchResult.data?.let {
CarResponseModel.Data(
vehicleNumber = it.carNum,
userName = it.owner,
)
},
)
}
}
Outbound
WebClient 설정과 Outbound 구현이다. 지금은 block()을 쓰고 있고, 실험 버전에 따라 awaitSingle()로 바꿔가며 사용한다.
요구되는 TPS에 따라 WebClient 설정도 달라질 수 있다.
커넥션·타임아웃 설정 근거는 CarWebClientConfig 코드 다음에 정리했다.
@Component
class CarOutbound(
private val carWebClient: WebClient,
) {
fun searchCarDataV1(car: Car): SearchCarDataResponseModel {
return carWebClient
.post()
.uri("/external-api/v1/car/search")
.bodyValue(
SearchCarDataRequestModel(
carNum = car.vehicleNumber,
owner = car.userName,
)
)
.retrieve()
.bodyToMono<SearchCarDataResponseModel>()
.block()
?: error("external-api response is empty")
}
}
@Configuration
class CarWebClientConfig {
@Bean
fun carConnectionProvider(): ConnectionProvider {
return ConnectionProvider.builder("car-external-connection-pool")
// default: 500
.maxConnections(500)
// 커넥션 획득 대기열
.pendingAcquireMaxCount(10000)
// 유휴 커넥션
.maxIdleTime(Duration.ofSeconds(60))
.build()
}
@Bean
fun carWebClient(
@Value("\${external-api.base-url:http://localhost:8081}")
baseUrl: String,
carConnectionProvider: ConnectionProvider
): WebClient {
// timeout 설정
val httpClient = HttpClient.create(carConnectionProvider)
.metrics(true, Function.identity())
// 연결 속도는 영향이 없다.
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10_000)
.responseTimeout(Duration.ofSeconds(70))
.doOnConnected {
it.addHandlerLast(ReadTimeoutHandler(75, TimeUnit.SECONDS))
.addHandlerLast(WriteTimeoutHandler(75, TimeUnit.SECONDS))
}
return WebClient.builder()
.clientConnector(ReactorClientHttpConnector(httpClient))
.baseUrl(baseUrl)
.build()
}
}
커넥션 관련 설정 근거는 다음과 같다.
| 설정 | 값 | 근거 |
|---|---|---|
maxConnections |
500 | Reactor Netty 기본값을 그대로 뒀다. V1 실험에서는 Tomcat Thread Pool(기본값 200)이 먼저 고갈될 것으로 예상해서 커넥션 풀은 일부러 건드리지 않았다. 이 기본값이 실제로 병목이 되는지는 V2-A 실험에서 확인한다. |
pendingAcquireMaxCount |
10000 | 커넥션을 못 얻은 요청이 대기하는 대기열 크기다. 커넥션이 고갈됐을 때 요청이 바로 실패하는 대신 대기열에 쌓이는 모습을 보고 싶어서 넉넉히 잡았다. |
maxIdleTime |
60초 | A업체 응답이 최대 65초까지 걸리는 걸 감안해, 커넥션이 유휴 상태로 오래 방치되지 않도록 60초로 뒀다. |
CONNECT_TIMEOUT_MILLIS |
10초 | 로컬 환경이라 연결 자체는 거의 즉시 이뤄진다. 재현하려는 건 연결 지연이 아니라 응답 지연이라 여유 있게 뒀다. |
responseTimeout |
70초 | A업체 응답 지연이 최대 65초이므로 여유를 더해 70초로 잡았다. 이보다 짧으면 정상 응답도 타임아웃으로 처리된다. |
ReadTimeoutHandler/WriteTimeoutHandler |
75초 | responseTimeout보다 살짝 길게 잡아, Netty 채널 레벨 타임아웃이 WebClient 레벨 타임아웃보다 먼저 끊어버리지 않도록 여유를 뒀다. |
External Controller (8081 port)
지표 모니터링 시에 동일한 자원을 쓰게 되므로 일부러 분리된 서버로 만들어서 처리했다.
지연 상황만 발생시키면 되므로 간단히 Thread.sleep()을 이용했다.
yaml 파일에는 server.tomcat.connection-timeout, server.tomcat.keep-alive-timeout, server.tomcat.threads.max를 설정했다.
60TPS를 3분간 유지한다고 하면 총 요청 수는 60 × 180 = 10,800건이다. 각 요청은 응답까지 최대 65초를 점유하므로, 정상 상태(steady state)에서는 어느 시점이든 대략 60 × 60 = 3,600건 이상의 요청이 동시에 처리 중이어야 밀리지 않는다. 이 모킹 서버는 실험 대상이 아니라 지연만 재현하면 되므로, threads.max를 5000으로 넉넉히 잡아 모킹 서버 자체가 병목이 되는 일은 없도록 했다.
@RestController
@RequestMapping("/external-api/v1/car")
class CarSearchController {
@PostMapping("/search")
fun search(
@RequestBody request: SearchCarDataRequestModel,
): ResponseEntity<SearchCarDataResponseModel> {
return run {
Thread.sleep(Random.nextLong(50_000L, 65_001L))
ResponseEntity.ok(
SearchCarDataResponseModel(
success = true,
data = SearchCarDataResponseModel.Data(
carNum = request.carNum,
owner = request.owner,
),
)
)
}
}
}
반대로 실험 대상인 api 서버(V1)의 Tomcat Thread Pool은 기본값인 200이다. 응답까지 평균 60초 정도 걸린다고 보면 200 / 60초 ≈ 3.33TPS 근처에서 병목이 발생할 것으로 예상할 수 있다.
server:
port: 8081
tomcat:
connection-timeout: 90000
keep-alive-timeout: 90000
threads:
max: 5000 # default가 200인데 5000으로 뒀다.
실험 전
| 설명 | |
|---|---|
| 실험 이유 | 외부 업체 API 응답 시간이 60초 이상 걸리는 상황이다. Tomcat Thread Pool이 고갈되는 현상이 생길 수 있다. |
| 실험 목적 | Tomcat Thread Pool 고갈 현상을 없앤다. 기존 TPS를 유지할 수 있는 상태를 만든다. |
어떤 지표를 볼 것인가?
실험 전에 가장 중요한 것은 어떤 지표를 보느냐이다.
로컬 테스트 모니터링을 위해 prometheus, grafana 를 사용했다.
확인할 지표
| 구분 | 지표 | 설명 | 이유 |
|---|---|---|---|
| Tomcat 스레드 | tomcat_threads_busy_threads | 지금 실제로 요청을 처리 중인(점유된) 워커 스레드 개수 | "스레드 풀이 고갈됐다"는 건 결국 스레드가 실제로 점유돼 있다는 뜻이다. 이걸 직접 보여주는 지표는 current가 아니라 busy다. |
| tomcat_threads_current_threads | 지금까지 생성된 워커 스레드 개수(점유 여부와 무관) | busy와 나란히 두면 "생성은 됐지만 점유되지는 않은" 스레드가 얼마나 되는지 드러난다 | |
| tomcat_threads_config_max_threads | Tomcat 최대 스레드 제한치 | busy/current를 이 값과 비교해 고갈까지 남은 여유를 파악하기 위함 | |
| Netty 커넥션 | reactor_netty_connection_provider_max_connections | Reactor Netty가 외부 A 업체와 맺을 수 있는 최대 HTTP 커넥션 수 | Tomcat 스레드가 여유있어도 외부 커넥션 풀이 병목이 되는지를 보기 위함 |
| reactor_netty_connection_provider_active_connections | A업체와 연결되어 실제 요청/응답을 처리 중인 커넥션 수 | 커넥션이 치솟는지 확인하기 위함 | |
| reactor_netty_connection_provider_pending_connections | 커넥션을 얻기 위한 대기열 | 외부 연동 제한으로 인한 병목이 생기는지 확인하기 위함 | |
| 서버성능 | http_server_requests_seconds_count | 서버가 처리한 총 요청수 | 실제 처리 성능(TPS 및 HTTP 상태별 처리량) 측정을 위함 |
제외한 지표
| 지표 | 안보는 이유 |
|---|---|
| api 응답 시간, Tomcat Thread 점유 시간 | 처리 속도 최적화가 목적이 아니다. (50~65초 정도로 지연을 강제시킨 상태로 테스트할 예정이다.) |
| TCP 커넥션 개수 | 외부 업체 API에 소켓 연결이 돼 있어서 영향이 충분히 갈 수 있다. 같이 모니터링하는 게 맞지만, 최초 목적이었던 Tomcat Thread 고갈 문제 해소에 중점을 둔 다음에 고려해보기로 한다. |
| DB Connection Pool | 트랜잭션 범위 밖에 나와있으므로 영향은 없다. (만약 있었다해도 범위를 분리하는 방식으로 처리하려고 한다.) |
대시보드
이 화면은 실험을 위해 설정해둔 패널 전체를 한 번에 캡처한 것이다. CPU, Memory, Socket(TCP) 연결 수 지표도 여기에 함께 잡혀 있다. 이번 글의 주제인 Tomcat Thread Pool 고갈과는 직접 관련이 없어서 개별 지표로 따로 다루지는 않았지만, 이후에는 같이 봐야 할 지표들이다. 서버 인스턴스 1개에서의 최대 Socket 연결 개수, 트래픽 부하로 인한 CPU 사용률, 점유되거나 대기열에 쌓이면서 늘어나는 Memory까지 함께 확인해야 하기 때문이다.

부하테스트 설정 (k6)
k6를 이용했다. 가상 유저 수(VU)가 5000으로 높은데 타임아웃 시간을 70초로 가정하고 60TPS라고 했을 때 약 4,200 VU가 있어야 k6가 병목없이 초당 60건 요청할 것으로 판단했다. 그래서 넉넉히 5,000VU로 설정했다. 외부 A업체 모킹 서버에서 Tomcat 스레드 개수를 크게 잡아놓은 이유도 5,000VU가 필요하다고 측정했기 때문이다.
결과적으로는 약 10,800(60 * 60 * 3)건 정도 3분 내에 API 호출할 계획이다.
| 설정 | 값 |
|---|---|
| 목표 부하 | 60TPS |
| 부하 시간 | 3m |
| 가상 유저 수 (선로드) | 1500 |
| 가상 유저 수 (최대) | 5000 |
| 요청 타임아웃 시간 | 70s |
실제로는 60TPS를 한 번에 쏘지 않는다. constant-arrival-rate executor로 20 iterations/s를 내는 시나리오 3개(tps_20_0, tps_20_1, tps_20_2)를 만들고, 5초 간격으로 시작 시각을 어긋나게 잡아 병렬로 겹쳐 돌린다. 그래서 0~5초는 20TPS, 5~10초는 40TPS, 10초 이후부터는 60TPS(20+20+20)가 유지된다.

실험 시작
실험 V1 - Thread-per-request
흐름도

k6 결과
Tomcat 스레드가 고갈되고 계속해서 요청이 밀리면서 대부분의 처리가 실패했다. k6에서 timeout 시간을 70초로 잡았는데 요청이 밀리면서 요청 자체가 실패한 케이스가 대부분을 차지한다. k6 요청 timeout 시간을 늘려서 테스트해도 되지만 비슷한 결과를 예상하여 늘린 상태로 테스트를 재시도하지는 않았다.
/\ |‾‾| /‾‾/ /‾‾/
/\ / \ | |/ / / /
/ \/ \ | ( / ‾‾\
/ \ | |\ \ | (‾) |
/ __________ \ |__| \__\ \_____/ .io
execution: local
script: loadtest/k6/timeout-example.js
output: -
scenarios: (100.00%) 3 scenarios, 15000 max VUs, 3m40s max duration (incl. graceful stop):
* tps_20_0: 20.00 iterations/s for 3m0s (maxVUs: 1500-5000, exec: callApi, gracefulStop: 30s)
* tps_20_1: 20.00 iterations/s for 3m0s (maxVUs: 1500-5000, exec: callApi, startTime: 5s, gracefulStop: 30s)
* tps_20_2: 20.00 iterations/s for 3m0s (maxVUs: 1500-5000, exec: callApi, startTime: 10s, gracefulStop: 30s)
...
time="2026-08-02T20:43:11+09:00" level=warning msg="Request Failed" error="Post \"http://localhost:8080/api/v1/car/verify\": request timeout"
time="2026-08-02T20:43:11+09:00" level=warning msg="Request Failed" error="Post \"http://localhost:8080/api/v1/car/verify\": request timeout"
...
✗ status is 200
↳ 1% — ✓ 200 / ✗ 9867
✗ response has body
↳ 1% — ✓ 200 / ✗ 9867
checks.........................: 1.98% ✓ 400 ✗ 19734
data_received..................: 49 kB 225 B/s
data_sent......................: 2.1 MB 9.6 kB/s
http_req_blocked...............: avg=1.56ms min=1µs med=773µs max=53.58ms p(90)=3.21ms p(95)=5.24ms
http_req_connecting............: avg=1.4ms min=0s med=651µs max=52.93ms p(90)=2.85ms p(95)=4.82ms
http_req_duration..............: avg=1m2s min=10s med=1m10s max=1m10s p(90)=1m10s p(95)=1m10s
{ expected_response:true }...: avg=57.42s min=50.03s med=57.39s max=1m4s p(90)=1m3s p(95)=1m3s
✗ http_req_failed................: 98.01% ✓ 9867 ✗ 200
http_req_receiving.............: avg=24.01µs min=0s med=0s max=31.24ms p(90)=0s p(95)=0s
http_req_sending...............: avg=91.31µs min=6µs med=55µs max=8.56ms p(90)=136µs p(95)=202.69µs
http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
http_req_waiting...............: avg=1m2s min=10s med=1m10s max=1m10s p(90)=1m10s p(95)=1m10s
http_reqs......................: 10067 45.755554/s
iteration_duration.............: avg=1m2s min=10s med=1m10s max=1m10s p(90)=1m10s p(95)=1m10s
iterations.....................: 10067 45.755554/s
vus............................: 203 min=10 max=4201
vus_max........................: 4500 min=4500 max=4500
running (3m40.0s), 00000/04500 VUs, 10067 complete and 735 interrupted iterations
tps_20_0 ✓ [ 100% ] 0331/1500 VUs 3m0s 20.00 iters/s
tps_20_1 ✓ [ 100% ] 0216/1500 VUs 3m0s 20.00 iters/s
tps_20_2 ✓ [ 100% ] 0188/1500 VUs 3m0s 20.00 iters/s
time="2026-08-02T20:45:34+09:00" level=error msg="thresholds on metrics 'http_req_failed' have been crossed"
V1, 위젯 - Tomcat Thread Pool Count
Tomcat Thread Pool Count 위젯에서 봐야 할 건 Busy Threads인데, 마지막으로 성공한 스크래핑 값이 1이다. 이걸 "스레드가 거의 안 바빴다"로 읽으면 안 된다 — 아직 부하가 다 쌓이기 전(테스트 시작 직후)에 우연히 잡힌 마지막 샘플일 뿐이고, 그 이후로는 Busy 값 자체가 아예 존재하지 않는다. Tomcat 스레드 200개가 전부 .block()에 묶이고 나면 /actuator/prometheus를 스크래핑하러 온 요청조차 처리할 스레드가 남지 않기 때문이다. 즉 Busy가 실제로 200에 근접하는 바로 그 순간은 Prometheus가 원천적으로 관찰할 수 없는 구간이다 — 지표가 끊겼다는 사실 자체가 스레드 풀이 완전히 고갈됐다는 걸 보여주는 증거인 셈이다.
같은 구간에서 Current Threads는 72까지 오른 뒤 멈춘다. Current는 Busy처럼 완전히 관찰 불가능한 상태까지 가지는 않았다는 정도만 보여줄 뿐, "몇 개가 실제로 점유됐는지"는 대답하지 못한다. 모든 요청을 처리하는 데는 1시간 정도 걸렸지만, 두 지표 모두 그보다 훨씬 일찍 끊겼다.

V1, 위젯 - System Throughput (TPS)
System Throughput (TPS) 위젯을 보면 60TPS까지 부하를 올렸지만 실제로는 4TPS대 초반 정도만 처리된 것으로 보인다. Tomcat Thread Pool이 200개이고 응답까지 약 50초~65초가 걸리므로, 200개의 스레드가 순식간에 요청 하나씩을 붙잡은 채 멈춰 선다. 앞서 계산한 대로 200 / 60초 ≈ 3.33TPS 근처가 이 구성의 처리량 한계였던 셈이다.

V1, 위젯 - Reactor Netty Connection Pool Status
Reactor Netty Connection Pool Status 위젯을 보면 active 200, pending 0이 유지되는 것을 볼 수 있다. Netty Connection max 기본값이 500인데, Tomcat 스레드가 먼저 고갈될 거라 예상해 일부러 조정하지 않았다. 다른 실험에서 60TPS를 Connection 대기 없이 처리하려면 약 5,000개로 설정해야 pending 없이 진행될 것으로 예상한다.

실험 V2 - Coroutine
Tomcat Thread로 요청을 받은 후 나머지 작업을 Coroutine에게 넘기는 실험이다. V1 Thread-per-request와 다르게, Tomcat Thread는 요청을 접수한 직후 곧바로 Thread Pool에 반납된다. 이게 가능한 이유는 Kotlin Coroutine의 Suspend 메커니즘 덕분이다. 외부 응답을 기다리는 동안 스레드를 점유하지 않고, 그 대기를 온전히 Netty에게 맡긴다. (kotlinx-coroutines-reactor docs)
Coroutine 실험은 A/B로 나눠 두 번했다. A 실험은 Netty Connection 개수가 부족해서 실험자체에 영향이 가는 것을 보였다. B 실험은 Netty Connection 개수를 수정한 후 테스트한 결과다.
흐름도

A실험 - k6 결과
/\ |‾‾| /‾‾/ /‾‾/
/\ / \ | |/ / / /
/ \/ \ | ( / ‾‾\
/ \ | |\ \ | (‾) |
/ __________ \ |__| \__\ \_____/ .io
execution: local
script: loadtest/k6/timeout-example.js
output: -
scenarios: (100.00%) 3 scenarios, 15000 max VUs, 3m40s max duration (incl. graceful stop):
* tps_20_0: 20.00 iterations/s for 3m0s (maxVUs: 1500-5000, exec: callApi, gracefulStop: 30s)
* tps_20_1: 20.00 iterations/s for 3m0s (maxVUs: 1500-5000, exec: callApi, startTime: 5s, gracefulStop: 30s)
* tps_20_2: 20.00 iterations/s for 3m0s (maxVUs: 1500-5000, exec: callApi, startTime: 10s, gracefulStop: 30s)
...
time="2026-08-02T20:12:29+09:00" level=warning msg="Request Failed" error="Post \"http://localhost:8080/api/v2/car/verify\": request timeout"
time="2026-08-02T20:12:29+09:00" level=warning msg="Request Failed" error="Post \"http://localhost:8080/api/v2/car/verify\": request timeout"
...
✗ status is 200
↳ 5% — ✓ 500 / ✗ 8923
✗ response has body
↳ 84% — ✓ 7922 / ✗ 1501
checks.........................: 44.68% ✓ 8422 ✗ 10424
data_received..................: 2.5 MB 11 kB/s
data_sent......................: 2.1 MB 9.6 kB/s
http_req_blocked...............: avg=1.2ms min=94µs med=459µs max=105.7ms p(90)=1.74ms p(95)=3.74ms
http_req_connecting............: avg=1.1ms min=73µs med=390µs max=92.52ms p(90)=1.55ms p(95)=3.48ms
http_req_duration..............: avg=49.65s min=44.99s med=45.01s max=1m10s p(90)=1m10s p(95)=1m10s
{ expected_response:true }...: avg=57.42s min=50.05s med=57.36s max=1m5s p(90)=1m3s p(95)=1m4s
✗ http_req_failed................: 94.69% ✓ 8924 ✗ 500
http_req_receiving.............: avg=166.36µs min=0s med=77µs max=40.01ms p(90)=274µs p(95)=504µs
http_req_sending...............: avg=73.55µs min=5µs med=42µs max=34.62ms p(90)=93µs p(95)=137µs
http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
http_req_waiting...............: avg=49.65s min=44.99s med=45.01s max=1m10s p(90)=1m10s p(95)=1m10s
http_reqs......................: 9424 42.827491/s
iteration_duration.............: avg=49.65s min=44.99s med=45.01s max=1m10s p(90)=1m10s p(95)=1m10s
iterations.....................: 9424 42.827491/s
vus............................: 485 min=4 max=3203
vus_max........................: 4500 min=4500 max=4500
running (3m40.0s), 00000/04500 VUs, 9423 complete and 1380 interrupted iterations
tps_20_0 ✓ [ 100% ] 0447/1500 VUs 3m0s 20.00 iters/s
tps_20_1 ✓ [ 100% ] 0465/1500 VUs 3m0s 20.00 iters/s
tps_20_2 ✓ [ 100% ] 0468/1500 VUs 3m0s 20.00 iters/s
time="2026-08-02T20:14:46+09:00" level=error msg="thresholds on metrics 'http_req_failed' have been crossed"
A실험, 위젯 - Tomcat Thread Pool Count
(Tomcat Thread Pool Max는 200으로 잡혀 있다.)
Busy Threads는 부하가 몰리는 동안에도 내내 1 근처에 머문다 — 200개 한도 중 실제로 점유된 스레드는 사실상 없다는 뜻이다. Coroutine은 외부 API 응답을 기다리는 동안 Tomcat 스레드를 곧바로 풀에 반납하기 때문에, 부하가 아무리 들어와도 스레드가 점유된 채로 쌓이지 않는다.
같이 표시된 Current Threads는 44까지 오른다. 이 숫자만 보면 "Tomcat 스레드를 44개 정도 쓰고 있구나"로 읽히기 쉽지만, Busy가 1 근처라는 걸 감안하면 Current는 "부하가 들어오는 순간 Tomcat이 접수용으로 만들어 둔 스레드 수"일 뿐이고, 그중 실제로 바쁜 스레드는 거의 없다는 걸 알 수 있다.

A실험, 위젯 - Reactor Netty Connection Pool Status
Netty Connection Max가 500이고, Connection Active 개수가 500까지 max만큼 사용했다.
그리고 Connection이 고갈되면서 Connection Pending 개수가 약 850개로 올랐다.

A실험, 위젯 - System Throughput (TPS)
27.5 TPS 정도로 V1에 비하면 오르긴 했지만, 원하던 정도는 아니다.

B실험 - k6 결과
/\ |‾‾| /‾‾/ /‾‾/
/\ / \ | |/ / / /
/ \/ \ | ( / ‾‾\
/ \ | |\ \ | (‾) |
/ __________ \ |__| \__\ \_____/ .io
execution: local
script: loadtest/k6/timeout-example.js
output: -
scenarios: (100.00%) 3 scenarios, 15000 max VUs, 3m40s max duration (incl. graceful stop):
* tps_20_0: 20.00 iterations/s for 3m0s (maxVUs: 1500-5000, exec: callApi, gracefulStop: 30s)
* tps_20_1: 20.00 iterations/s for 3m0s (maxVUs: 1500-5000, exec: callApi, startTime: 5s, gracefulStop: 30s)
* tps_20_2: 20.00 iterations/s for 3m0s (maxVUs: 1500-5000, exec: callApi, startTime: 10s, gracefulStop: 30s)
✓ status is 200
✓ response has body
checks.........................: 100.00% ✓ 18284 ✗ 0
data_received..................: 2.3 MB 10 kB/s
data_sent......................: 2.1 MB 9.6 kB/s
http_req_blocked...............: avg=1.08ms min=98µs med=605µs max=39.63ms p(90)=1.64ms p(95)=2.62ms
http_req_connecting............: avg=949.11µs min=63µs med=482µs max=39.52ms p(90)=1.42ms p(95)=2.31ms
http_req_duration..............: avg=57.44s min=50.01s med=57.37s max=1m5s p(90)=1m3s p(95)=1m4s
{ expected_response:true }...: avg=57.44s min=50.01s med=57.37s max=1m5s p(90)=1m3s p(95)=1m4s
✓ http_req_failed................: 0.00% ✓ 0 ✗ 9143
http_req_receiving.............: avg=242.63µs min=12µs med=145µs max=63.14ms p(90)=393µs p(95)=617µs
http_req_sending...............: avg=75.95µs min=8µs med=56µs max=2.87ms p(90)=133µs p(95)=171µs
http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
http_req_waiting...............: avg=57.44s min=50.01s med=57.37s max=1m5s p(90)=1m3s p(95)=1m4s
http_reqs......................: 9143 41.552358/s
iteration_duration.............: avg=57.44s min=50.01s med=57.37s max=1m5s p(90)=1m3s p(95)=1m4s
iterations.....................: 9143 41.552358/s
vus............................: 582 min=7 max=3484
vus_max........................: 4500 min=4500 max=4500
running (3m40.0s), 00000/04500 VUs, 9142 complete and 1660 interrupted iterations
tps_20_0 ✓ [ 100% ] 0543/1500 VUs 3m0s 20.00 iters/s
tps_20_1 ✓ [ 100% ] 0550/1500 VUs 3m0s 20.00 iters/s
tps_20_2 ✓ [ 100% ] 0567/1500 VUs 3m0s 20.00 iters/s
B실험, 위젯 - Tomcat Thread Pool Count
Busy Threads는 A실험과 마찬가지로 내내 1 근처다 — Netty Connection을 500에서 5000으로 늘려도 Tomcat 스레드 점유량 자체는 달라지지 않는다. Coroutine 구조에서 Tomcat 스레드가 실제로 묶이는 시간은 거의 없기 때문이다.
Max 200 중 Current Thread는 10개 안팎에서 시작해 58개까지 오른 뒤 그 지점에서 고정된다. A실험보다 Netty Connection을 늘렸을 뿐인데 Current도 같이 올라간 걸 보면, 이 값이 "몇 개가 점유돼 있는가"가 아니라 "부하량에 비례해 얼마나 많은 스레드를 접수용으로 만들었는가"에 가깝다는 걸 다시 확인할 수 있다.

B실험, 위젯 - Reactor Netty Connection Pool Status
Active Connection이 약 3,400~3,490까지 올라간 뒤 유지되고, Pending은 시종일관 0이다. 앞서 '기본 환경'에서 계산한 "60TPS × 60초 ≈ 3,600건 동시 처리" 예측과 거의 일치한다.

B실험, 위젯 - System Throughput (TPS)
안정 구간에서는 58~63TPS로, 목표치인 60TPS에 근접한다. k6 결과의 41.5TPS는 초반 부하를 20 → 40 → 60TPS로 단계적으로 올리는 구간과 종료 후 graceful stop 구간까지 포함한 전체 평균이라 낮게 잡힌 것이고, 실제 정상 구간 TPS는 목표치와 큰 차이가 없다.

A/B 실험 정리
| 분류 | 실험 조건 | 실험 이유 | 실험 결과 |
|---|---|---|---|
| A | Netty Connection max 개수가 500인 경우 | 60TPS 실험 시에 Netty Connection 500으로는 부족한지 확인한다 | Netty Connection이 병목 지점으로 확인됨. Netty Connection max 개수를 늘려야 한다. |
| B | Netty Connection max 개수가 5000인 경우 | Netty Connection이 병목이 되지 않도록 설정했다. | http_req_failed 0%. Netty Connection과 Tomcat Thread 모두 병목 없이 부하를 받아냈다. |
실험 V3 - WebFlux
코드
Tomcat으로 요청을 받고, Netty 이벤트 루프로 처리를 넘긴다. 중간에 동기 처리 구간이 남아 있다면 Tomcat 워커 스레드는 반납되지 않는다. 반대로 Reactor Stream이 끊기지 않고 응답까지 이어진다면, Tomcat 워커 스레드는 반납되고 나머지는 Netty 이벤트 루프가 처리한다.
Channel은 소켓을 통해 네트워크 데이터 버퍼를 받고, 완료 여부는 Selector가 감시한다. 완료되면 이벤트 루프가 다시 Tomcat 스레드를 불러 마무리 응답 작업을 넘긴다.
흐름도

k6 결과
/\ |‾‾| /‾‾/ /‾‾/
/\ / \ | |/ / / /
/ \/ \ | ( / ‾‾\
/ \ | |\ \ | (‾) |
/ __________ \ |__| \__\ \_____/ .io
execution: local
script: loadtest/k6/timeout-example.js
output: -
scenarios: (100.00%) 3 scenarios, 15000 max VUs, 3m40s max duration (incl. graceful stop):
* tps_20_0: 20.00 iterations/s for 3m0s (maxVUs: 1500-5000, exec: callApi, gracefulStop: 30s)
* tps_20_1: 20.00 iterations/s for 3m0s (maxVUs: 1500-5000, exec: callApi, startTime: 5s, gracefulStop: 30s)
* tps_20_2: 20.00 iterations/s for 3m0s (maxVUs: 1500-5000, exec: callApi, startTime: 10s, gracefulStop: 30s)
✓ status is 200
✓ response has body
checks.........................: 100.00% ✓ 18318 ✗ 0
data_received..................: 2.3 MB 10 kB/s
data_sent......................: 2.1 MB 9.6 kB/s
http_req_blocked...............: avg=1.16ms min=117µs med=747µs max=31.1ms p(90)=1.66ms p(95)=2.71ms
http_req_connecting............: avg=1.02ms min=100µs med=613µs max=30.99ms p(90)=1.46ms p(95)=2.45ms
http_req_duration..............: avg=57.46s min=50.01s med=57.47s max=1m5s p(90)=1m3s p(95)=1m4s
{ expected_response:true }...: avg=57.46s min=50.01s med=57.47s max=1m5s p(90)=1m3s p(95)=1m4s
✓ http_req_failed................: 0.00% ✓ 0 ✗ 9159
http_req_receiving.............: avg=267.84µs min=11µs med=142µs max=32.72ms p(90)=425µs p(95)=667µs
http_req_sending...............: avg=74.71µs min=7µs med=54µs max=4.64ms p(90)=124µs p(95)=165µs
http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
http_req_waiting...............: avg=57.46s min=50.01s med=57.47s max=1m5s p(90)=1m3s p(95)=1m4s
http_reqs......................: 9159 41.623497/s
iteration_duration.............: avg=57.46s min=50.02s med=57.47s max=1m5s p(90)=1m3s p(95)=1m4s
iterations.....................: 9159 41.623497/s
vus............................: 551 min=9 max=3488
vus_max........................: 4500 min=4500 max=4500
running (3m40.0s), 00000/04500 VUs, 9159 complete and 1644 interrupted iterations
tps_20_0 ✓ [ 100% ] 0551/1500 VUs 3m0s 20.00 iters/s
tps_20_1 ✓ [ 100% ] 0548/1500 VUs 3m0s 20.00 iters/s
tps_20_2 ✓ [ 100% ] 0545/1500 VUs 3m0s 20.00 iters/s
V3, 위젯 - Tomcat Thread Pool Count
Busy Threads는 B실험과 동일하게 1 근처에 머문다 — WebFlux 역시 Tomcat 스레드가 응답을 기다리는 동안 점유되지 않는다는 점에서 Coroutine과 다르지 않다.
Max 200 중 Current Thread는 10개 안팎에서 시작해 68개까지 오른 뒤 그 지점에서 고정된다. B실험(58개)과 거의 같은 모양이다.

V3, 위젯 - Reactor Netty Connection Pool Status
Active Connection이 약 3,490까지 올라간 뒤 유지되고, Pending은 시종일관 0이다. B실험과 마찬가지로 '기본 환경'에서 계산한 약 3,600건 예측과 거의 일치한다.

V3, 위젯 - System Throughput (TPS)
안정 구간에서는 58~63TPS로, B실험과 동일하게 목표치인 60TPS에 근접한다.

실험 결과
종합 비교
| 버전 | 목표 TPS | 실제 처리량 (http_reqs) | 실패율 | Tomcat Thread (Busy / Current) | Netty Connection |
|---|---|---|---|---|---|
| V1 Thread-per-request | 60 | 45.8/s | 98.01% | Busy는 마지막 성공 스크래핑(테스트 극초반) 값이라 1로 낮게 찍힌 뒤 지표 자체가 끊김 — 스레드가 전부 .block()에 묶이면 Prometheus 스크래핑을 처리할 스레드조차 남지 않는다는 뜻이라, 지표 단절 자체가 완전 고갈의 증거다. Current는 72까지 오른 뒤 함께 끊김 |
active 200 / pending 0 (Tomcat이 먼저 고갈돼 Connection까지 못 감) |
| V2-A Coroutine (Netty max 500) | 60 | 42.8/s | 94.69% | Busy 1 근처 — 실제로 점유된 스레드는 거의 없음 (Current 44) | active 500(max) / pending 최대 약 850 |
| V2-B Coroutine (Netty max 5000) | 60 | 41.6/s (안정 구간 58~63) | 0.00% | Busy 1 근처(200 중), Current 58 | active 약 3,400 안팎 유지 / pending 0 |
| V3 WebFlux | 60 | 41.6/s (안정 구간 58~63) | 0.00% | Busy 1 근처(200 중), Current 68 | active 약 3,490 안팎 유지 / pending 0 |
V2-B, V3 는 System Throughput 위젯을 보면 정상 구간(10초~180초)에서는 58~63TPS로 목표치인 60TPS 정도로 볼 수 있다. Netty Connection Active도 약 3,400~3,490까지 오르는데, 이는 '기본 환경'에서 계산한 "60TPS × 60초 ≈ 3,600건 동시 처리" 예측과 거의 일치한다. 예측했던 숫자가 실측으로 확인된 셈이다.
Coroutine vs WebFlux, 뭘 골라야 할까
V2-B(Coroutine)와 V3(WebFlux)의 k6 결과는 사실상 같다. 처리량 41.5TPS 안팎, 실패율 0%, http_req_duration 평균 57초대로 거의 겹친다. Tomcat 스레드가 즉시 반납되고 Netty가 대기를 넘겨받는 구조도 동일하다. 이 시나리오만 놓고 보면 성능으로는 둘을 가를 수 없다.
그렇다면 기준은 성능이 아니라 다른 곳에 있다.
먼저 짚어야 할 건 지금 환경이 처음부터 끝까지 Spring MVC 기반이라는 점이다. 이번 실험의 V3 "WebFlux"도 서버 자체를 WebFlux(Netty) 기반으로 새로 띄운 게 아니라, Spring MVC 컨트롤러가 Mono를 반환하도록 만든 것뿐이다. "WebFlux를 쓴다"고 했을 때 지금처럼 MVC 위에 반응형 타입만 얹은 상태인지, 아니면 애플리케이션 자체를 WebFlux 기반으로 새로 띄운 상태인지부터 구분해야 실제로 뭘 얻을 수 있는지가 갈린다.
- Coroutine: 기존 코드가 Spring MVC 기반이라면 서버 스택을 바꿀 필요 없이 그대로 얹을 수 있다.
suspend fun은 여전히 동기 코드처럼 읽혀서 전환 비용도 낮다. - WebFlux: 지금처럼 Spring MVC 위에서
Mono만 반환하는 정도로는 Coroutine과 실질적인 차이가 없다. 이점을 제대로 보려면 애플리케이션을 WebFlux(Netty) 기반으로 새로 띄우고 R2DBC 등 리액티브 생태계로 전체를 맞춰야 한다.
마치며
Tomcat Thread Pool이 고갈되는 상황을 그대로 재현하고, Thread-per-request / Coroutine / WebFlux 세 방식을 같은 부하로 비교했다. Thread-per-request는 실패율 98.01%로 무너졌고, Netty Connection 개수까지 맞춘 Coroutine과 WebFlux는 실패율 0%로 부하를 그대로 받아냈다.
NIO를 적용한다고 문제가 저절로 사라지는 건 아니다. 소켓 커넥션 수 설정, 컨텍스트 전파 방식, CPU/메모리 부하, 트래픽 스파이크 시 연동된 외부 서비스나 DB가 감당할 수 있는지까지 함께 검토해야 한다. 이번 실험은 그중 Tomcat Thread Pool 고갈이라는 문제 하나만 좁혀서 본 것이다.