https://github.com/dog-feet-bird-feet/server/commit/b5879ac57777dba96bd58d0fd9128c9391ba85ac
개요
기존 RestTemplate의 블로킹 통신 방식이 동시 요청 처리 시 병목 현상을 일으켜 응답 지연을 발생시키는 것을 발견했다. 이를 해결하고자 WebFlux 기반의 WebClient를 도입하여 웹 서버와 AI 서버 간의 통신을 논블로킹 비동기 방식으로 전환했다.
그러나 JPA의 Blocking으로 인해 큰 성능 차이는 발생하지 않았다.
따라서, 블로킹 방식이고 러닝 커브가 없는 RestClinet를 도입하였다.
문제 정의
K6을 통한 부하 테스트로 발견한 기존 방식의 RestTemplate 기반의 통신 방식의 한계가 있다고 판단했다. RestTemplate의 동기/블로킹 방식 사용으로 동시 요청 시 평균 응답 속도가 5초 이상 지연 발생하는 것을 발견했다. 요청 스레드 블로킹으로 인한 스레드 풀 부족 및 과도한 컨텍스트 스위칭 가능성 때문이라고 예상했다.
예상 해결 방안
- 비동기 HTTP 호출을 통해 병렬 처리가 가능할 것으로 보임
- 현재 RestTemplate의 경우, Non-Blocking이므로 WebClient 도입이 시급해 보임
- 스프링 어플리케이션 내부의 블로킹 작업을 분리
- @Async를 통한 요청 처리를 별도의 스레드로 처리
3. 기술적 의사결정 및 해결 과정
왜 WebClient인가?
- 논블로킹, 비동기 HTTP 클라이언트
- 외부 AI 서버와의 네트워크 통신 자체를 논블로킹으로 전환
- 병렬처리(@Async, Batch)를 사용하지 않은 이유
- 네트워크 I/O와는 무관
- 애플리케이션 내부의 블로킹 작업을 별도의 스레드 풀로 위임하여 메인 스레드가 즉시 다른 작업을 수행할 수 있도록 하는 데 사용한다.
- 싱글 코어 CPU에서의 성능 저하
- 개발 중인 로컬 PC의 경우 8코어이지만 배포된 EC2 t2.micro의 1코어 CPU를 제공한다.
- 따라서 병렬적으로 처리하는 방식으로는 한계가 있다고 판단했다.
- 싱글 코어 CPU에서 병렬 처리를 할 경우 스레드 수만 증가하고 번강하 가면서 스케쥴링을 하므로 순차 작업보다 성능이 떨어질 수 있다.
- 네트워크 I/O와는 무관
RestTemplate? WebClient?
RestTemplate

- Spring Framework 3.0에 추가
- Java Servlet API를 통해 요청 스레드 생성
- 응답 시까지 Blocking하여 동기 환경에서 제공
- 스레드 풀 부족 현상이나 과도한 Context Swtiching 발생 가능
WebClient

