Armeria는 Netty 기반으로 HTTP 및 HTTP 위에서 동작하는 각종 프로토콜로 서비스를 구성할 수 있게 해주는 프레임워크 입니다. 최근에 Armeria 홈페이지의 Production checklist에 Armeria Client와 관련하여 다음과 같은 내용이 추가되었습니다.
Armeria – Production checklist
Consider increasingClientFactoryBuilder.maxNumEventLoopsPerEndpoint()
andClientFactoryBuilder.maxNumEventLoopsPerHttp1Endpoint()
when a client needs to send a large number of requests to a specific endpoint. The client will assign more CPU resources and create more connections by increasing the number of event loops up to the quantity inClientFactory.eventLoopGroup()
.
Armeria Client를 사용할 때, maxNumEventLoopsPerEndpoint 설정을 적절히 해주지 않으면 성능상의 병목 지점이 될 수 있다는 이야기입니다. 이 설정이 아르메리아 클라이언트가 동작하는데 어떻게 작용하는지 알아보려고 합니다.
논블로킹 패러다임과 소켓 통신을 위한 버퍼의 동시성 문제
Netty는 모든 작업을 비동기 방식으로 처리하는 event-driven 프레임워크입니다. 기본적으로, 모든 처리 로직은 논블로킹 방식으로 실행되며, 이는 각 스레드가 다수의 동시 작업을 처리하게 함을 의미합니다. 이는 전통적인 블로킹 애플리케이션에서 볼 수 있는 요청과 워커 스레드가 1:1로 대응되는 규칙과 다릅니다.
HTTP/2의 여러 개의 스트림을 통한 동시 요청 처리나, TCP Multiplexing을 통한 단일 연결에서 여러 컨텍스트의 동시 처리 같은 기술적 표준은 단일 소켓 연결에서 다수의 컨텍스트가 존재하게 합니다.
단일 스레드가 수많은 요청을 동시에 처리할 수 있고, 단일 연결이 다수의 요청을 동시에 처리할 수 있습니다. 그렇다면, 스레드와 연결은 완전히 독립적일까요?
그렇지 않습니다. 입출력 데이터를 Direct Buffer 메모리에 읽고 쓰는 작업은 여러 코어에서 병렬로 처리되기에 절대 안전하지 않습니다. 단일 연결에 대한 입출력 버퍼의 읽기와 쓰기 작업은 동시성을 단일로 제한해야 합니다.
이에 따라, Netty의 각 Channel은 단일 EventLoop (Netty에서 하나의 이벤트 루프는 싱글 스레드 워커에서 처리됩니다)에 할당되도록 설계되었습니다. 이를 통해 각 채널의 입출력 처리 이벤트는 할당된 해당 EventLoop에서만 처리되어, 단일 소켓의 입출력 버퍼에 대한 동시성을 제한할 수 있습니다. 이는 채널(소켓 연결)과 해당 이벤트 루프의 할당 및 등록이 1:1로 강한 결합을 이룬다는 것을 의미합니다.
IO 처리에서 코어를 두 개 이상 쓰려면 연결을 늘려야 한다
문제는 동시성이 단일로 제한되면 실질적으로 단일 코어만 사용하게 된다는 점입니다. 이는 Armeria나 논블로킹 패러다임 자체의 문제가 아닌, 하나의 소켓 연결에 대한 일반적인 제한사항입니다. 블로킹 IO에서는 스레드와 연결이 거의 1:1로 묶이기 때문에, 이러한 문제가 두드러지지 않을 뿐입니다.
만약, 어떤 앱이 엄청나게 IO 집약적인 역할을 한다고 가정해 보죠.
코어 하나로 감당이 안될정도의 처리를 해야한다면, 어떻게 해야 할까요?
당연히, 여러 코어를 활용하기 위해 할당할 EventLoop의 수를 늘려야 합니다. 그러나 하나의 채널은 단 하나의 EventLoop에만 할당될 수 있습니다. 따라서, 할당 가능한 EventLoop의 수를 증가시키기 위해 채널의 수를 늘려야 합니다. 이는 곧 클라이언트가 서버와 더 많은 연결을 생성해야 함을 의미합니다.
실제 Armeria의 설정과 동작 방식
Armeria로 돌아가 봅시다. maxNumEventLoopsPerEndpoint 와 maxNumEventLoopsPerHttp1Endpoint 가 있는데, 이름에서 보이듯 각각 HTTP/2 연결과 HTTP 1.1 연결에 대한 설정입니다. Armeria는 Netty Channel에 EventLoop를 어사인 하기에 앞서서, 아르메리아 클라이언트의 Endpoint 레벨에서도 사용하게 될 EventLoop들을 먼저 지정해두도록 되어 있습니다. 그 EventLoop들을 만들어진 Channel 들에 배분하듯이 나누어주며 등록하게 됩니다.
HTTP/2의 경우, 서버가 허용한다면 하나의 연결을 통해 이론상 무제한의 동시성을 얻을 수 있습니다. 대부분의 시나리오에서 단일 연결로 모든 요청을 처리하기에 충분합니다. 그에 따라 maxNumEventLoopsPerEndpoint의 기본값은 1입니다.
반면, HTTP 1.1에서는 한 번에 하나의 요청만 처리할 수 있습니다. 새로운 요청이 들어올 때, 클라이언트는 새 연결을 생성하거나, 유휴 연결이 생길 때까지 기다려야 합니다. 이 때문에 maxNumEventLoopsPerHttp1Endpoint는 기본적으로 무제한으로 설정되어, 필요에 따라 새로운 연결을 자유롭게 생성할 수 있게 되어있습니다.
HTTP/2를 사용할 경우, 요청이 너무 적거나, 많은 경우만 아니라면, 연결 수는 대략 maxNumEventLoopsPerEndpoint와 EndpointGroup.size의 곱으로 수렴합니다. 요청이 연결을 웜업시키지도 못할 만큼 적거나, 서버가 허용한 스트림 수에 제한이 너무 가혹한경우 예상보다 적거나 많은 연결이 수립될 수 있습니다.
일반적으로, IO 처리만 하는 경우에는 하나의 이벤트 루프로도 충분하지만, IO가 병목 지점이 되거나, IO 처리 결과로 발생하는 콜백에 무거운 CPU 계산이 포함된 경우, 이벤트 루프 수를 늘려 성능을 개선할 수 있습니다. maxNumEventLoopsPerEndpoint와 EndpointGroup.size의 곱이 CPU 코어 수를 초과하도록 설정하면, 이론적으로 모든 코어를 IO 처리에 활용하여 최대 성능을 달성할 수 있습니다.
L3/L4 Switch가 있는 통신 구간의 설정 조정
maxNumEventLoopsPerEndpoint 설정은 성능 병목을 해소하는 것 외에도, 연결 수를 증가시켜 트래픽 분산에 유용하게 활용될 수 있습니다.

