Spring @Async
Spring의 @Async는 메서드를 별도 스레드에서 비동기로 실행하게 만드는 애노테이션이다. 단순히 붙이면 동작하는 것처럼 보이지만, 내부 동작과 주의사항을 모르면 예외가 무시되거나 MDC 컨텍스트가 사라지는 등 운영 장애로 이어질 수 있다.
@Async 동작 원리
프록시 기반 동작
@Async는 Spring AOP 프록시를 통해 동작한다. @EnableAsync가 설정되면 Spring은 @Async가 붙은 메서드를 가진 빈을 프록시로 감싸고, 해당 메서드 호출을 가로채서 TaskExecutor에 위임한다.
호출자 → [프록시] → TaskExecutor의 스레드 풀 → [실제 메서드 실행]
↑ 여기서 별도 스레드 전환
프록시 동작 방식
// 내부적으로 이런 식으로 동작함
public class UserServiceProxy extends UserService {
@Override
public void sendWelcomeEmail(Long userId) {
taskExecutor.execute(() -> super.sendWelcomeEmail(userId)); // 별도 스레드
}
}
동작하지 않는 경우
@Service
public class UserService {
// 잘못된 예 1: 같은 빈 내부에서 this로 호출 (프록시 우회)
public void register(User user) {
save(user);
sendWelcomeEmail(user.getId()); // 프록시 거치지 않음 → @Async 무시
}
@Async
public void sendWelcomeEmail(Long userId) {
// 이 메서드는 동기로 실행됨
}
// 잘못된 예 2: private 메서드 (프록시가 오버라이드 불가)
@Async
private void privateAsyncMethod() {
// @Async 동작 안 함
}
}
해결책: 빈을 분리한다
@Service
@RequiredArgsConstructor
public class UserService {
private final EmailService emailService; // 별도 빈
public void register(User user) {
save(user);
emailService.sendWelcomeEmail(user.getId()); // 프록시 통과 → @Async 동작
}
}
@Service
public class EmailService {
@Async
public void sendWelcomeEmail(Long userId) {
// 별도 스레드에서 실행
}
}
@EnableAsync 설정
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10); // 기본 스레드 수
executor.setMaxPoolSize(50); // 최대 스레드 수
executor.setQueueCapacity(500); // 대기 큐 크기
executor.setThreadNamePrefix("async-"); // 스레드 이름 접두사
executor.setKeepAliveSeconds(60); // 유휴 스레드 유지 시간
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return new CustomAsyncExceptionHandler();
}
}
TaskExecutor
스레드 풀 동작 원리
요청 도착 시 스레드 할당 순서:
1. corePoolSize 미만 → 새 스레드 생성
2. corePoolSize 이상 → queueCapacity에 넣음
3. queueCapacity 가득 참 → maxPoolSize까지 새 스레드 생성
4. maxPoolSize도 가득 참 → RejectedExecutionHandler 실행
※ 큐가 먼저 차고 그 다음에 maxPoolSize까지 늘어남
(일반적인 직관과 반대)
ThreadPoolTaskExecutor 상세 설정
@Bean(name = "emailTaskExecutor")
public ThreadPoolTaskExecutor emailTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("email-async-");
executor.setKeepAliveSeconds(30);
// 거부 정책
// AbortPolicy (기본): RejectedExecutionException 발생
// CallerRunsPolicy: 호출자 스레드에서 직접 실행 (요청 손실 없음)
// DiscardPolicy: 조용히 버림
// DiscardOldestPolicy: 가장 오래된 작업을 버리고 재시도
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// 애플리케이션 종료 시 작업 완료 대기
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
여러 Executor 사용
@Async("emailTaskExecutor")
public void sendEmail(String to, String body) {
// emailTaskExecutor에서 실행
}
@Async("reportTaskExecutor")
public void generateReport(Long reportId) {
// reportTaskExecutor에서 실행
}
반환 타입
// 반환값 없음
@Async
public void sendNotification(Long userId) {
// fire-and-forget
}
// Future 반환 (레거시)
@Async
public Future<String> processAsync() {
return new AsyncResult<>("결과");
}
// CompletableFuture 반환 (권장)
@Async
public CompletableFuture<String> processAsync() {
String result = doHeavyWork();
return CompletableFuture.completedFuture(result);
}
// 호출자에서 결과 수집
CompletableFuture<String> future = service.processAsync();
String result = future.get(5, TimeUnit.SECONDS); // 타임아웃 설정 필수
예외 처리
void 반환 메서드의 예외
void 반환 @Async 메서드에서 발생한 예외는 호출자에게 전파되지 않는다. 기본적으로 예외가 그냥 삼켜진다.
@Async
public void riskyOperation() {
throw new RuntimeException("예외 발생!"); // 호출자는 모름
}
// 호출자
service.riskyOperation(); // 예외가 발생해도 알 방법이 없음
AsyncUncaughtExceptionHandler 등록
public class CustomAsyncExceptionHandler implements AsyncUncaughtExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(CustomAsyncExceptionHandler.class);
@Override
public void handleUncaughtException(Throwable ex, Method method, Object... params) {
log.error("@Async 메서드 예외 발생. method={}, params={}",
method.getName(), Arrays.toString(params), ex);
// 알림 발송, 메트릭 수집 등
alertService.sendAlert("비동기 작업 실패: " + method.getName());
}
}
// AsyncConfig에서 등록
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return new CustomAsyncExceptionHandler();
}
CompletableFuture 반환 시 예외
@Async
public CompletableFuture<String> processAsync() {
try {
String result = doWork();
return CompletableFuture.completedFuture(result);
} catch (Exception e) {
return CompletableFuture.failedFuture(e); // 예외를 Future에 담아 반환
}
}
// 호출자에서 처리
service.processAsync()
.thenAccept(result -> log.info("성공: {}", result))
.exceptionally(ex -> {
log.error("실패", ex);
return null;
});
MDC 전파
문제
@Async는 스레드를 전환하기 때문에 MDC(Mapped Diagnostic Context) 값이 새 스레드로 자동 전파되지 않는다.
// 요청 스레드에서 MDC 설정
MDC.put("requestId", "abc-123");
MDC.put("userId", "42");
// @Async 메서드 호출 → 새 스레드에서 실행
emailService.sendEmail(userId); // 새 스레드에서 MDC 값이 없음
// 로그에서 requestId, userId가 공백으로 남음
해결: TaskDecorator
public class MdcTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
// 현재 스레드(호출자)의 MDC 값을 캡처
Map<String, String> contextMap = MDC.getCopyOfContextMap();
return () -> {
try {
// 새 스레드에 MDC 값 복원
if (contextMap != null) {
MDC.setContextMap(contextMap);
}
runnable.run();
} finally {
// 스레드 풀 재사용 시 오염 방지
MDC.clear();
}
};
}
}
@Bean
public ThreadPoolTaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(500);
executor.setTaskDecorator(new MdcTaskDecorator()); // MDC 전파 설정
executor.initialize();
return executor;
}
Security Context 전파
Spring Security의 SecurityContextHolder도 같은 문제가 있다.
public class SecurityMdcTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
Map<String, String> mdcContext = MDC.getCopyOfContextMap();
SecurityContext securityContext = SecurityContextHolder.getContext();
return () -> {
try {
if (mdcContext != null) MDC.setContextMap(mdcContext);
SecurityContextHolder.setContext(securityContext);
runnable.run();
} finally {
MDC.clear();
SecurityContextHolder.clearContext();
}
};
}
}
또는 SecurityContextHolder의 전략을 변경해서 자동 전파할 수 있다.
@Bean
public MethodInvokingFactoryBean securityContextHolderStrategy() {
MethodInvokingFactoryBean bean = new MethodInvokingFactoryBean();
bean.setTargetClass(SecurityContextHolder.class);
bean.setTargetMethod("setStrategyName");
bean.setArguments(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
return bean;
}
트랜잭션과 @Async
@Async 메서드는 호출자의 트랜잭션을 공유하지 않는다. 새 스레드에서 실행되므로 트랜잭션 컨텍스트가 전파되지 않는다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final NotificationService notificationService;
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order);
// 여기서 호출해도 새 스레드에서 실행되므로 트랜잭션 공유 안 됨
notificationService.sendOrderNotification(order.getId());
// 만약 이 트랜잭션이 롤백되어도 알림은 이미 발송될 수 있음
}
}
@Service
public class NotificationService {
@Async
@Transactional(propagation = Propagation.REQUIRES_NEW) // 새 트랜잭션 시작
public void sendOrderNotification(Long orderId) {
// 별도 트랜잭션으로 실행
}
}
주문 저장 후 알림 발송 보장이 필요하다면 Transactional Event Listener 사용
@Service
public class OrderService {
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order);
applicationEventPublisher.publishEvent(new OrderPlacedEvent(order.getId()));
// 트랜잭션 커밋 후 이벤트 처리 → 순서 보장
}
}
@Component
public class OrderEventListener {
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderPlaced(OrderPlacedEvent event) {
// 트랜잭션 커밋 후 비동기 실행
notificationService.send(event.orderId());
}
}
실무 패턴
비동기 처리 결과 수집
@Service
public class DashboardService {
@Async
public CompletableFuture<Long> countActiveUsers() {
return CompletableFuture.completedFuture(userRepository.countByStatus(ACTIVE));
}
@Async
public CompletableFuture<Long> countTodayOrders() {
return CompletableFuture.completedFuture(orderRepository.countToday());
}
@Async
public CompletableFuture<BigDecimal> getTodayRevenue() {
return CompletableFuture.completedFuture(orderRepository.sumRevenueToday());
}
}
@Service
@RequiredArgsConstructor
public class ReportService {
private final DashboardService dashboardService;
public DashboardReport buildReport() throws ExecutionException, InterruptedException {
// 3개 쿼리 병렬 실행
CompletableFuture<Long> users = dashboardService.countActiveUsers();
CompletableFuture<Long> orders = dashboardService.countTodayOrders();
CompletableFuture<BigDecimal> revenue = dashboardService.getTodayRevenue();
CompletableFuture.allOf(users, orders, revenue).join(); // 모두 완료 대기
return new DashboardReport(users.get(), orders.get(), revenue.get());
}
}
타임아웃 처리
@Async
public CompletableFuture<String> callExternalApi(String param) {
String result = externalApiClient.call(param);
return CompletableFuture.completedFuture(result);
}
// 호출자에서 타임아웃 처리
CompletableFuture<String> future = service.callExternalApi("param");
try {
String result = future.get(3, TimeUnit.SECONDS);
} catch (TimeoutException e) {
future.cancel(true); // 취소 시도
log.warn("API 호출 타임아웃");
} catch (ExecutionException e) {
log.error("API 호출 실패", e.getCause());
}
체크리스트
@Async 사용 시 확인사항:
□ @EnableAsync 설정되어 있는가?
□ @Async 메서드가 public인가?
□ 같은 빈 내부에서 this로 호출하지 않는가?
□ ThreadPoolTaskExecutor를 직접 설정했는가? (기본값은 SimpleAsyncTaskExecutor)
□ void 메서드의 예외 처리를 위해 AsyncUncaughtExceptionHandler 등록했는가?
□ MDC 전파를 위해 TaskDecorator 적용했는가?
□ 트랜잭션 경계가 올바른가?
□ CompletableFuture 반환 시 타임아웃 설정이 있는가?
□ 애플리케이션 종료 시 작업 완료 대기 설정이 있는가?