@Async와 ThreadLocal 함정

한 줄 정의

Spring의 트랜잭션, SecurityContext, MDC는 모두 ThreadLocal 기반이므로, @Async나 CompletableFuture로 스레드가 바뀌면 전파되지 않고, 스레드 풀에서 재사용되면 이전 값이 남는다.

실무에서 왜 문제 되는가

  • @Async 메서드에서 DB 저장이 커밋되지 않거나 별도 트랜잭션으로 동작하는 이유를 모른다.
  • 비동기 작업에서 SecurityContextHolder.getContext()가 null이 되는 이유를 모른다.
  • 로그에 traceId가 비동기 구간에서 사라진다 (MDC).
  • 스레드 풀에서 이전 요청의 사용자 정보가 다음 요청에 노출되는 보안 사고가 난다.
  • CompletableFuture.supplyAsync() 안에서 호출자의 DB 커넥션을 공유할 수 없어 예상치 못한 커넥션 풀 고갈이 생긴다.

ThreadLocal 동작 원리

ThreadLocal은 각 스레드가 독립적으로 값을 저장하는 저장소다. 같은 변수명이지만 스레드마다 다른 값을 가진다.

ThreadLocal<String> context = new ThreadLocal<>();
 
// Thread-1에서
context.set("user-A");
context.get(); // "user-A"
 
// Thread-2에서
context.get(); // null (Thread-1의 값과 무관)
Thread-1: ┌─ ThreadLocal ─┐
          │ userId = "A"  │
          └───────────────┘

Thread-2: ┌─ ThreadLocal ─┐
          │ userId = null │
          └───────────────┘

Spring에서 ThreadLocal을 쓰는 곳

기능ThreadLocal 사용저장하는 값
@TransactionalTransactionSynchronizationManagerDB Connection, 트랜잭션 상태
Spring SecuritySecurityContextHolder인증 정보 (Authentication)
MDC (로깅)MDC.put()traceId, requestId
RequestContextHolderRequestContextHolderHttpServletRequest

핵심: 이 모든 것이 “현재 스레드”에 바인딩된다. 스레드가 바뀌면 전부 끊긴다.

@Async에서 트랜잭션이 동작하지 않는 이유

@Service
public class OrderService {
 
    @Transactional
    public void createOrder(OrderRequest request) {
        orderRepository.save(new Order(request));
        notificationService.sendAsync(request); // ❌ 다른 스레드
    }
}
 
@Service
public class NotificationService {
 
    @Async
    public void sendAsync(OrderRequest request) {
        // 이 메서드는 다른 스레드에서 실행됨
        // → 호출자의 트랜잭션에 참여 불가
        // → 호출자의 SecurityContext 접근 불가
        // → MDC의 traceId도 없음
        logRepository.save(new NotificationLog(request)); // 별도 트랜잭션 필요
    }
}
Thread-1 (요청 스레드)          Thread-2 (@Async 스레드)
┌─────────────────────┐       ┌─────────────────────┐
│ Transaction: active │       │ Transaction: none   │
│ SecurityContext: ✓  │       │ SecurityContext: ✗   │
│ MDC traceId: abc123 │  ──→  │ MDC traceId: null    │
└─────────────────────┘       └─────────────────────┘

원인: @Async는 별도 스레드에서 실행되므로 호출자의 ThreadLocal에 접근할 수 없다.

대응 전략

@Service
public class NotificationService {
 
    // 방법 1: 비동기 메서드에 자체 트랜잭션 선언
    @Async
    @Transactional
    public void sendAsync(OrderRequest request) {
        logRepository.save(new NotificationLog(request)); // 자체 트랜잭션
    }
 
    // 방법 2: 필요한 값을 파라미터로 전달
    @Async
    public void sendAsync(Long orderId, String traceId, String username) {
        MDC.put("traceId", traceId); // 수동 설정
        try {
            // 비동기 처리
        } finally {
            MDC.clear();
        }
    }
}
전략방법적용 대상
자체 @Transactional 선언비동기 메서드에 독립 트랜잭션DB 작업이 필요한 경우
파라미터로 값 전달ThreadLocal 값을 인자로 넘김traceId, userId 등
SecurityContext 전파 설정MODE_INHERITABLETHREADLOCAL인증 정보 전파
TaskDecorator실행 전 ThreadLocal 복사MDC, SecurityContext 일괄 전파

