서버에 클라이언트 1만 개가 동시에 연결되어 있다고 해보자. 스레드를 1만 개 만드는 건 비현실적이고, 그렇다고 단일 스레드로 전부 처리하면 느릴 텐데 — Netty는 이 사이에서 어떤 구조를 택했을까?

이전 글에서 Netty가 java.nio의 Selector 기반 이벤트 루프를 캡슐화했다는 이야기를 했습니다. 이번에는 그 캡슐화의 결과물인 EventLoop 와 EventLoopGroup 을 자세히 파고들어 봅니다.


EventLoop란

EventLoop는 단일 스레드가 Selector를 돌면서 자신에게 할당된 Channel들의 I/O 이벤트를 처리하는 루프 입니다.

java.nio로 직접 구현하면 아래와 같은 구조가 됩니다.

JAVA
// 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 내부의 동작 흐름을 정리하면 이렇습니다.

  1. Selector.select()로 I/O 이벤트 대기
  2. 발생한 이벤트를 순회하며 해당 Channel의 파이프라인에 전달
  3. 태스크 큐에 쌓인 작업이 있으면 실행
  4. 1번으로 돌아가 반복
PLAINTEXT
┌─────────────────────────────────────────┐
│              EventLoop (단일 스레드)       │
│                                         │
│   ┌───────────┐    ┌───────────────┐    │
│   │ Selector  │───▶│ I/O 이벤트 처리 │    │
│   │ .select() │    │ (read/write)  │    │
│   └───────────┘    └───────┬───────┘    │
│                            │            │
│                    ┌───────▼───────┐    │
│                    │  태스크 큐 실행  │    │
│                    │ (execute/      │    │
│                    │  schedule)     │    │
│                    └───────┬───────┘    │
│                            │            │
│                     다시 select()로 ──────┘
└─────────────────────────────────────────┘

EventLoopGroup: EventLoop의 집합

EventLoopGroup은 여러 개의 EventLoop를 묶어 관리하는 컨테이너 입니다. 새로운 Channel이 등록될 때 그룹 내의 EventLoop 중 하나를 라운드로빈 방식으로 골라 할당합니다.

가장 많이 쓰이는 구현체는 NioEventLoopGroup 으로, 내부적으로 java.nio.channels.Selector를 사용하는 NioEventLoop를 여러 개 생성합니다.

JAVA
// 스레드 수를 지정하지 않으면 CPU 코어 수 × 2만큼 생성
EventLoopGroup group = new NioEventLoopGroup();

// 스레드 수를 명시적으로 지정
EventLoopGroup group = new NioEventLoopGroup(4);

기본 스레드 수가 CPU 코어 × 2인 이유는, I/O 대기 시간 동안 다른 Channel을 처리할 수 있도록 CPU 코어보다 약간 여유 있게 잡는 것이 일반적으로 효율적이기 때문입니다. 물론 워크로드 특성에 따라 튜닝이 필요합니다.

EventLoopGroup이 Channel을 할당하는 과정을 그림으로 보면 이렇습니다.

PLAINTEXT
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**을 넘기는 코드를 자주 볼 수 있습니다.

JAVA
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 GroupWorker 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 전체 흐름

PLAINTEXT
클라이언트 연결 요청


┌─────────────────────┐
│   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 이벤트를 처리              │
└──────────────────────────────────────────────┘
  1. 클라이언트가 서버 포트로 TCP 연결을 시도합니다.
  2. Boss EventLoop 가 OP_ACCEPT 이벤트를 감지하고 accept()를 호출합니다.
  3. 생성된 SocketChannelWorker EventLoopGroup 에 등록합니다.
  4. Worker 내부에서 라운드로빈으로 EventLoop 하나가 선택되고, 해당 Channel이 바인딩됩니다.
  5. 이후 이 Channel의 모든 I/O는 바인딩된 Worker EventLoop에서 처리됩니다.

Channel-EventLoop 바인딩: 스레드 안전성의 핵심

Netty에서 가장 중요한 설계 원칙 중 하나는 "하나의 Channel은 항상 같은 EventLoop(스레드)에서 처리된다" 는 것입니다.

JAVA
// Channel이 등록되면 EventLoop가 고정됨
channel.eventLoop();  // 항상 같은 EventLoop 인스턴스를 반환

이 바인딩 덕분에 얻는 이점은 다음과 같습니다.

  • ** 동기화가 필요 없습니다.** 한 Channel의 이벤트는 항상 같은 스레드에서 순서대로 처리되므로, synchronizedLock 같은 동기화 코드가 불필요합니다.
  • ** 이벤트 순서가 보장됩니다.** 네트워크에서 도착한 순서대로 핸들러에 전달됩니다.
  • ** 컨텍스트 스위칭이 최소화됩니다.** 한 Channel과 관련된 모든 작업이 같은 스레드에서 일어나므로, CPU 캐시 활용도도 높아집니다.

만약 Channel이 여러 스레드에서 동시에 처리될 수 있다면, 핸들러 코드 곳곳에 synchronized 블록이 필요해질 것입니다. Netty가 Channel-EventLoop 바인딩을 보장하기 때문에, ** 핸들러 코드를 마치 단일 스레드 프로그램처럼 작성할 수 있다 **는 것이 큰 장점입니다.


EventLoop 1:N Channel

하나의 EventLoop는 ** 여러 Channel을 동시에 담당 **합니다. 이것이 Netty가 적은 수의 스레드로 수만 개의 연결을 처리할 수 있는 비결입니다.

PLAINTEXT
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을 따로 만들어서 처리합니다.
JAVA
// 블로킹 작업을 별도 스레드풀에서 실행하는 방법
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 바인딩을 하나의 그림으로 종합해 보겠습니다.

PLAINTEXT
                      클라이언트 연결 요청들
                     ┌───┬───┬───┬───┬───┐
                     │ 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 를 살펴보겠습니다.

댓글 로딩 중...