This icon created by Freepik - Flaticon
지난 몇 년 동안 네티 프레임워크를 사용하여 우주 지상국 소프트웨어를 개발했습니다. 개발을 진행하면서 저와 동료들이 네티의 특징을 제대로 이해하지 못해 실수했던 몇몇 경험들이 있는데 그 경험들을 바탕으로 네티의 중요한 구조적 특징들에 대해 나누어서 정리해 보려고 합니다.
이번 글에서는 네티의 비동기적 특징에 대해 정리합니다.
아래는 netty.io 페이지에서 네티의 핵심을 설명하는 한 줄 설명입니다. 영어의 어순 영향도 있겠지만 네티를 설명하는 첫 단어로 등장할 만큼 비동기(asynchronous)라는 개념은 네티의 중요한 특징 중 하나라고 할 수 있습니다. (비동기 개념 - 「왜 비동기 코드를 작성하시나요?」)
그리고 아래 코드 한 줄은 채널로 메시지를 전송하는 단순한 코드입니다. writeAndFlush() 메서드 뒤에 숨겨진 프레임워크 코드가 블랙박스로 보이던 때는 단순히 이렇게 믿고 코드를 작성했습니다.
“네티가 비동기적으로 동작하니까 내 요청을 받은 후 실행을 즉시 반환하고 이후에 네티가 알아서 어떻게 어떻게 처리해 줄거야!”
channel.writeAndFlush("hello");
틀린 말이 아닐 뿐더러 사실 이 정도의 지식으로 네티를 활용하는데 문제가 없습니다. 그런데 그때 당시 개발중이던 시스템에서 어떤 문제가 생겨서 전송하는 메시지와 메시지 사이에 일정한 간격을 유지시켜 주어야 하는 요구가 생겼습니다. 그때 아마도 네티가 비동기 특성을 구현한 구조를 잘 이해했다면 손쉽게 문제를 해결했을 텐데 직접 바퀴를 열심히 재발명해서 메시지 간격을 스케줄링 할 수 있는 쓰레드와 메시지큐를 두고 직접 처리했던 기억이 납니다. (관련 블로그 글 - 메시지 큐를 활용한, 유연한 전송 구조 만들기」) 이후에 알게 되었지만 네티는 이미 메시지 전송을 처리하는 별도의 쓰레드(EventLoop)와 메시지 전송 작업을 버퍼링할 수 있는 작업 큐를 가지고 있습니다. 또한 시간 지연을 수행해도 다른 I/O 작업에 영향을 주지 않도록 별도의 쓰레드를 핸들러에 추가해 줄 수도 있었습니다. (이전 글 - 「Netty 구조적 특징 (1) : 쓰레드 모델」)
저는 사실 다른 문제 때문에 writeAndFlush() 메서드 내부 구현을 따라가 보게 되었는데 그때의 기억을 살려 네티가 비동기적인 특징을 어떻게 구현하고 있는지 내부 코드를 보며 정리해 보려합니다. 이제 이후부터는 네티가 어떻게 어떻게 알아서 처리해 주겠지 보다 더 나은 이해를 가질 수 있을 겁니다. 그리고 위와 같은 상황이 생기면 네티의 특징을 잘 활용해서 코드를 훨씬 쉽게 작성할 수 있게 될 겁니다.
그럼 Channel의 writeAndFlush 메서드 부터 시작해서 한 겹씩 안으로 들어가 보겠습니다. 먼저 Channel은 단순하게 writeAndFlush 요청을 ChannelPipeline에게 위임합니다.
public abstract class AbstractChannel ... {
private final DefaultChannelPipeline pipeline;
public ChannelFuture writeAndFlush(Object msg) {
return pipeline.writeAndFlush(msg); // 채널 파이프라인으로 전송 요청 전달
}
}
ChannelPipeline 역시 단순하게 ChannelHandlerContext 타입의 tail 객체에게 writeAndFlush 요청 처리를 위임합니다. ChannelPipeline은 head와 tail이라는 두 멤버를 가지는데 이는 Double Linked-List 구조의 앞과 뒤라고 할 수 있습니다. 파이프라인은 ChannelHandlerContext 타입 객체가 Double Linked-List 구조로 연결되어 있는 형태이고, 전송 요청한 메시지는 tail 부터 시작해서 전체 파이프라인을 흘러가고 수신된 메시지는 head 부터 시작해서 파이프라인을 흘러 갑니다.
public class DefaultChannelPipeline ... {
final AbstractChannelHandlerContext tail; // 파이프라인의 뒤 (전송이 시작되는 부분)
public final ChannelFuture writeAndFlush(Object msg) {
return tail.writeAndFlush(msg); // 파이프라인의 뒤에 메시지 전달
}
}
ChannelHandlerContext 내부에서는 멤버 메서드인 write 까지 전송 메시지가 전달되어 집니다. 그리고 이제 write 메서드가 비동기 처리의 핵심 부분이라고 할 수 있습니다. write 메서드에서 일어나는 일을 요약하면 다음과 같습니다.
1) 파이프라인에서 쓰기 속성을 가진 다음 핸들러 컨텍스트(ChannelHandlerContext)를 찾습니다.
2) 다음 핸들러의 실행자(EventExecutor) 참조를 얻습니다.
3) 현재 쓰레드와 다음 핸들러 실행자(EventExecutor) 쓰레드가 같은지 검사합니다.
4) 현재 쓰레드와 다음 핸들러 실행자(EventExecutor) 쓰레드가 같다면 다음 핸들러를 현재 쓰레드에서 직접 호출합니다.
5) 현재 쓰레드와 다음 핸들러 실행자(EventExecutor) 쓰레드가 다르다면, 메시지 전송을 처리할 작업을 생성해서 이벤트 루프의 작업큐에 추가해 줍니다.
쓰기 작업을 요청하는 쓰레드는 핸들러를 실행하는 이벤트 루프 쓰레드와 다르기 때문에 처음에 한 번은 항상 작업이 큐에 삽입되고 요청 쓰레드와 다른 이벤트 루프 쓰레드에 의해 비동기적으로 쓰기 작업이 처리되게 됩니다. 이 부분이 네티의 비동기 특징을 구현하는 핵심이라고 할 수 있습니다. 사실 이런 구조는 네티의 특징이라기 보다 대부분의 비동기적인 코드를 구현하기 위한 기본적인 뼈대라고 할 수 있습니다. 제가 직접 구현한 코드나 Java의 ThreadPool 구현 역시 이와 같이 요청과 실제 작업 쓰레드를 분리하고, 쓰레드와 쓰레드 사이는 메시지큐로 작업을 버퍼링 했다가 비동기적으로 처리하도록 되어 있습니다.
abstract class AbstractChannelHandlerContext ... {
...
@Override
public ChannelFuture writeAndFlush(Object msg, ChannelPromise promise) {
write(msg, true, promise);
return promise;
}
private void write(Object msg, boolean flush, ChannelPromise promise) {
...
// 1) 다음 핸들러 컨텍스트 찾기
final AbstractChannelHandlerContext next = findContextOutbound(flush ? (MASK_WRITE | MASK_FLUSH) : MASK_WRITE);
// 2) 다음 핸들러의 실행자 참조 얻기
EventExecutor executor = next.executor();
// 3) 현재 실행 쓰레드와 다음 실행자 쓰레드 비교
if (executor.inEventLoop()) {
// 4) 같으면 다음 핸들러 직접 호출
next.invokeWriteAndFlush(m, promise);
...
} else {
// 5) 다르면 작업을 생성해서 다음 실행자의 작업큐에 추가
final WriteTask task = WriteTask.newInstance(next, m, promise, flush);
if (!safeExecute(executor, task, promise, m, !flush)) {
task.cancel();
}
}
...
}
}
이벤트 루프에서는 쓰기를 요청한 쓰레드와 다른 쓰레드에 의해 큐에서 작업을 꺼내서 차례대로 작업을 처리합니다. 이벤트 푸프 코드는 복잡해서 핵심 부분만 추상화해서 아주 간략해 표현해 보면 크게 두가지 일을 하는 것을 알 수 있습니다. 첫째는 저수준 채널에서 이벤트를 모니터링하고 있다가 실제 I/O 동작을 수행하는 것입니다. 두 번째는 앞서 채널 파이프라인에서 일어나는 읽기, 쓰기 등의 작업 요청을 큐에서 꺼내서 처리하는 일입니다. 이벤트 루프는 종료될 때까지 이 일을 반복합니다.
public final class NioEventLoop extends SingleThreadEventLoop {
@Override
protected void run() {
int selectCnt = 0;
for (;;) {
...
processSelectedKeys(); // 채널의 I/O 처리
...
runAllTasks(...); // 파이프라인의 요청 작업 처리
...
}
}
}
정리하면 네티는 사용자의 I/O 요청을 비동기적으로 처리합니다. 사용자의 요청을 받아서 작업 큐에 넣은 다음, 별도의 이벤트 루프 쓰레드에서 큐에서 작업을 꺼내서 요청 쓰레드와는 비동기적으로 작업을 수행합니다. 이 덕분에 우리는 주어진 시스템 자원을 최대한 활용해 높은 처리량(Throughput)의 가진 고성능 네트워크 프로그램을 개발할 수 있습니다.