외부 API를 비동기로 호출해볼까
여행 코스 설계 API는 두 종류(구글, 오디세이)의 외부 API를 호출하고 있다. 이때 구글 API는 최대 5번, 오디세이 API는 최대 4번 호출된다. 성능 개선의 목적을 가지고, 기존 동기 방식으로 호출하고 있던 외부 API를 비동기로 호출해보았다.
속도 제한 필요 | 병렬 호출 | |
구글 API | x | → 가능 |
오디세이 API | o(대략 200ms) | → 불가능 |
호출 속도 제한 이슈로 인해 오디세이 API는 기존의 동기 호출 방식을 유지했다.
동기 호출 vs 비동기 호출
newCachedThreadPool 을 이용하여 구글 API를 비동기로 호출하도록 했다.
var futures = IntStream.range(0, n)
.mapToObj(index -> executor.submit(() -> 구글 API 호출))
.toList();
return futures.stream()
.map(this::awaitFutureResult)
.toList();
- 테스트 (jmeter 사용)
API users loop period(sec) 여행 코스 설계 100 1 x - 결과
- 동기, 비동기 방식 모두 TPS는 1.6으로 동일했다. 평균 응답 시간은 약 1초 감소했다.
테스트 결과로 기대한만큼의 성능 차이는 발견하지 못했다. 이는 호출 속도 제한이 있는 오디세이 API로 인해서 병목 현상이 발생했기 때문인데, response time 그래프를 보면 이를 관찰할 수 있다.
구글 API로의 요청을 비동기로 빠르게 보낸다고 해도, 오디세이 API 호출 속도가 제한되어 있기 때문에 나온 결과이다.
또한 그래프를 보면, 많은 응답이 테스트 후반에 도착하고 있다. 초반에는 모든 작업이 함께 진행되어 부하가 생기지만, 후반으로 갈수록 호출 속도가 제한되어 하지 못했던 외부(오디세이) API 호출 작업만 남아 있기 때문이다.
비동기로 호출하기로 했다.
현재 상황에서 API를 비동기로 호출 한다고해서 유의미한 성능 개선을 가져가긴 어렵다는 것을 알게되었다. 그럼에도 불구하고 나는 비동기 호출 방식을 선택했다.
I/O 작업을 수행하는 별도의 스레드 풀을 생성하여 사용한다면, 서버가 더 많은 동시 요청을 받아서 처리할 수 있으므로 전반적인 처리량이 증가할 수 있을 것이다.
진짜 그럴까?
DB로부터 축제 정보를 조회해오는 축제 조회 API와 외부 API를 호출하는 여행 코스 설계 API 요청을 동시에 보내, 축제 조회 API의 TPS를 확인해보고자 한다.
테스트
비동기 작업을 수행할 스레드 풀로는 newCachedThreadPool 을 사용했다. 요청마다 스레드를 만들고, 일정기간이 지나도록 사용되지 않으면 제거하므로 유연하다는 장점이 있다.
API | users | loop | period(sec) |
축제 조회 | 200 | infinite | 120 |
여행 코스 설계 | 100 | 1 | x |
- 결과 - 동기 호출
API TPS 평균 응답 시간(ms) 축제 조회 304.7 625 여행 코스 설계 1.6 43628 - 결과- 비동기 (newCachedThreadPool) 호출
API TPS 평균 응답 시간(ms) 축제 조회 460.3 → 51.07% 증가 415 → 33.6% 감소 여행 코스 설계 1.6 43149
예상한대로, I/O 작업을 위한 스레드 풀을 만들었을 때, 즉 비동기로 외부 API를 호출할 때 서버가 더 많은 요청을 동시에 처리할 수 있었다.
어떤 Executor를 사용할까
기존에는 newCachedThreadPool 을 사용했다. newCachedThreadPool 는 작업이 들어오는대로 스레드를 만들기 때문에, 유연하지만 스레드가 무한하게 생성될 수 있어 메모리 자원 고갈을 조심해야한다.
- newVirtualThreadPerTaskExecutor
API TPS 평균 응답 시간(ms) 축제 조회 467.1 407 여행 코스 설계 1.6 43011 newFixedThreadPool: 실제 배포되어 있는 서비스가 아니라서, 트래픽을 예상할 수 없으므로 시도하지 않았다.
newVirtualThreadPerTaskExecutor 를 사용하기로 했다
newCachedThreadPool 를 사용하는 것보다 축제 조회 API의 TPS가 1.48% 증가하고 응답 시간이 1.93% 감소했기 때문이다.
↪️ 가상 스레드 추가 테스트
다음과 같이 설정을 주어 서버가 아예 가상 스레드 기반으로 동작하도록 해봤다.
spring:
threads:
virtual:
enabled: true
'축제 조회'는 DB에서 축제 정보를 조회해오는 블로킹 I/O 작업이니까, 성능이 가장 좋지 않을까 기대했었다.
API | TPS | 평균 응답 시간(ms) |
축제 조회 | 319 | 594 |
여행 코스 설계 | 1.6 | 42232 |
예상과 다르게 축제 조회 API의 TPS가 가상 스레드로 전환하기 이전보다 낮게 측정되었다. 하지만 그와 동시에 여행 코스 설계 API의 평균 응답 시간이 가장 짧게 측정되기도 했다.
알고리즘 수행 작업에는 ForkJoinPool의 commonPool을 사용했고, 핀 현상이 발생하지 않은 것을 확인했음에도 그런걸보니, CPU가 알고리즘 수행하는 것이 I/O 작업 수행에 영향을 끼친 걸로 추측했다. (선점형 스케쥴링 방식이 아니고 cooperative 방식으로 동작해서 그런가?)
WebClient를 선택하지 않은 이유
WebMVC 프로젝트라도, 외부 API를 비동기 논블로킹 방식으로 호출하기 위해 WebClient를 사용하곤 한다. 그치만 나는 WebClient를 선택하지 않았다. 블로킹 코드에서 논블로킹 코드를 사용하려다보니, WebClient를 사용해도 결국에는 스레드가 API 응답을 기다려야 했기 때문이다.
WebClient를 제대로 활용하려면 외부 API 응답을 받아 처리하는 어댑터 클래스와 서비스 로직 일부를 리액티브 코드로 바꿔줘야 했는데, 그럴 바엔 가상 스레드를 사용하는 쪽이 간편할 것이라고 판단했다.
var futures = 모든 요청을 동시에 생성 [
WebClient.get().uri(...).retrieve().toFuture()
]
// 여기서 블로킹
var results = futures.map(future -> future.get())
이런 식으로 코딩할 수 있었으면 시도해봤을 것 같다.
+) webFlux로 전환하기
기존 프로젝트는 WebMVC + Tomcat 으로 동작하고 있다. 그래서 일단 여행 코스 설계 API만 WebFlux로 전환해봤다.
- 테스트 (jmeter 사용)
API users loop period(sec) 여행 코스 설계 100 1 x - 결과
API TPS 평균 응답 시간(ms) 여행 코스 설계 1.6 27268
WebFlux에서는 모든 것이 이벤트 루프로 동작하여 이벤트를 비동기 논블로킹으로 처리하기 때문에, 많은 동시 요청을 스무스하게 처리하는 모습을 볼 수 있다.
이렇듯 긍정적인 성능 개선을 확인하였지만, 다른 API들이 WebMVC로 동작하는데 해당 API만 WebFlux로 동작시켰다가 예상하지 못한 영향이 발생할 수 있지 않을까하여 WebFlux를 선택하지 않았다. WebFlux를 선택한다면 아예 전환하거나, 따로 서버를 분리하거나 하는 게 좋을 것 같다.
지금 당장은 newVirtualThreadPerTaskExecutor를 사용해서 외부 API를 호출하고, 외부 API key를 여러 개 사용해서 속도 제한으로 인한 병목 현상의 영향을 줄이는 방식이 최우선인 것 같다. 그럼에도 불구하고 응답 시간이 길어진다면 WebFlux로의 전환을 고려하는 것이 좋을 것 같다.
현재 이 프로젝트를 혼자서 리팩토링하고 있기에, key를 여러 개 둘 수 없어서 아쉽게도 가능성만을 열어두고 마무리하겠다.
'Spring' 카테고리의 다른 글
@ResponseBody를 이용한 응답 과정 알아보기 (0) | 2024.09.13 |
---|---|
HttpServletResponse와 HttpOutputMessage (0) | 2024.09.11 |
서블릿에서 HTTP Response 보내기 (0) | 2024.08.31 |
영속성 관리(ORM 표준 기술 {3} ) (0) | 2023.07.06 |
JPA(자바 ORM 표준 JPA 프로그래밍-기본편) { 2 } (0) | 2023.07.05 |