Native Transport — epoll & kqueue
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을 반환하면서 발생하는 객체 할당과 복사 비용
// 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로 호출하는 구현체 입니다.
| 플랫폼 | EventLoopGroup | Channel | 내부 메커니즘 |
|---|---|---|---|
| Linux | EpollEventLoopGroup | EpollServerSocketChannel | epoll 직접 호출 |
| macOS/BSD | KQueueEventLoopGroup | KQueueServerSocketChannel | kqueue 직접 호출 |
| 범용 (JDK) | NioEventLoopGroup | NioServerSocketChannel | java.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두 개의 시스템 콜로 동작
┌─────────────────────────────────────────────────────┐
│ 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 호출로 직접 실행하고, 결과를 네이티브 배열로 받아옵니다.
// 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: 새 데이터가 도착한 시점에만 한 번 알린다
Level-triggered (JDK NIO):
데이터 도착 → 알림 → 일부 읽음 → 알림 → 나머지 읽음 → 알림 (데이터 없으면 중단)
Edge-triggered (Native Transport):
데이터 도착 → 알림 → 전부 읽음 (EAGAIN까지) → 끝
ET 모드에서는 불필요한 wake-up이 사라지므로, 연결 수가 많을수록 차이가 벌어집니다.
3. SO_REUSEPORT
Linux 3.9+에서 지원하는 소켓 옵션으로, ** 같은 포트에 여러 소켓을 바인딩 **할 수 있습니다.
// 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단계: 의존성 추가
<!-- 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>
// 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단계: 코드 변경
변경 포인트는 딱 두 군데입니다 — EventLoopGroup과 Channel 클래스.
// 변경 전 — 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에 따라 자동으로 전환하는 유틸을 만들어두면 편합니다.
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;
}
}
// 사용 예시 — 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 NIO | Native (epoll) | 차이 |
|---|---|---|---|
| 초당 요청 처리량 (RPS) | 100% (기준) | 20~30% 향상 | |
| 평균 지연 시간 | 100% (기준) | 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 요청 자체를 커널에 제출하고, 완료되면 결과를 받아오는 구조입니다.
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를 개발 중입니다.
<!-- 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>
// 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 NIO | Native (epoll/kqueue) | io_uring |
|---|---|---|---|
| 플랫폼 | 전체 | Linux / macOS | Linux 5.1+ |
| 트리거 모드 | Level-triggered | Edge-triggered | 해당 없음 (완전 비동기) |
| 시스템 콜 | JNI 다중 래핑 | 직접 JNI 1회 | 링 버퍼 (시스템 콜 최소) |
| SO_REUSEPORT | 불가 | 가능 | 가능 |
| 안정성 | 매우 높음 | 높음 | 인큐베이터 |
| 전환 비용 | - | 낮음 (클래스 2개 교체) | 낮음 |
기억할 포인트를 요약하면 이렇습니다.
- JDK NIO는 범용 추상화의 대가로 OS 고급 기능과 성능을 포기한다
- Native Transport는 ** 클래스 두 개 교체 **만으로 20~30% 성능 향상을 기대할 수 있다
- edge-triggered 모드와 SO_REUSEPORT가 성능 차이의 핵심이다
- 런타임 자동 전환 유틸을 만들어두면 개발(macOS) ↔ 프로덕션(Linux) 전환이 매끄럽다
- io_uring은 미래지만, 지금은 epoll이 안정적인 선택이다