Netty의 NioEventLoopGroup이 충분히 빠르다고 느꼈는데 — 같은 코드에서 클래스 두 개만 바꿨더니 처리량이 20~30% 올라갔다면, 그 차이는 어디서 오는 걸까?

이전 글에서 EventLoopGroup과 EventLoop의 구조를 살펴봤습니다. 이번에는 JDK NIO의 한계 를 짚고, Netty가 이를 어떻게 Native Transport 로 극복하는지 정리해 봅니다.


JDK NIO의 한계

JDK의 java.nio.channels.Selector는 OS의 I/O 다중화(select, epoll, kqueue)를 JNI로 래핑한 범용 추상화 입니다.

범용이라는 건 장점이자 한계이기도 합니다. 플랫폼 독립적으로 설계되다 보니, OS가 제공하는 고급 기능을 활용하지 못합니다.

구체적인 한계들

  • **JNI 호출 오버헤드 **: Selector.select() 한 번에도 여러 차례 JNI 경계를 넘나든다. 이벤트가 빈번할수록 누적 비용이 커진다
  • **level-triggered만 지원 **: JDK의 epoll 래퍼는 level-triggered 모드만 사용한다. 조건이 유지되는 동안 매번 이벤트가 발생하므로, 고부하 환경에서 불필요한 wake-up이 많다
  • **OS 고급 옵션 접근 불가 **: SO_REUSEPORT, TCP_FASTOPEN 같은 소켓 옵션을 JDK API로는 설정할 수 없다
  • **selectedKeys 복사 비용 **: Selector.selectedKeys()가 매번 Set을 반환하면서 발생하는 객체 할당과 복사 비용
JAVA
// JDK NIO의 select 루프 — 매 반복마다 JNI 호출 + Set 복사
while (!stopped) {
    int ready = selector.select();          // JNI 호출 (epoll_wait 래핑)
    Set<SelectionKey> keys = selector.selectedKeys(); // Set 객체 할당
    for (SelectionKey key : keys) {
        handleEvent(key);
    }
    keys.clear();                           // GC 부담
}

한마디로, JDK NIO는 "모든 OS에서 동작하는 것"에 초점을 맞추다 보니 각 OS의 강점을 살리지 못한다.


Netty Native Transport란

Native Transport는 JDK의 NIO 래퍼를 우회하고, OS의 I/O 시스템 콜을 Netty가 직접 JNI로 호출하는 구현체 입니다.

플랫폼EventLoopGroupChannel내부 메커니즘
LinuxEpollEventLoopGroupEpollServerSocketChannelepoll 직접 호출
macOS/BSDKQueueEventLoopGroupKQueueServerSocketChannelkqueue 직접 호출
범용 (JDK)NioEventLoopGroupNioServerSocketChanneljava.nio.Selector

핵심은 JDK를 거치지 않고 OS에 직접 접근 한다는 것입니다. JDK가 범용으로 깎아낸 부분을 Netty가 플랫폼별로 최적화하여 되살린 셈입니다.

epoll과 kqueue — 간단 비교

둘 다 I/O 다중화 메커니즘이지만, 속한 OS가 다릅니다.

  • epoll: Linux 2.6+에서 제공. epoll_create, epoll_ctl, epoll_wait 세 개의 시스템 콜로 동작
  • kqueue: FreeBSD/macOS에서 제공. kqueue, kevent 두 개의 시스템 콜로 동작
PLAINTEXT
┌─────────────────────────────────────────────────────┐
│                    Netty Application                 │
├──────────────┬──────────────┬───────────────────────┤
│  NIO (JDK)   │  epoll       │  kqueue               │
│  Selector    │  (Linux)     │  (macOS/BSD)           │
├──────────────┼──────────────┼───────────────────────┤
│  JNI → OS    │  직접 JNI    │  직접 JNI              │
│  (범용 래핑)  │  (최적화)    │  (최적화)              │
└──────────────┴──────────────┴───────────────────────┘

왜 네이티브가 더 빠른가

Native Transport가 빠른 이유를 세 가지로 나눌 수 있습니다.

1. JNI 호출 감소

JDK NIO는 Selector.select() 내부에서 여러 번 JNI 경계를 넘습니다. Netty의 Native Transport는 epoll_wait를 한 번의 JNI 호출로 직접 실행하고, 결과를 네이티브 배열로 받아옵니다.

JAVA
// Netty의 epoll 구현 (개념적 의사 코드)
// JNI 호출 1번으로 준비된 이벤트를 네이티브 배열에 직접 채운다
int ready = Native.epollWait(epollFd, events, timerFd, timeoutMillis);
// events는 JNI 쪽에서 직접 채운 배열 — Set 할당/복사 없음
for (int i = 0; i < ready; i++) {
    processEvent(events[i]);
}

2. Edge-Triggered 모드