TaskDecorator로 ThreadLocal 전파

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
 
    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(100);
        executor.setTaskDecorator(new MdcTaskDecorator());
        executor.initialize();
        return executor;
    }
}
 
public class MdcTaskDecorator implements TaskDecorator {
 
    @Override
    public Runnable decorate(Runnable runnable) {
        // 호출 스레드의 MDC 값을 캡처
        Map<String, String> contextMap = MDC.getCopyOfContextMap();
 
        return () -> {
            try {
                if (contextMap != null) {
                    MDC.setContextMap(contextMap); // 비동기 스레드에 설정
                }
                runnable.run();
            } finally {
                MDC.clear(); // 반드시 정리
            }
        };
    }
}

스레드 풀 + ThreadLocal 누수

스레드 풀은 스레드를 재사용한다. ThreadLocal을 정리하지 않으면 이전 요청의 값이 다음 요청에 남는다.

요청 A (userId=100) → Thread-1에 ThreadLocal 저장 → 응답 → Thread-1 반환

요청 B (인증 안 됨) → Thread-1 재할당 → ThreadLocal에 userId=100이 남아있음 ❌

누수가 발생하는 패턴

// ❌ 위험: 정리하지 않음
public class UserContext {
    private static final ThreadLocal<Long> currentUserId = new ThreadLocal<>();
 
    public static void set(Long userId) {
        currentUserId.set(userId);
    }
 
    public static Long get() {
        return currentUserId.get();
    }
}
 
// Interceptor에서 set만 하고 remove를 안 함
public class AuthInterceptor implements HandlerInterceptor {
 
    @Override
    public boolean preHandle(HttpServletRequest request, ...) {
        UserContext.set(extractUserId(request));
        return true;
    }
    // afterCompletion에서 remove를 안 하면 누수
}

올바른 패턴

// ✅ 안전: afterCompletion에서 반드시 정리
public class AuthInterceptor implements HandlerInterceptor {
 
    @Override
    public boolean preHandle(HttpServletRequest request, ...) {
        UserContext.set(extractUserId(request));
        return true;
    }
 
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
                                 Object handler, Exception ex) {
        UserContext.remove(); // 반드시 정리
    }
}

정리 위치

방식정리 위치비고
Filterfinally 블록에서 remove()doFilter 감싸기
InterceptorafterCompletion()에서 remove()예외 시에도 호출됨
TaskDecorator비동기 작업의 finally에서 clear()위 예제 참고
try-with-resources 패턴AutoCloseable 구현범위가 명확한 경우

자주 나는 실수

  • @Async 메서드가 호출자의 트랜잭션에 참여한다고 가정한다.
  • @Async 메서드에서 SecurityContextHolder로 인증 정보를 꺼내려 한다.
  • ThreadLocal에 값을 저장하고 remove()를 호출하지 않아서 스레드 풀에서 누수된다.
  • CompletableFuture.supplyAsync()에서 MDC traceId가 사라지는 이유를 모른다.
  • InheritableThreadLocal을 쓰면 해결된다고 생각하지만, 스레드 풀에서는 스레드 생성 시점에만 상속되므로 재사용 시에는 전파되지 않는다.

핵심 요약

Spring의 트랜잭션, SecurityContext, MDC는 모두 ThreadLocal에 바인딩됩니다. @AsyncCompletableFuture로 스레드가 바뀌면 이 값들이 전파되지 않습니다.

비동기 메서드에서 DB 작업이 필요하면 자체 @Transactional을 선언해야 합니다. traceId나 인증 정보가 필요하면 파라미터로 전달하거나 TaskDecorator로 복사합니다.

스레드 풀에서 ThreadLocal을 정리하지 않으면 이전 요청의 값이 다음 요청에 남는 보안/정합성 문제가 발생합니다. afterCompletion() 또는 finally에서 반드시 remove()를 호출해야 합니다.

꼬리 질문

점검 퀴즈

아래 문항은 개념을 실제로 설명할 수 있는지 점검하기 위한 것이다. 선택지를 누르면 정답 여부와 이유가 표시된다.

객관식@Async 메서드에서 기존 요청 스레드의 트랜잭션이 그대로 이어지지 않는 이유는?

OX스레드 풀 환경에서 ThreadLocal 값을 정리하지 않으면 다음 요청에 이전 요청의 값이 남을 수 있다.

객관식CompletableFuture에서 MDC traceId를 유지하려면 필요한 접근은?

관련 문서