Armeria의 request scoping과 leak 탐지

동기 서버 vs 비동기 서버

Spring + Tomcat과 같은 멀티쓰레딩 방식의 서버는 요청이 들어오면 전담 쓰레드를 생성해서 작업을 처리합니다. 따라서 시간이 오래 걸리는 연산이나 쓰레드를 블록하여 동기 방식으로 IO 작업을 처리한다해도 다른 요청 처리에 영향을 미치지 않습니다. 덕분에 멀티쓰레딩 서버는 코드의 복잡도가 낮아지고 중심 비즈니스 로직에 집중할 수 있습니다.

하지만 동기식 서버의 대표적인 단점으로 c10k 문제가 있습니다. 동시에 1만개의 요청이 들어왔을 때 서버는 각 요청에 대응하는 1만개의 쓰레드를 생성합니다. 쓰레드가 너무 많으면 컨텍스트 스위칭 비용이 커져 실제 요청을 처리하는 시간보다 컨텍스트 스위칭에 대부분의 시간을 소비하여 응답 지연 시간이 길어집니다.

대안으로 적은 수의 이벤트 루프 쓰레드들이 요청을 비동기 방식으로 처리하는 리액터 패턴으로 서버를 구축할 수 있습니다. 서버에서 요청을 처리할 때는 서드 파티 서버에 요청을 보내거나 DB 서버에 쿼리를 날리는 등 각종 IO 작업을 거칩니다. 동기 방식 서버는 IO 작업을 처리하는 동안 쓰레드를 블록하여 완료를 기다리지만 비동기 방식 서버의 이벤트 루프 쓰레드는 완료를 기다리지 않고 다른 요청을 처리합니다. 추후 요청이 완료되어 데이터가 준비되었을 때 다시 작업을 이어서 처리합니다. 동시 요청이 많아져도 쓰레드의 수가 늘어나지 않기 때문에 컨텍스트 스위칭 비용이 작아 c10k 문제를 겪지 않습니다. 대신 실수로 이벤트 루프 쓰레드를 블록하거나 시간이 오래 걸리는 작업을 실행했을 때 모든 응답이 지연되는 큰 문제를 겪을 수 있기 때문에 코드 작성 시 주의해야 합니다.

최근 Java Concurrency in Practice 책을 읽다가 재밌는 사실을 알았습니다. 저는 멀티쓰레딩(동기) 방식 서버가 먼저 등장하고 멀티플렉싱(비동기) 서버가 나중에 등장한 줄 알았습니다. 그런데 오히려 반대로 과거에는 기술의 한계로 쓰레드 수를 늘리는데 제한이 있었기 때문에 소수의 쓰레드로 요청을 처리하기 위해 멀티플렉싱 서버가 먼저 등장했습니다. 그 후 기술의 발전으로 쓰레드 수 제한이 풀리고, 멀티플렉싱 서버의 복잡한 동시성 처리 문제를 피할 수 있는 멀티쓰레딩 서버를 사용하기 시작했습니다. 그런데 이제 매우 많은 동시 요청을 처리해야 하는 서비스들이 등장해 다시 멀티플렉싱 서버가 주목을 받는다니 재밌지 않나요?

비동기 서버의 request scoping

멀티쓰레딩 서버에서는 하나의 요청을 전담하는 쓰레드가 있지만 멀티플렉싱 서버에서는 하나의 요청이 여러 쓰레드를 거쳐 처리될 수 있습니다. 예를 들어 시간이 오래걸리는 작업의 경우 이벤트 루프 쓰레드가 아닌 별도의 쓰레드를 생성해 작업을 넘겨서 처리합니다. 이 때 요청의 컨텍스트 정보(추적 ID, 요청 처리 시간, 스택 트레이스 등의 메타데이터)를 쓰레드 간에 유지하는 행위를 request scoping이라 합니다. 심지어 MSA에서는 하나의 요청이 쓰레드를 넘어 여러 서비스를 거쳐서 처리되므로 request scope의 범위는 더 넓어질 수 있습니다. 이 글에서는 서비스 간 요청 컨텍스트 유지에 대해서는 다루지 않고 범위를 좁혀 Armeria에서 쓰레드 간에 요청 컨텍스트를 유지하는 방법에 집중합니다.

Armeria는 다양한 방법으로 요청 컨텍스트 정보를 다른 쓰레드로 전달할 수 있습니다. 다음 코드는 요청 처리 중에 서드파티 API를 사용할 때 그 응답을 비동기로 처리하는 예시입니다.

코드의 주석을 보면 RequestContext#push() 메소드를 이용해 이전 쓰레드에서 시작한 요청의 컨텍스트 정보를 다음 쓰레드로 넘겨줍니다. 해당 메소드는 컨텍스트 정보를 ThreadLocal에 저장합니다. 이제 쓰레드에서 로그를 남기거나 에러 발생 시 컨텍스트에 담긴 정보를 함께 남겨 개발자에게 도움이 되는 정보를 제공할 수 있습니다.

참고로 Armeria에서는 위 코드와 같이 명시적으로 컨텍스트를 저장하는 방법 말고도 ContextAwareFuture 또는 ContextAwareExecutor를 사용할 수 있습니다. Armeria가 추천하는 best practice는 ContextAwareExecutor를 사용하는 방법인데 자세한 방법은 Armeria의 request scoping 발표 영상을 참고하시면 좋습니다.

Request scope leak

