이 시리즈는 Spring의 비동기 처리를 처음부터 단계별로 다룬다.
2. ThreadPoolTaskExecutor 설정과 스레드 풀 관리
5. TaskDecorator와 컨텍스트 전파 ← 현재 문서
@Async 메서드는 별도 스레드에서 실행된다. 그런데 웹 요청을 처리하면서 ThreadLocal에 저장해둔 사용자 정보나, MDC에 넣어둔 로그 추적 ID가 비동기 스레드에서는 사라진다. 왜 그런지, 그리고 어떻게 해결하는지를 알아보자.
ThreadLocal과 비동기의 문제
ThreadLocal은 스레드마다 독립적인 저장소를 가지는 변수다. 웹 애플리케이션에서 현재 로그인한 사용자 정보를 ThreadLocal에 저장하는 패턴을 흔히 사용한다.
public class UserContext {
private static final ThreadLocal<String> currentUser = new ThreadLocal<>();
public static void setCurrentUser(String username) {
currentUser.set(username);
}
public static String getCurrentUser() {
return currentUser.get();
}
public static void clear() {
currentUser.remove();
}
}
ThreadLocal— 각 스레드가 자기만의 독립적인 값을 가진다. A 스레드에서set("admin")을 해도 B 스레드에서get()하면null이 나온다.
문제는 @Async 메서드가 다른 스레드에서 실행된다는 것이다. 톰캣 스레드에서 UserContext.setCurrentUser("admin@codeit.com")을 설정했어도, 비동기 스레드에서 UserContext.getCurrentUser()를 호출하면 null이 반환된다.
같은 문제가 MDC(Mapped Diagnostic Context)에서도 발생한다. MDC는 로그 추적용 컨텍스트인데, 내부적으로 ThreadLocal을 사용한다. 비동기 스레드에서 로그를 찍으면 traceId가 빠져서, 요청 전체의 로그를 하나로 추적할 수 없게 된다.
TaskDecorator란
TaskDecorator는 Spring이 제공하는 인터페이스로, 스레드 풀에 작업이 제출되기 직전에 Runnable을 감싸는 역할을 한다. 원본 Runnable을 받아서 전/후처리가 추가된 새 Runnable을 반환한다.
이 메커니즘을 이용하면, 메인 스레드의 컨텍스트를 캡처해서 비동기 스레드에 복원할 수 있다.
UserContext 전파용 TaskDecorator 만들기
@Slf4j
public class UserContextTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
String currentUser = UserContext.getCurrentUser();
log.info("TaskDecorator - 사용자 정보 캡처: {}", currentUser);
return () -> {
try {
UserContext.setCurrentUser(currentUser);
log.info("TaskDecorator - 사용자 정보 전파 완료: {} -> {}",
Thread.currentThread().getName(), currentUser);
runnable.run();
} finally {
UserContext.clear();
log.info("TaskDecorator - 사용자 정보 정리 완료");
}
};
}
}
동작 순서가 핵심이다.
decorate()메서드는 메인 스레드에서 실행된다. 이 시점에UserContext.getCurrentUser()로 사용자 정보를 캡처한다.- 반환하는 람다(
return () -> { ... })는 비동기 스레드에서 실행된다. 캡처해둔 값을 비동기 스레드의 UserContext에 설정한다. finally에서 반드시 정리한다. 스레드 풀의 스레드는 재사용되므로, 정리하지 않으면 다른 요청의 사용자 정보가 남아 있을 수 있다.
스레드 풀의 스레드는 작업이 끝나도 소멸하지 않고 다음 작업에 재사용된다. ThreadLocal을 정리하지 않으면 이전 요청의 사용자 정보가 다음 요청에 남아 있게 된다. 심각한 보안 문제가 될 수 있다.
MDC 전파용 TaskDecorator
로그 추적 ID를 비동기 스레드로 전파하는 Decorator도 동일한 패턴이다.
@Slf4j
public class LoggingTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
Map<String, String> mdcContext = MDC.getCopyOfContextMap();
return () -> {
try {
if (mdcContext != null) {
MDC.setContextMap(mdcContext);
}
runnable.run();
} finally {
MDC.clear();
}
};
}
}
MDC.getCopyOfContextMap()— 메인 스레드의 MDC 전체를 복사한다.traceId,requestId등 모든 키-값이 포함된다.MDC.setContextMap()— 비동기 스레드에 MDC를 복원한다.MDC.clear()— 작업 완료 후 정리.
통합 TaskDecorator
UserContext와 MDC를 모두 전파하는 통합 Decorator를 만들 수도 있다.
@Slf4j
public class ContextPropagatingTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
String currentUser = UserContext.getCurrentUser();
Map<String, String> mdcContext = MDC.getCopyOfContextMap();
return () -> {
try {
UserContext.setCurrentUser(currentUser);
if (mdcContext != null) {
MDC.setContextMap(mdcContext);
}
runnable.run();
} finally {
UserContext.clear();
MDC.clear();
}
};
}
}
Executor에 TaskDecorator 등록
만든 TaskDecorator를 Executor에 설정한다.
@Bean(name = "notificationExecutor")
public ThreadPoolTaskExecutor notificationExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(50);
executor.setMaxPoolSize(100);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("notification-");
executor.setTaskDecorator(new ContextPropagatingTaskDecorator());
executor.initialize();
return executor;
}
setTaskDecorator()— Executor에 TaskDecorator를 설정한다. 이 Executor를 사용하는 모든@Async메서드에 자동으로 적용된다.
하지만 setTaskDecorator()는 하나의 Decorator만 받는다. UserContext 전파와 MDC 전파를 별도 Decorator로 만들었다면 어떻게 둘 다 적용할까?
CompositeTaskDecorator
여러 TaskDecorator를 순차적으로 적용하는 Composite 패턴이다.
public class CompositeTaskDecorator implements TaskDecorator {
private final List<TaskDecorator> decorators;
public CompositeTaskDecorator(List<TaskDecorator> decorators) {
this.decorators = decorators;
}
@Override
public Runnable decorate(Runnable runnable) {
Runnable wrappedRunnable = runnable;
for (TaskDecorator decorator : decorators) {
wrappedRunnable = decorator.decorate(wrappedRunnable);
}
return wrappedRunnable;
}
}
- 전달받은 Decorator 목록을 순서대로 적용한다. 각 Decorator가 이전 Decorator의 결과를 감싸는 구조다.
Executor에 적용할 때는 이렇게 사용한다.
executor.setTaskDecorator(new CompositeTaskDecorator(
List.of(
new UserContextTaskDecorator(),
new LoggingTaskDecorator()
)
));
새로운 TaskDecorator가 필요하면 리스트에 추가하기만 하면 된다. 기존 코드를 수정할 필요가 없다.
UserContext 복원
원본 작업 실행
UserContext 정리
MDC 정리
실제 사용 예시
컨트롤러에서 사용자 정보와 추적 ID를 설정하고, 비동기 메서드에서 확인해보자.
@PostMapping("/with-trace")
public ResponseEntity<?> createOrderWithTrace(@RequestBody Map<String, Object> request) {
String traceId = "TRACE-" + System.currentTimeMillis();
MDC.put("traceId", traceId);
UserContext.setCurrentUser("admin@codeit.com");
String orderId = "ORD-" + System.currentTimeMillis();
String customerName = (String) request.get("customerName");
Integer amount = (Integer) request.getOrDefault("amount", 4000);
coffeeService.makeCoffeeAsync("아메리카노");
notificationService.sendOrderConfirmation(orderId, customerName);
paymentService.processPayment(orderId, customerName, amount);
MDC.clear();
UserContext.clear();
return ResponseEntity.ok(Map.of("orderId", orderId, "traceId", traceId, "status", "PROCESSING"));
}
비동기 서비스에서 컨텍스트를 확인한다.
@Async("notificationExecutor")
public void sendOrderConfirmation(String orderId, String customerName) {
String currentUser = UserContext.getCurrentUser();
log.info("[{}] 주문 확인 알림 발송 - 주문번호={}, 고객={}, 요청자={}",
Thread.currentThread().getName(), orderId, customerName, currentUser);
sleep(2000);
log.info("[{}] 고객 알림 발송", Thread.currentThread().getName());
}
TaskDecorator가 정상 동작하면, notification-1 스레드에서도 currentUser에 admin@codeit.com이 찍히고, 로그에도 traceId가 포함된다.
자주 하는 실수
finally에서 정리 누락
가장 치명적인 실수다. 스레드 풀의 스레드는 재사용된다. 이전 요청의 A 사용자 정보가 다음 요청의 B 사용자에게 노출될 수 있다. finally 블록에서 반드시 clear()를 호출하자.
TaskDecorator를 Executor에 등록하지 않음
TaskDecorator를 만들어두고 executor.setTaskDecorator()를 빠뜨리는 경우가 있다. 모든 비동기 Executor에 필요한 TaskDecorator가 설정되어 있는지 확인하자.
SecurityContext 전파 누락
Spring Security의 SecurityContextHolder도 ThreadLocal 기반이다. 비동기 스레드에서 인증 정보가 필요하면 TaskDecorator에 SecurityContext 전파 로직을 추가해야 한다. 또는 SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL)을 설정할 수 있지만, 스레드 풀 환경에서는 주의가 필요하다.
정리
- ThreadLocal과 MDC는 스레드별 독립 저장소이므로, 비동기 스레드에서는 값이 사라진다.
- TaskDecorator로 메인 스레드의 컨텍스트를 캡처하고, 비동기 스레드에 복원한다.
- 작업 완료 후 finally에서 반드시 컨텍스트를 정리해야 한다 (메모리 누수 + 보안 문제 방지).
- CompositeTaskDecorator로 여러 Decorator를 조합해서 사용할 수 있다.
다음 편에서는 Spring의 비동기 HTTP 클라이언트인 WebClient를 사용해 외부 API를 비동기로 호출하는 방법을 다룬다.