[Spring] 비동기 환경에서 MDC와 SecurityContext 전달

dustle·2026년 6월 7일

컨텍스트 정보란

요청 단위로 유지되어야 하는 정보를 컨텍스트라고 부릅니다. 대표적인 예시는 다음과 같습니다.

  • MDC (Mapped Diagnostic Context): Logback/SLF4J에서 로그에 요청 ID, 사용자 ID 등을 자동으로 찍기 위한 저장소입니다
  • SecurityContext: Spring Security에서 현재 인증된 사용자 정보(Authentication)를 담는 저장소입니다

두 컨텍스트의 공통점은 내부적으로 ThreadLocal에 저장된다는 점입니다.
ThreadLocal은 이름 그대로 스레드 단위로 데이터를 보관합니다. 그래서 다른 스레드로 작업이 넘어가면 컨텍스트가 사라집니다.

// SecurityContextHolder 내부
private static final ThreadLocalSecurityContextHolderStrategy ...

// MDC 내부
static final ThreadLocal<Map<String, String>> ...

해결 방법

1. MDC 수동 복사

가장 단순한 방법은 작업을 넘기기 전에 MDC를 복사해두고, 새 스레드에서 다시 설정하는 것입니다.

Map<String, String> contextMap = MDC.getCopyOfContextMap();

CompletableFuture.runAsync(() -> {
    if (contextMap != null) {
        MDC.setContextMap(contextMap);
    }
    try {
        // 실제 작업
    } finally {
        MDC.clear(); // 스레드 풀 재사용 시 누수 방지
    }
});

매번 작성하기 번거롭기 때문에 보통 헬퍼나 데코레이터로 감쌉니다.

2. Spring TaskDecorator

Spring의 ThreadPoolTaskExecutorTaskDecorator를 통해 작업을 감쌀 수 있습니다. @AsyncThreadPoolTaskExecutor를 사용하는 경우 가장 깔끔한 방법입니다.

public class ContextCopyingDecorator implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable runnable) {
        Map<String, String> mdcContext = MDC.getCopyOfContextMap();
        SecurityContext securityContext = SecurityContextHolder.getContext();
        RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes();

        return () -> {
            try {
                if (mdcContext != null) {
                    MDC.setContextMap(mdcContext);
                }
                SecurityContextHolder.setContext(securityContext);
                RequestContextHolder.setRequestAttributes(requestAttributes);

                runnable.run();
            } finally {
                MDC.clear();
                SecurityContextHolder.clearContext();
                RequestContextHolder.resetRequestAttributes();
            }
        };
    }
}

Executor 등록 시 데코레이터를 지정합니다.

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean
    public TaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(20);
        executor.setTaskDecorator(new ContextCopyingDecorator());
        executor.initialize();
        return executor;
    }
}

3. SecurityContextHolder 옵션 변경

Spring Security는 SecurityContextHolder의 저장 옵션을 바꿀 수 있습니다.

SecurityContextHolder.setStrategyName(
    SecurityContextHolder.MODE_INHERITABLETHREADLOCAL
);

옵션은 세 가지입니다.

  • MODE_THREADLOCAL (기본값): 스레드 단위로 격리됩니다
  • MODE_INHERITABLETHREADLOCAL: 부모 스레드에서 생성된 자식 스레드에 컨텍스트가 자동 전달됩니다
  • MODE_GLOBAL: 전체 JVM에서 공유합니다 (보통 사용하지 않습니다)

MODE_INHERITABLETHREADLOCAL의 한계는, 스레드 풀처럼 미리 생성된 스레드에는 적용되지 않는다는 점입니다. 스레드가 생성되는 시점에만 부모 컨텍스트를 받기 때문입니다. 따라서 스레드 풀을 사용하는 환경에서는 TaskDecorator 방식이 더 안전합니다.

4. DelegatingSecurityContext 래퍼

Spring Security는 SecurityContext를 전달해주는 래퍼 클래스를 제공합니다.

