Home 비동기 환경에서 MDC의 문맥 복사 어떤 원리로 동작할까?
Post
Cancel

비동기 환경에서 MDC의 문맥 복사 어떤 원리로 동작할까?

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)는 실행 가능한 코드 조각 입니다. 자바에서는 익명 함수죠.

In computer programming, an anonymous function (function literal, expression or block) is a function definition that is not bound to an identifier.





처음에 봤던 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를 사용할 때, 왜 비동기에서 문맥을 복사해야 하는지 살펴보았습니다. 해결 방법 자체는 조금만 찾으면 나오기 때문에 문맥을 복사하는 이유나 원리만 잘 알아두도록 합시다.


This post is licensed under CC BY 4.0 by the author.

애플리케이션의 타임존은 어떻게 관리하는 것이 좋을까?

Git History 관리 중 발생한 충돌 해결기