요청 단위로 유지되어야 하는 정보를 컨텍스트라고 부릅니다. 대표적인 예시는 다음과 같습니다.
두 컨텍스트의 공통점은 내부적으로 ThreadLocal에 저장된다는 점입니다.
ThreadLocal은 이름 그대로 스레드 단위로 데이터를 보관합니다. 그래서 다른 스레드로 작업이 넘어가면 컨텍스트가 사라집니다.
// SecurityContextHolder 내부
private static final ThreadLocalSecurityContextHolderStrategy ...
// MDC 내부
static final ThreadLocal<Map<String, String>> ...
가장 단순한 방법은 작업을 넘기기 전에 MDC를 복사해두고, 새 스레드에서 다시 설정하는 것입니다.
Map<String, String> contextMap = MDC.getCopyOfContextMap();
CompletableFuture.runAsync(() -> {
if (contextMap != null) {
MDC.setContextMap(contextMap);
}
try {
// 실제 작업
} finally {
MDC.clear(); // 스레드 풀 재사용 시 누수 방지
}
});
매번 작성하기 번거롭기 때문에 보통 헬퍼나 데코레이터로 감쌉니다.
Spring의 ThreadPoolTaskExecutor는 TaskDecorator를 통해 작업을 감쌀 수 있습니다. @Async나 ThreadPoolTaskExecutor를 사용하는 경우 가장 깔끔한 방법입니다.
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;
}
}
Spring Security는 SecurityContextHolder의 저장 옵션을 바꿀 수 있습니다.
SecurityContextHolder.setStrategyName(
SecurityContextHolder.MODE_INHERITABLETHREADLOCAL
);
옵션은 세 가지입니다.
MODE_THREADLOCAL (기본값): 스레드 단위로 격리됩니다MODE_INHERITABLETHREADLOCAL: 부모 스레드에서 생성된 자식 스레드에 컨텍스트가 자동 전달됩니다MODE_GLOBAL: 전체 JVM에서 공유합니다 (보통 사용하지 않습니다)MODE_INHERITABLETHREADLOCAL의 한계는, 스레드 풀처럼 미리 생성된 스레드에는 적용되지 않는다는 점입니다. 스레드가 생성되는 시점에만 부모 컨텍스트를 받기 때문입니다. 따라서 스레드 풀을 사용하는 환경에서는 TaskDecorator 방식이 더 안전합니다.
Spring Security는 SecurityContext를 전달해주는 래퍼 클래스를 제공합니다.
ExecutorService delegateExecutor = Executors.newFixedThreadPool(10);
Executor secureExecutor = new DelegatingSecurityContextExecutor(delegateExecutor);
secureExecutor.execute(() -> {
// SecurityContextHolder.getContext()로 인증 정보 접근 가능
});
종류는 다음과 같습니다.
DelegatingSecurityContextRunnableDelegatingSecurityContextCallableDelegatingSecurityContextExecutorDelegatingSecurityContextExecutorServiceDelegatingSecurityContextScheduledExecutorServiceMDC는 별도로 처리해야 하므로, TaskDecorator와 비교했을 때 SecurityContext만 필요한 경우에 적합합니다.
CompletableFuture.runAsync()나 supplyAsync()는 기본적으로 ForkJoinPool.commonPool()을 사용합니다. 이 경우에도 위 데코레이터를 사용한 Executor를 명시적으로 지정해야 합니다.
CompletableFuture.runAsync(() -> {
// 작업
}, contextAwareExecutor); // TaskDecorator 적용된 Executor
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로 데이터를 넣고, 각 단계에서 꺼내 쓰는 방식을 사용합니다. 직접 구현하기 까다로우므로 보통 라이브러리를 활용합니다.
Hooks.enableAutomaticContextPropagation() 한 줄로 MDC, SecurityContext 등이 자동 전달되도록 설정할 수 있습니다Hooks.enableAutomaticContextPropagation();
ContextRegistry.getInstance()
.registerThreadLocalAccessor(
"traceId",
() -> MDC.get("traceId"),
value -> MDC.put("traceId", value),
() -> MDC.remove("traceId")
);
@Async 메서드도 결국 TaskExecutor를 사용합니다. 위에서 정의한 TaskDecorator가 적용된 Executor를 @Async("taskExecutor")로 지정하면 됩니다.
@Service
public class OrderService {
@Async("taskExecutor")
public void sendNotification() {
// MDC, SecurityContext 모두 사용 가능
log.info("알림 발송"); // traceId 자동 포함
}
}
스레드 풀은 스레드를 재사용합니다. 작업 끝에 MDC.clear()와 SecurityContextHolder.clearContext()를 호출하지 않으면 이전 요청의 정보가 다음 요청에 흘러갑니다. 사용자 A의 인증 정보로 사용자 B의 요청이 처리되는 보안 사고로 이어질 수 있습니다.
비동기 작업 내부에서 예외가 발생하더라도 finally 블록에서 컨텍스트가 정리되도록 작성해야 합니다.
직접 데코레이터를 짜기 부담스럽다면 ttl-runnable(TransmittableThreadLocal, 알리바바)이나 Micrometer Context Propagation 같은 라이브러리를 검토해볼 수 있습니다.
비동기 환경에서 컨텍스트 누락은 로그 추적 실패 정도로 끝나지 않고 인증/인가 사고로 번질 수 있습니다.
Executor를 정의하는 시점에 TaskDecorator로 컨텍스트 전달과 정리를 함께 묶어두는 것이 안전합니다.