Armeria에서 request scoping을 쉽게 사용하는 방법

우리에게 익숙한 Tomcat 기반의 Spring Web MVC 서버는 멀티쓰레딩 방식으로 요청이 들어오면 이를 전담하는 쓰레드를 생성하여 처리합니다. 따라서 특별히 별도의 쓰레드를 생성하지 않는 한 하나의 쓰레드가 모든 작업을 실행합니다. 반면 Armeria와 같은 멀티플렉싱 방식은 하나의 요청이 여러 쓰레드를 거쳐 처리될 수 있습니다. 모든 요청 처리의 중심이 되는 이벤트 루프 쓰레드를 블록하면 다른 모든 요청이 지연되기 때문에 오래 걸리는 작업이나 각종 IO 작업을 실행할 때 반드시 별도의 쓰레드를 생성해 작업을 위임해야 합니다.

그럼 하나의 요청이 여러 쓰레드를 거쳐 처리될 때 문제는 무엇일까요? 대표적인 예시로 로깅 문제가 있습니다. 보통 MDC 툴을 사용해 로그를 남길 때 현재 요청의 고유 아이디를 함께 기록합니다. 이 때 요청의 고유 아이디 등의 데이터를 요청 컨텍스트라고 합니다. 그리고 요청 컨텍스트는 ThreadLocal에 저장합니다. 멀티쓰레딩 서버의 경우 하나의 쓰레드가 하나의 요청을 전담해서 처리해서 괜찮습니다. 하지만 Armeria와 같은 멀티플렉싱 서버는 요청을 처리 도중 다른 쓰레드에게 작업이 넘어가면 그 때부터 기록하는 로그에는 컨텍스트 정보가 유실되어 추적이 어려워집니다. 따라서 Armeria의 경우 쓰레드가 바뀔 때 컨텍스트를 직접 ThreadLocal에 집어 넣는 과정을 거쳐야 합니다. 이런 작업을 request scoping이라 합니다. (더 자세한 내용은 과거 글인 Armeria의 request scoping과 leak 탐지에서 다뤘으니 관심있으시면 참고해주세요.)

서론이 길었습니다. 이 글에서는 Armeria가 request scoping을 위해 제공하는 편리한 도구들을 소개하고 어떻게 작동하는지 알아봅니다. 가장 기본적인 직접 요청 컨텍스트를 집어넣는 방법 외에 다양한 방법이 있습니다. 원리는 거의 비슷하지만 강 방법을 공부하면서 새로 배운 내용이 많아 공유해봅니다.

직접 요청 컨텍스트 집어넣기

가장 기본적인 방법은 직접 요청 컨텍스트를 집어넣는 것입니다. 먼저 아래 Armeria의 AnnotatedService 서버 코드 예시를 보겠습니다.

위 코드는 요청을 받았을 때 서드 파티 서비스에 요청을 보낸 뒤 받은 응답을 조작하여 최종적으로 사용자에게 응답하는 코드입니다. 서드 파티 서비스에 요청을 보낼 때 현재 쓰레드를 블록하여 기다리지 않고 Armeria의 WebClient를 이용하여 비동기로 요청을 보냅니다. 이 때 서드 파티 서비스가 보낸 응답을 별도의 쓰레드에서 처리할 수 있기 때문에 RequestContext#push() 메소드로 현재 요청 컨텍스트를 ThreadLocal에 먼저 저장합니다. 이 때 try-with-resources 구문을 사용하는데 작업이 끝난 후 ThreadLocal에 저장한 요청 컨텍스트를 지우기 위함입니다. 하나의 쓰레드에 여러 요청 컨텍스트를 저장하려 하면 문제가 될 수 있기 때문에 반드시 try-with-resources 구문을 활용해야 합니다.

Runnable and Callable

직접 요청 컨텍스트를 집어넣는 과정이 번거롭기 때문에 Armeria는 자동으로 컨텍스트를 저장하고 회수하는 다양한 데이터 타입을 제공합니다. 먼저 RunnableCallable을 wrapping한 ContextAwareRunnable, ContextAwareCallable이 있습니다. RequestContext#makeContextAware 메소드로 wrapping할 수 있습니다. 각각 Executor#execute 또는 ExecutorService#submit의 인자로 넘긴 후 별도의 쓰레드에서 실행될 때 자동으로 요청 컨텍스트를 저장하고 안전하게 회수합니다. 소스 코드 역시 열어보면 상당히 간단하기 때문에 보시면 도움이 될 것 같습니다.

