성능 최적화 9편 - @Async와 CompletableFuture로 비동기 처리하기
지난 글에서 APM으로 확인했던 예시를 다시 보면, 결제 API 호출에서 780ms가 소요되며 전체 응답 시간의 대부분을 차지하고 있었습니다. 이런 외부 연동 구간은 쿼리 튜닝이나 캐싱으로는 해결이 안 됩니다. 이번 글에서는 여러 작업을 동시에 실행해서 전체 응답 시간을 줄이는 비동기 처리 방법을 정리합니다.
1. 순차 처리의 한계
주문 상세 조회 시 결제 정보와 쿠폰 정보를 각각 외부 API와 내부 서비스에서 가져와야 한다고 가정해보겠습니다.
public OrderDetailDto getOrderDetail(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
PaymentInfo paymentInfo = paymentClient.getPaymentInfo(order.getPaymentId()); // 700ms
CouponInfo couponInfo = couponService.getAppliedCoupon(order.getCouponId()); // 300ms
return OrderDetailDto.of(order, paymentInfo, couponInfo);
}
두 작업이 서로 관련 없는 독립적인 조회인데도, 순차적으로 실행되면 700ms + 300ms = 1000ms가 그대로 누적됩니다.
2. @Async로 비동기 메서드 만들기
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "asyncExecutor")
public Executor asyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(50);
executor.setThreadNamePrefix("async-task-");
executor.initialize();
return executor;
}
}
@Service
@RequiredArgsConstructor
public class PaymentAsyncService {
private final PaymentClient paymentClient;
@Async("asyncExecutor")
public CompletableFuture<PaymentInfo> getPaymentInfoAsync(Long paymentId) {
PaymentInfo paymentInfo = paymentClient.getPaymentInfo(paymentId);
return CompletableFuture.completedFuture(paymentInfo);
}
}
@Async가 붙은 메서드는 호출한 스레드와 별개의 스레드 풀에서 실행됩니다. 반환 타입을 CompletableFuture로 감싸면, 호출부에서 결과를 나중에 꺼내볼 수 있습니다.
3. 두 작업을 동시에 실행하기
public OrderDetailDto getOrderDetail(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
CompletableFuture<PaymentInfo> paymentFuture =
paymentAsyncService.getPaymentInfoAsync(order.getPaymentId());
CompletableFuture<CouponInfo> couponFuture =
couponAsyncService.getAppliedCouponAsync(order.getCouponId());
CompletableFuture.allOf(paymentFuture, couponFuture).join();
PaymentInfo paymentInfo = paymentFuture.join();
CouponInfo couponInfo = couponFuture.join();
return OrderDetailDto.of(order, paymentInfo, couponInfo);
}
두 작업을 동시에 시작시키고, allOf().join()으로 둘 다 끝나기를 기다립니다. 이렇게 하면 전체 소요 시간은 두 작업의 합이 아니라, 둘 중 더 오래 걸리는 작업 기준(700ms)으로 줄어듭니다.
순차 처리 : 700ms + 300ms = 1000ms
동시 처리 : max(700ms, 300ms) = 700ms
4. 예외 처리
비동기 작업 중 하나가 실패하면 join() 호출 시 CompletionException으로 감싸져서 던져집니다. exceptionally나 handle로 예외 상황을 처리할 수 있습니다.
CompletableFuture<PaymentInfo> paymentFuture = paymentAsyncService
.getPaymentInfoAsync(order.getPaymentId())
.exceptionally(ex -> {
log.error("결제 정보 조회 실패, paymentId={}", order.getPaymentId(), ex);
return PaymentInfo.empty(); // 기본값으로 대체
});
외부 API 하나가 실패했다고 전체 요청이 실패하는 것을 막고 싶다면, 이렇게 기본값으로 대체하거나 부분 실패를 허용하는 방식으로 설계할 수 있습니다.
5. @Async 사용 시 흔히 하는 실수
같은 클래스 내부에서 호출하면 비동기로 동작하지 않습니다.
@Service
public class OrderService {
public void process() {
this.doAsyncWork(); // 프록시를 거치지 않아 비동기로 동작하지 않음
}
@Async
public void doAsyncWork() {
// ...
}
}
@Async는 스프링 AOP 프록시를 통해 동작하기 때문에, 같은 클래스 안에서 this로 호출하면 프록시를 거치지 않아 그냥 동기 메서드처럼 실행됩니다. 반드시 별도의 빈으로 분리해서 호출해야 합니다.
6. 스레드 풀 사이즈도 커넥션 풀처럼 고려해야 한다
@Async용 스레드 풀도 앞서 다룬 HikariCP 커넥션 풀과 마찬가지로, 무작정 크게 잡으면 안 됩니다. 스레드가 늘어날수록 컨텍스트 스위칭 비용이 늘고, 외부 API 서버가 감당 가능한 동시 요청 수를 넘으면 오히려 외부 서버 쪽에서 지연이 발생할 수 있습니다. 예상되는 동시 요청 수와 외부 시스템의 처리 능력을 함께 고려해 풀 사이즈를 정해야 합니다.
7. 정리
- 서로 독립적인 여러 작업은 순차 실행 대신 동시에 실행해서 전체 응답 시간을 줄일 수 있다
@Async+CompletableFuture조합으로 비동기 처리를 구현하고,allOf().join()으로 결과를 모아 받는다@Async는 프록시 기반으로 동작하므로 같은 클래스 내부 호출로는 적용되지 않는다- 비동기 스레드 풀 사이즈도 외부 시스템의 처리 능력을 고려해 설정해야 한다
APM으로 병목 구간을 찾고, 그 구간이 서로 독립적인 여러 작업으로 이루어져 있다면 비동기 처리가 효과적인 해결책이 될 수 있습니다. 다만 모든 병목이 비동기로 해결되는 것은 아니며, 하나의 작업 자체가 느린 경우라면 그 작업 자체의 최적화가 먼저입니다.
'성능 최적화' 카테고리의 다른 글
| 성능 최적화 STEP 11 - Kafka로 무거운 작업 비동기 분리하기 (0) | 2026.07.09 |
|---|---|
| 성능 최적화 STEP 10 - JMeter로 부하 테스트하고 개선 효과 검증하기 (0) | 2026.07.08 |
| 성능 최적화 STEP 8 - APM으로 병목 구간 추적하기 (0) | 2026.07.07 |
| 성능 최적화 STEP 7 - HikariCP 커넥션 풀 튜닝으로 DB 병목 줄이기 (0) | 2026.07.07 |
| 성능 최적화 STEP 6 - 분산 락(Distributed Lock)으로 동시성 문제 해결하기 (0) | 2026.07.06 |