Spring 비동기 처리는 요청 처리 중 오래 걸리는 작업을 별도 스레드로 분리하여 메인 스레드의 점유 시간을 최소화한다.
이로 인해 동시 요청이 많은 환경에서도 스레드 고갈을 방지하고 서버 처리량(throughput)을 높일 수 있다.
결과적으로 응답 지연 감소, 안정성 향상, 확장성 확보라는 핵심 이점을 제공한다.
비동기 처리로 오래 걸리는 작업을 즉시 분리하면 사용자는 빠른 응답을 먼저 받고,
실제 작업은 백그라운드에서 진행되므로 UI 지연·멈춤 없이 체감 성능이 크게 향상된다.
서비스 지연으로 인한 사용자 이탈률을 낮추는 데 효과적이다.
| 구분 | Java 비동기 처리 | Spring 비동기 처리 |
|---|---|---|
| 구현 방식 | Thread / Executor / CompletableFuture 직접 사용 | @Async 기반 선언적 비동기 처리 |
| 설정 방식 | 스레드풀·큐 크기 수동 설정 | ThreadPoolTaskExecutor를 Bean으로 설정 |
| 결과 처리 | Future/CompletableFuture 직접 제어 | 반환 타입에 따른 공통 패턴 제공 |
| 예외 처리 | try-catch, callback 직접 구성 | Spring 예외 처리 체계와 자연스럽게 연계 |
| 스프링 연동성 | 컨텍스트 전파 로직 직접 구현 필요 | AOP 기반으로 동작하나, 트랜잭션 분리 및 SecurityContext 미전파 등 주의 필요 |
| 코드 복잡도 | 인프라 코드 혼재 | 비즈니스 로직 중심, 구조 단순 |
Spring 비동기 처리는 다음과 같은 구조로 구성된다:
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
exec.setCorePoolSize(5);
exec.setMaxPoolSize(20);
exec.setQueueCapacity(100);
exec.setThreadNamePrefix("async-");
exec.initialize();
return exec;
}
}
@Service
public class MailService {
@Async
public void sendMail(String to) {
System.out.println("Sending mail to " + to);
}
}
Spring은 ThreadPoolTaskExecutor를 이용하여 비동기 작업 실행용 전용 스레드 풀을 구성한다.
주요 구성 요소:
비동기로 분리하기 좋은 작업:
동기로 유지해야 하는 작업:
동기 방식
비동기 방식(@Async)
Spring AOP 프록시가 메서드를 가로채서:
@Service
public class MailService {
@Async
public void sendMail(String to) {
System.out.println("Sending mail to " + to);
}
}
@Service
@Async
public class NotificationService {
public void sendEmail(String email) { }
public void sendSms(String phoneNumber) { }
}
@Async
public Future<String> loadData() {
return new AsyncResult<>("done");
}
@Async
public CompletableFuture<String> fetchData() {
return CompletableFuture.completedFuture("ok");
}
| 제약 | 설명 | 대응 |
|---|---|---|
| 프록시 기반 | 프록시를 통해 호출돼야 동작 | 반드시 외부 빈을 통해 호출 |
| Self-invocation | 같은 클래스 내부 호출 시 비동기 적용 안됨 | 구조 분리 |
| 접근 제한자 | public만 프록시 적용 가능 | @Async 메서드는 public |
| 트랜잭션 전파 | 별도 스레드라 트랜잭션 전파 안됨 | 필요 시 @Transactional 별도 선언 |
@Service
public class ReportService {
public void generateReport() {
generateReportAsync(); // 비동기 아님
}
@Async
public void generateReportAsync() { }
}
Spring AOP는 프록시 기반으로 동작하므로, 외부에서 빈을 통해 호출해야 프록시가 개입한다. 같은 클래스 내부에서
this.method()로 호출하면 프록시를 거치지 않고 실제 객체의 메서드가 직접 호출되어@Async가 적용되지 않는다.
@Service
public class PushAsyncService {
@Async // 비동기 적용되지 않음
private void sendPushAsync(String message) { }
}
@Service
public class OrderService {
@Transactional
public void placeOrder() {
saveOrder();
sendOrderNotificationAsync();
}
@Async
public void sendOrderNotificationAsync() { }
}
TaskExecutor는 스레드 실행을 추상화하여 비동기 작업을 처리하기 위한 표준 실행 계약을 제공한다.
| 구현체 | 특징 | 적합한 용도 |
|---|---|---|
| SimpleAsyncTaskExecutor | 매 요청마다 스레드 생성, 스레드풀 없음 | 가벼운 테스트, 간단한 작업 |
| ThreadPoolTaskExecutor | 가장 널리 사용, 풀 기반, 성능·안정성 우수 | 웹 서비스, 대량 요청 처리 |
| WorkManagerTaskExecutor | JCA WorkManager 기반, WebLogic 등 WAS용 | JEE WAS 환경 |
| ConcurrentTaskExecutor | 기존 ExecutorService 래핑 | 직접 만든 풀을 Spring에서 재사용 |
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "asyncExecutor")
public Executor asyncExecutor() {
ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
// I/O 바운드 기준 예시
exec.setCorePoolSize(8);
exec.setMaxPoolSize(32);
exec.setQueueCapacity(200);
exec.setKeepAliveSeconds(60);
exec.setThreadNamePrefix("async-worker-");
exec.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
exec.initialize();
return exec;
}
}
Tomcat 설정 예:
server:
tomcat:
threads:
max: 200
min-spare: 20
max-connections: 10000
connection-timeout: 20000
keep-alive-timeout: 20000
max-keep-alive-requests: 100
accept-count: 100
→ 둘은 완전히 독립적으로 동작
@EnableAsync
public class AsyncConfig {
@Bean(name = "customExecutor")
public TaskExecutor customExecutor() {
ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
exec.setCorePoolSize(4);
exec.setMaxPoolSize(8);
exec.setQueueCapacity(100);
exec.setThreadNamePrefix("custom-");
exec.initialize();
return exec;
}
}
@Service
public class MailService {
@Async("customExecutor")
public void sendMail() {
System.out.println("메일 전송: " + Thread.currentThread().getName());
}
}
@Configuration
@EnableAsync
public class AsyncMultiConfig {
@Bean("fastExecutor")
public TaskExecutor fastExecutor() {
return new ThreadPoolTaskExecutor() {{
setCorePoolSize(2);
setMaxPoolSize(4);
setThreadNamePrefix("fast-");
initialize();
}};
}
@Bean("ioExecutor")
public TaskExecutor ioExecutor() {
return new ThreadPoolTaskExecutor() {{
setCorePoolSize(8);
setMaxPoolSize(16);
setThreadNamePrefix("io-");
initialize();
}};
}
}
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: always
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-prometheus'
}
/actuator/metrics에서 확인 가능 @Configuration
@EnableAsync
@RequiredArgsConstructor
public class AsyncConfig {
private final MeterRegistry meterRegistry;
@Bean("monitoredExecutor")
public ThreadPoolTaskExecutor monitoredExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-");
executor.initialize();
ExecutorServiceMetrics.monitor(
meterRegistry,
executor.getThreadPoolExecutor(),
"async-executor",
Collections.emptyList()
);
return executor;
}
}
@Bean("ioExecutor")
public ThreadPoolTaskExecutor ioExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("io-");
executor.setRejectedExecutionHandler((r, exec) -> {
int poolSize = exec.getPoolSize();
int active = exec.getActiveCount();
int queueSize = exec.getQueue().size();
log.warn("스레드풀 포화 발생 poolSize={}, active={}, queueSize={}",
poolSize, active, queueSize);
throw new RejectedExecutionException("스레드풀 포화 상태");
});
executor.initialize();
return executor;
}
@RestController
@RequiredArgsConstructor
public class ExecutorMonitorController {
private final ThreadPoolTaskExecutor monitoredExecutor;
@GetMapping("/monitor/executor")
public Map<String, Object> status() {
ThreadPoolExecutor tp = monitoredExecutor.getThreadPoolExecutor();
return Map.of(
"poolSize", tp.getPoolSize(),
"activeCount", tp.getActiveCount(),
"queueSize", tp.getQueue().size(),
"corePoolSize", tp.getCorePoolSize(),
"maxPoolSize", tp.getMaximumPoolSize()
);
}
}
Spring Event 시스템은 다음과 같은 구조를 제공한다:
활용 분야:
EventPublisher는 발행된 이벤트를 ApplicationEventMulticaster로 전달하여,
리스너들에게 알맞게 분배하는 출발점 역할을 한다.
| 장점 | 설명 |
|---|---|
| 느슨한 결합 | 발행자·소비자 간 의존도 감소 |
| 확장성 | 소비자 개별 확장 가능 |
| 비동기 처리 | 메인 로직 처리 속도 향상 |
| 유연성 | 리스너 추가만으로 기능 확장 |
| 관심사 분리 | 핵심 로직과 후처리 분리 |
public class UserCreatedEvent {
private final Long userId;
public UserCreatedEvent(Long userId) {
this.userId = userId;
}
public Long getUserId() {
return userId;
}
}
@Service
@RequiredArgsConstructor
public class UserService {
private final ApplicationEventPublisher publisher;
public void createUser(Long userId) {
publisher.publishEvent(new UserCreatedEvent(userId));
}
}
@Component
public class UserCreatedListener
implements ApplicationListener<UserCreatedEvent> {
@Override
public void onApplicationEvent(UserCreatedEvent event) {
System.out.println("신규 사용자 생성 이벤트: " + event.getUserId());
}
}
@Component
public class UserEventHandler {
@EventListener
public void handle(UserCreatedEvent event) {
System.out.println("사용자 생성됨: " + event.getUserId());
}
}
@EventListener(condition = "#event.userId > 100")
public void handleOnlyBigId(UserCreatedEvent event) {
System.out.println("조건 충족: " + event.getUserId());
}
@EventListener(condition = "#event.username == 'admin'")
public void handleAdmin(UserLoginEvent event) {
System.out.println("관리자 로그인");
}
@Component
public class UserCreatedEventListener {
@Async("eventExecutor")
@EventListener
public void onUserCreated(UserCreatedEvent event) {
System.out.println("비동기 스레드: " + Thread.currentThread().getName());
System.out.println("신규 사용자 생성 처리");
}
}
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("eventExecutor")
public ThreadPoolTaskExecutor eventExecutor() {
ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
exec.setCorePoolSize(4);
exec.setMaxPoolSize(8);
exec.setThreadNamePrefix("event-");
exec.initialize();
return exec;
}
}
TransactionalEventListener는 트랜잭션의 단계에 따라 이벤트 실행 시점을 제어한다.
| Phase | 설명 | 활용 |
|---|---|---|
| BEFORE_COMMIT | 커밋 직전 실행 | 정합성 체크 |
| AFTER_COMMIT | 커밋 성공 이후 | 알림, 외부 API |
| AFTER_ROLLBACK | 롤백 시에만 실행 | 보상 처리 |
| AFTER_COMPLETION | 성공·실패 관계없이 종료 시 | 정리 작업 |
public class OrderCreatedEvent {
private final Long orderId;
public OrderCreatedEvent(Long orderId) {
this.orderId = orderId;
}
public Long getOrderId() {
return orderId;
}
}
@Service
@RequiredArgsConstructor
public class OrderService {
private final ApplicationEventPublisher publisher;
private final OrderRepository orderRepository;
@Transactional
public void createOrder() {
Order order = new Order();
orderRepository.save(order);
publisher.publishEvent(new OrderCreatedEvent(order.getId()));
}
}
@Component
public class OrderEventHandler {
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void beforeCommit(OrderCreatedEvent event) {
System.out.println("BEFORE_COMMIT: 검증");
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void afterCommit(OrderCreatedEvent event) {
System.out.println("AFTER_COMMIT: 알림 발송");
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void afterRollback(OrderCreatedEvent event) {
System.out.println("AFTER_ROLLBACK: 보상 처리");
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
public void afterCompletion(OrderCreatedEvent event) {
System.out.println("AFTER_COMPLETION: 정리 작업");
}
}
비동기(@Async)는 스레드가 다르기 때문에 트랜잭션이 전파되지 않는다.
// ❌ 잘못된 사용 - BEFORE_COMMIT과 @Async
@Async
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handle(OrderCreatedEvent event) {
// 커밋 전에 비동기로 실행되면 트랜잭션 정합성 보장 불가
}
// ✅ 유효한 사용 - AFTER_COMMIT과 @Async
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(OrderCreatedEvent event) {
// 커밋 확정 후 비동기 실행 - 알림, 외부 연동 등에 적합
}
→ 트랜잭션 종속 로직에는 절대 @Async를 사용하면 안 됨
| 분야 | 설명 |
|---|---|
| 사용자 활동 로깅 | 비동기로 기록하여 성능 저하 방지 |
| 알림 발송 | SMS/Email 푸시 등 |
| 캐시 갱신 | 데이터 변경 시 자동 무효화 |
| 외부 시스템 연동 | ERP·CRM 연결 작업 분리 |
→ Kafka / RabbitMQ 같은 메시지 브로커 필요
| 항목 | 도메인 이벤트 | 통합 이벤트 |
|---|---|---|
| 목적 | 내부 로직 간 결합도 감소 | 시스템 간 통신 |
| 범위 | 단일 애플리케이션 | 여러 서비스/시스템 |
| 전달 | Spring Event | Kafka / RabbitMQ |
| 트랜잭션 | 트랜잭션과 밀접 | 독립적 비동기 처리 |
| 예 | OrderPaidEvent | OrderCreatedIntegrationEvent |
@Async 메서드는 별도의 스레드에서 실행되므로, 예외가 호출자에게 전파되지 않는다.
따라서 호출부에서는 예외를 전혀 감지할 수 없다.
@Async
public void sendEmail() {
throw new RuntimeException("이메일 전송 실패.");
}
// 호출부
emailService.sendEmail(); // ❌ 예외 감지 불가
비동기 메서드를 호출하는 try-catch는 실행 스레드가 다르기 때문에 예외를 잡지 못한다.
@Async
public void sendEmail() {
throw new RuntimeException("이메일 전송 실패.");
}
try {
emailService.sendEmail(); // @Async
} catch (Exception e) {
System.out.println("이 블록은 실행되지 않음");
}
void 반환 메서드는 결과와 실패 여부를 확인할 방법이 없다.
@Async
public void processTask() {
throw new IllegalStateException("처리 실패");
}
processTask(); // 호출부는 실패 여부 확인 불가
System.out.println("호출부는 에러를 감지할 수 없다");
void 메서드는 예외가 호출부에 전달되지 않기 때문에,
AsyncUncaughtExceptionHandler를 반드시 사용해야 한다.
@Async
public void sendEmail() {
throw new RuntimeException("이메일 전송 실패");
}
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) ->
System.out.println("비동기 예외: " + ex.getMessage());
}
}
@Async
public Future<String> loadUser() {
throw new IllegalStateException("사용자 조회 실패");
}
Future<String> result = loadUser();
try {
result.get(); // ExecutionException으로 감싸져 던져짐
} catch (Exception e) {
System.out.println("Future 예외: " + e.getCause().getMessage());
}
@Async
public CompletableFuture<String> fetchData() {
throw new RuntimeException("데이터 조회 실패");
}
fetchData()
.exceptionally(ex -> {
System.out.println("CompletableFuture 예외: " + ex.getMessage());
return "fallback";
})
.thenAccept(result -> System.out.println("결과: " + result));
@Slf4j
public class CustomAsyncExceptionHandler implements AsyncUncaughtExceptionHandler {
@Override
public void handleUncaughtException(Throwable ex, Method method, Object... params) {
log.error("비동기 예외 발생");
log.error("예외: {}", ex.getMessage());
log.error("메서드: {}", method.getName());
log.error("파라미터: {}", Arrays.toString(params));
}
}
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return new CustomAsyncExceptionHandler();
}
}
@Override
public void handleUncaughtException(Throwable ex, Method method, Object... params) {
String methodName = method.getName();
String paramValues = Arrays.toString(params);
String errorMessage = ex.getMessage();
log.error("[Async Error] method={}, params={}, message={}",
methodName, paramValues, errorMessage);
}
실제 운영 환경에서는 Slack, Sentry, CloudWatch 등과 연계하여
비동기 예외를 실시간 알림으로 받아야 한다.
@Override
public void handleUncaughtException(Throwable ex, Method method, Object... params) {
log.error("비동기 예외 감지: {}", ex.getMessage());
// Sentry 연동 예시
Sentry.captureException(ex);
// Slack 전송 예시
slackNotifier.send(
"[Async Error] 메서드: " + method.getName() +
", 파라미터: " + Arrays.toString(params) +
", 메시지: " + ex.getMessage()
);
}
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
exec.setCorePoolSize(4);
exec.setMaxPoolSize(20);
exec.setQueueCapacity(100);
exec.setThreadNamePrefix("async-");
exec.initialize();
return exec;
}
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return new AsyncUncaughtExceptionHandler() {
@Override
public void handleUncaughtException(Throwable ex, Method method, Object... params) {
System.out.println("비동기 예외: " + ex.getMessage());
System.out.println("메서드: " + method.getName());
System.out.println("파라미터: " + Arrays.toString(params));
}
};
}
@Override
public void handleUncaughtException(Throwable ex, Method method, Object... params) {
if (ex instanceof IllegalArgumentException) {
log.warn("[비동기 경고] 잘못된 파라미터: {}", ex.getMessage());
return;
}
if (ex instanceof IllegalStateException) {
log.error("[비동기 비즈니스 오류] {}", ex.getMessage());
slackNotifier.send("비즈니스 오류: " + ex.getMessage());
return;
}
// 기타 시스템 오류
log.error("[비동기 시스템 오류] {}", ex.getMessage(), ex);
sentry.captureException(ex);
}
@Service
public class RetryService {
@Retryable(
value = { IOException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 1000)
)
public String callExternalApi() throws IOException {
System.out.println("외부 API 호출");
throw new IOException("일시적 장애");
}
@Recover
public String recover(IOException e) {
return "기본 응답 반환";
}
}
public String getUserProfileFallback() {
return "{ \"name\": \"Guest\", \"role\": \"Unknown\" }";
}
public String getUserProfile() {
try {
return externalApi.getUserProfile();
} catch (Exception e) {
return getUserProfileFallback(); // 장애 시 폴백
}
}
CircuitBreaker circuitBreaker =
CircuitBreaker.ofDefaults("externalApi");
Supplier<String> decoratedSupplier =
CircuitBreaker.decorateSupplier(circuitBreaker,
() -> externalApi.getData()
);
try {
String result = Try.ofSupplier(decoratedSupplier)
.recover(ex -> "fallback-data")
.get();
System.out.println(result);
} catch (Exception e) {
System.out.println("회로 차단기로 인해 호출 차단");
}
TaskDecorator는 Spring의 비동기 작업(@Async)이 새로운 스레드에서 실행될 때,
원래 웹 요청 스레드가 가지고 있던 다음과 같은 맥락(context)을 그대로 전달하도록 도와주는 기능이다.
비동기는 스레드가 다르기 때문에 기존 ThreadLocal이 초기화됨 →
TaskDecorator가 이를 복사해주는 역할을 한다.
예시: MDC에 넣은 requestId가 비동기 로그에서는 사라짐
@Slf4j
@RestController
public class DemoController {
@GetMapping("/test")
public String test() {
MDC.put("requestId", UUID.randomUUID().toString());
asyncService.runAsync(); // 비동기 실행
return "ok";
}
}
비동기 로그에서는 requestId가 찍히지 않음:
[async-thread-1] ---- log ---- requestId=null
Spring Security 인증 정보도 기본적으로 ThreadLocal 기반이므로 비동기 스레드에서는 SecurityContext가 비어 있다.
→ 인증된 사용자 기반 처리(알림, 로그 기록 등) 불가능
public class MdcTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
Map<String, String> contextMap = MDC.getCopyOfContextMap();
return () -> {
try {
if (contextMap != null) {
MDC.setContextMap(contextMap); // 기존 스레드 MDC 복사
}
runnable.run();
} finally {
MDC.clear(); // 메모리 누수 방지
}
};
}
}
---
### 3-2. ThreadPoolTaskExecutor에 TaskDecorator 적용
```java
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
exec.setCorePoolSize(8);
exec.setMaxPoolSize(32);
exec.setQueueCapacity(200);
exec.setThreadNamePrefix("async-");
exec.setTaskDecorator(new MdcTaskDecorator()); // 핵심
exec.initialize();
return exec;
}
}
Spring Security 인증 정보(SecurityContextHolder)를 전달하도록 확장할 수 있다.
public class SecurityContextTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
SecurityContext context = SecurityContextHolder.getContext();
return () -> {
try {
SecurityContextHolder.setContext(context); // 인증 정보 복사
runnable.run();
} finally {
SecurityContextHolder.clearContext();
}
};
}
}
public class ContextCopyingDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable task) {
Map<String, String> mdc = MDC.getCopyOfContextMap();
SecurityContext securityContext = SecurityContextHolder.getContext();
String userId = UserContext.getUserId(); // 예: ThreadLocal 기반 사용자 ID
return () -> {
try {
if (mdc != null) MDC.setContextMap(mdc);
SecurityContextHolder.setContext(securityContext);
UserContext.setUserId(userId);
task.run();
} finally {
MDC.clear();
SecurityContextHolder.clearContext();
UserContext.clear();
}
};
}
}
[http-nio-1] requestId=ab12...
[async-1] requestId=null
[http-nio-1] requestId=ab12...
[async-1] requestId=ab12... ← 전달됨
→ 요청 단위 추적성이 좋아지고, 비동기 로그가 전체 흐름과 연결됨
Sleuth, OpenTelemetry 등과 함께 활용하면
비동기 호출에서도 Trace ID가 끊기지 않는다.
✔ 모든 ThreadLocal 기반 정보는 명확히 정리
✔ MDC, SecurityContext, 커스텀 Context를 반드시 clear() 호출
✔ 하나의 데코레이터에 여러 맥락을 통합하는 것이 효율적
✔ WebFlux 환경에서도 동일 패턴 사용 가능
✔ 보안 정보(SecurityContext)는 외부 노출 위험이 있으므로 필터링 필요
TaskDecorator는 Spring의 비동기 작업(@Async)이 새로운 스레드에서 실행될 때,
원래 웹 요청 스레드가 가지고 있던 다음 정보를 그대로 전달하도록 도와주는 기능이다.
비동기는 스레드가 다르기 때문에 기존 ThreadLocal이 초기화되므로
TaskDecorator가 이를 복사해주어야 한다.
예시: MDC에 넣은 requestId가 비동기 로그에서는 사라지는 문제
@Slf4j
@RestController
public class DemoController {
@GetMapping("/test")
public String test() {
MDC.put("requestId", UUID.randomUUID().toString());
asyncService.runAsync(); // 비동기 실행
return "ok";
}
}
비동기 로그 예:
[async-thread-1] ---- log ---- requestId=null
Spring Security는 ThreadLocal 기반 → 비동기 스레드에서는 SecurityContext가 비어 있음
→ 인증 사용자 기반 작업 불가
public class MdcTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
Map<String, String> contextMap = MDC.getCopyOfContextMap();
return () -> {
try {
if (contextMap != null) {
MDC.setContextMap(contextMap); // 기존 스레드 MDC 복사
}
runnable.run();
} finally {
MDC.clear(); // 누수 방지
}
};
}
}
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
exec.setCorePoolSize(8);
exec.setMaxPoolSize(32);
exec.setQueueCapacity(200);
exec.setThreadNamePrefix("async-");
exec.setTaskDecorator(new MdcTaskDecorator()); // 핵심
exec.initialize();
return exec;
}
}
public class SecurityContextTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
SecurityContext context = SecurityContextHolder.getContext();
return () -> {
try {
SecurityContextHolder.setContext(context); // 인증 정보 복사
runnable.run();
} finally {
SecurityContextHolder.clearContext();
}
};
}
}
public class ContextCopyingDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable task) {
Map<String, String> mdc = MDC.getCopyOfContextMap();
SecurityContext securityContext = SecurityContextHolder.getContext();
String userId = UserContext.getUserId(); // ThreadLocal 사용자 ID
return () -> {
try {
if (mdc != null) MDC.setContextMap(mdc);
SecurityContextHolder.setContext(securityContext);
UserContext.setUserId(userId);
task.run();
} finally {
MDC.clear();
SecurityContextHolder.clearContext();
UserContext.clear();
}
};
}
}
[http-nio-1] requestId=ab12...
[async-1] requestId=null
[http-nio-1] requestId=ab12...
[async-1] requestId=ab12...
→ 비동기 로그에서도 requestId가 유지되어 추적성이 높아짐
✔ ThreadLocal 기반 정보는 항상 명확히 정리
✔ MDC, SecurityContext, CustomContext는 반드시 clear() 호출
✔ 하나의 데코레이터에 여러 컨텍스트를 통합 가능
✔ WebFlux에서도 같은 패턴 적용
✔ 보안 민감 정보는 외부로 노출되지 않도록 주의