Java NIO Selector 내부 구조 — epoll 위에 세운 자바 비동기 IO
한 스레드가 수천 개의 소켓을 들고 있을 수 있는 이유는 무엇일까요. Netty, Reactor Netty, Spring WebFlux, 심지어 JDK 13 이후의 평범한
java.net.Socket까지, JVM 위 거의 모든 IO가 결국 같은 한 가지 기둥 위에 서 있습니다. 그 기둥의 이름은Selector입니다. 이 글은 자바SelectorAPI가 어떤 추상화를 제공하는지, 그 아래에서EPollSelectorImpl이 리눅스epoll시스템 콜을 어떻게 부르는지, 그리고 가상 스레드 도입 이후 그 관계가 어떻게 바뀌었는지를 OpenJDK 소스 기준으로 정리합니다.
대상 독자는 Netty/Reactor를 어렴풋이 알고 있지만 Selector.select() 호출 뒤에서 무슨 일이 벌어지는지 직접 본 적은 없는 백엔드 개발자입니다.
NIO가 풀려는 문제
자바 1.0의 java.io는 블로킹 스트림 모델입니다. InputStream.read()는 OS 버퍼에 바이트가 도착할 때까지 호출 스레드를 통째로 멈춥니다. 한 연결을 다루려면 한 스레드를 통째로 묶어야 한다는 뜻입니다. 동시 연결 N개를 받으면 스레드 N개가 필요하고, 각 스레드는 1MB 안팎의 스택과 컨텍스트 스위치 비용을 가져갑니다. 이 모델로는 동시 연결 1만 개를 넘기기 어렵습니다. 1999년 댄 케글의 표현을 빌리면 "C10K problem"입니다.
해법은 두 갈래로 갈렸습니다.
- 이벤트 다중화(Event Multiplexing): 한 스레드가 여러 파일 디스크립터를 동시에 감시하다가, OS로부터 "이 fd가 읽을 준비 됐다"는 알림을 받아 차례로 처리합니다. POSIX의
select,poll, 리눅스의epoll, BSD의kqueue가 이 경로입니다. - 사용자 수준 스레드(Green/Virtual Thread): 블로킹 IO API 형식은 유지하되, 호출 스레드를 OS 스레드와 분리해 가볍게 다룹니다. Go의 goroutine, Project Loom의 가상 스레드가 이 경로입니다.
자바 NIO(JSR-51, 2002)는 첫 번째 경로를 택했습니다. 이후 20년 동안 JVM 위 고성능 네트워킹은 거의 전부 NIO 위에 세워졌고, JDK 21에서 가상 스레드가 GA되면서 두 경로가 같은 런타임에 공존하게 되었습니다. 이 글의 마지막에서 두 모델이 어떻게 만나는지 다시 다룹니다.
NIO 세 축: Channel, Buffer, Selector
java.nio.channels 패키지의 핵심 추상화는 셋입니다.
Channel: OS 파일 디스크립터를 감싼 양방향 IO 끝점.SocketChannel,ServerSocketChannel,DatagramChannel,FileChannel,Pipe.SourceChannel/SinkChannel이 있습니다.Buffer: 바이트(또는 다른 원시 타입) 컬렉션을 담는 고정 크기 컨테이너.position,limit,capacity로 표현되는 뷰 모델 위에서flip(),clear(),compact()로 전환합니다. 가장 자주 쓰이는 것은ByteBuffer입니다.Selector: 여러SelectableChannel을 동시에 감시하다가, 어느 채널이 어떤 IO 작업에 준비됐는지를 한 번의 시스템 콜로 알려주는 다중화기.
이 글의 주인공은 셋째 축인 Selector이지만, 채널과 버퍼를 무시하고서는 동작을 설명할 수 없습니다. Selector가 "준비됐다"고 알려주면, 그 채널에서 실제로 바이트를 옮기는 것은 Channel.read(ByteBuffer) 또는 Channel.write(ByteBuffer)입니다.
SelectableChannel과 SelectionKey
Selector에 등록할 수 있는 채널은 SelectableChannel의 하위 타입입니다. 등록 절차는 두 단계입니다.
SocketChannel ch = SocketChannel.open();
ch.configureBlocking(false); // 1. 비블로킹 모드 필수
SelectionKey key = ch.register(selector, SelectionKey.OP_READ); // 2. 관심 집합 지정
블로킹 모드인 채널은 register 시 IllegalBlockingModeException을 던집니다. Selector는 비블로킹 채널만을 다룹니다. 이는 채널이 "준비됐다"는 알림 직후 즉시 read를 호출해도 그 호출이 절대 스레드를 멈춰서는 안 된다는 약속 때문입니다.
등록의 결과로 받는 SelectionKey는 채널·셀렉터·관심 집합·준비 집합·첨부 객체를 묶는 핸들입니다. OpenJDK 소스의 상수 정의는 다음과 같습니다.
public static final int OP_READ = 1 << 0; // 1
public static final int OP_WRITE = 1 << 2; // 4
public static final int OP_CONNECT = 1 << 3; // 8
public static final int OP_ACCEPT = 1 << 4; // 16
비트 마스크입니다. 여러 관심을 합칠 때는 OR로 묶습니다(OP_READ | OP_WRITE). 채널 종류마다 의미 있는 비트가 정해져 있습니다.
| 채널 | OP_READ | OP_WRITE | OP_CONNECT | OP_ACCEPT |
|---|---|---|---|---|
| ServerSocketChannel | - | - | - | O |
| SocketChannel | O | O | O | - |
| DatagramChannel | O | O | - | - |
| Pipe.SourceChannel | O | - | - | - |
| Pipe.SinkChannel | - | O | - | - |
SelectionKey는 두 개의 비트 마스크를 들고 있습니다.
interestOps: 다음 선택 동작에서 어떤 IO 작업이 준비됐는지 검사할지 명세. 사용자가interestOps(int)로 바꿉니다.readyOps: 가장 최근 선택 동작에서 OS가 "준비됐다"고 보고한 작업의 집합. 사용자는 읽기만 가능합니다.
여기에 attach(Object) / attachment()가 더해집니다. 채널마다 별도의 상태(파싱 중인 프로토콜 위치, 응답 버퍼, 사용자 ID 등)를 묶어 두는 슬롯입니다. 한 번에 하나의 객체만 첨부할 수 있고, 새 객체를 첨부하면 이전 것은 떨어져 나갑니다.
Selector의 세 가지 키 집합
자바독에 나오는 Selector의 모델은 명확합니다. 셋의 키 집합을 유지합니다.
- Key set (
keys()): 현재 등록된 모든 키. 직접 수정 불가. - Selected-key set (
selectedKeys()): 가장 최근 선택 동작에서 적어도 하나의 관심 작업이 준비됐다고 OS가 보고한 키들의 부분집합. 사용자가 제거할 수 있지만, 직접 추가는 불가. - Cancelled-key set:
key.cancel()이 호출됐지만 아직 등록 해제되지 않은 키들의 부분집합. 외부 접근 불가.
선택 동작(select, selectNow, select(timeout)) 한 번은 세 단계입니다.
- 취소된 키들을 모든 집합에서 제거하고, 대응 채널을 OS 등록에서 해제합니다.
- OS에 "준비된 채널이 있느냐"를 묻습니다. 응답으로 받은 각 채널에 대해, 그 키가 selected-key set에 없으면 추가하고
readyOps를 새로 설정합니다. 이미 있으면readyOps를 누적(|=)합니다. - 2단계 중간에 새로 취소된 키가 있으면 다시 1단계처럼 처리합니다.
여기서 중요한 미묘함은 selected-key set의 키는 사용자가 명시적으로 제거해야 한다는 점입니다. 다음 select 호출은 이전에 남아 있던 키를 자동으로 비워주지 않습니다. 처리한 키를 매번 iterator.remove()로 빼지 않으면, 다음 라운드의 동일 채널 이벤트가 같은 키 자리에 누적되어 readyOps만 갱신될 뿐 새로 들어왔는지 누적된 것인지 구분할 수 없게 됩니다. 표준 이벤트 루프 패턴이 다음과 같은 이유입니다.
while (running) {
int n = selector.select();
if (n == 0) continue;
Iterator<SelectionKey> it = selector.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey k = it.next();
it.remove(); // 반드시 제거
if (k.isAcceptable()) accept(k);
else if (k.isReadable()) read(k);
else if (k.isWritable()) write(k);
}
}
Selector 구현체 계층
API의 Selector는 추상 클래스이고, 실제 구현은 플랫폼별로 달라집니다. JDK가 따라가는 사슬은 다음과 같습니다.
Selector (abstract)
└ AbstractSelector
└ SelectorImpl
├ EPollSelectorImpl (Linux)
├ KQueueSelectorImpl (macOS, BSD)
└ WindowsSelectorImpl (Windows, 전통적인 select 기반)
어느 구현이 선택될지는 SelectorProvider.provider()가 정합니다. 기본 전략은 OS를 보고 결정하고, 시스템 프로퍼티 java.nio.channels.spi.SelectorProvider로 강제 교체할 수 있습니다. 이 글에서는 가장 흔한 환경인 리눅스의 EPollSelectorImpl을 중심으로 봅니다.
EPollSelectorImpl 핵심 필드
OpenJDK master 브랜치 기준 src/java.base/linux/classes/sun/nio/ch/EPollSelectorImpl.java의 주요 필드는 다음과 같습니다.
private final int epfd; // epoll_create1 결과
private final long pollArrayAddress; // epoll_event 배열의 네이티브 주소
private final EventFD eventfd; // wakeup 신호용 eventfd
private final Map<Integer, SelectionKeyImpl> fdToKey; // fd → 키 역인덱스
private final Deque<SelectionKeyImpl> updateKeys; // 관심 변경 큐
다섯 줄로 구조가 거의 다 드러납니다.
epfd: 인스턴스 하나당 한 개의 epoll 인스턴스.epoll_create1(EPOLL_CLOEXEC)의 반환값입니다.pollArrayAddress:epoll_wait가 채울struct epoll_event배열의 시작 주소. JNI 경계를 줄이기 위해 미리 잡아둔 오프힙 메모리입니다.eventfd:wakeup()한 번에 셀렉터를 깨우기 위한 보조 fd. 자세한 동작은 뒤에서 봅니다.fdToKey: epoll이 이벤트를 줄 때 받는 것은 fd 정수뿐입니다. 그 fd가 어떤SelectionKey에 묶여 있는지를 역추적하기 위한 맵.updateKeys:key.interestOps(x)호출은 즉시epoll_ctl을 부르지 않고 큐에 쌓입니다. 다음select진입에서 한꺼번에 OS 쪽으로 흘려보냅니다. 이 지연 적용이 흔히 "lazy registration"이라 불리는 동작입니다.
doSelect 흐름
핵심 메서드의 시그니처는 다음과 같습니다.
protected int doSelect(Consumer<SelectionKey> action, long timeout) throws IOException
select(), select(long), selectNow() 모두 결국 이 함수로 들어옵니다. 한 라운드의 흐름은 다음과 같습니다.
flowchart TD
A[doSelect entry] --> B[processDeregisterQueue]
B --> C[processUpdateQueue]
C --> D{timeout?}
D -->|0| E[EPoll.wait nonblocking]
D -->|>0 or infinite| F[EPoll.wait blocking]
F --> G[blocked in kernel epoll_wait]
G --> H[event arrives or wakeup]
E --> I[processEvents]
H --> I
I --> J{eventfd in events?}
J -->|yes| K[clear interrupt flag]
J -->|no| L[lookup fdToKey]
L --> M[update readyOps]
M --> N[add to selectedKeys]
K --> O[processDeregisterQueue]
N --> O
O --> P[return ready count]
세 가지 핵심 단계만 따로 보겠습니다.
1. processUpdateQueue
updateKeys에 쌓인 키 하나하나에 대해, 현재 interestOps와 epoll 상에 등록된 이벤트 마스크를 비교해 다음 중 하나의 epoll_ctl 호출로 변환합니다.
- 새 fd면
EPOLL_CTL_ADD - 기존 fd의 이벤트 마스크가 바뀌면
EPOLL_CTL_MOD - 모든 관심이 사라지면
EPOLL_CTL_DEL
자바의 OP_READ/OP_WRITE는 그대로 EPOLLIN/EPOLLOUT 비트로 번역됩니다. OP_ACCEPT는 서버 소켓에서 새 연결이 들어왔을 때 accept 호출이 가능함을 의미하고, 커널 입장에서는 청취 소켓의 EPOLLIN입니다. OP_CONNECT도 EPOLLOUT 이벤트로 번역됩니다 (비블로킹 connect는 즉시 반환하고, 핸드셰이크가 끝나면 쓰기 가능 이벤트로 통지됩니다).
2. EPoll.wait
JNI 경계로 내려가 epoll_wait(epfd, events, maxEvents, timeout)를 호출합니다. 자바 측에 노출되는 시그니처는 다음과 같습니다.
static int wait(int epfd, long pollAddress, int numfds, int timeout) throws IOException;
pollAddress가 앞서 본 pollArrayAddress입니다. epoll_wait은 다음 셋 중 하나로 깨어납니다.
- 감시 중인 fd 중 하나가 준비됨
- 타임아웃 만료
- 다른 스레드가
wakeup()호출 (→eventfd쓰기)
리눅스 커널이 채워주는 struct epoll_event는 다음과 같습니다.
struct epoll_event {
uint32_t events; /* EPOLLIN, EPOLLOUT, EPOLLERR, EPOLLHUP, ... */
epoll_data_t data; /* uint64_t (fd 정수를 그대로 박아 넣는 관행) */
};
JDK는 data 자리에 fd 정수를 그대로 넣어 두고, 깨어났을 때 그 정수를 다시 자바 fdToKey 맵으로 환원합니다.
3. processEvents
깨어난 fd 목록을 순회하면서 각각에 대해 processReadyEvents를 호출합니다. 흐름은 다음과 같습니다.
- 그 fd가
eventfd이면, "이건 wakeup"이라고 표시하고 카운터를 비웁니다 (eventfd.read()로 카운터 0으로). - 아니면
fdToKey에서 키를 찾고,interestOps와 OS가 보고한events의 교집합을 계산해readyOps로 설정한 뒤 selected-key set에 추가합니다.
마지막에는 다시 한 번 processDeregisterQueue를 돌립니다. 사용자가 이벤트 핸들러 안에서 key.cancel()을 호출했을 수 있기 때문입니다.
Linux epoll 시스템 콜 세 개
JDK가 부르는 시스템 콜은 단 셋입니다. 시그니처와 의미만 잠시 살펴봅니다.
int epoll_create1(int flags);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
epoll_create1은 커널이 관리하는 "관심 목록"의 핸들 fd를 만듭니다. JDK는EPOLL_CLOEXEC플래그를 함께 전달해,fork+exec시 자식 프로세스로 fd가 새지 않도록 합니다.epoll_ctl로 fd를 더하거나(ADD) 빼거나(DEL) 마스크만 바꿉니다(MOD).epoll_wait은 준비된 fd의 배열을 받습니다.timeout = -1이면 무한 대기,0이면 즉시 반환, 양수면 ms 단위 타임아웃입니다.
epoll이 select/poll보다 빠른 이유는 단순합니다. select/poll은 매 호출마다 fd 집합 전체를 커널·사용자 공간 사이에서 복사하고, 커널 안에서 선형 스캔으로 준비 상태를 확인합니다. 반면 epoll은 관심 목록을 커널 안의 RB-tree에 유지하고, 준비된 fd만 별도의 ready list에 모았다가 한 번에 돌려줍니다. fd 수 N과 동시에 활동 중인 fd 수 K가 갈수록 벌어질수록 epoll이 압도적으로 유리합니다.
Edge-Triggered와 Level-Triggered
epoll은 두 가지 통지 모드를 지원합니다.
- Level-Triggered (LT, 기본): 조건이 유지되는 동안
epoll_wait이 매 라운드 그 fd를 보고합니다. fd에 5KB가 도착했는데 1KB만 읽고 다음epoll_wait을 호출하면, 4KB가 남았으므로 다시 보고됩니다. - Edge-Triggered (ET,
EPOLLET플래그): 조건이 새로 충족되는 순간에만 한 번 보고합니다. 5KB가 도착해 1KB만 읽었어도, 다음 라운드에서는 새 바이트가 도착하지 않는 한 보고되지 않습니다. 따라서 ET 모드는 한 번 깨어났을 때read가EAGAIN을 돌려줄 때까지 반복해야 합니다.
JDK의 기본 EPollSelectorImpl은 LT 모드를 씁니다. 사용자가 한 라운드에서 모든 바이트를 읽어내지 못해도 다음 select에서 다시 깨워주기 때문에 다루기 쉽습니다. Netty 같은 라이브러리는 별도의 EpollEventLoop에서 ET 모드를 직접 사용해 라운드당 시스템 콜 수를 줄이지만, 그 경우 "EAGAIN까지 빨아들이기"라는 사용자 측 책임이 추가됩니다.
wakeup 메커니즘 — pipe에서 eventfd로
Selector.wakeup()은 다른 스레드가 select로 잠들어 있는 셀렉터를 깨우기 위한 API입니다. epoll은 자기 자신이 감시 중인 fd 외에는 어떤 자극에도 반응하지 않으므로, 깨우려면 감시 중인 fd 중 하나에 신호를 써넣어야 합니다.
오랫동안 JDK는 pipe(2)로 만든 fd 쌍의 읽기 끝을 epoll에 등록해 두고, wakeup이 호출되면 다른 끝에 1바이트를 써넣었습니다. 단순하지만 pipe 한 쌍이 fd 두 개를 점유하고, write/read 둘 다 시스템 콜을 일으킵니다.
JDK 17의 JDK-8253478에서 이 메커니즘이 eventfd(2)로 교체됐습니다. eventfd는 fd 하나로 64비트 카운터를 들고 있는 가벼운 신호 객체입니다. wakeup()은 그 카운터에 1을 더하고, select가 깨어나면 read로 카운터를 0으로 비웁니다.
public Selector wakeup() {
synchronized (interruptLock) {
if (!interruptTriggered) {
try { eventfd.set(); } // write 8 bytes to eventfd
catch (IOException ioe) { throw new InternalError(ioe); }
interruptTriggered = true;
}
}
return this;
}
이 코드는 idempotent합니다. 이미 wakeup이 진행 중이면 추가 시스템 콜을 하지 않습니다. 깨어난 뒤 processEvents가 eventfd가 readEvent에 끼어 있는 것을 발견하면, 한 번 read로 카운터를 비우면서 interruptTriggered를 false로 되돌립니다.
ByteBuffer Direct vs Heap — 채널 IO에서의 차이
Selector가 "이 채널 읽을 준비됐다"고 알려준 뒤, 실제로 바이트를 옮기는 것은 channel.read(buffer)입니다. 여기서 한 가지 미묘한 행동이 있습니다.
JNI 경계 너머에서 read 시스템 콜은 안정된 네이티브 메모리 주소를 요구합니다. 자바 힙 메모리는 GC에 의해 언제든 옮겨질 수 있어 그 주소를 직접 넘길 수 없습니다. 따라서 JDK는 다음과 같이 분기합니다.
- 인자로 받은
ByteBuffer가DirectByteBuffer이면, 그 버퍼의 네이티브 주소를 그대로 시스템 콜에 넘깁니다. HeapByteBuffer이면, 스레드 로컬 캐시에서 임시 DirectByteBuffer를 빌려와 거기로 먼저 읽어 들이고, 그다음 자바 힙으로 다시System.arraycopy로 복사합니다.
요지는 셋입니다.
- 채널 IO에 자주 쓰는 큰 버퍼는
ByteBuffer.allocateDirect(size)로 미리 잡아 두는 편이 빠릅니다. - 잠깐 쓰고 버리는 작은 버퍼는 임시 direct 버퍼의 풀링 비용이 더 들 수 있어 heap 쪽이 나을 수도 있습니다.
- Direct 버퍼는 GC가 직접 회수하지 않고,
Cleaner등 별도 메커니즘이 해제합니다. 누수 위험이 있으니 사용량을 모니터링해야 합니다.
-Xlog:gc+nmt/-Djdk.internal.io.tracker 같은 진단 옵션이나, MaxDirectMemorySize로 상한을 강제하는 방법이 운영에서 자주 쓰입니다.
JEP 353 — 평범한 Socket도 이제 NIO 위에 산다
JDK 13(2019)부터 java.net.Socket과 java.net.ServerSocket의 기본 구현이 바뀌었습니다. JEP 353 "Reimplement the Legacy Socket API"는 옛 PlainSocketImpl을 NioSocketImpl로 대체했습니다. 의미는 두 가지입니다.
- 옛 API를 그대로 쓰는 코드도 내부적으로는 NIO 인프라(특히 비블로킹 fd와
NetJNI 헬퍼)를 이용합니다. Socket.connect(timeout)처럼 타임아웃이 있는 호출은 fd를 일시적으로 비블로킹으로 바꾸고 폴링해 시간을 잰 뒤, 다시 블로킹으로 돌려놓습니다.
이 변화의 진짜 목적은 호환성 유지보다 Project Loom과의 통합이었습니다. 가상 스레드가 IO에서 멈출 때 캐리어 스레드까지 함께 잠겨서는 안 됩니다. 새 구현은 synchronized 대신 j.u.c.locks를 쓰고, 블로킹 호출을 NIO 폴링으로 분해해 두어 가상 스레드 런타임이 그 지점에서 캐리어를 다른 일에 양보할 수 있게 했습니다. JDK 19/21에서 가상 스레드가 들어왔을 때 이 토대 위에서 바로 동작할 수 있었던 이유입니다.
비상시 옛 구현으로 돌아가는 시스템 프로퍼티 jdk.net.usePlainSocketImpl도 함께 도입됐는데, JDK 21에서 마침내 제거됐습니다.
가상 스레드와 Selector — JEP 491 이후
가상 스레드는 블로킹 API의 형태를 유지하면서도 캐리어 스레드를 점유하지 않는 IO를 목표로 합니다. JDK 21의 가상 스레드는 다음과 같이 동작합니다.
- 가상 스레드가
SocketChannel.read(...)를 호출하면, 채널은 한 번 비블로킹read를 시도합니다. - 즉시 바이트가 있으면 그대로 돌아오고, 없으면 가상 스레드를 "공유 셀렉터" 위에 등록한 뒤 캐리어 스레드에서 언마운트합니다.
- 백그라운드 폴러 스레드가 그 셀렉터를 돌리다가 fd가 준비되면, 해당 가상 스레드를 다시 마운트해 깨웁니다.
JDK 21의 초기 구현에서는 가상 스레드 안에서 synchronized 블록을 잡고 IO를 호출하면 캐리어가 같이 묶이는 "pinning" 문제가 있었습니다. JDK 24/25에 들어간 JEP 491 "Synchronize Virtual Threads without Pinning"이 이 한계를 풀면서, NIO 다중화 위에 가상 스레드를 얹는 그림이 거의 마무리됐습니다.
요약하면 두 갈래로 분기했던 동시성 IO 모델이 한 점에서 만났습니다.
- 명시적으로
Selector를 다루는 코드 — Netty, Reactor Netty, Spring WebFlux의 하부 — 는 여전히 같은 NIO API 위에 있고 - 평범한 자바 코드를 가상 스레드 풀에서 돌리면, 보이지 않는 곳에서 같은
Selector가 같은 epoll을 굴리며 가상 스레드의 IO를 처리합니다.
운영에서 부딪히는 함정
오랜 NIO 운영 사례에서 반복되는 함정을 정리합니다.
1. epoll-spin 100% CPU 버그
JDK 6 시절부터 알려진 고전 버그입니다. 특정 커널·드라이버 조합에서 epoll_wait이 어떤 이벤트도 보고하지 않은 채 즉시 반환을 반복해, 셀렉터 루프가 select()만 무한히 돌면서 코어 하나를 풀로 잡아먹습니다. 원인은 EPOLLHUP/EPOLLERR가 통지됐지만 자바 측 interestOps에는 매칭되는 비트가 없어 selected 키에 들어가지 않고, 그렇다고 OS가 그 fd를 무시해 주지도 않아 다음 epoll_wait에서 다시 즉시 통지되는 패턴입니다.
Netty는 일정 시간 내 0개 이벤트 select가 임계치(기본 512회)를 넘으면 셀렉터를 통째로 재구성해 회피합니다. 직접 짠 코드라면 같은 가드를 두거나, 가능하다면 가상 스레드 + 평범한 블로킹 API로 옮겨 이 문제를 회피하는 편이 안전합니다.
2. selectedKeys 비우지 않기
앞서 한번 짚었지만, 가장 흔한 버그입니다. 처리한 키를 iterator.remove()로 빼지 않으면 selected-key set이 계속 부풀어 오릅니다. 같은 채널의 다음 이벤트는 readyOps만 갱신될 뿐 새 항목으로 보이지 않아, 이벤트 누락처럼 보이는 증상이 납니다.
3. interestOps를 잘못된 스레드에서 바꾸기
SelectionKey.interestOps(int) 자체는 스레드 안전합니다. 하지만 다른 스레드에서 변경한 직후 같은 셀렉터의 select가 이미 잠들어 있다면, 그 변경은 다음 라운드에서야 반영됩니다. 동기화가 필요한 시나리오에서는 interestOps 변경 뒤 selector.wakeup()을 함께 호출해 강제로 다음 라운드를 시작시키는 패턴이 표준입니다.
4. DirectByteBuffer 누수
채널과 함께 자주 쓰이지만 회수가 까다롭습니다. -XX:MaxDirectMemorySize로 상한을 못 박아두고, JFR jdk.NativeMemoryUsage 이벤트나 jdk.internal.misc.VM.maxDirectMemory()로 사용량을 추적하는 편이 좋습니다.
5. 채널을 닫지 않고 키만 cancel
key.cancel()은 다음 선택 동작에서 epoll 등록 해제를 일으키지만, 실제 fd는 채널을 직접 close()해야 닫힙니다. cancel만 한 채로 채널 참조까지 잃으면 OS fd가 누수됩니다. 표준 패턴은 항상 try-with-resources로 채널을 감싸거나 finally에서 close를 보장하는 것입니다.
정리
Selector라는 단순한 API는 다음의 합입니다.
- 자바 측에서 세 개의 키 집합과 3단계 선택 알고리즘이라는 추상 모델
- 그 아래 OS별
SelectorImpl— 리눅스에서는EPollSelectorImpl EPollSelectorImpl이 가진 다섯 개의 필드와 lazyepoll_ctl,epoll_wait호출- 사용자 스레드를 깨우기 위한
eventfd트릭 - 채널 IO에서 direct/heap 버퍼가 갈리는 JNI 경계
- JEP 353으로 합쳐진 옛 Socket API와의 통합
- JEP 491 이후 가상 스레드가 같은 셀렉터를 공유하는 그림
이 단순한 한 줄짜리 API 호출 — selector.select() — 의 의미를 이해하면, Netty의 EventLoop, Reactor Netty의 Loop, Spring WebFlux의 워커 모델, 그리고 가상 스레드 풀의 IO 처리까지가 모두 같은 한 가지 그림 위에 다시 그려집니다.
참고자료
- JSR 51: New I/O APIs for the Java Platform
- Java SE 21 java.nio.channels.Selector
- Java SE 21 java.nio.channels.SelectionKey
- Java SE 21 java.nio.ByteBuffer
- OpenJDK Source: EPollSelectorImpl.java
- OpenJDK Source: SelectorImpl.java
- OpenJDK Source: Selector.java
- OpenJDK Source: SelectionKey.java
- Linux man page: epoll(7)
- Linux man page: epoll_create(2)
- Linux man page: epoll_ctl(2)
- Linux man page: epoll_wait(2)
- Linux man page: eventfd(2)
- JDK-8253478: epoll Selector should use eventfd for wakeup
- JEP 353: Reimplement the Legacy Socket API
- JEP 373: Reimplement the Legacy DatagramSocket API
- JEP 444: Virtual Threads
- JEP 491: Synchronize Virtual Threads without Pinning
- The C10K problem (Dan Kegel)