Executor, ExecutorService and ScheduledExecutorService

마찬가지입니다. 각 데이터 타입 인스턴스의 execute 혹은 submit 메소드를 사용할 경우 인자로 받는 Runnable, Callable 인스턴스를 ContextAwareRunnable, ContextAwareCallable로 변환합니다. 각 메소드 내부에서 RequestContext#makeContextAware 메소드를 이용해 변환합니다. 따라서 소스코드가 크게 어렵지 않아 쉽게 이해할 수 있습니다. 그 외에 Armeria에서 독자적으로 사용하는 BlockingTaskExecutor를 감싼 ContextAwareBlockingTaskExecutor도 있으니 참고하시면 좋습니다.

참고로 RequestContext#makeContextPropagating 스태틱 메소드로 wrapping하는 경우 PropagatingContextAwareExecutor로 변환할 수 있습니다. 이는 ContextAwareExecutor와 다르게 인스턴스 내부에 저장된 컨텍스트를 사용하지 않습니다. 대신 execute 메소드를 호출하는 쓰레드(caller)에 저장된 컨텍스트를 꺼내서 사용합니다. 예를 들어 똑같은 Executor를 여러 요청 처리에서 재사용할 때 일일이 ExecutorContextAwareExecutor로 변환하는 대신 PropagatingContextAwareExecutor로 변환하면 더 쉽게 재사용할 수 있습니다.

각종 Function

Function, BiFunction, Consumer, BiConsumer도 모두 ContextAware~ 타입으로 변환할 수 있습니다. Function은 꼭 별도의 쓰레드에서 실행하지 않을 수 있는데요. 같은 쓰레드에서 실행할 때도 현재 쓰레드에 저장된 요청 컨텍스트와 새롭게 저장하려는 요청 컨텍스트가 서로 같다면 충돌하지 않아 문제를 일으키지 않습니다.

CompletableFuture and CompletionStage

CompletableFuture를 context aware로 변환할 때 CompletionStage로 먼저 변환을 거치기 때문에 2가지를 묶어서 설명합니다. CompletableFuture의 경우 다른 데이터 타입들과 다르게 단순히 wrapping 데이터 타입을 만들고 내부에 context를 저장하는 방식과 살짝 다릅니다. 직접 소스 코드를 보면서 자세히 동작을 알아보겠습니다.

우리가 context aware하게 만들고 싶은 CompletableFuture 인스턴스를 생성하는데 A라고 해보겠습니다. 이를 RequestContext#makeContextAware 메소드의 인자로 넣어서 변환을 시작합니다. 먼저 새로운 ContextAwareFuture 인스턴스 B를 만듭니다. 그리고 A의 handle 메소드를 이용해 A의 결과를 B로 전달하는 간접적인 방식으로 두 인스턴스를 연결합니다. 다른 데이터 타입들은 A를 wrapping해 B를 생성했는데 왜 CompletableFuture는 그렇게 하지 않을까요? 사실 이 질문에 대해 완벽한 답을 찾진 못했습니다. 제 생각은 wrapping하는 방식도 가능해보였습니다. 다만 제 추측에 CompletableFuture의 다양한 API를 구현할 때 더 확장성이나 유지보수성이 좋기 때문이지 않을까 생각합니다.

