콘텐츠로 이동

Ktor CIO가 HTTP 벤치마크를 이상하게 만들었을 때

HTTP client들이 같은 benchmark harness를 통과하는 소개용 일러스트
같은 HTTP abstraction 뒤에서도 client마다 병목은 전혀 다른 곳에서 튀어나온다.

bluetape4k-projectsio/http 모듈은 Apache HC5, OkHttp3, Java HttpClient, Vert.x WebClient, Ktor CIO를 같은 API 뒤에 세운다. 쓰는 쪽에서는 같은 HttpClient abstraction처럼 보이지만, 실제로는 client마다 연결 재사용, coroutine bridge, virtual thread, server fixture, pool 설정이 다르게 움직인다.

그래서 이런 모듈에서 제일 위험한 문장은 “취향대로 고르면 됩니다”다. 어느 workload에서 어떤 client가 버티는지 모르면, 추상화는 편의가 아니라 랜덤 선택 버튼이 된다. 이 benchmark 정리는 단순한 튜닝 작업이 아니었다. 숫자로 보고, 왜 그런 숫자가 나왔는지 기록하고, 선택 기준을 남기자는 작은 캠페인이었다.

첫 측정은 기분 좋게 시작하지 않았다. Ktor CIO가 꽤 이상한 숫자를 냈다.

HTTP client base throughput chart

HTTP client high-latency throughput benchmark chart comparing OkHttp, HC5, Java HttpClient, Vert.x, and Ktor CIO
Ktor CIO의 느린 초기 숫자에서 출발해 공정한 benchmark, Vert.x pool 병목 수정, workload별 HTTP client 선택 기준까지 정리한 글.

처음 Ktor CIO row를 넣었을 때는 표를 다시 보고 싶지 않았다. /ping은 659.071 ops/s, 50 ms delay endpoint는 16.501 ops/s였다. 같은 benchmark에서 다른 client들은 훨씬 높은 throughput을 내고 있었다. 표만 보면 거의 “이 row를 왜 넣었지?”였다.

그런데 이 숫자를 그대로 결론으로 쓰면 안 됐다. CIO를 전체 JMH concurrency로 돌리면 Docker fixture에서 local ephemeral port가 먼저 바닥났다. 그래서 CIO row만 thread 하나로 제한한 상태였다. 같은 표에 있었지만, 같은 규칙으로 뛴 결과는 아니었다.

그래서 CIO 후속 작업의 핵심은 “CIO를 빠르게 보이게 하자”가 아니었다. “CIO를 같은 조건에서 재자”였다. 느린 숫자를 덮지 않고, 왜 느렸는지 남긴 다음 fixture를 고치는 쪽을 택했다.

후속 작업에서 가장 극적인 변화는 Ktor가 아니라 Vert.x였다.

Vert.x WebClient benchmark는 기본 HTTP/1 pool cap에 걸려 있었다. client 자체의 능력을 잰 것이 아니라, fixture와 default pool 설정이 만든 병목을 잰 셈이다. PoolOptions를 명시하자 high-latency throughput은 87.844 ops/s에서 1,818.508 ops/s로 올라갔다. 약 20.7배다.

이 숫자만 보면 그럴듯한 optimization 이야기다. 하지만 더 중요한 교훈은 단순하다.

Benchmark가 느리다고 바로 production code를 고치지 말자. 먼저 측정 장치를 의심해야 한다.

다음 정리 작업에서는 측정 조건을 다시 맞췄다.

  • HTTP client benchmark fixture를 BluetapeWebfluxServer로 맞췄다.
  • CIO만 thread 하나로 돌리는 예외를 제거했다.
  • 모든 row가 class-level JMH thread count를 사용하게 했다.
  • local port exhaustion을 피하기 위해 warmup 1초, measurement 1초의 짧은 equal-thread snapshot으로 맞췄다.

이 결정이 좋았던 이유는 “CIO가 이겼다” 같은 보기 좋은 결론을 만들지 않았기 때문이다. 오히려 CIO의 한계를 더 잘 드러냈다. Ktor CIO 3.5는 pipelining을 끄면 HTTP/1 connection을 적극적으로 열고, pipelining=true를 강제로 켜면 local mock fixture에서 ClosedReadChannelException: unexpected EOF 또는 hang을 만들었다. 그래서 이번 글도 결론을 키우지 않는다. default CIO behavior를 유지한 상태에서, 같은 concurrency로 짧게 비교한 snapshot만 다룬다.

Base /ping benchmark는 connection reuse와 local variance에 민감하다. production ranking으로 읽으면 안 된다. 같은 fixture에서 같은 순간에 본 snapshot으로 보는 편이 맞다.

Client row/ping ops/s
Java HttpClient sync7,276.492
HC5 classic virtual thread7,246.690
OkHttp3 virtual thread6,955.796
Vert.x WebClient coroutines6,043.906
Ktor CIO coroutines2,052.281

High-latency endpoint는 더 흥미롭다. 50 ms delay가 들어가면 blocking, coroutine, virtual thread의 차이가 단순 CPU loop가 아니라 실제 service wait time에 더 가까워진다.

Client row50 ms delay ops/s
OkHttp3 virtual thread1,902.171
HC5 classic virtual thread1,888.018
Java HttpClient virtual thread1,883.634
HC5 classic1,880.023
HC5 async coroutines1,860.655
Vert.x WebClient coroutines1,859.003
Ktor CIO coroutines1,515.026
HC5 classic coroutines1,216.306

CIO는 여전히 선두권이 아니다. 하지만 처음의 16.501 ops/s처럼 “이건 뭔가 망했다”에 가까운 수치와는 다른 이야기다. 같은 조건에서는 1,515.026 ops/s까지 올라왔다. 대신 connection behavior와 fixture compatibility라는 숙제도 같이 남았다.

이 benchmark 이후 io/http의 client 선택을 감으로 말하기는 어려워졌다. 적어도 출발점은 생겼다.

상황먼저 볼 선택지
짧은 local call, sync API, 단순 사용성Java HttpClient, HC5 classic
wait-heavy HTTP workloadOkHttp3/HC5/Java virtual thread path
coroutine-native integration과 non-blocking client가 중요함HC5 async, Vert.x WebClient
Ktor stack과 통합성이 더 중요함Ktor CIO, 단 long-run capacity는 별도 fixture로 재측정

핵심은 “가장 빠른 client 하나”가 아니다. io/http가 제공해야 하는 것은 workload에 따라 고를 수 있는 검증된 경로다. 이번 작업은 그 선택지를 만들었다.

개인적으로 이번 작업에서 제일 좋았던 결과는 Vert.x 20배 향상도, CIO 반전도 아니었다. 느린 숫자가 나왔을 때 바로 implementation을 고치지 않고, benchmark fixture를 먼저 의심한 점이었다.

처음 CIO 숫자를 봤을 때는 솔직히 실망스러웠다. “Ktor 3까지 붙였는데 이 정도면 블로그에 쓰기도 민망한데?”라는 생각이 먼저 들었다. 그런데 그 민망함 덕분에 측정 조건을 다시 봤고, 결국 더 좋은 기록이 남았다.

Benchmark는 “내 코드가 빠르다”를 증명하는 도구만은 아니다. 가끔은 “방금 본 숫자를 그대로 믿으면 안 된다”를 알려준다. 이번 io/http 작업은 그쪽에 가까웠고, 그래서 더 쓸모 있었다.

댓글

GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.