RequestContext#push()를 사용할 때는 주의할 점은 반드시 try-with-resource 구문을 사용해야 한다는 것입니다. 그 이유는 쓰레드를 빠져나갈 때 ThreadLocal에 저장된 컨텍스트 정보를 제거해야 하기 때문입니다. 만약 여기서 실수하면 쓰레드가 다른 요청을 처리하기 위해 새로운 요청 컨텍스트를 집어넣을 때 충돌이 발생합니다. Armeria는 개발자가 이런 실수를 알 수 있도록 하나의 ThreadLocal에 충돌하는 컨텍스트를 집어넣을 때 예외를 발생시킵니다. 이와 같이 ThreadLocal에 저장된 컨텍스트 정보를 작업이 끝난 후에 제거하지 않는 것을 request scope leak 또는 request context leak이라 합니다.

Request scope leak이 발생한 상태에서 충돌하는 컨텍스트를 집어넣을 때 에러를 발생시키지만 스택 트레이스를 봐도 어디서 leak이 발생했는지 알기는 어렵습니다. 그 이유는 에러가 발생한 쓰레드 이전에 거쳐온 쓰레드의 스택 트레이스를 출력할 수 없기 때문입니다. 그래서 이를 보완하기 위해 설정을 통해 기본 RequestContextStorage 구현체를 LeakTracingRequestContextStorage로 확장할 수 있습니다. RequestContextStorage는 컨텍스트 정보를 저장하는 저장소를 추상화한 인터페이스입니다. 이 때 기본 구현체는 ThreadLocal에 컨텍스트를 저장합니다. LeakTracingRequestContextStorage 구현체는 ThreadLocal에 컨텍스트 정보를 저장할 때 현재 쓰레드의 스택트레이스를 함께 저장합니다. 따라서 나중에 에러 발생 시 어디서 leak이 발생했는지 추가 정보를 제공합니다.

Request scoping을 통해 얻을 수 있는 대표적 이점 중 하나로 에러 발생 시 스택 트레이스를 통한 원인 추적이 있습니다. 하나의 요청이 여러 쓰레드를 거쳐 처리될 경우 에러가 발생하면 현재 쓰레드의 스택 트레이스만 출력되고 이전 쓰레드의 스택 트레이스를 볼 수 없어 정확히 어디가 원인인지 알기 어렵습니다. 이 때 request scoping을 통해 스택 트레이스 정보도 컨텍스트에 넣어서 저장하면 쓰레드가 바뀐 후 에러가 발생해도 이전 쓰레드의 스택 트레이스를 함께 출력할 수 있습니다. 예를 들어 Reactive Streams 구현체로 유명한 Project Reactor도 비동기 처리 중 에러 발생 시 전체 스택 트레이스를 볼 수 있는 기능을 제공합니다. (참고로 Reactor 공식 문서에서 친절하게 예시까지 제공해서 설명하는데 이 내용에 관심이 있으시면 꼭 읽어보시길 추천드립니다.) 아쉽게도 Armeria는 아직 이 기능을 제공하지 않습니다. LeakTracingRequestContextStorage는 비슷한 기능을 갖지만 request scope leak 상황에서 한정적으로 스택트레이스를 수집합니다. 사실 이 글도 스택 트레이스 수집 기능을 만들기 위해 공부하다가 배운 내용을 정리했습니다.

정리

동기 방식의 멀티쓰레딩 서버는 간결한 코드로 로직을 작성할 수 있지만, 요청마다 담당 쓰레드를 생성하기 때문에 동시에 매우 많은 요청을 처리하기 힘들다는 단점이 있습니다. 반대로 비동기 방식의 멀티플렉싱 서버는 소수의 쓰레드로 요청을 처리하기 때문에 멀티쓰레딩 서버보다 훨씬 더 많은 수의 요청을 동시에 처리할 수 있습니다. 하지만 비동기 서버 역시 단점이 있는데 그 중 대표적인 단점으로 하나의 요청이 여러 쓰레드를 거쳐 처리되는 경우 컨텍스트 정보가 유실되는 문제가 있습니다. 이를 해결하기 위해 쓰레드가 바뀔 때 컨텍스트 정보를 전파하는 request scoping이 필요합니다.

Armeria에서는 쓰레드가 바뀔 때 직접 RequestContext 정보를 ThreadLocal에 저장하는 방식으로 request scoping을 구현합니다. 이 때 실수로 작업이 끝난 후 ThreadLocal에 있는 컨텍스트 정보를 제거하지 않은 상황을 request scope leak이라 합니다. Armeria는 leak이 발생했을 때 충돌하는 컨텍스트 정보를 ThreadLocal에 저장하려 시도하면 에러를 발생시킵니다. 추가로 leak이 발생한 위치를 쉽게 찾을 수 있도록 기본 RequestContextStorage 구현체를 LeakTracingRequestContextStorage로 확장하여 에러 발생 시 더 많은 스택 트레이스 정보를 확인할 수 있습니다.

아직 Armeria는 에러 발생 시 요청이 거쳐온 모든 쓰레드의 스택 트레이스를 확인할 수 있는 기능이 없습니다. 현재 이 기능을 구현하기 위해 열심히 공부하고 있는데요. 저는 비동기 서버의 개념에 대해 알게 된지 오래되었지만 request scoping 같은 비동기 서버에서 필요한 기능에 대해 Armeria에 기여하면서 처음 알게 되었습니다. 동시에 수많은 요청을 처리해야 하는 서버나 마이크로서비스에 대한 관심이 높아졌는데 막상 편리하게 쓰는 기능이 어떻게 만들어졌는지 모르는 경우가 많습니다. 이 분야에 관심이 많지만 어떻게 알아가야 할지 막막하신 분들은 Armeria 같은 오픈 소스에 기여하는 것으로 시작하면 정말 큰 도움이 될 것 같습니다.

참고

comments powered by Disqus

Related