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
서버 코드 예시를 보겠습니다.
@Get("/test") | |
public HttpResponse getTest(RequestContext ctx) { | |
CompletableFuture<HttpResponse> future = new CompletableFuture<>(); | |
WebClient client = WebClient.of("https://icanhazdadjoke.com"); | |
HttpResponse response = client.execute(RequestHeaders.of(HttpMethod.GET, "/", HttpHeaderNames.ACCEPT, HttpHeaderValues.APPLICATION_JSON)); | |
response.aggregate().thenAccept(aggregatedRes -> { | |
try (SafeCloseable ignored = ctx.push()) { | |
AggregatedHttpResponse res = ... // do something with `aggregatedRes` | |
future.complete(res.toHttpResponse()); | |
} | |
}); | |
return HttpResponse.from(future); | |
} |
위 코드는 요청을 받았을 때 서드 파티 서비스에 요청을 보낸 뒤 받은 응답을 조작하여 최종적으로 사용자에게 응답하는 코드입니다. 서드 파티 서비스에 요청을 보낼 때
현재 쓰레드를 블록하여 기다리지 않고 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를 저장하는 방식과 살짝 다릅니다. 직접
소스 코드를 보면서 자세히 동작을 알아보겠습니다.
default <T> CompletionStage<T> makeContextAware(CompletionStage<T> stage) { // A 인스턴스를 인자로 받음 | |
// 생략 | |
final CompletableFuture<T> future = JavaVersionSpecific.get().newContextAwareFuture(this); // B 인스턴스 생성 | |
stage.handle((result, cause) -> { // A와 B를 간접적으로 연결 | |
try (SafeCloseable ignored = push()) { | |
if (cause != null) { | |
future.completeExceptionally(cause); | |
} else { | |
future.complete(result); | |
} | |
} catch (Throwable t) { | |
future.completeExceptionally(t); | |
} | |
return null; | |
}); | |
return future; // B를 반환 | |
} |
우리가 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
를 사용하므로 편하게 사용하고 블록해도 괜찮습니다. 사용 예시는 다음과 같습니다. 위에서 직접 요청 컨텍스트를
저장하는 코드 예시와 비교하면 좀 더 코드를 이해하기 쉽습니다.
@Get("/test") | |
public HttpResponse getTest(RequestContext ctx) { | |
CompletableFuture<HttpResponse> future = new CompletableFuture<>(); | |
WebClient client = WebClient.of("https://icanhazdadjoke.com"); | |
HttpResponse response = client.execute(RequestHeaders.of(HttpMethod.GET, "/", HttpHeaderNames.ACCEPT, HttpHeaderValues.APPLICATION_JSON)); | |
ContextAwareEventLoop eventLoop = ctx.eventLoop(); | |
response.aggregate().thenAcceptAsync(aggregatedRes -> { | |
AggregatedHttpResponse res = ... // do something with `aggregatedRes` | |
future.complete(res.toHttpResponse()); | |
}, eventLoop); | |
return HttpResponse.from(future); | |
} |
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를 사용할 때 좀 더 현실적으로 겪어볼만한 문제를 주제로 또 글을 써보겠습니다.