Spring/Spring Security

[Spring Security] 비동기 환경에서 컨텍스트(MDC, SecurityContext) 전달하기

leejunkim 2025. 10. 16. 10:46

WeeklyPaper: 비동기 환경에서 MDC(Logback Mapped Diagnostic Context)나 SecurityContext 같은 컨텍스트 정보를 스레드 간에 전달해야 할 경우, 처리하는 방법에 대해 설명하세요.


스프링 어플리케이션에서 비동기 처리를 위해 @Async 와 같은 기술을 사용할 때, 기존 스레드의 컨텍스트 정보가 새로 생성된 스레드로 전파되지 않는 문제가 발생한다. 대표적으로 로그 추적을 위한 MDC(Mapped Diagnostic Context)나 인증 정보를 담고 있는 SecurityContext가 여기에 해당된다.

간단한 용어 복습

  • @Async
    • 스프링에서 메서드를 별도의 스레드에서 비동기 (asynchronous)적으로 실행하도록 만들어주는 어노테이션이다.
    • 오래 걸리는 작업을 백그라운드에서 처리하도록 맡겨두고, 원래의 흐름은 기다리지 않고 바로 다음 코드를 실행하게 해준다.
  • 스레드의 컨텍스트 정보
    • 해당 스레드에서 실행되는 특정 작업 단위(예: 하나의 사용자 요청)와 관련된 부가적인 상태 정보다. 이 정보는 메서드의 파라미터로 직접 전달되지 않아도, 해당 작업이 진행되는 동안 스레드 내 어디서든 접근할 수 있는 데이터다. 주로 ThreadLocal에 저장되어 스레드와 생명주기를 같이한다.
    • 예시로는 MDC (로그 추적을 위한 데이터), SecurityContext(인증/권한을 위한 데이터)가 있다.
  •  MDC(Mapped Diagnostic Context) 
    • 현재 실행중인 스레드에 메타 정보를 넣고 관리하는 공간이다. 내부적으로 Map을 관리하고 있어 (key, value)형태로 값을 저장할 수 있다.
    • 메타 정보를 스레드 별로 관리하기 때문에 내부적으로는 ThreadLocal  사용한다 (한 스레드에서 MDC에 저장한 값은 다른 스레드에 영향을 주지 않는다). 즉, 여러 사용자의 요청을 동시에 처리하는 애플리케이션 환경에서 각 요청의 컨텍스트를 안전하게 분리해서 관리할 수 있게 해준다.
    • 스프링 어플리케이션에서 요청(request)이 오면 UUID를 넣고 로그에 이를 포함시키면, UUID로 로그를 찾아서 특정 요청과 관련된 모든 로그를 쉽게 필터링하고 추적할 수 있다.
  • SecurityContext
    • Authentication 객채가 저장되는 보관소로 필요 시 언제든지 Authentication 객체를 꺼내어 쓸 수 있도록 제공되는 클래스다. 그냥 Authentication 객체를 감싸고 있는 보관함이라고 생각하면 된다. 
      • Authentication (인증 객체)는 사용자의 인증 정보를 저장하는 토큰 개념이다. 인증이나, 인증 후 세션에 담기 위한 용도로 쓰인다.
    • SecurityContextHolder에 의해 관리되는데, SecurityContextHolder은 MDC와 마찬가지로 ThreadLocal을 사용해서 SecurityContext를 저장한다. 이 때문에 MDC와 같이 동일한 스레드 내에서는 어디서든 SecurityContextHolder.getContext()를 통해 현재 사용자의 Authentication 정보를 꺼내 쓸 수 있다.

ThreadLocal

  • 위와 같이 MDC나 SecurityContext에서 사용되는 ThreadLocal은, 스레드별로 독립적인 데이터를 저장하는 공간이다. 결과적으로 비동기 작업 중에는 로그에 추적 ID가 누락되거나, 인증 정보가 없어 null이 되는 상황이 발생한다.

그러면 컨텍스트 정보를 스레드 간에 전달하고 싶을 때 어떻게 해야할까?

해결 방안 - TaskDecorator 활용

가장 일반적인 방법은 TaskDecorator를 직접 구현해서 사용하는 것이다. TaskDecorator는 비동기 작업을 실행하기 직전에 스레드 컨텍스트를 설정하고, 작업이 끝난 후 정리하는 로직을 추가할 수 있도록 한다.

  1. TaskDecorator 구현체를 ThreadPoolTaskExecutor Bean에 설정한다.
    • public class CustomTaskDecorator implements TaskDecorator {
      	Map<String, String> mdcContextMap = MDC.getCopyOfContextMap();
          SecurityContext securityContext = SecurityContextHolder.getContext();
          return () -> {
                  try {
                      // 자식 스레드에 컨텍스트 정보 설정
                      if (mdcContextMap != null) {
                          MDC.setContextMap(mdcContextMap);
                      }
                      SecurityContextHolder.setContext(securityContext);
      
                      // 원래의 비동기 작업 실행
                      runnable.run();
                  } finally {
                      // 작업 완료 후 컨텍스트 정보 정리
                      MDC.clear();
                      SecurityContextHolder.clearContext();
                      // RequestContextHolder.resetRequestAttributes();
                  }
              };
      }
      이처럼 TaskDecorator을 implements해준다
    • @Configuration
      @EnableAsync
      public class CustomTaskExecutorConfig {
      
      	@Bean
          public Executor taskExecutor() {
              ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
              executor.setCorePoolSize(~~);
              executor.setMaxPoolSize(~~);
              executor.setQueueCapacity(~~);
              executor.setThreadNamePrefix("~~~");
              executor.setTaskDecorator(new CustomTaskDecorator());
              executor.initialize();
              return executor;
          }
      }
      그리고 직접 만든 Task Decorator를 ThreadPoolTaskExecutor에 등록해준다.
  2. 비동기 작업을 요청한 기존 스레드에서 SecurityContextHolder의 SecurityContext, MDC의 ContextMap을 변수에 저장한다.
  3. TaskDecorator가 감싼 새로운 스레드에서 실제 작업을 실행하기 직전에, (1)에서 미리 저장해둔 컨텍스트 정보들을 SecurityContextHolder.setContext(), MDC.setContextMap()을 통해 설정한다.
  4. 작업이 완료된 후에는 finally 블록을 통해 SecurityContextHolder.clearContext(), MDC.clear()을 호출해서 컨텍스트를 정리해야 한다. 

자료