위에 첨부한 코드를 보면 익숙한 try-with-resources 구문이 나오기 때문에 저기서 컨텍스트 저장이 일어나고 끝이라고 착각할 수 있습니다. 저는 처음에 그렇게 코드를 이해해서 어려움을 겪었는데요. 위 코드의 try-with-resources는 간접적으로 두 future을 연결하는 과정을 실행하는 쓰레드에 컨텍스트를 저장합니다. 이후 makeContextAware 메소드의 결과로 만들어진 ContextAwareFuture의 콜백 함수를 실행하는 쓰레드에 요청 컨텍스트를 저장하는 과정은 따로 분리되어 있습니다. 이는 ContextAwareFuture 클래스를 보시면 확인할 수 있습니다. 대표적으로 thenAccept, thenApply 등의 메소드 인자로 콜백 함수를 받고 이후 작업이 완료된 후 별도의 쓰레드가 콜백을 실행할 때 try-with-resources로 또 요청 컨텍스트를 저장해줍니다. 그리고 결과로 반환하는 CompletableFuture 역시 한 번 더 ContextAwareFuture로 변환해 메소드 체이닝을 할 때 모두 context aware하게 실행할 수 있습니다. (참고로 Java 9 버전 이상에서는 CompletableFuture#newIncompleteFuture 메소드를 사용해 메소드 체이닝을 더욱 쉽게 구현합니다. 자세한 소스 코드는 Java9ContextAwareFuture를 참고해주세요.)

최종적으로 정리하겠습니다. 내가 context aware하게 만들고 싶은 CompletableFuture 인스턴스를 A라고 하겠습니다. 빈 ContextAwareFuture 인스턴스 B를 만드록 handle 메소드를 이용해 A와 B를 간접적으로 연결합니다. handle 메소드는 별도의 쓰레드에서 실행될 수 있으므로 내부에서 try-with-resources를 사용합니다. 그 다음 B에 콜백 함수를 달아서 사용할 때 마찬가지로 별도의 쓰레드에서 실행될 수 있으므로 또 요청 컨텍스트 저장 과정이 있습니다. 이 로직은 ContextAwareFuture 내부에 존재합니다. 마지막으로 콜백함수 실행 결과로 나오는 CompletableFuture 인스턴스 역시 context aware하게 만들어 메소드 체이닝을 가능하게 만듭니다.

EventLoop

마지막으로 Netty의 EventLoop를 변환한 ContextAwareEventLoop입니다. 명시적으로 사용자가 변환해서 사용하진 않고 RequestContext#eventLoop 메소드를 사용해 현재 채널의 EventLoop 인스턴스를 변환하여 반환합니다. Armeria request scoping 발표 영상에서 별도의 쓰레드에서 작업을 처리할 때 가장 권장하는 방식이 ContextAwareEventLoop 인스턴스를 CompletableFuture#thenAccepAsync의 인자로 사용하는 것입니다. 이름에 EventLoop가 들어가서 가장 중요한 이벤트 루프 쓰레드에서 작업을 처리하는 것 아닐까 생각하실 수 있습니다. 내부 소스 코드를 따라가보면 FakeChannel 인스턴스의 EventLoop를 사용하므로 편하게 사용하고 블록해도 괜찮습니다. 사용 예시는 다음과 같습니다. 위에서 직접 요청 컨텍스트를 저장하는 코드 예시와 비교하면 좀 더 코드를 이해하기 쉽습니다.

Netty의 EventLoop는 인터페이스는 ScheduledExecutorService를 상속하기 때문에 우리가 익숙한 API를 모두 사용하실 수 있습니다. 그 외 Netty EventLoop API를 사용할 때 자동으로 context aware하게 사용할 수 있습니다. 관련 데이터 타입으로 ContextAwarePromise, ContextAwareFuture (java.util.concurrent.CompletableFuture가 아닌 io.netty.util.concurrent.Future를 wrapping한 타입입니다.) 등을 내부에서 사용합니다. Netty의 Promise, Future에 콜백 함수를 등록해놓으면 추후 별도의 쓰레드에서 실행할 때 자동으로 컨텍스트를 저장하고 사용합니다. 이렇게 다양한 API를 사용할 수 있기 때문에 RequestContext.eventLoop() 메소드를 활용하면 유용할 때가 많습니다.

정리

멀티플렉싱 방식의 서버는 이벤트 루프 쓰레드를 IO 등의 작업으로 블록해선 안되기 때문에 하나의 요청이 여러 쓰레드를 거쳐 처리될 수 있습니다. 이 때 다른 쓰레드로 바뀌면 요청 컨텍스트 정보가 사라지기 때문에 작업을 실행하기 전에 ThreadLocal에 요청 컨텍스트를 먼저 저장하는 과정이 필요합니다. 이 글에서는 요청 컨텍스트를 저장할 때 직접 저장하는 방법부터 Armeria가 제공하는 다양한 데이터 타입을 이용해 저장하는 방법까지 알아봤습니다.

사실 글을 다 쓰고 나니 내용이 다소 두서 없었다는 생각이 듭니다. 처음 글을 쓸 때는 context aware한 작업 실행을 원하는 분들에게 도움이 되었으면 하는 마음에 글을 썼는데 정보가 집중되어있지 않고 과하다는 생각도 드네요. 현재 저는 ContextAware~ 인스턴스를 사용할 때 예외 처리 콜백 함수를 인자로 받는 PR을 진행하고 있습니다. 이 PR을 끝내고 Armeria를 사용할 때 좀 더 현실적으로 겪어볼만한 문제를 주제로 또 글을 써보겠습니다.

comments powered by Disqus

Related