JDK의 epoll 래퍼는 level-triggered(LT) 모드만 사용합니다. Netty Native Transport는 edge-triggered(ET) 모드를 사용합니다.

  • Level-triggered: 읽을 데이터가 있는 동안 epoll_wait가 매번 해당 fd를 반환한다
  • Edge-triggered: 새 데이터가 도착한 시점에만 한 번 알린다
PLAINTEXT
Level-triggered (JDK NIO):
  데이터 도착 → 알림 → 일부 읽음 → 알림 → 나머지 읽음 → 알림 (데이터 없으면 중단)

Edge-triggered (Native Transport):
  데이터 도착 → 알림 → 전부 읽음 (EAGAIN까지) → 끝

ET 모드에서는 불필요한 wake-up이 사라지므로, 연결 수가 많을수록 차이가 벌어집니다.

3. SO_REUSEPORT

Linux 3.9+에서 지원하는 소켓 옵션으로, ** 같은 포트에 여러 소켓을 바인딩 **할 수 있습니다.

JAVA
// SO_REUSEPORT 활성화 — 커널이 들어오는 연결을 여러 스레드에 분산
ServerBootstrap b = new ServerBootstrap();
b.group(new EpollEventLoopGroup())
 .channel(EpollServerSocketChannel.class)
 .option(EpollChannelOption.SO_REUSEPORT, true);  // Native Transport에서만 가능

SO_REUSEPORT 없이는 Boss 스레드 하나가 모든 accept()를 처리하고 Worker에게 분배합니다. SO_REUSEPORT를 켜면 ** 커널이 직접** 들어오는 연결을 여러 소켓(스레드)에 분산시켜서, accept 자체가 병목이 되는 상황을 해소합니다.

이 세 가지가 합쳐지면서 실제 환경에서 체감할 수 있는 성능 차이가 발생한다. 특히 동시 연결 수가 수만~수십만 수준일 때 차이가 극대화된다.


전환 방법

JDK NIO에서 Native Transport로 전환하는 과정은 놀라울 만큼 간단합니다.

1단계: 의존성 추가

XML
<!-- Maven — Linux epoll -->
<dependency>
    <groupId>io.netty</groupId>
    <artifactId>netty-transport-native-epoll</artifactId>
    <version>${netty.version}</version>
    <classifier>linux-x86_64</classifier>  <!-- 플랫폼에 맞는 classifier -->
</dependency>

<!-- Maven — macOS kqueue -->
<dependency>
    <groupId>io.netty</groupId>
    <artifactId>netty-transport-native-kqueue</artifactId>
    <version>${netty.version}</version>
    <classifier>osx-x86_64</classifier>
</dependency>
GROOVY
// Gradle — Linux epoll
implementation "io.netty:netty-transport-native-epoll:${nettyVersion}:linux-x86_64"

// Gradle — macOS kqueue
implementation "io.netty:netty-transport-native-kqueue:${nettyVersion}:osx-x86_64"

2단계: 코드 변경

변경 포인트는 딱 두 군데입니다 — EventLoopGroupChannel 클래스.

JAVA
// 변경 전 — JDK NIO
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();

ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
 .channel(NioServerSocketChannel.class);

// 변경 후 — Linux Native Transport
EventLoopGroup bossGroup = new EpollEventLoopGroup(1);
EventLoopGroup workerGroup = new EpollEventLoopGroup();

ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
 .channel(EpollServerSocketChannel.class);

런타임 자동 전환 패턴

실무에서는 OS에 따라 자동으로 전환하는 유틸을 만들어두면 편합니다.

JAVA
public class TransportUtil {

    // 현재 OS에 따라 적절한 EventLoopGroup을 반환
    public static EventLoopGroup createEventLoopGroup(int nThreads) {
        if (Epoll.isAvailable()) {
            return new EpollEventLoopGroup(nThreads);
        } else if (KQueue.isAvailable()) {
            return new KQueueEventLoopGroup(nThreads);
        }
        return new NioEventLoopGroup(nThreads);
    }

    // 현재 OS에 따라 적절한 ServerSocketChannel 클래스를 반환
    public static Class<? extends ServerSocketChannel> serverSocketChannelClass() {
        if (Epoll.isAvailable()) {
            return EpollServerSocketChannel.class;
        } else if (KQueue.isAvailable()) {
            return KQueueServerSocketChannel.class;
        }
        return NioServerSocketChannel.class;
    }
}
JAVA
// 사용 예시 — OS와 무관하게 최적의 Transport가 선택됨
EventLoopGroup bossGroup = TransportUtil.createEventLoopGroup(1);
EventLoopGroup workerGroup = TransportUtil.createEventLoopGroup(0);

ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
 .channel(TransportUtil.serverSocketChannelClass());

ChannelHandler나 비즈니스 로직은 전혀 건드릴 필요가 없다. Netty의 추상화 덕분에 Transport 계층만 교체하면 나머지는 그대로 동작한다.


성능 차이

벤치마크 수치