예를 들어, 두 서버 그룹 A와 B가 서버 간 통신을 할 때를 가정해봅시다. 서비스 디스커버리를 사용하여 A 서버의 노드들이 B 서버의 모든 구성 노드를 인식할 수 있거나, L7 Switch(예: nginx, haproxy 등)가 중간에 위치하여 요청 단위로 라운드 로빈으로 분배할 수 있다면 이상적입니다. 하지만, 어른의 사정으로, 서비스 디스커버리 사용이 불가능하고, L3/L4 Switch가 중간에 위치하는 경우에는 상황이 복잡해집니다.
서버 A의 Armeria Client 입장에서, 서버 B의 구성이 어떻든, 서버 B의 Endpoint는 단일 IP(1.2.3.4)로 인식됩니다. 이 경우, EndpointGroup.size가 1이고 maxNumEventLoopsPerEndpoint의 기본값 역시 1이므로, 서버 A 각각의 노드들은 서버 B로 가는 단 하나의 연결만을 생성합니다. L3/L4 Switch가 TCP 연결을 라운드 로빈 방식으로 분산시키더라도, 연결 수가 적어 연결이 균등하게 분배되지 않을 위험이 높습니다. 최악의 경우, 서버 B의 특정 노드가 서버 A로부터 모든 연결을 받게 되어 요청을 독박쓰는 상황이 발생할 수 있습니다.
이는 L3/L4 Switch가 중계 역할을 하는 환경에서 HTTP/2 통신을 위해 maxNumEventLoopsPerEndpoint을 기본값으로 설정하는 것이 적절하지 않음을 의미합니다. 이러한 문제를 완화하기 위해서는 L3/L4 Switch가 엮인 구간에서는, maxNumEventLoopsPerEndpoint 설정을 증가시켜 더 많은 연결을 생성하거나, HTTP 1.1 통신을 우선적으로 사용하도록 설정을 조정할 필요가 있습니다.