@Async와 함께 사라지다 (feat. TaskDecorator)
목차
1. 스레드가 가진 데이터, ThreadLocal
2. 데이터가 사라졌어요 (예제코드)
3. 비동기와 사라진 데이터
3. ThreadLocal 유지하기
1. 스레드가 가진 데이터, ThreadLocal
ThreadLocal는 각 스레드마다 별도의 내부 저장소를 지원해준다. 일반적으로 지역 변수는 해당 변수를 선언한 코드 블록 내에서만 사용 가능하지만, ThreadLocal를 이용해 저장한 데이터는 데이터를 저장한 스레드 내에서라면 어디서든 사용 가능하다.
예를 들어 아래와 같이 ThreadLocal에 자주 사용되는 사용자 정보 등을 저장할 수도 있다. 사용자 정보를 저장하고 싶을때는 UserSessionContext.set(), 꺼내오고 싶다면 UserSessionContext.currentSession()을 호출하면 되게끔 만들었다.
public class UserSessionContext {
private static final ThreadLocal<UserDto> threadLocal = new new ThreadLocal<>();
public static UserDto currentSession() {
return threadLocal.get();
}
public static void set(UserDto session) {
if (session == null) {
clear();
return;
}
threadLocal.set(session);
}
public static void clear() {
threadLocal.remove();
}
}
🔻 ThreadLocal을 꼭 이용해야할까요?
이 블록은 글쓴이의 개인적인 생각이다. 개인적으로는 ThreadLocal를 이용하면서 편함보다는 불편함이 더 많았다. 그래서 글쓴이의 경험을 통해 느꼈던 불편한 점들을 정리하니, ThreadLocal 사용을 고려하는 사람이 있다면 더 좋은 방법은 없을지 고민해보면 좋을 것 같다.
- 변경이 어려운 코드
: 필연적으로 static method를 사용해야하기 때문에 밀접한 연관관계를 가져 다른 코드로 대체하기 어려운, 변경이 어려운 코드가 생성될 수 밖에 없다. - 디버깅이 어려운 코드
: ThreadLocal은 같은 스레드 내에서라면 언제 어디서나 값을 변경될 수 있기 때문에 디버깅이 까다로워질수 밖에 없다. - 찌꺼기 데이터
: 스레드는 매번 생성되고 소멸되는게 아니라 재사용될 확률이 크다. 만약 이전 스레드에서 저장했던 데이터가 제대로 지워지지 않았다면 재사용될 때 문제가 발생할 수 있다. (이 글 상단의 ThreadLocal 예제에서 clear() 메서드를 만든 이유다.) - 비동기에서 사용할 경우의 문제점
해당 포스팅을 작성하게 된 계기가 바로 이 문제 때문이다. 자세한 내용은 아래에서 다루겠다.
ThreadLocal를 처음 봤더라도 간접적으로 사용중일 수도 있다. 예를 들어 자바 로깅 프레임워크에서 제공하는 기술인 MDC도 ThreadLocal을 이용해 만들었다. MDC란 Mapped Diagnostic Context의 약자인데, 프로그램 실행을 추적할 때 유용한 정보를 저장할 때 쓰이곤 한다. (MDC에 대해 더 공부하고 싶다면? -> Baeldung Tutorial Link)
2. 데이터가 사라졌어요 (예제코드)
아래와 같은 예제를 만들었다.
- 두 API는 MdcLogInterceptor를 통해 MDC 데이터를 추가, 삭제된다.
- 두 API는 Controller, Service가 호출될때마다 MDC에 저장된 모든 데이터를 콘솔로그에 출력한다.
- 한 API는 비동기 호출, 다른 API는 동기 호출이 일어난다.
🔻 예제 코드
MdcLogInterceptor
@Slf4j
@Configuration
public class MdcInterceptorConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new MdcLogInterceptor())
.addPathPatterns("/test/**");
}
private static class MdcLogInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
MDC.put("Url", String.valueOf(request.getRequestURL()));
MDC.put("Controller", controllerInfo(handler));
return true;
}
private String controllerInfo(Object handler) {
try {
if (handler instanceof HandlerMethod handlerMethod) {
return handlerMethod.getBeanType().getSimpleName() + "#" + handlerMethod.getMethod().getName();
}
} catch (Exception e) {
log.warn("MdcLogInterceptor : {}", e.getMessage(), e);
}
return "";
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
MDC.clear();
}
}
}
TestController
@Slf4j
@RestController
@RequiredArgsConstructor
public class TestController {
private final TestService service;
@GetMapping("/test/async")
public String withAsync() {
MdcPrinter.printAllMDC("TestController#withAsync");
service.async();
return "ok";
}
@GetMapping("/test/sync")
public String withSync() {
MdcPrinter.printAllMDC("TestController#withSync");
service.sync();
return "ok";
}
}
TestService
@Service
public class TestService {
@Async
public void async() {
MdcPrinter.printAllMDC("TestService#async");
}
public void sync() {
MdcPrinter.printAllMDC("TestService#sync");
}
}
MdcPrinter
@Slf4j
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class MdcPrinter {
public static void printAllMDC(String message) {
log.info("====={}=====", message);
var mdc = MDC.getCopyOfContextMap();
if (mdc == null) {
log.info("MDC is null");
} else {
mdc.forEach((key, value) -> log.info("{}: {}", key, value));
}
log.info("=====================================");
}
}
/test/sync를 호출해보았다. 원하는 정보가 잘 출력된다.
/test/async를 호출해보았다. MDC에 담긴 값이 사라졌다.
3. 비동기와 사라진 데이터
이유는 간단하다. @Async로 인해 실행되는 메서드는 별도의 스레드에서 실행되기 때문이다. 예제 코드를 보면 MdcLogInterceptor에서 MDC에 데이터를 저장했고, TestService::sync는 같은 스레드를 사용하기 때문에 MDC에 데이터가 존재했다. 하지만 TestService::async에서는 @Async를 통해 새로운 스레드, 즉 MdcLogInterceptor와 별도의 스레드를 사용했기 때문에 MDC에 데이터가 없다.
📝 정리
- @Async가 추가된 메서드는 별도의 스레드에서 실행된다.
- MDC는 LocalThread를 이용해 만들어져있다.
- LocalThread는 각 스레드마다 별도의 내부 저장소를 지원해준다.
4. ThreadLocal 유지하기
해결 방법 역시 간단하다. 스레드 전환할 때 ThreadLocal 정보를 복사하면 된다. Spring 4.3에 등장한 TaskDecorator를 이용하면 비동기 시 손쉽게 ThreadLocal을 복사할 수 있다. (예제 코드에서는 MDC를 사용했기 때문에 MDC를 복사하는 코드로 작성하였다.)
@Configuration
public class AsyncConfig {
@Bean
public ThreadPoolTaskExecutor asyncThreadPoolTaskExecutor() {
ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
taskExecutor.setTaskDecorator(new CopyTaskDecorator());
return taskExecutor;
}
private static class CopyTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
var mdc = MDC.getCopyOfContextMap();
return () -> {
try {
// MDC에 null을 set하려고 하면 Exception이 발생한다
if(mdc != null) {
MDC.setContextMap(mdc);
}
runnable.run();
} finally {
MDC.clear();
}
};
}
}
}
참고
- inflearn 인강 / 김영한님 / 스프링 핵심 원리 고급편 - section 2 쓰레드 로컬
- https://www.baeldung.com/java-threadlocal
- https://www.baeldung.com/cs/async-vs-multi-threading
- https://www.baeldung.com/spring-async
- https://techblog.woowahan.com/13429/
- https://stackoverflow.com/questions/45890181/logging-mdc-with-async-and-taskdecorator