👩🏻‍💻 Programming/SpringBoot

ThreadLocal과 @Async

한국의 메타몽 2025. 8. 6. 15:12

목차

  1. Java의 ThreadLocal이란?
  2. @Async로 비동기 호출시 ThreadLocal은?
  3. 직접 격었던 관련 이슈


1. Java의 ThreadLocal이란?

Java 공식 문서의 ThreadLocal을 먼저 읽고오자.

ThreadLocal instances are typically private static fields in classes that wish to associate state with a thread (e.g., a user ID or Transaction ID).



ThreadLocal에는 어떤 데이터가 담기는걸까?
일반적으로 ThreadLocal하나의 요청 처리 과정 중에서 동일한 Thread 안에서만 유효해야하는 컨텍스트성 데이터를 저장하는데 쓰인다.

개인적으로 경험했던 실무 중, Java 기반 SpringFramework 코드에서는 대표적으로 아래 데이터들이 ThreadLocal에 저장된다.



(1) RequestContextHolder

public abstract class RequestContextHolder  {

    private static final boolean jsfPresent =
            ClassUtils.isPresent("javax.faces.context.FacesContext", RequestContextHolder.class.getClassLoader());

    private static final ThreadLocal<RequestAttributes> requestAttributesHolder =
            new NamedThreadLocal<>("Request attributes");

    private static final ThreadLocal<RequestAttributes> inheritableRequestAttributesHolder =
            new NamedInheritableThreadLocal<>("Request context");

    // ... 이하 생략

익숙한 Request Attribute를 확인할 수 있다.


(2) TransactionSynchronizationManager

public abstract class TransactionSynchronizationManager {

    private static final Log logger = LogFactory.getLog(TransactionSynchronizationManager.class);

    private static final ThreadLocal<Map<Object, Object>> resources =
            new NamedThreadLocal<>("Transactional resources");

    private static final ThreadLocal<Set<TransactionSynchronization>> synchronizations =
            new NamedThreadLocal<>("Transaction synchronizations");

    private static final ThreadLocal<String> currentTransactionName =
            new NamedThreadLocal<>("Current transaction name");

    // ... 이하 생략

마찬가지로 트랜잭션 상태를 관리할때 종종 사용하던 곳에서도 사용이 확인된다.


(3) SecurityContextHolder

    private static void initializeStrategy() {
        if (MODE_PRE_INITIALIZED.equals(strategyName)) {
            Assert.state(strategy != null, "When using " + MODE_PRE_INITIALIZED
                    + ", setContextHolderStrategy must be called with the fully constructed strategy");
            return;
        }
        if (!StringUtils.hasText(strategyName)) {
            // Set default
            strategyName = MODE_THREADLOCAL;
        }
        if (strategyName.equals(MODE_THREADLOCAL)) {
            strategy = new ThreadLocalSecurityContextHolderStrategy();
            return;
        }
        if (strategyName.equals(MODE_INHERITABLETHREADLOCAL)) {
            strategy = new InheritableThreadLocalSecurityContextHolderStrategy();
            return;
        }
        if (strategyName.equals(MODE_GLOBAL)) {
            strategy = new GlobalSecurityContextHolderStrategy();
            return;
        }
        // Try to load a custom strategy
        try {
            Class<?> clazz = Class.forName(strategyName);
            Constructor<?> customStrategy = clazz.getConstructor();
            strategy = (SecurityContextHolderStrategy) customStrategy.newInstance();
        }
        catch (Exception ex) {
            ReflectionUtils.handleReflectionException(ex);
        }
    }

Spring Security를 다루면 본적이 있는 SecurityContextHolder에서도 확인 가능하다.


(4) MDC (Mapped Diagnostic Context)

Java에서 로깅시 사용하는 MDC도 ThreadLocal을 이용해 만들었다. (ex : @Sl4j)
MDC와 관련하여 자세하게 알고 싶다면 Baeldung - Improved Java Logging with Mapped Diagnostic Context (MDC)을 참고해보자.


상기 ThreadLocal의 사용처를 예시로 보면, 일반적인 동기 API를 콜 했을때 아래와 같은 흐름을 예상할 수 있다.