ExecutorService delegateExecutor = Executors.newFixedThreadPool(10);
Executor secureExecutor = new DelegatingSecurityContextExecutor(delegateExecutor);

secureExecutor.execute(() -> {
    // SecurityContextHolder.getContext()로 인증 정보 접근 가능
});

종류는 다음과 같습니다.

  • DelegatingSecurityContextRunnable
  • DelegatingSecurityContextCallable
  • DelegatingSecurityContextExecutor
  • DelegatingSecurityContextExecutorService
  • DelegatingSecurityContextScheduledExecutorService

MDC는 별도로 처리해야 하므로, TaskDecorator와 비교했을 때 SecurityContext만 필요한 경우에 적합합니다.

5. CompletableFuture에서의 처리

CompletableFuture.runAsync()supplyAsync()는 기본적으로 ForkJoinPool.commonPool()을 사용합니다. 이 경우에도 위 데코레이터를 사용한 Executor를 명시적으로 지정해야 합니다.

CompletableFuture.runAsync(() -> {
    // 작업
}, contextAwareExecutor); // TaskDecorator 적용된 Executor

6. WebFlux (Reactor) 환경

WebFlux는 스레드 모델 자체가 다릅니다. 한 요청이 여러 스레드를 옮겨 다니며 처리되기 때문에 ThreadLocal 기반 컨텍스트가 동작하지 않습니다.

Reactor는 자체적인 Context API를 제공합니다.

@GetMapping("/order")
public Mono<String> order() {
    return ReactiveSecurityContextHolder.getContext()
        .map(ctx -> ctx.getAuthentication().getName())
        .flatMap(username -> service.process(username));
}

MDC는 contextWrite로 데이터를 넣고, 각 단계에서 꺼내 쓰는 방식을 사용합니다. 직접 구현하기 까다로우므로 보통 라이브러리를 활용합니다.

  • Micrometer Context Propagation: Reactor Context와 ThreadLocal 간 자동 변환을 지원합니다
  • Hooks.enableAutomaticContextPropagation() 한 줄로 MDC, SecurityContext 등이 자동 전달되도록 설정할 수 있습니다
Hooks.enableAutomaticContextPropagation();

ContextRegistry.getInstance()
    .registerThreadLocalAccessor(
        "traceId",
        () -> MDC.get("traceId"),
        value -> MDC.put("traceId", value),
        () -> MDC.remove("traceId")
    );

7. @Async 메서드에서 컨텍스트 전파

@Async 메서드도 결국 TaskExecutor를 사용합니다. 위에서 정의한 TaskDecorator가 적용된 Executor를 @Async("taskExecutor")로 지정하면 됩니다.

@Service
public class OrderService {

    @Async("taskExecutor")
    public void sendNotification() {
        // MDC, SecurityContext 모두 사용 가능
        log.info("알림 발송"); // traceId 자동 포함
    }
}

주의할 점

컨텍스트 정리 (clear)

스레드 풀은 스레드를 재사용합니다. 작업 끝에 MDC.clear()SecurityContextHolder.clearContext()를 호출하지 않으면 이전 요청의 정보가 다음 요청에 흘러갑니다. 사용자 A의 인증 정보로 사용자 B의 요청이 처리되는 보안 사고로 이어질 수 있습니다.

비동기 예외 처리

비동기 작업 내부에서 예외가 발생하더라도 finally 블록에서 컨텍스트가 정리되도록 작성해야 합니다.

라이브러리 선택

직접 데코레이터를 짜기 부담스럽다면 ttl-runnable(TransmittableThreadLocal, 알리바바)이나 Micrometer Context Propagation 같은 라이브러리를 검토해볼 수 있습니다.


정리

비동기 환경에서 컨텍스트 누락은 로그 추적 실패 정도로 끝나지 않고 인증/인가 사고로 번질 수 있습니다.
Executor를 정의하는 시점에 TaskDecorator로 컨텍스트 전달과 정리를 함께 묶어두는 것이 안전합니다.

0개의 댓글