1. 글을 작성하게 된 계기
비동기에서 왜 MDC의 문맥 복사가 필요한지, 어떻게 동작하는지 한 번 더 정리하기 위해 글을 작성하게 되었습니다.
이를 이해하기 위해서는 ThreadLocal과 람다에 대해 알고 있어야 합니다.
2. 왜 비동기를 사용할 때, 문맥을 복사해야 할까?
비동기에서 MDC의 문맥을 복사 해서 처리하는 해결책은 쉽게 찾을 수 있습니다. 그런데 왜 이렇게 해야 할까요?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class MdcTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(@NonNull final Runnable runnable) {
final Map<String, String> context = MDC.getCopyOfContextMap();
return () -> {
try {
if (context != null) {
MDC.setContextMap(context);
}
runnable.run();
} finally {
MDC.clear();
}
};
}
}
2-1. ThreadLocal
MDC는 내부적으로 ThreadLocal 을 사용합니다. ThreadLocal은 동시성을 보장하기 위한 자바 컬렉션 으로 동일 쓰레드에 대한 문맥을 저장하고요.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class MDC {
......
public static void put(String key, String val) throws IllegalArgumentException {
if (key == null) {
throw new IllegalArgumentException("key parameter cannot be null");
} else if (mdcAdapter == null) {
throw new IllegalStateException("MDCAdapter cannot be null. See also http://www.slf4j.org/codes.html#null_MDCA");
} else {
mdcAdapter.put(key, val);
}
}
......
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class LogbackMDCAdapter implements MDCAdapter {
final ThreadLocal<Map<String, String>> readWriteThreadLocalMap = new ThreadLocal<Map<String, String>>();
final ThreadLocal<Map<String, String>> readOnlyThreadLocalMap = new ThreadLocal<Map<String, String>>();
private final ThreadLocalMapOfStacks threadLocalMapOfDeques = new ThreadLocalMapOfStacks();
public void put(String key, String val) throws IllegalArgumentException {
if (key == null) {
throw new IllegalArgumentException("key cannot be null");
}
Map<String, String> current = readWriteThreadLocalMap.get();
if (current == null) {
current = new HashMap<String, String>();
readWriteThreadLocalMap.set(current);
}
}
......
}
그런데 비동기를 사용하면 기존 쓰레드와 다른 문맥의 쓰레드 에서 작업이 이루어집니다. 비동기 쓰레드 풀 설정이 있다면 비동기 쓰레드 풀의 쓰레드를 활용하고, 없다면 새로운 쓰레드를 생성해서요. 혹은 기존 쓰레드 문맥에서 처리하기도 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@EnableAsync
@Configuration
public class AsyncConfig {
@Bean(name = "taskExecutor")
public TaskExecutor taskExecutor() {
final ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(200);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("Async-");
executor.setTaskDecorator(new MdcTaskDecorator());
executor.initialize();
return executor;
}
}
즉, 비동기로 작업을 처리하면 기존 문맥과 다른, 새로운 쓰레드에서 작업을 처리 하며, 따라서 기존 쓰레드의 ThreadLocal을 활용할 수 없게 됩니다. 이를 사용하기 위해서는 기존 쓰레드 문맥을 복사해 새로운 쓰레드에 저장해 줘야 하죠.
1
2
3
4
+-------------------+ 비동기 처리 시작 +-------------------+
| Main Thread | --------------------------------> | Async Thread |
| (기존 문맥) | | (새 문맥) |
+-------------------+ +-------------------+
2-2. 람다
람다(Lambda)는 실행 가능한 코드 조각 입니다. 자바에서는 익명 함수죠.
처음에 봤던 decorate 메서드 내부를 보면 람다를 반환하는 것을 볼 수 있는데요, 이는 반환 시점에는 실행되지 않지만 다른 문맥에서 실행 가능한 코드 조각입니다. 즉, 반환하는 람다는 다른 쓰레드에서 실행되며, 여기에 기존 쓰레드의 문맥을 복사하는 것이죠.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class MdcTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(@NonNull final Runnable runnable) {
// 기존 MDC에서 문맥 복사
final Map<String, String> context = MDC.getCopyOfContextMap();
return () -> {
try {
if (context != null) {
// 외부 문맥을 저장. 람다 블록 내부 MDC는 비동기로 실행될 쓰레드의 MDC.
MDC.setContextMap(context);
}
runnable.run();
} finally {
MDC.clear();
}
};
}
}
조금 더 들어가보면 decorate 메서드는 기존 쓰레드 에서 호출됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class MdcTaskDecorator implements TaskDecorator {
// decorate 메서드는 기존 쓰레드에서 호출
@Override
public Runnable decorate(@NonNull final Runnable runnable) {
final Map<String, String> context = MDC.getCopyOfContextMap();
return () -> {
try {
if (context != null) {
MDC.setContextMap(context);
}
runnable.run();
} finally {
MDC.clear();
}
};
}
}
이는 @Async 어노테이션을 사용할 경우, AsyncExecutionInterceptor 가 이를 가로채는데, 여기로 실행됩니다. 물론 CompletableFuture나 쓰레드 풀을 사용하면 다르게 동작하겠죠?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
public class AsyncExecutionInterceptor extends AsyncExecutionAspectSupport implements MethodInterceptor, Ordered {
......
@Nullable
public Object invoke(final MethodInvocation invocation) throws Throwable {
Class<?> targetClass = invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis()) : null;
Method userMethod = BridgeMethodResolver.getMostSpecificMethod(invocation.getMethod(), targetClass);
AsyncTaskExecutor executor = this.determineAsyncExecutor(userMethod);
if (executor == null) {
throw new IllegalStateException("No executor specified and no default executor set on AsyncExecutionInterceptor either");
} else {
Callable<Object> task = () -> {
try {
Object result = invocation.proceed();
if (result instanceof Future<?> future) {
return future.get();
}
} catch (ExecutionException var5) {
this.handleError(var5.getCause(), userMethod, invocation.getArguments());
} catch (Throwable var6) {
this.handleError(var6, userMethod, invocation.getArguments());
}
return null;
};
// doSubmit 호출
return this.doSubmit(task, executor, invocation.getMethod().getReturnType());
}
}
......
// 여기서 실행
@Nullable
protected Object doSubmit(Callable<Object> task, AsyncTaskExecutor executor, Class<?> returnType) {
if (CompletableFuture.class.isAssignableFrom(returnType)) {
return executor.submitCompletable(task);
} else if (ListenableFuture.class.isAssignableFrom(returnType)) {
return ((AsyncListenableTaskExecutor)executor).submitListenable(task);
} else if (Future.class.isAssignableFrom(returnType)) {
return executor.submit(task);
} else if (Void.TYPE != returnType && !"kotlin.Unit".equals(returnType.getName())) {
throw new IllegalArgumentException("Invalid return type for async method (only Future and void supported): " + returnType);
} else {
executor.submit(task);
return null;
}
}
}
3. 주의할 점
스프링 MVC를 사용할 때, 사용자 요청이 애플리케이션에 들어오면 HttpServletRequest, HttpServletResponse 객체가 만들어집니다. DispatcherServlet, Servlet, Interceptor 등에서 자주 볼 수 있죠?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class DispatcherServlet extends FrameworkServlet {
......
protected void doService(HttpServletRequest request, HttpServletResponse response) throws Exception {
this.logRequest(request);
Map<String, Object> attributesSnapshot = null;
if (WebUtils.isIncludeRequest(request)) {
attributesSnapshot = new HashMap();
Enumeration<?> attrNames = request.getAttributeNames();
label116:
while (true) {
String attrName;
do {
if (!attrNames.hasMoreElements()) {
break label116;
}
attrName = (String) attrNames.nextElement();
} while (!this.cleanupAfterInclude && !attrName.startsWith("org.springframework.web.servlet"));
attributesSnapshot.put(attrName, request.getAttribute(attrName));
}
}
}
......
해당 객체들은 동일 쓰레드 내에서 RequestContextHolder 를 통해 찾아올 수 있습니다. 이 또한 ThreadLocal을 사용하거든요. 따라서 비동기를 사용하면 현재 문맥을 알 수 없기 때문에 null 값이 반환됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public abstract class RequestContextHolder {
private static final boolean jsfPresent = ClassUtils.isPresent("jakarta.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");
......
@Nullable
public static RequestAttributes getRequestAttributes() {
RequestAttributes attributes = (RequestAttributes)requestAttributesHolder.get();
if (attributes == null) {
attributes = (RequestAttributes)inheritableRequestAttributesHolder.get();
}
return attributes;
}
......
}
아래 테스트를 돌려보면 그 결과를 바로 알 수 있습니다. 따라서 이를 사용하기 위해서는 마찬가지로 문맥을 복사한 후 전달해야 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@SpringBootTest
class ThreadPoolTest {
@Test
fun whenAsyncContextThenRequestAttributesShouldBeNull() {
val mockRequest = MockHttpServletRequest()
setRequestAttributes(ServletRequestAttributes(mockRequest))
val executor = createTaskExecutor()
val future = CompletableFuture.runAsync({
val asyncRequestAttributes: RequestAttributes? = RequestContextHolder.getRequestAttributes()
assert(asyncRequestAttributes == null)
}, executor)
future.get(3, SECONDS)
RequestContextHolder.resetRequestAttributes()
}
private fun createTaskExecutor(): ThreadPoolTaskExecutor {
val executor = ThreadPoolTaskExecutor()
executor.corePoolSize = 1
executor.maxPoolSize = 1
executor.queueCapacity = 10
executor.initialize()
return executor
}
}
4. 정리
MDC를 사용할 때, 왜 비동기에서 문맥을 복사해야 하는지 살펴보았습니다. 해결 방법 자체는 조금만 찾으면 나오기 때문에 문맥을 복사하는 이유나 원리만 잘 알아두도록 합시다.