  1. 클라이언트가 HTTP 요청을 보냄
  2. 톰캣의 요청 처리 스레드(ex : http-nio-8080-exec-1)가 할당됨
  3. DispatcherServlet -> Controller -> Service -> Repository 등 로직을 순차적으로 처리
  4. 결과를 만들어서 응답으로 리턴
  5. 스레드는 응답을 반환한 후에 종료 (또는 스레드 풀로 반납)

-> 즉, 하나의 요청은 한 스레드에서 시작해서 응답을 반환할 때까지 끝까지 이어진다.
-> 비동기 로직을 사용하지 않는 한, 같은 스레드에서 모든 처리가 이루어진다.
-> 고로 동기 API를 콜 했을때는 처음부터 끝까지 동일한 ThreadLocal을 사용하게 된다.



2. @Async로 비동기 호출시 ThreadLocal은?

@Async로 비동기 처리를 사용하는 경우, 중간에 스레드가 바뀌게 된다.
따라서 ThreadLocal 값은 공유되지 않는다.

이는 @Async 뿐만 아니라 WebClient, CompletableFuture 등 다른 비동기 처리에서도 동일하게 적용된다.

만약 비동기 처리시, ThreadLocal 데이터를 공유하고 싶다면 하기 둘 중 한 가지 방법을 고려할 수 있다.

  1. (최초 요청이 들어오는) Controller 레벨에서부터 필요한 값을 DTO로 전달해서 사용
  2. (Spring 4.3 이상부터 지원) TaskDecorator를 이용해 ThreadLocal 복사

2번의 경우 GPT가 제시한 하기 예시 코드를 참고해보자.


✅ ThreadLocal 복사를 위한 TaskDecorator 예시 (Click)

1. ThreadLocal 정의

public class UserContextHolder {
    private static final ThreadLocal<String> userId = new ThreadLocal<>();

    public static void setUserId(String id) {
        userId.set(id);
    }

    public static String getUserId() {
        return userId.get();
    }

    public static void clear() {
        userId.remove();
    }
}

2. TaskDecorator 구현

import org.springframework.core.task.TaskDecorator;

public class ContextCopyingTaskDecorator implements TaskDecorator {
    @Override
    public Runnable decorate(Runnable runnable) {
        String contextUserId = UserContextHolder.getUserId();

        return () -> {
            try {
                UserContextHolder.setUserId(contextUserId);
                runnable.run();
            } finally {
                UserContextHolder.clear(); // 누수 방지
            }
        };
    }
}

3. Async 설정에 TaskDecorator 적용

import org.springframework.context.annotation.*;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean(name = "asyncExecutor")
    public Executor asyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("Async-");

        executor.setTaskDecorator(new ContextCopyingTaskDecorator());
        executor.initialize();
        return executor;
    }
}

4. Async 메서드에서 ThreadLocal 값 사용

@Slf4j
@Service
public class MyService {

    @Async("asyncExecutor")
    public void asyncMethod() {
        log.info("User ID in async thread: {}", UserContextHolder.getUserId());
    }
}

5. 컨트롤러에서 ThreadLocal 값 설정 및 호출

@Slf4j
@RestController
public class MyController {

    private final MyService myService;

    public MyController(MyService myService) {
        this.myService = myService;
    }

    @GetMapping("/test")
    public String test() {
        UserContextHolder.setUserId("user-123");
        myService.asyncMethod();
        return "OK";
    }
}  


3. 직접 격었던 관련 이슈

return Optional.ofNullable(RequestContextHolder.getRequestAttributes())
            .filter(ServletRequestAttributes.class::isInstance)
            .map(ServletRequestAttributes.class::cast)
            .map(ServletRequestAttributes::getRequest)
            .map(request -> request.getHeader(LANGUAGE))
            .orElse(DEFAULT_LANGUAGE);

Request Attribute 의 언어 설정을 가져오는 로직이 있었다.
혹시 모를 NPE를 위해 Optional로 선언했으나, 불필요해보이는 Optional 로직을 제거하는 과정에서 이슈가 발생했다.
최초로 요청이 들어왔던 스레드에서는 이슈가 없었지만, 최초의 스레드 이후 개별적으로 실행된 비동기 스레드에서 RequestContextHolder.getRequestAttributes() 호출시 null이 발생했다.

이는 최초의 스레드와 비동기 스레드는 서로 다른 ThreadLocal을 갖고 있기 때문이다.
다만 위 로직에서는 애초에 Optional로 처리하여 값을 반환하고 있었기 때문에, 비동기 스레드에서 발생하는 이슈를 확인할 길이 없었다.

TaskDecorator를 구현할까 고민했지만, 해당 언어 설정만 가져오면 되는 일이었으므로
불필요한 ThreadLocal 데이터를 가져오는 것을 방지하고 누수 방지 로직을 별도로 적용하는 번거로움을 덜기 위해
Controller 레이어의 DTO에서 필요한 값을 전달해주는 것으로 마무리했다.

해당 이슈를 계기로 ThreadLocal의 개념을 되짚어보는 계기가 되었고, Optional을 언제 사용하면 좋은지를 고찰할 수 있는 기회를 가질 수 있었다.