들어가며
실무에서 이커머스 주문 처리 시스템을 개발하다 보면 다음과 같은 상황을 자주 마주친다.
•
주문 저장
•
재고 차감
•
결제 요청
•
알림 발송 (이메일, SMS, 푸시)
Java의 ExecutorService와 CompletableFuture를 활용하면 이런 비동기 처리를 깔끔하게 구현할 수 있다.
이 작업들을 순차적으로 처리하면 응답 시간이 길어지고, 특히 알림 발송처럼 주문 완료 응답에 직접적인 영향을 줄 필요 없는 작업까지 기다려야 한다는 문제가 생긴다.
핵심 개념: ExecutorService
"An Executor that provides methods to manage termination and methods that can produce a Future for tracking progress of one or more asynchronous tasks."
ExecutorService는 java.util.concurrent 패키지에 속하며, 스레드 풀을 관리하고 비동기 작업을 제출(submit)·추적할 수 있게 해주는 인터페이스다.
주요 구현체
구현체 | 설명 |
ThreadPoolExecutor | 가장 유연한 범용 스레드 풀 |
ScheduledThreadPoolExecutor | 주기적/지연 실행 지원 |
ForkJoinPool | 분할 정복 방식의 병렬 처리 |
Executors 팩토리 클래스를 통해 빠르게 생성할 수 있지만, 실무에서는 ThreadPoolExecutor를 직접 생성하는 것이 권장된다. 큐 크기와 스레드 수를 명시적으로 제어할 수 있어야 장애 대응이 가능하기 때문이다.
실무 시나리오: 주문 처리 흐름
요구사항
•
주문 저장 + 재고 차감 → 동기 처리 (트랜잭션 필요)
•
알림 발송 (이메일, 푸시) → 비동기 처리 (응답 지연 불필요)
코드 구현
import java.util.concurrent.*;
@Service
public class OrderService {
// 실무에서는 Executors.newFixedThreadPool() 대신
// 직접 ThreadPoolExecutor를 생성해 큐 크기를 제어한다
private final ExecutorService notificationExecutor = new ThreadPoolExecutor(
2, // corePoolSize
5, // maximumPoolSize
60L, TimeUnit.SECONDS, // keepAliveTime
new LinkedBlockingQueue<>(100), // workQueue (bounded!)
new ThreadPoolExecutor.CallerRunsPolicy() // RejectedExecutionHandler
);
private final OrderRepository orderRepository;
private final StockService stockService;
private final NotificationClient notificationClient;
public OrderResponse placeOrder(OrderRequest request) {
// 1. 동기 처리: 트랜잭션이 필요한 핵심 로직
Order order = orderRepository.save(request.toEntity());
stockService.decrease(request.getProductId(), request.getQuantity());
// 2. 비동기 처리: 응답에 영향 없는 알림 발송
CompletableFuture
.runAsync(() -> notificationClient.sendEmail(order), notificationExecutor)
.exceptionally(ex -> {
log.error("이메일 발송 실패. orderId={}", order.getId(), ex);
return null;
});
CompletableFuture
.runAsync(() -> notificationClient.sendPush(order), notificationExecutor)
.exceptionally(ex -> {
log.error("푸시 알림 실패. orderId={}", order.getId(), ex);
return null;
});
return OrderResponse.from(order);
}
}
Java
복사
핵심 개념: CompletableFuture
"A Future that may be explicitly completed (setting its value and status), and may be used as a CompletionStage, supporting dependent functions and actions that trigger upon its completion."
CompletableFuture는 Java 8에서 추가된 클래스로, Future의 한계(블로킹 get, 체이닝 불가)를 극복한다.
메서드 | 설명 |
runAsync(Runnable, Executor) | 반환값 없는 비동기 작업 |
supplyAsync(Supplier, Executor) | 반환값 있는 비동기 작업 |
thenApply | 결과를 변환 (map과 유사) |
thenCompose | 비동기 체이닝 (flatMap과 유사) |
exceptionally | 예외 발생 시 폴백 처리 |
allOf | 여러 작업 병렬 실행 후 전체 완료 대기 |
주의사항: CallerRunsPolicy를 쓴 이유
ThreadPoolExecutor의 RejectedExecutionHandler는 큐가 꽉 찼을 때의 정책이다.
"A handler for rejected tasks that runs the rejected task directly in the calling thread of the execute method, unless the executor has been shut down, in which case the task is discarded."
CallerRunsPolicy를 선택한 이유:
•
AbortPolicy (기본값): 예외를 던져 요청 자체가 실패할 수 있음
•
DiscardPolicy: 알림이 조용히 유실됨
•
CallerRunsPolicy: 호출한 스레드(요청 처리 스레드)가 직접 실행 → 자동 속도 조절(back-pressure) 효과
알림 발송은 유실 허용 가능성이 낮으므로 CallerRunsPolicy가 적합하다.
병렬 처리로 확장: allOf 활용
여러 알림을 모두 발송하고 그 결과를 확인해야 하는 경우 CompletableFuture.allOf()를 쓴다.
public void sendAllNotifications(Order order) {
CompletableFuture<Void> emailFuture =
CompletableFuture.runAsync(() -> notificationClient.sendEmail(order), notificationExecutor);
CompletableFuture<Void> smsFuture =
CompletableFuture.runAsync(() -> notificationClient.sendSms(order), notificationExecutor);
CompletableFuture<Void> pushFuture =
CompletableFuture.runAsync(() -> notificationClient.sendPush(order), notificationExecutor);
// 세 작업이 모두 완료될 때까지 논블로킹으로 대기
CompletableFuture.allOf(emailFuture, smsFuture, pushFuture)
.exceptionally(ex -> {
log.error("알림 발송 중 일부 실패", ex);
return null;
})
.join(); // 필요한 경우에만 결과 대기
}
Java
복사
주의: .join()은 블로킹 호출이다. 응답 시간이 중요한 컨트롤러 레이어에서는 호출하지 않는 것이 좋다.
리소스 해제: shutdown
ExecutorService는 애플리케이션 종료 시 반드시 정리해야 한다.
"Initiates an orderly shutdown in which previously submitted tasks are executed, but no new tasks will be accepted."
@PreDestroy
public void destroy() {
notificationExecutor.shutdown();
try {
if (!notificationExecutor.awaitTermination(5, TimeUnit.SECONDS)) {
notificationExecutor.shutdownNow();
}
} catch (InterruptedException e) {
notificationExecutor.shutdownNow();
Thread.currentThread().interrupt();
}
}
Java
복사
shutdown() → 실행 중인 작업 완료 후 종료
shutdownNow() → 즉시 종료 시도 (인터럽트 발생)
정리
•
ExecutorService + ThreadPoolExecutor로 스레드 풀을 명시적으로 제어하자
•
CompletableFuture.runAsync()로 응답 흐름과 무관한 작업을 비동기 분리하자
•
RejectedExecutionHandler는 서비스 특성에 맞게 선택하자 (알림 → CallerRunsPolicy)
•
@PreDestroy에서 반드시 shutdown()을 호출하자
비동기 처리는 성능 개선에 강력한 도구지만, 스레드 누수와 예외 유실에 항상 주의해야 한다. 공식 문서의 계약(contract)을 이해하고 사용하는 것이 안전한 시스템의 기반이다.