Netty 공식 벤치마크와 커뮤니티 테스트에서 보고된 대략적인 수치입니다.

항목JDK NIONative (epoll)차이
초당 요청 처리량 (RPS)100% (기준)120130%20~30% 향상
평균 지연 시간100% (기준)8090%10~20% 감소
GC 빈도높음낮음selectedKeys Set 할당 제거 효과
컨텍스트 스위치많음적음ET 모드로 불필요한 wake-up 감소

수치는 환경(커널 버전, 동시 연결 수, 트래픽 패턴)에 따라 크게 달라질 수 있습니다.

언제 네이티브를 써야 하는가?

** 쓰는 것이 유리한 경우:**

  • 동시 연결 수가 수만 이상인 고성능 서버
  • 지연 시간에 민감한 실시간 시스템 (게임 서버, 금융 시스템)
  • 리눅스 프로덕션 환경에서 최대 성능이 필요할 때
  • SO_REUSEPORT, TCP_FASTOPEN 같은 OS 기능이 필요할 때

** 굳이 필요 없는 경우:**

  • 동시 연결 수가 수백~수천 수준이고, 충분한 처리량이 나올 때
  • 멀티 플랫폼(Windows 포함)을 지원해야 하고, 분기 처리가 부담될 때
  • 프로토타이핑이나 테스트 단계

판단 기준을 한 줄로 요약하면 — "JDK NIO로 성능 요구사항을 충족하지 못할 때" 네이티브를 고려하면 된다. 전환 비용이 낮으니 병목이 확인되면 바로 시도해볼 만하다.


io_uring — 차세대 비동기 I/O

Linux 5.1+에서 도입된 io_uring 은 epoll의 다음 세대로 불리는 비동기 I/O 인터페이스입니다.

epoll과의 차이

epoll은 "이벤트가 준비됐다"고 알려주면 애플리케이션이 직접 read/write 시스템 콜을 호출합니다. io_uring은 I/O 요청 자체를 커널에 제출하고, 완료되면 결과를 받아오는 구조입니다.

PLAINTEXT
epoll 방식:
  앱 → epoll_wait (시스템 콜) → "fd 준비됨" → read (시스템 콜) → 데이터

io_uring 방식:
  앱 → SQ에 read 요청 기록 (시스템 콜 없음) → 커널이 처리 → CQ에서 결과 수확 (시스템 콜 없음)

핵심은 Submission Queue(SQ) 와 Completion Queue(CQ) 라는 링 버퍼를 커널과 유저 공간이 공유한다는 것입니다. 시스템 콜 없이 메모리 맵된 링 버퍼에 읽고 쓰는 것만으로 I/O가 가능합니다.

Netty의 io_uring 지원

Netty는 incubator 프로젝트로 io_uring Transport를 개발 중입니다.

XML
<!-- io_uring 의존성 (인큐베이터) -->
<dependency>
    <groupId>io.netty.incubator</groupId>
    <artifactId>netty-incubator-transport-native-io_uring</artifactId>
    <version>${iouring.version}</version>
    <classifier>linux-x86_64</classifier>
</dependency>
JAVA
// io_uring 사용 예시
EventLoopGroup group = new IOUringEventLoopGroup();

ServerBootstrap b = new ServerBootstrap();
b.group(group)
 .channel(IOUringServerSocketChannel.class)
 .childHandler(new MyChannelInitializer());

io_uring의 현재 상태

  • ** 장점 **: 시스템 콜 오버헤드 대폭 감소, 배치 I/O 처리에 강함
  • ** 주의점 **: 아직 incubator 단계로 프로덕션 검증이 epoll 대비 부족하다. 커널 5.1+ 필요
  • ** 전망 **: 장기적으로는 epoll을 대체할 가능성이 높지만, 현 시점에서는 epoll Native Transport가 안정적인 선택

정리

구분JDK NIONative (epoll/kqueue)io_uring
플랫폼전체Linux / macOSLinux 5.1+
트리거 모드Level-triggeredEdge-triggered해당 없음 (완전 비동기)
시스템 콜JNI 다중 래핑직접 JNI 1회링 버퍼 (시스템 콜 최소)
SO_REUSEPORT불가가능가능
안정성매우 높음높음인큐베이터
전환 비용-낮음 (클래스 2개 교체)낮음

기억할 포인트를 요약하면 이렇습니다.

  • JDK NIO는 범용 추상화의 대가로 OS 고급 기능과 성능을 포기한다
  • Native Transport는 ** 클래스 두 개 교체 **만으로 20~30% 성능 향상을 기대할 수 있다
  • edge-triggered 모드와 SO_REUSEPORT가 성능 차이의 핵심이다
  • 런타임 자동 전환 유틸을 만들어두면 개발(macOS) ↔ 프로덕션(Linux) 전환이 매끄럽다
  • io_uring은 미래지만, 지금은 epoll이 안정적인 선택이다
댓글 로딩 중...