[Spring] Spring MVC에서 WebClient 도입 효과 테스트

Loopy·2026년 3월 2일

삽질기록

목록 보기
31/31
post-thumbnail

현재 프로젝트에서 외부 API를 호출해서 열차 실시간 정보를 받아오고 있지만, 해당 포스팅에서 볼 수 있듯이 Spring MVC에서 Non-Blocking I/O인 WebClient를 정말 Reactive하게 사용할 수 있는지, 그리고 도입 시의 이점이 궁금해져 해당 포스팅을 작성해보았다.

Spring MVC 공식 문서를 참고해보면, MVC도 컨트롤러 반환값으로 async/reactive 타입을 지원하고 있다. 이 경우 Servlet async processing을 통해 원래 요청 스레드에서 빠져나온 뒤, 나중에 결과가 준비되면 다시 dispatch해서 응답을 완료하게 된다.

즉 Blocking I/O와 다르게 외부 I/O 대기 동안 Tomcat 요청 스레드가 계속 붙잡혀 있지 않도록 만드는 것이 가능하다. 또한 상황에 따라서 Event Loop 방식으로 동작하기 때문에 latency / CPU 사용률에서도 이점을 얻을 수 있다. 실제로 효과가 있는지 테스트해보자.

☁️ Webclient 부하 테스트

1) RestTemplate/RestClient

override fun getTrainRealTimes(stationName: String, startIndex: Int, endIndex: Int): TrainRealTimeDto {
      val url = "${publicDataProperties.realTimeStationArrivalPrefixUri}/${publicDataProperties.realTimeStationArrivalToken}/${publicDataProperties.realTimeStationArrivalSuffixUri}/$startIndex/$endIndex/$stationName"
      val response = restTemplate.exchange(url, HttpMethod.GET, null, TrainRealTimeDto::class.java).body!!
      return response.takeIf { it.status == null } ?: TrainRealTimeDto(500, null, emptyList())
}

2) WebClient + block

// build.gradle
implementation("org.springframework.boot:spring-boot-starter-webflux")
override fun getTrainRealTimesByMono(stationName: String, startIndex: Int, endIndex: Int): Mono<TrainRealTimeDto> {
    val url =
          "${publicDataProperties.realTimeStationArrivalPrefixUri}/" +
                 "${publicDataProperties.realTimeStationArrivalToken}/" +
                 "${publicDataProperties.realTimeStationArrivalSuffixUri}/" +
                 "$startIndex/$endIndex/$stationName"

    return webClient.get()
       		.uri(url)
            .retrieve()
            .onStatus(HttpStatusCode::isError) {
                Mono.error(BusinessException(ResponseCode.FAILED_TO_GET_TRAIN_INFO))
            }
            .bodyToMono(TrainRealTimeDto::class.java)
}
  1. Tomcat request thread
  2. WebClient async HTTP 요청
  3. Netty event loop가 응답 처리
  4. Tomcat thread가 결과 받아서 반환

실제로도 해당 포스팅과 같이 톰캣 스레드가 대기하지 않고 바로 스레드 풀로 반환되어 DB 커넥션 에러나, 다른 요청에 영향을 주지 않는지 테스트 해보자.

Jmeter - 요청 성공/실패 및 TPS 확인

일부 요청은 Delay API로, 일부 요청은 열차와 관계없는 유저 API를 호출해보았다. 만약 읽기 트랜잭션과 외부 API 호출을 분리하지 않았다면, WebClient를 사용하더라도 스레드는 반환되지만 커넥션은 트랜잭션이 끝날 때까지 계속 점유되어 DB Connection Timeout이 발생했을 것이다.

하지만 읽기 트랜잭션과 외부 API 호출을 분리해두었기 때문에, 커넥션 타임아웃 없이 I/O 요청 시 스레드도 즉시 반환된다. 덕분에 다른 기능에 영향을 주지 않고 각각의 요청을 안정적으로 처리하고 있는 것을 확인할 수 있다.

  • 왼쪽 : DB 호출이 발생하는 유저 정보 조회 API, 10초 1000 User
  • 오른쪽 : DB <-> 외부 API 호출 분리된 열차 정보 조회 API, 10초 1000 User

만약 jpa open-in-view 설정을 다시 true 로 설정한다면? 이전 포스팅에서와 같이 어마무시한 Connection Time Out 예외를 마주하게 될 것이다.

Thread Dump - 스레드 상태 확인

실제 Thread Dump를 떠서 확인해보면, reactor-http-nio-X 스레드에서 blocking request thread가 외부 응답을 붙잡고 기다리지 않고, 이벤트 루프가 커널에게 준비되면 알려줘 하고 감시하는 전형적인 NIO 패턴으로 동작하고 있는 것을 볼 수 있다.

이와 반대로 톰캣의 http-nio-8080-exec-X 스레드는 TIMED_WAITING (parking) 상태로, 스레드가 LockSupport.park() 로 잠들어 있다. 즉 아래 로그를 보면 알 수 있듯이, Tomcat 스레드가 할 일이 없어서 요청 큐(TaskQueue)에서 다음 작업을 기다리는 중인 것이다. 이로써 우리는 정말 WebClient가 Non-Blocking 방식으로 동작한다는 것을 눈으로 확인할 수 있다.

at java.util.concurrent.LinkedBlockingQueue.poll
at org.apache.tomcat.util.threads.TaskQueue.poll
at org.apache.tomcat.util.threads.ThreadPoolExecutor.getTask

