EventLoopGroup & EventLoop
서버에 클라이언트 1만 개가 동시에 연결되어 있다고 해보자. 스레드를 1만 개 만드는 건 비현실적이고, 그렇다고 단일 스레드로 전부 처리하면 느릴 텐데 — Netty는 이 사이에서 어떤 구조를 택했을까?
이전 글에서 Netty가 java.nio의 Selector 기반 이벤트 루프를 캡슐화했다는 이야기를 했습니다. 이번에는 그 캡슐화의 결과물인 EventLoop 와 EventLoopGroup 을 자세히 파고들어 봅니다.
EventLoop란
EventLoop는 단일 스레드가 Selector를 돌면서 자신에게 할당된 Channel들의 I/O 이벤트를 처리하는 루프 입니다.
java.nio로 직접 구현하면 아래와 같은 구조가 됩니다.
// java.nio로 직접 구현한 이벤트 루프 (의사 코드)
while (!stopped) {
selector.select(); // 이벤트가 발생할 때까지 대기
Set<SelectionKey> keys = selector.selectedKeys();
for (SelectionKey key : keys) {
handleEvent(key); // 읽기, 쓰기, 연결 수락 등 처리
}
keys.clear();
runPendingTasks(); // 큐에 쌓인 태스크 실행
}
Netty의 EventLoop는 이 패턴을 내부에 감추고, 개발자에게는 "이벤트가 오면 핸들러가 호출된다" 라는 단순한 모델만 노출합니다.
EventLoop가 처리하는 것은 I/O 이벤트만이 아닙니다.channel.eventLoop().execute(task)같은 방식으로 일반 태스크나 스케줄 태스크도 같은 스레드에서 실행할 수 있습니다. I/O와 태스크를 한 스레드에서 처리하기 때문에 별도의 동기화가 필요 없다는 점이 핵심입니다.
EventLoop 내부의 동작 흐름을 정리하면 이렇습니다.
Selector.select()로 I/O 이벤트 대기- 발생한 이벤트를 순회하며 해당 Channel의 파이프라인에 전달
- 태스크 큐에 쌓인 작업이 있으면 실행
- 1번으로 돌아가 반복
┌─────────────────────────────────────────┐
│ EventLoop (단일 스레드) │
│ │
│ ┌───────────┐ ┌───────────────┐ │
│ │ Selector │───▶│ I/O 이벤트 처리 │ │
│ │ .select() │ │ (read/write) │ │
│ └───────────┘ └───────┬───────┘ │
│ │ │
│ ┌───────▼───────┐ │
│ │ 태스크 큐 실행 │ │
│ │ (execute/ │ │
│ │ schedule) │ │
│ └───────┬───────┘ │
│ │ │
│ 다시 select()로 ──────┘
└─────────────────────────────────────────┘
EventLoopGroup: EventLoop의 집합
EventLoopGroup은 여러 개의 EventLoop를 묶어 관리하는 컨테이너 입니다. 새로운 Channel이 등록될 때 그룹 내의 EventLoop 중 하나를 라운드로빈 방식으로 골라 할당합니다.
가장 많이 쓰이는 구현체는 NioEventLoopGroup 으로, 내부적으로 java.nio.channels.Selector를 사용하는 NioEventLoop를 여러 개 생성합니다.
// 스레드 수를 지정하지 않으면 CPU 코어 수 × 2만큼 생성
EventLoopGroup group = new NioEventLoopGroup();
// 스레드 수를 명시적으로 지정
EventLoopGroup group = new NioEventLoopGroup(4);
기본 스레드 수가
CPU 코어 × 2인 이유는, I/O 대기 시간 동안 다른 Channel을 처리할 수 있도록 CPU 코어보다 약간 여유 있게 잡는 것이 일반적으로 효율적이기 때문입니다. 물론 워크로드 특성에 따라 튜닝이 필요합니다.
EventLoopGroup이 Channel을 할당하는 과정을 그림으로 보면 이렇습니다.
NioEventLoopGroup (스레드 4개)
┌──────────────────────────────────────────────────┐
│ │
│ EventLoop-0 EventLoop-1 EventLoop-2 EventLoop-3 │
│ (T-0) (T-1) (T-2) (T-3) │
│ │
└──────────────────────────────────────────────────┘
↑ ↑ ↑
│ │ │
Channel 등록 시 라운드로빈으로 배정
Boss vs Worker 모델
Netty 서버를 구성할 때 ServerBootstrap에 ** 두 개의 EventLoopGroup**을 넘기는 코드를 자주 볼 수 있습니다.
EventLoopGroup bossGroup = new NioEventLoopGroup(1); // 연결 수락 전담
EventLoopGroup workerGroup = new NioEventLoopGroup(); // I/O 처리 전담
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast(new MyHandler());
}
});
이 구조에서 각 그룹이 하는 일은 명확하게 나뉩니다.
| 구분 | Boss Group | Worker Group |
|---|---|---|
| 담당 채널 | ServerSocketChannel | 수락된 SocketChannel |
| 주요 이벤트 | OP_ACCEPT (연결 수락) | OP_READ, OP_WRITE (데이터 I/O) |
| 일반적 스레드 수 | 1 (포트 1개 기준) | CPU 코어 × 2 |
왜 Boss와 Worker를 분리할까?
하나의 EventLoopGroup으로 accept와 I/O를 전부 처리하면 되지 않느냐는 의문이 들 수 있습니다. 분리하는 이유는 ** 역할의 특성이 다르기 때문 **입니다.
- accept는 빈도가 낮지만 빠르게 처리해야 합니다. 새 클라이언트가 연결을 시도했는데 accept가 밀리면, 연결 자체가 지연되거나 타임아웃됩니다.
- I/O 처리는 빈도가 높고 시간이 걸릴 수 있습니다. 데이터를 읽고 파이프라인을 타며 비즈니스 로직까지 수행하다 보면, EventLoop 하나가 상당 시간 점유될 수 있습니다.
만약 이 둘을 같은 EventLoop에 몰아넣으면, I/O 처리가 바쁜 시점에 accept가 뒤로 밀려 새 연결이 지연될 수 있습니다. Boss를 따로 두면 accept 이벤트는 항상 전용 스레드에서 즉시 처리되므로, 클라이언트 입장에서 연결 지연이 발생하지 않습니다.
Boss/Worker 전체 흐름
클라이언트 연결 요청
│
▼
┌─────────────────────┐
│ Boss EventLoop │
│ (accept 전담) │
│ │
│ ServerSocketChannel │
│ OP_ACCEPT 감시 │
└────────┬────────────┘
│
│ accept() → 새 SocketChannel 생성
│
▼
┌──────────────────────────────────────────────┐
│ Worker EventLoopGroup │
│ │
│ EventLoop-0 EventLoop-1 EventLoop-2 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │Channel-A │ │Channel-B │ │Channel-D │ │
│ │Channel-C │ │Channel-E │ │Channel-F │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ 각 EventLoop가 자신의 Channel들의 │
│ OP_READ / OP_WRITE 이벤트를 처리 │
└──────────────────────────────────────────────┘
- 클라이언트가 서버 포트로 TCP 연결을 시도합니다.
- Boss EventLoop 가
OP_ACCEPT이벤트를 감지하고accept()를 호출합니다. - 생성된
SocketChannel을 Worker EventLoopGroup 에 등록합니다. - Worker 내부에서 라운드로빈으로 EventLoop 하나가 선택되고, 해당 Channel이 바인딩됩니다.
- 이후 이 Channel의 모든 I/O는 바인딩된 Worker EventLoop에서 처리됩니다.
Channel-EventLoop 바인딩: 스레드 안전성의 핵심
Netty에서 가장 중요한 설계 원칙 중 하나는 "하나의 Channel은 항상 같은 EventLoop(스레드)에서 처리된다" 는 것입니다.
// Channel이 등록되면 EventLoop가 고정됨
channel.eventLoop(); // 항상 같은 EventLoop 인스턴스를 반환
이 바인딩 덕분에 얻는 이점은 다음과 같습니다.
- ** 동기화가 필요 없습니다.** 한 Channel의 이벤트는 항상 같은 스레드에서 순서대로 처리되므로,
synchronized나Lock같은 동기화 코드가 불필요합니다. - ** 이벤트 순서가 보장됩니다.** 네트워크에서 도착한 순서대로 핸들러에 전달됩니다.
- ** 컨텍스트 스위칭이 최소화됩니다.** 한 Channel과 관련된 모든 작업이 같은 스레드에서 일어나므로, CPU 캐시 활용도도 높아집니다.
만약 Channel이 여러 스레드에서 동시에 처리될 수 있다면, 핸들러 코드 곳곳에
synchronized블록이 필요해질 것입니다. Netty가 Channel-EventLoop 바인딩을 보장하기 때문에, ** 핸들러 코드를 마치 단일 스레드 프로그램처럼 작성할 수 있다 **는 것이 큰 장점입니다.
EventLoop 1:N Channel
하나의 EventLoop는 ** 여러 Channel을 동시에 담당 **합니다. 이것이 Netty가 적은 수의 스레드로 수만 개의 연결을 처리할 수 있는 비결입니다.
EventLoop-0 (단일 스레드)
├── Channel-A
├── Channel-C
├── Channel-G
└── Channel-K
EventLoop-1 (단일 스레드)
├── Channel-B
├── Channel-D
└── Channel-H
EventLoop-2 (단일 스레드)
├── Channel-E
├── Channel-F
└── Channel-I
Selector가 select()를 호출하면 등록된 Channel들 중 ** 지금 I/O 준비가 된 것들만** 반환합니다. EventLoop는 준비된 Channel들만 순회하면서 처리하고, 나머지는 건드리지 않습니다.
이 구조에서 주의할 점이 있습니다.
- ** 핸들러에서 오래 걸리는 작업을 하면 안 됩니다.** 하나의 EventLoop가 Channel-A의 핸들러에서 블로킹되면, 같은 EventLoop에 바인딩된 Channel-C, G, K 모두 그 시간 동안 I/O 처리가 멈춥니다.
- ** 블로킹 작업은 별도 스레드풀로 위임해야 합니다.** DB 조회나 외부 API 호출 같은 블로킹 작업은
EventExecutorGroup을 따로 만들어서 처리합니다.
// 블로킹 작업을 별도 스레드풀에서 실행하는 방법
EventExecutorGroup businessGroup = new DefaultEventExecutorGroup(16);
ch.pipeline().addLast(new MyDecoder());
// businessGroup 스레드에서 실행됨 — EventLoop를 블로킹하지 않음
ch.pipeline().addLast(businessGroup, new MyBusinessHandler());
ch.pipeline().addLast(new MyEncoder());
전체 구조 다이어그램
지금까지 살펴본 Boss/Worker 모델, Channel 할당, EventLoop 바인딩을 하나의 그림으로 종합해 보겠습니다.
클라이언트 연결 요청들
┌───┬───┬───┬───┬───┐
│ A │ B │ C │ D │ E │ ...
└─┬─┴─┬─┴─┬─┴─┬─┴─┬─┘
│ │ │ │ │
└───┴───┼───┴───┘
│
▼
┌────────────────────────────────┐
│ Boss EventLoopGroup (1) │
│ ┌──────────────────────────┐ │
│ │ Boss EventLoop (T-0) │ │
│ │ │ │
│ │ ServerSocketChannel │ │
│ │ [OP_ACCEPT] │ │
│ └────────────┬─────────────┘ │
└───────────────┼────────────────┘
│
accept() 후 Worker에 Channel 등록
│
┌───────────────▼────────────────┐
│ Worker EventLoopGroup (N) │
│ │
│ EventLoop-0 EventLoop-1 │
│ ┌──────────┐ ┌──────────┐ │
│ │ Ch-A │ │ Ch-B │ │
│ │ Ch-C │ │ Ch-D │ │
│ │ Ch-E │ │ │ │
│ └──────────┘ └──────────┘ │
│ │
│ Channel ↔ EventLoop 1:1 바인딩 │
│ EventLoop : Channel = 1 : N │
└────────────────────────────────┘
정리
- EventLoop 는 단일 스레드 + Selector 기반 이벤트 루프로, I/O 이벤트와 태스크를 한 스레드에서 처리합니다.
- EventLoopGroup 은 EventLoop의 집합이며, 새 Channel을 라운드로빈으로 할당합니다.
- Boss Group 은
accept전담, Worker Group 은 실제 데이터 I/O 전담으로 분리하여 연결 수락이 I/O 부하에 밀리지 않도록 합니다. - **Channel-EventLoop 바인딩 **(1:1)이 스레드 안전성을 보장하고, 핸들러 코드에서 동기화를 없애 줍니다.
- 하나의 EventLoop가 ** 여러 Channel을 담당 **(1:N)하기 때문에, 핸들러에서 블로킹 작업은 반드시 별도 스레드풀로 위임해야 합니다.
다음 글에서는 Channel에 연결된 이벤트 처리 경로인 ChannelPipeline 과 ChannelHandler 를 살펴보겠습니다.