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는 자동으로 컨텍스트를 저장하고 회수하는 다양한 데이터 타입을 제공합니다. 먼저 Runnable
과
Callable
을 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
를 여러 요청 처리에서 재사용할 때 일일이 Executor
를 ContextAwareExecutor
로 변환하는
대신 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를 사용할 때 좀 더 현실적으로 겪어볼만한 문제를 주제로 또 글을 써보겠습니다.