혹은 이 시간 동안 다른 요청을 처리하고 있기도 한다.

만약 WebClient.block() 을 활용하게 되면, 그림과 같이 Mono.block() 에서 Tomcat Thread가 Waiting 하고 있는 것을 볼 수 있다.

☁️ Webclient 성능 테스트

이번에는 앞서 말한 것처럼, WebClient는 Netty 기반이라 HTTP connection 관리가 좋다고 한다. 즉, TPS와 스레드 수는 비슷할지라도 CPU 사용률과 API latency 측면에서 이득을 볼 수 있는 것이다.

그렇다면 정말 Webclient는 연결을 효율적으로 처리할 수 있을까? 한번 테스트해보자.

1. thread 사용량 테스트

RestTemplate

  • RestTemplate은 200개 요청에 대해 229개의 스레드가 살아있다.

WebClient

  • WebClient는 200개 요청에 대해 236개의 스레드가 살아있다.

이처럼 WebClient는 Netty event-loop 기반이라 보통 event-loop thread 2~8개만 사용하지만, 결국은 요청을 받는 톰캣 스레드 자원도 필요하므로 실제로는 WebClient가 thread 개수가 좀 더 많은 것을 볼 수 있다.

2. connection pool 효율 테스트 - Latency, CPU usage

WebClient는 Netty 기반의 event-loop와 non-blocking I/O를 사용하여, 적은 수의 스레드로 다수의 HTTP connection을 효율적으로 관리한다고 한다.

그렇다면 왜 CPU 이용률이 줄어들까? 스레드가 적을 수록 OS context switching 비용(커널 <-> 유저 모드 전환/캐시 초기화 등)이 줄어들기 때문이다. 반면 Blocking은 요청과 스레드가 비례하기 때문에 스레드 관리를 위한 CPU의 일만 더 많아지게 된다. (어짜피 I/O 요청이 대다수이므로 CPU를 많이 쓸 필요가 없다.)

RestTemplate

Webclient

실제 같은 요청을 보냈을 때 CPU 이용률은 19.2% -> 9.7% 로 감소하고, API 응답 시간도 6000ms -> 4742ms로 줄어든 것을 볼 수 있다.

☁️ Spring MVC VS Spring Webflux. Reactive인 Webflux가 항상 좋은가에 대한 고찰

자 그러면 현재까지 테스트 한 것처럼 Non-Blocking I/O가 효율적이라면, 왜 많은 서비스가 여전히 Spring MVC 기반을 유지하고 있을까? 내 기준에서는 '서비스 특성에 따라 선택이 달라진다'가 가장 현실적인 답이라고 생각한다.

일반적인 웹 서비스의 경우 DB 접근 비중이 높은데, 우리가 흔히 사용하는 JPA, MyBatis, JdbcTemplate은 모두 Blocking 방식이다. 이 상태에서 Webflux로 전환하더라도 DB 구간에서 여전히 Blocking이 발생하기 때문에, 기대만큼의 성능 개선을 얻기 어려운 경우가 많다.

또한 Webflux로 전환할 경우 단순히 컨트롤러 레벨이 아니라 서비스 전반의 구조를 변경해야 하고, 디버깅 난이도나 러닝 커브까지 고려하면 전환 비용이 적지 않다. 이런 점까지 포함해서 보면, 대부분의 CRUD 중심 서비스에서는 MVC 구조를 유지하는 것이 더 합리적인 선택일 수 있다.

그리고 무엇보다 MVC 구조에서도 스레드 풀 튜닝, 커넥션 풀 관리, 캐싱 전략 등을 통해 충분히 높은 수준의 성능을 확보할 수 있으니, 아직 Spring MVC를 유지하는 기업들이 많은 것으로 판단된다.

Webflux 효과를 얻을 수 있는 서비스

  • 스트리밍 서비스
  • DB 호출 거의 없이 외부 API 호출이 많을 경우(feat. API Gateway)

☁️ (결론) Webclient를 도입하지 않은 이유

종합하자면 WebClient는 Non-Blocking I/O로 동작하기 때문에 응답 지연 상황에서도 서버 장애를 방지할 수 있고, CPU와 Latency 측면에서도 이점이 있다는 것을 테스트로 확인했다.

다만 WebClient는 스레드 블로킹은 해소하지만, 외부 API가 느린 동안 사용자는 여전히 응답을 기다려야 하는 문제가 존재한다. 도메인 특성 상 실시간 열차 도착 정보는 "지금 즉시" 확인해야 의미가 있는 데이터이므로, 3초간 대기하게 하는 것보다 서킷 브레이커가 열린 상태에서 로컬 캐시의 스테일 데이터를 즉시 반환하는 것이 사용자 경험 측면에서 더 적합하다고 판단했고, 해당 포스팅에서의 서킷 브레이커 적용을 통해서 문제가 해결이 되었기 때문에 이 상황에서는 더 합리적이라고 판단했다.

따라서 최종 해결책으로 도입하지는 않았지만, 그래도 이론적으로만 Non-Blocking I/O에 대해 아는 것보다 직접 스레드 덤프와 부하 테스트를 수행해보며 더욱 깊이 있게 파악할 수 있었던 시간이었던 것 같다.

참고 자료
https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-ann-async.html?utm_source=chatgpt.com

profile
개인용으로 공부하는 공간입니다. 피드백 환영합니다 🙂

0개의 댓글