- Sprint Framework 5.0에 추가
- Spring Reactive Framework에서 제공
- 기본적으로 논블로킹, 비동기 제공. 블로킹, 동기도 설정 가능
- 스레드 대신 Task를 생성하고, 큐에서 Task를 관리하여 적당한 응답 가능 시 실행
반응형 프로그래밍 및 WebClient 학습 내용을 통해 자세한 내용 확인.
Spring MVC 환경에서 WebClient가 돌아갈까?
Spring MVC 내부에서 WebClient를 사용할 경우, WebFlux에서 사용되는 EventLoop Group과 MVC의 ThreadPool을 함께 사용한다.
만약 요청이 오게되면 Thead가 생성되고 해당 쓰레드는 EventLoop를 할당받아 사용한다.
그 이후 네트워크 I/O가 완료되면, callback이 트리거되어 결과가 반환된다.
따라서, 사용하는데에 있어 문제가 되지 않지만, MVC 환경이 blocking 처리되기 때문에 100% 비동기 논블로킹 로직이 구현되지 않을 수 있다.
Reactor 선택
Flux는 0개 이상, N개의 아이템을 방출하게 된다. Mono의 경우에는 0개 또는 1개의 아이템을 방출한다.
현재 이미지 분석 기능의 경우, 응답이 여러 개거나 스트림 형태가 아니다.
1번 요청에 대해 딱 1개의 AppraisalResponse 객체 반환하기 때문에 Flux보단 Mono의 사용이 더욱 적절하다고 판단했다.
@Transactional
public Mono<AppraisalResponse> appraise(final long memberId, final AppraisalRequest request) {
return webClient.post()
.uri(fastApiEndpoint)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(request)
.retrieve()
.bodyToMono(AppraisalAIResponse.class)
.map(response -> saveAppraisal(memberId, response))
.map(AppraisalResponse::from);
}
private AnalysisResult saveAppraisal(long memberId, AppraisalAIResponse response) {
Member member = memberRepository.findById(memberId).orElseThrow(() -> BaseException.from(MEMBER_NOT_FOUND));
AnalysisResult result = response.toEntity();
result.update(member);
resultRepository.save(result);
return result;
}
JPA 연산 시 블로킹으로 인한 성능 저하
현재 JPA와 관련된 문제가 발생했다.
JPA의 save()는 블로킹 I/O이고, Reactor (Mono)는 기본적으로 논블로킹이다.
이 때문에 블로킹 코드를 Reactor 안에 직접 넣으면 Netty 이벤트 루프를 막아 전체 성능에 악영향을 줄 수 있다고 판단했다.
따라서, 이를 해결하기 위해 block()을 추가했다.
성능이 왜 비슷하지?
두 방법 모두 TPS와 HTTP 처리 시간이 비슷하게 나와서 의외였다.
왜 이런 결과가 나왔는지 생각해봤다.
- JPA의 save()가 블로킹 작업이므로 전체 요청 처리가 블로킹으로 작동
- AWS 인스턴스가 싱글코어이기 때문에 비동기 처리로 인한 스위칭 오버헤드
JPA의 필수적인 사용으로 인해 비동기 요청이 큰 효과를 보지 못하는 상황이 발생했다.
따라서, RestTemplate과 같은 동기 요청을 사용하기로 했다. 왜냐하면 WebClient의 경우 리액티브 프로그래밍 지식과 WebFlux에 대한 러닝커브가 존재하기 때문이다.
그러나 RestTemplate을 사용하는 대신 Spring 6.1부터 제공하는 RestClient를 도입하기로 결정했다.
RestClient에 도입
RestClient를 도입한 이유는 메서드 체이닝을 지원하여 RestTemplate보다 가독성이 뛰어나고, 스프링 공식문서에서도 해당 기술 사용을 권장하고 있기 때문이다.
RestClient
Spring 6.1(Spring Boot 3.2)에 추가된 동기식 HTTP Client이다.
동기/블로킹 요청이기 때문에 RestTemplate와 큰 성능적 차이는 존재하지 않는다고 가정했다.
[ RestClient 적용 코드 ]
public AppraisalResponse appraise(final long memberId, final AppraisalRequest request) {
RestClient restClient = RestClient.builder()
.baseUrl(fastApiEndpoint)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.build();
AppraisalAIResponse response = restClient
.post()
.uri(uriBuilder -> uriBuilder.path("/analyze").build())
.body(request)
.retrieve()
.body(AppraisalAIResponse.class);
return AppraisalResponse.from(saveAppraisal(memberId, Objects.requireNonNull(response)));
}
결과 및 성과
기존 RestTemplate을 사용한 동기/블로킹 방식은 동시 요청 처리 시 병목 현상을 야기하며 평균 응답 속도를 5초 이상 지연시키는 문제가 발견됐다. 이는 요청 스레드 블로킹으로 인한 스레드 풀 부족 및 과도한 컨텍스트 스위칭 가능성을 증가시켰다.
WebClient 도입 후 수행된 부하 테스트 결과, http_req_duration (평균 응답 시간)은 약 5.43초로 나타났다. 이는 RestTemplate을 사용했을 때와 비교하여 큰 성능 개선을 보이지 않았다. WebClient는 WebFlux의 별도 설치와 block() 추가, 그리고 리액티브 프로그래밍에 대한 학습 곡선이 있다는 단점도 존재했다.
이러한 점들을 고려하여, Spring 6.1 (Spring Boot 3.2)부터 도입된 동기식 HTTP 클라이언트인 RestClient를 도입하기로 결정했다.
RestClient는 기존 RestTemplate과 유사한 동기식 방식이지만, 러닝 커브가 없다는 장점이 있다. RestClient 도입 후 수행된 부하 테스트 결과, http_req_duration은 평균 약 5.09초로 측정되어 WebClient 사용 시보다 약간의 성능 개선을 보였다.
'Spring' 카테고리의 다른 글
| [Spring Boot] Redis Pub/Sub 기반 쿠폰 발급 시스템 구축 (0) | 2025.11.30 |
|---|---|
| [Spring Boot] Scale Out을 위한 Redis Pub/Sub 기반 공개 채팅 아키텍처 도입 (0) | 2025.11.30 |
| [Spring Boot] N+1 문제 해결 기술 비교 (FetchJoin, @EntityGraph, batch-size) (0) | 2025.11.30 |
| [Spring Boot] Soft Delete로 개인정보 보호하기 (0) | 2025.11.30 |
| [Spring Boot] 공개 채팅 구축 및 동시성 문제 해결 (0) | 2025.11.27 |