Kuma's Curious Paradise
외부 API 호출, 어떤 비동기 방식을 선택해야 할까? - WebClient, CompletableFuture, @Async 본문
Spring 기반 애플리케이션에서 외부 API를 호출하는 방식은 다양하다.
최근에는 WebClient를 사용하는 경우가 많아졌지만, 여전히 @Async, CompletableFuture를 활용한 방식도 자주 보여진다.
이러한 방식들은 겉보기에는 비슷해 보이지만, 내부 동작 원리와 스레드 처리 방식이 다르다.
이들은 정확히 어떻게 다른걸까? 이 글에서는 아래와 같은 질문에 답하기 위해, 각 방식이 어떻게 동작하는지, 특히 WebClient는 어느 때 적합하며 어떻게 다루어야 하는지를 설명한다.
- WebClient는 논블로킹이라는데, 그게 정확히 어떤 의미일까?
- @Async와 CompletableFuture는 어떤 차이가 있을까?
- 외부 API 요청이 많아지면 어떤 방식이 더 유리할까?
1. 동기/비동기, 블로킹/논블로킹 개념 정리
외부 API 호출 방식의 차이를 이해하려면, 먼저 "동기 / 비동기"와 "블로킹 / 논블로킹"이라는 용어를 분명히 구분할 필요가 있다.
1-1. 동기 vs 비동기 : 응답이 올 때까지 기다리는가?
- 동기(Synchronous)
: 요청을 보내고, 응답이 올 때까지 기다린다.
→ 호출한 쪽이 응답을 받을 때까지 다음 작업을 하지 못한다. - 비동기(Asynchronous)
: 요청을 보내고, 바로 다음 작업을 이어간다.
→ 호출한 쪽이 응답을 기다리지 않고, 이후에 콜백 등을 통해 결과를 받는다.
1-2. 블로킹 vs 논블로킹 : 작업이 끝날 때까지 스레드가 점유되는가?
- 블로킹(Blocking)
: 작업이 끝날 때까지 현재 스레드가 멈춘다.
→ 해당 스레드는 다른 일을 할 수 없다. - 논블로킹(Non-blocking)
: 작업을 요청해놓고, 그 작업이 끝나지 않아도 스레드는 다른 일을 계속할 수 있다.
→ 결과가 준비되면 콜백 등으로 알려준다.
1-3. 네 가지 방식의 조합
| 블로킹 | 논블로킹 | |
| 동기 | RestTemplate JDBC 등 I/O |
X |
| 비동기 | @Async CompletableFuture.runAsync() 등 |
WebClient |
2. 자바와 스프링, WebClient의 스레드풀 사용
@Async, CompletableFuture, WebClient는 스레드가 어떻게 관리되고 소비되는지가 다르다.
자바와 스프링이 각각 스레드를 어떻게 관리하고, 비동기 처리 시 어떤 스레드풀이 동작하는지를 살펴보자.
2-1. 자바에서의 스레드풀
Java에는 크게 두 가지 관점에서 스레드풀이 존재한다:
| 종류 | 설명 | 생성 방식 (예시) |
| 일반 스레드풀 | - 개발자가 직접 정의하는 FixedThreadPool, CachedThreadPool … - 명시적 크기 지정 가능. - Executor 인터페이스의 구현체 |
Executors.newFixedThreadPool() |
| 병렬 스레드풀 | 자바 8부터 도입된 기본 병렬 스레드풀 | CompletableFuture.supplyAsync(), parallelStream()에서 기본 사용됨 |
- @Async 없이 CompletableFuture만 사용할 시, CompletableFuture는 별도 지정이 없을 경우 FolkJoinPool을 사용한다.
- FolkJoinPool-commonPool은 CPU 수에 따라 만들어진 적은 수의 스레드풀(cpu 코어 수 - 1) 로, CPU 연산 중심 (ex. 이미지 압축…) 병렬 처리에 최적화되어 있다. 하나의 큰 작업을 분할-정복식으로 실행한다.
- 병렬 비동기 구조지만, 내부의 commonPool에서 스레드를 점유하는 블로킹 작업 방식이다.
- 자바에서 제공하는 스레드풀이므로, Spring의 관리를 받지 못해 트랜잭션, AOP 등의 장점을 누릴 수 없다.
2-2. 스프링의 스레드풀
Spring은 내부적으로 자바의 스레드풀을 래핑(wrapping)해서 관리합니다.
| 종류 | 설명 | 예시 |
| SimpleAsyncTaskExecutor | 제한 없이 새 스레드를 생성하는 비권장 기본값 | @Async 기본 설정 (AsyncConfig에서 따로 지정해 주지 않으면 이 스레드풀을 사용하게 됨. 테스트용 코드로 1000개의 비동기 요청을 보내 본 결과 스레드 1000개를 제한 없이 생성하는 것을 확인.) |
| ThreadPoolTaskExecutor | 기본값 지정 가능하고 제한된 스레드 수를 갖는 권장 설정 | 아래처럼 ThreadPoolTaskExecutor를 지정한 테스트용 코드로 1000개의 비동기 요청을 보내 본 결과 스레드 2개 사용 후 ‘TaskRejectedException’이 터지며 스레드 고갈을 알리는 것을 확인. @Configuration @EnableAsync public class AsyncConfig { @Bean public Executor customAsyncExecutor() { ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor(); threadPoolTaskExecutor.setCorePoolSize(2); threadPoolTaskExecutor.setMaxPoolSize(2); threadPoolTaskExecutor.setQueueCapacity(0); threadPoolTaskExecutor.initialize(); return threadPoolTaskExecutor; } |
| Scheduler (reactor 기반) | Schedulers.parallel(), Schedulers.boundedElastic() 등이 사용하는 스케줄링용 스레드풀 |
- @EnableAsync 를 설정 없이 선언하면 SimpleAsyncTaskExecutor를 기본 구현체로 사용한다. 매 요청마다 새로운 스레드가 생성되기 때문에 예시에 적은 바와 같이 ThreadPoolTaskExecutor로 커스터마이징하여 스레드수를 제한하는 것이 권장된다.
- 스프링은 자바의 스레드풀을 그대로 사용하지 않고 스프링 컨텍스트에서 관리할 수 있는 형태로 래핑(wrapping)한다. 위 코드에서 사용된 ThreadPoolTaskExecutor는 스프링이 제공하는 스레드풀 구현체로, 내부적으로는 자바의 ThreadPoolExecutor를 감싸고 있다. 구체적으로는 다음과 같은 상속 구조를 가진다:
ThreadPoolTaskExecutor → ThreadPoolExecutor → AbstractExecutorService → ExecutorService
이 클래스를 @Bean으로 등록하면, 스프링은 이를 인식하여 트랜잭션, AOP, 보안 컨텍스트 등의 기능과 함께 사용할 수 있도록 관리한다. - 예를 들어 AsyncConfig에 위와 같이 스레드풀을 Bean 등록해두면, @Async 메서드가 호출될 때 Spring AOP에 의해 생성된 프록시 객체가 대신 실행되며, 해당 프록시 내부에서 스레드풀에서 스레드를 하나 꺼내 비동기적으로 메서드를 실행한다.
2-3. WebClient의 스레드풀
WebClient는 내부적으로 Reactor 기반의 Netty를 사용한다.
- 요청은 Netty의 이벤트 루프 스레드에서 비동기로 처리된다. (reactor-http-nio-x)
- Netty의 NioEventLoopGroup은 기본적으로 CPU 코어 수 * 2 개의 스레드를 생성하며, 이 수는 커스터마이징할 수 있다다. (webClient를 bean 등록할 때 함께 설정이 가능하다.)
- block()을 호출하면 호출 스레드(main or tomcat 스레드)가 응답이 올 때까지 기다린다. (블로킹) 이때 Netty의 스레드는 블로킹되지 않지만, webclient의 논블로킹 특성을 무효화하고 호출 스레드를 블로킹한다.
3. WebClient의 내부 구조 이해
3.1 WebClient는 어떤 클라이언트인가?
Spring WebClient는 non-blocking, reactive, http client이다. (java doc 첫줄)
- Non-blocking: 요청을 보낸 스레드가 응답을 기다리며 아무것도 못 하는 블로킹 방식이 아니라, 요청 이후 스레드는 다른 작업을 수행할 수 있는 방식이다. 이는 리소스 효율이 좋고, 대규모 동시 처리에 적합하다.
- Reactive: 데이터를 기다리면서 프로그램 흐름을 미리 지정해두는 반응형 프로그래밍 모델이다. (마치 다른 일을 하다가 이름을 부르면 쳐다보고 일을 처리하듯이.) 선언적 체이닝을 통해 비동기 흐름을 직관적으로 구성할 수 있다.
- Client: WebClient는 HTTP 요청을 보내는 역할을 수행한다.
즉, WebClient는 비동기 & 논블로킹 방식의 선언적 HTTP 클라이언트이다.
3.2 전통적인 서블릿 기반 서버 vs 리액티브 서버
| 구분 | 전통적 서버 Tomcat | 리액티브 서버 (Netty, Reactor Netty) |
| 스레드 모델 | 요청당 스레드 하나 할당 | 이벤트 루프 기반, 적은 스레드 사용 |
| 방식 | 동기 & 블로킹 | 비동기 & 논블로킹 |
| 장점 | 순차 흐름, 디버깅 쉬움 | 고성능, 동시성에 유리 |
| 단점 | 자원 소비 큼, 스레드 낭비 | 디버깅 어려움, 높은 러닝 커브 |
전통적인 Tomcat 기반 서버는 요청마다 스레드를 생성하거나 풀에서 가져와 점유한다. 반면 Netty는 이벤트 루프 기반으로 적은 수의 스레드로 다수의 요청을 처리한다. 하지만 Netty는 직접 쓰기엔 복잡하기 때문에, 이를 추상화한 것이 Reactor Netty다.
Spring의 WebClient나 WebFlux는 Reactor Netty 위에서 동작하며, 다음과 같은 구조를 가진다:
WebClient/WebFlux → Reactor Netty → Netty
3.3 Reactor 기반 프로그래밍: Mono와 Flux
Reactor는 Java의 Reactive Streams 표준을 구현한 비동기 라이브러리다.
이때 사용하는 주요 타입은 Mono, Flux로 두 가지다:
| 타입 | 설명 | 예시 |
| Mono<T> | 0 또는 1개의 데이터를 비동기로 전달 | 사용자 정보 1건, API 응답 |
| Flux<T> | 0개 이상의 데이터를 스트림으로 전달 | 실시간 데이터 스트리밍, 리스트 응답 등 |
이들은 모두 Publisher<T> 인터페이스의 구현체이며, Reactor Netty는 Netty에서 발생한 이벤트를 감지해 이를 Mono나 Flux로 감싸 전달한다.
3.4 이벤트 기반 vs 리액티브 기반의 차이
- Netty는 전형적인 이벤트 기반 처리(Event-Driven) 방식이다. 각 이벤트마다 핸들러를 등록하고, 이벤트 발생 시 호출된다.
- Reactor Netty는 이 구조를 선언적이고 체이닝 가능한 방식(아래 코드처럼)으로 추상화하여, 데이터 흐름을 명확히 표현할 수 있게 해준다.
UserDto userDto = webClient.method(api.getMethod())
.uri(api.getUrl())
.contentType(api.getMediaType())
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.retrieve()
.bodyToMono(UserDto.class)
.doOnError(e -> log.error("Error to findUser: {}", e.getMessage()))
.onErrorResume(e -> Mono.empty())
.block(); // 동기식으로 UserDto 반환
3.5 Mono 내부 동작 원리
Mono는 크게 세 가지 시그널을 가진다:
- onNext(T) : 값이 정상적으로 도착
- onComplete() : 정상 종료
- onError(Throwable) : 에러 발생
이러한 시그널 흐름을 바탕으로 다음과 같은 메서드들이 구성된다. (메서드 이름이 왜 이와 같이 구성되는지 함께 알면 잊어버리지 않는다!)
| 메서드 | 설명 |
| doOnNext(...) | 값이 도착했을 때 실행할 로직 |
| doOnError(...) | 에러가 발생했을 때 실행할 로직 |
| onErrorReturn(...) | 에러 시 기본값 반환 |
| onErrorResume(...) | 에러 시 대체 흐름 제공 |
3.6 WebCleint의 핵심 메서드들
3.6.1 retrieve() / toEntity() / bodyToMono() / bodyToFlux()
- WebClient의 가장 일반적인 응답 처리 방식 중 하나
- 요청이 성공했을 경우 응답 본문(body)을 가져올 수 있는 진입점을 제공 (단독 사용 시 아무 일도 X)
- 2xx 이외 상태코드는 예외로 처리됨 (WebClientResponseException 발생)
- 이후 .toEntity(), .bodyToMono(), .bodyToFlux()와 함께 사용
webClient.get()
.uri("/example")
.retrieve() // 응답 바디를 추출하겠다는 진입점 제공
.bodyToMono(String.class); // 이후 여기서 응답 파싱 일어나야
3.6.2 exchangeToMono()
- retrieve()보다 더 유연한 방식으로 응답을 처리할 수 있도록 도와주는 메서드
- ClientResponse 객체 전체를 전달받아, 응답 상태 코드, 헤더, 바디 등 원하는 곳에 접근할 수 있도록 함
- retrieve()는 상태 코드가 4xx, 5xx인 경우 기본적으로 WebClientResponseException을 던지지만, 그 외 다른 에러 처리를 하고 싶을 때 or 상태 코드에 따른 분기 처리가 필요할 때 사용 가능
webClient.get()
.uri("/hello")
.exchangeToMono(response -> {
// 이렇게 상태 코드에 접근하는 것이 가능
if (response.statusCode().is2xxSuccessful()) {
return response.bodyToMono(String.class);
} else {
return Mono.error(new RuntimeException("요청 실패: " + response.statusCode()));
}
});
3.6.3 body() / bodyValue()
- 요청(Request) 본문을 설정할 때 사용
- bodyValue(Object value): 단순한 객체를 요청 본문에 넣을 때 사용하며, 내부적으로 Mono.just(value)로 감싸기 때문에 보통 이거면 충분
- body(Publisher<T>, Class<T>): Flux/Mono와 같은 리액티브 타입을 요청 본문에 실을 때 사용
3.6.4 subscribe()
- Mono나 Flux에서 데이터 처리를 시작하게 만드는 트리거 메서드
- 이 메서드가 호출되기 전까지는 요청이 발생하지 않음
3.6.5 toFuture()
- Mono나 Flux를 Java의 CompletableFuture로 변환해주는 메서드
- 기존 CompletableFuture 기반 코드와 연동할 때 사용
** 다시 보기
UserDto userDto = webClient.method(api.getMethod())
.uri(api.getUrl())
.contentType(api.getMediaType())
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
// ClientResponse의 응답 본문(body)을 받는 진입점
.retrieve()
// 응답 본문을 원하는 형태로 파싱
.bodyToMono(UserDto.class)
// 에러나면 로그를 찍겠다
.doOnError(e -> log.error("Error to findUser: {}", e.getMessage()))
// 추가로, 에러가 일어나면 빈 mono를 반환하겠다
.onErrorResume(e -> Mono.empty())
.block(); // 동기식으로 UserDto 반환
(resume은 ‘재개하다’라는 단어로, 이력서를 뜻하는 resume와 뜻이 다른 동음이의어이다. onErrorResume()은 오류가 발생하더라도 대체 흐름을 통해 작업을 “재개”하겠다는 의미로, resume 단어가 사용된 것이다. 즉, 예외로 전체 스트림을 종료하지 않고 새로운 Publisher로 이어서 흐름을 계속한다.)
이렇게 여러 비동기 방식의 흐름과 내부 구조에 대해 알아 보았다.
Flux와 멀티 플렉싱, java 21에서 등장한 virtual thread와도 연관지어 더 많은 이야기를 할 수 있었을 텐데 차마 손도 대지 못했다…. 그래도 앞으로 더 많은 것을 함께 이해할 출발점이 되기를 바라며 글을 마친다!
'스프링' 카테고리의 다른 글
| CircuitBreaker 1 : 내부 구조 살펴보기 (3) | 2025.08.02 |
|---|---|
| S3에서 파일을 지웠지만 새 파일이 다운로드되지 않는 문제 + 해결 (1) | 2025.07.28 |
| Spring BeanPostProcessor 관련 WARN 로그 발생 원인 및 해결 (0) | 2025.07.06 |
| gradle이란? 빌드 툴에 대하여 (feat.build.gradle) (2) | 2025.05.28 |
| Jackson과 친해지기: @JsonFormat, @JsonCreator, @JsonProperty (1) | 2025.05.02 |