EventLoop 내부 동작
EventLoop가 "이벤트 루프"라는 건 알겠는데, 그 루프 안에서 실제로 무슨 일이 벌어지는 걸까? select 한 번 돌고 끝이 아니라, I/O 처리와 태스크 실행 사이에서 시간을 어떻게 나누는지까지 이해해야 Netty 튜닝이 가능해진다.
이전 글에서 EventLoop가 단일 스레드 + Selector 기반 이벤트 루프라는 것을 살펴봤습니다. 이번에는 그 루프의 내부 구현 을 열어 봅니다. NioEventLoop.run() 메서드가 실제로 어떤 순서로 동작하는지, 태스크 큐와 스케줄링은 어떻게 처리되는지, 그리고 EventLoop에서 절대 해서는 안 되는 것들까지 정리합니다.
run() 메서드의 세 단계
NioEventLoop의 run() 메서드는 select → processSelectedKeys → runAllTasks 세 단계를 무한 반복하는 루프 입니다.
실제 네티 소스 코드를 단순화하면 이런 구조입니다.
// NioEventLoop.run() 핵심 흐름 (단순화)
@Override
protected void run() {
for (;;) {
// 1단계: I/O 이벤트 대기
select(wakenUp.getAndSet(false));
// 2단계: 발생한 I/O 이벤트 처리
processSelectedKeys();
// 3단계: 태스크 큐에 쌓인 작업 실행
runAllTasks(ioTime * (100 - ioRatio) / ioRatio);
}
}
각 단계를 하나씩 살펴보겠습니다.
1단계: select() — I/O 이벤트 감지
Selector.select()를 호출해서 등록된 Channel들 중 I/O 준비가 된 것들을 찾습니다. 여기서 네티는 단순히 select()만 부르지 않고, 몇 가지 최적화를 추가합니다.
- **태스크 큐에 작업이 있으면
selectNow()로 논블로킹 조회 **: 큐에 쌓인 태스크가 있는데select()로 블로킹되면 태스크 처리가 밀리기 때문입니다. - **epoll bug 우회 **: JDK의 유명한 epoll 버그(CPU 100% 현상)를 감지하면 Selector를 재생성합니다.
┌──────────────────────────────────────────────────┐
│ NioEventLoop.run() │
│ │
│ ┌────────────┐ │
│ │ select() │◀─────────────── 루프 시작 │
│ │ │ ▲ │
│ └─────┬──────┘ │ │
│ │ │ │
│ ▼ │ │
│ ┌──────────────────┐ │ │
│ │processSelectedKeys│ │ │
│ │ (I/O 이벤트 처리) │ │ │
│ └─────┬────────────┘ │ │
│ │ │ │
│ ▼ │ │
│ ┌──────────────────┐ │ │
│ │ runAllTasks() │ │ │
│ │ (태스크 큐 실행) │──────────────┘ │
│ └──────────────────┘ │
└──────────────────────────────────────────────────┘
2단계: processSelectedKeys() — I/O 이벤트 처리
select()가 반환한 SelectionKey 집합을 순회하면서, 각 Key에 연결된 Channel의 파이프라인으로 이벤트를 전달합니다.
// processSelectedKeys 내부 동작 (단순화)
private void processSelectedKeys() {
Set<SelectionKey> selectedKeys = selector.selectedKeys();
for (SelectionKey key : selectedKeys) {
// 각 Key에 attachment로 연결된 NioChannel 가져오기
NioChannel ch = (NioChannel) key.attachment();
int readyOps = key.readyOps();
if ((readyOps & SelectionKey.OP_READ) != 0) {
// 읽기 가능 → channelRead 이벤트 발생
ch.unsafe().read();
}
if ((readyOps & SelectionKey.OP_WRITE) != 0) {
// 쓰기 가능 → flush 처리
ch.unsafe().forceFlush();
}
if ((readyOps & SelectionKey.OP_CONNECT) != 0) {
// 연결 완료 → channelActive 이벤트 발생
ch.unsafe().finishConnect();
}
}
}
네티는 기본적으로 SelectedSelectionKeySet이라는 최적화된 자료구조를 사용해서, JDK의 HashSet 기반 selectedKeys() 대신 배열 기반으로 순회합니다. 이 최적화 덕분에 키가 많을 때 순회 성능이 눈에 띄게 좋아집니다.
3단계: runAllTasks() — 태스크 큐 실행
I/O 이벤트 처리가 끝나면, taskQueue와 scheduledTaskQueue에 쌓인 작업들을 실행합니다. 이 단계가 왜 필요한지는 다음 섹션에서 자세히 살펴보겠습니다.
태스크 큐(taskQueue) — 외부에서 EventLoop에 작업 제출하기
EventLoop가 I/O 이벤트만 처리하는 건 아닙니다. ** 외부 스레드가 EventLoop에 작업을 제출하면, 그 작업이 태스크 큐에 들어가서 runAllTasks() 단계에서 실행 **됩니다.
execute()로 작업 제출
// 외부 스레드(비즈니스 로직 스레드 등)에서 EventLoop에 작업 제출
channel.eventLoop().execute(() -> {
// 이 코드는 EventLoop 스레드에서 실행된다
channel.writeAndFlush(response);
});
execute(Runnable task)를 호출하면 내부적으로 이런 일이 일어납니다.
- 전달받은
Runnable을taskQueue에 넣는다 (MPSC 큐 — 여러 생산자, 단일 소비자) - EventLoop 스레드가
select()에서 블로킹 중이라면wakeup()으로 깨운다 - EventLoop가 다음
runAllTasks()단계에서 큐의 작업을 꺼내 실행한다
외부 스레드 A ──execute(task1)──▶ ┌──────────┐
│ taskQueue │
외부 스레드 B ──execute(task2)──▶ │ (MPSC) │──▶ EventLoop 스레드가 꺼내서 실행
│ │
외부 스레드 C ──execute(task3)──▶ └──────────┘
MPSC는 Multiple Producer Single Consumer의 약자입니다. 여러 외부 스레드가 동시에 작업을 넣을 수 있지만, 꺼내서 실행하는 건 항상 EventLoop 스레드 하나뿐입니다. 이 구조 덕분에 소비자 쪽에서는 동기화 비용이 거의 없습니다.
왜 태스크 큐가 필요한가?
Channel의 모든 I/O는 바인딩된 EventLoop 스레드에서 처리되어야 스레드 안전합니다. 그런데 비즈니스 로직이 별도 스레드풀에서 돌아가고 있다면? 처리 결과를 Channel로 보내려면 결국 EventLoop 스레드에서 write()를 호출해야 합니다. 이때 execute()로 작업을 제출하면, EventLoop 스레드에서 안전하게 실행됩니다.
// 별도 스레드풀에서 비즈니스 로직 실행 후, 결과를 Channel로 전송
businessExecutor.submit(() -> {
MyResponse result = heavyComputation(); // 시간이 오래 걸리는 작업
// EventLoop 스레드로 돌아가서 write
channel.eventLoop().execute(() -> {
channel.writeAndFlush(result);
});
});
스케줄링 — scheduledTaskQueue와 HashedWheelTimer
scheduledTaskQueue: EventLoop 내장 스케줄러
schedule() 메서드로 지연 실행이나 주기적 실행을 예약할 수 있습니다. 예약된 작업은 scheduledTaskQueue (우선순위 큐)에 들어갔다가, runAllTasks() 단계에서 실행 시점이 된 것들이 taskQueue로 옮겨져서 실행됩니다.
// 5초 후에 한 번 실행
channel.eventLoop().schedule(() -> {
System.out.println("5초 경과");
}, 5, TimeUnit.SECONDS);
// 1초 간격으로 반복 실행
channel.eventLoop().scheduleAtFixedRate(() -> {
channel.writeAndFlush(new PingMessage());
}, 0, 1, TimeUnit.SECONDS);
HashedWheelTimer와의 차이
네티에는 HashedWheelTimer라는 별도의 타이머도 있습니다. 둘 다 지연 실행을 지원하지만 동작 방식이 다릅니다.
| 구분 | scheduledTaskQueue | HashedWheelTimer |
|---|---|---|
| 실행 스레드 | EventLoop 스레드 | 별도 타이머 스레드 |
| 자료구조 | 우선순위 큐 (PriorityQueue) | 해시 휠 (시간 슬롯 배열) |
| 시간 정확도 | EventLoop의 루프 주기에 의존 | tick 간격(기본 100ms)에 의존 |
| 적합한 용도 | Channel별 소규모 타이머 (idle 감지 등) | 대량의 타임아웃 관리 (수만 개의 연결 타임아웃) |
| 동기화 | 불필요 (같은 EventLoop 스레드) | 필요 (콜백에서 Channel 조작 시 execute() 필요) |
// HashedWheelTimer 사용 예시 — 대량의 연결 타임아웃에 적합
HashedWheelTimer timer = new HashedWheelTimer(
100, TimeUnit.MILLISECONDS, // tick 간격
512 // 휠 크기
);
// 타이머 콜백은 별도 스레드에서 실행됨
timer.newTimeout(timeout -> {
// Channel 조작은 EventLoop로 넘겨야 안전
channel.eventLoop().execute(() -> {
channel.close();
});
}, 30, TimeUnit.SECONDS);
scheduledTaskQueue는 Channel 단위로 간단한 타이머가 필요할 때,HashedWheelTimer는 수만 개의 타임아웃을 효율적으로 관리해야 할 때 쓴다고 기억하면 됩니다.IdleStateHandler가 내부적으로scheduledTaskQueue를 사용하는 대표적인 예입니다.
ioRatio — I/O와 태스크의 시간 배분
EventLoop는 한 루프 안에서 I/O 처리와 태스크 처리를 모두 해야 합니다. 그런데 태스크가 너무 많으면 I/O가 밀리고, I/O만 처리하면 태스크가 밀립니다. ioRatio는 이 둘 사이의 시간 비율을 조절하는 설정 입니다.
기본값: 50
// ioRatio 기본값은 50
EventLoopGroup group = new NioEventLoopGroup();
// ioRatio 변경 (NioEventLoop에 직접 접근)
((NioEventLoop) channel.eventLoop()).setIoRatio(70);
ioRatio가 50이면 이런 의미입니다.
- I/O 처리(
processSelectedKeys)에 10ms가 걸렸다면 - 태스크 처리(
runAllTasks)에도 최대 10ms까지 허용 - 10ms가 지나면 남은 태스크가 있어도 다음 루프로 넘어간다
ioRatio = 50 (기본값)
┌──────────────────────────────────────────┐
│ select │ I/O 처리 (10ms) │ 태스크 (10ms) │
└──────────────────────────────────────────┘
ioRatio = 70
┌──────────────────────────────────────────┐
│ select │ I/O 처리 (10ms) │ 태스크 (~4ms) │
└──────────────────────────────────────────┘
ioRatio = 100 (특수)
┌──────────────────────────────────────────────────┐
│ select │ I/O 처리 │ 태스크 (시간 제한 없이 전부 실행) │
└──────────────────────────────────────────────────┘
ioRatio 계산 공식
태스크 허용 시간 = ioTime × (100 - ioRatio) / ioRatio
ioRatio = 50→ 태스크 허용 시간 = ioTime × 1 (I/O와 동일)ioRatio = 70→ 태스크 허용 시간 = ioTime × 0.43 (I/O의 약 43%)ioRatio = 100→ 시간 제한 없이 모든 태스크 실행 (특수 케이스)
언제 조정하는가?
- I/O 지연이 중요한 경우 (게임 서버, 실시간 메시징):
ioRatio를 70~80으로 올려서 I/O 응답성을 높인다 - ** 태스크가 많고 중요한 경우** (대량의 스케줄 태스크):
ioRatio를 30~40으로 낮춰서 태스크 처리에 더 많은 시간을 할당한다 - ** 대부분의 경우 **: 기본값 50에서 시작하고, 프로파일링 결과를 보고 조정한다
eventLoop.execute()의 의미 — 직접 실행 or 큐잉
eventLoop.execute(task)를 호출했을 때 실제 동작은 ** 호출한 스레드가 누구냐 **에 따라 달라집니다.
// EventLoop.execute()의 내부 동작 (단순화)
public void execute(Runnable task) {
boolean inEventLoop = inEventLoop(); // 현재 스레드가 EventLoop 스레드인가?
addTask(task); // 큐에 추가
if (!inEventLoop) {
// 외부 스레드 → EventLoop가 select()에서 잠들어 있을 수 있으니 깨운다
wakeup(inEventLoop);
}
}
핵심은 inEventLoop() 체크입니다.
| 호출 스레드 | 동작 |
|---|---|
| EventLoop 스레드 자신 | 태스크를 큐에 넣는다. 현재 루프의 runAllTasks()에서 실행된다. |
| ** 외부 스레드** | 태스크를 큐에 넣고, EventLoop를 wakeup()으로 깨운다. |
이 패턴을 네티 내부에서는 매우 자주 볼 수 있습니다. 예를 들어 Channel.write()를 보면:
// AbstractChannel.write() 내부 (단순화)
public void write(Object msg) {
if (eventLoop.inEventLoop()) {
// EventLoop 스레드에서 호출 → 바로 실행
doWrite(msg);
} else {
// 외부 스레드에서 호출 → execute()로 큐잉
eventLoop.execute(() -> doWrite(msg));
}
}
Netty의 많은 API가 내부적으로 이
inEventLoop()체크를 한다. 그래서channel.writeAndFlush()를 어떤 스레드에서 호출해도 안전하다. EventLoop 스레드가 아니면 자동으로 큐잉되기 때문이다.
EventLoop에서 절대 하면 안 되는 것
EventLoop는 단일 스레드입니다. 이 스레드가 멈추면 바인딩된 ** 모든 Channel의 I/O가 멈춥니다 **. 이것이 네티 성능 문제의 가장 흔한 원인입니다.
1. Thread.sleep()
// 절대 하면 안 되는 코드
public class BadHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
Thread.sleep(1000); // EventLoop 스레드가 1초간 멈춤!
ctx.writeAndFlush(response);
}
}
EventLoop에 1,000개의 Channel이 바인딩되어 있다면, sleep(1000) 한 번이 1,000개 Channel 전부의 I/O를 1초간 멈추게 합니다.
2. 동기 DB 쿼리
// 절대 하면 안 되는 코드
public class BadDbHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
// JDBC 쿼리 — 네트워크 왕복 + 쿼리 실행 시간만큼 블로킹
ResultSet rs = statement.executeQuery("SELECT * FROM users WHERE id = ?");
// ...
}
}
3. 동기 HTTP/파일 I/O
// 절대 하면 안 되는 코드
public class BadHttpHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
// 동기 HTTP 호출 — 외부 서버 응답 대기 시간만큼 블로킹
HttpResponse response = httpClient.execute(new HttpGet("https://api.example.com"));
}
}
올바른 해결법: 블로킹 작업을 별도 스레드풀로 위임
// 방법 1: EventExecutorGroup을 파이프라인에 지정
EventExecutorGroup businessGroup = new DefaultEventExecutorGroup(16);
ch.pipeline().addLast(new MyDecoder());
// businessGroup의 스레드에서 실행 — EventLoop를 블로킹하지 않음
ch.pipeline().addLast(businessGroup, new MyBlockingHandler());
ch.pipeline().addLast(new MyEncoder());
// 방법 2: 핸들러 내부에서 직접 별도 스레드풀 사용
public class AsyncDbHandler extends ChannelInboundHandlerAdapter {
private final ExecutorService dbExecutor = Executors.newFixedThreadPool(16);
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
// DB 작업을 별도 스레드풀에서 실행
dbExecutor.submit(() -> {
String result = queryDatabase(msg);
// 결과를 EventLoop 스레드로 돌아가서 write
ctx.channel().eventLoop().execute(() -> {
ctx.writeAndFlush(result);
});
});
}
}
블로킹 여부를 감지하는 방법
네티 4.1에는 BlockingOperationException이라는 예외가 있어서, EventLoop 스레드에서 Future.sync()나 Future.await()를 호출하면 즉시 예외를 던집니다.
// EventLoop 스레드에서 이렇게 하면 BlockingOperationException 발생
ChannelFuture future = channel.writeAndFlush(msg);
future.sync(); // 예외! — EventLoop에서 블로킹 대기 시도
이 예외가 터졌다면, 해당 코드가 EventLoop 스레드에서 실행되고 있다는 뜻이다.
addListener()로 비동기 콜백으로 바꿔야 한다.
// 올바른 방법: 리스너로 비동기 처리
ChannelFuture future = channel.writeAndFlush(msg);
future.addListener((ChannelFutureListener) f -> {
if (f.isSuccess()) {
System.out.println("전송 완료");
} else {
f.cause().printStackTrace();
}
});
전체 흐름 다이어그램
지금까지 살펴본 내용을 하나의 흐름으로 종합하면 이렇습니다.
NioEventLoop 전체 동작
외부 스레드들 EventLoop 스레드
┌────────┐ ┌──────────────────────────┐
│Thread-A│──execute(task)──▶ │ │
│Thread-B│──schedule(task)──▶ │ for (;;) { │
│Thread-C│──execute(task)──▶ │ │
└────────┘ │ ① select() │
│ │ │ │
│ wakeup() ─────────────▶│ ▼ │
│ │ ② processSelectedKeys│
│ │ (I/O 이벤트 처리) │
│ │ │ │
│ │ ▼ │
│ │ ③ runAllTasks() │
└──────▶ taskQueue ────────▶│ (태스크 + 스케줄) │
│ │ │
scheduledTaskQueue──│────────┘ │
│ } │
└──────────────────────────┘
정리
- NioEventLoop의
run()은select()→processSelectedKeys()→runAllTasks()세 단계를 무한 반복한다. - ** 태스크 큐(taskQueue)** 는 MPSC 큐로, 외부 스레드가
execute()로 제출한 작업을 EventLoop 스레드가 꺼내 실행한다. - scheduledTaskQueue 는 EventLoop 내장 스케줄러이며,
HashedWheelTimer와 달리 같은 스레드에서 실행되어 동기화가 불필요하다. - ioRatio(기본 50) 로 I/O 처리와 태스크 처리의 시간 비율을 조정할 수 있다. 100으로 설정하면 시간 제한 없이 모든 태스크를 실행한다.
inEventLoop()체크 가 네티 API 곳곳에 들어가 있어서, 어떤 스레드에서 호출해도 안전하게 동작한다.- **EventLoop 스레드에서 블로킹 호출은 절대 금지 **.
Thread.sleep(), 동기 DB 쿼리, 동기 HTTP 호출 등은 반드시 별도 스레드풀로 위임해야 한다.