NIO와 이벤트 루프 - 셀렉터, 다이렉트 버퍼, Netty가 대신 해주는 일
자바에서 소켓을 다루는 방법은 둘이다. InputStream.read()로 블로킹하거나, Selector로 준비된 채널만 골라 처리하거나. 후자가 NIO이고 Netty가 그 위에 있다. 무엇이 달라지고 무엇을 직접 해야 하는지 보면, 프레임워크가 감춘 것이 드러난다.
블로킹 IO와 NIO
블로킹 모델은 단순하다. read()를 부르면 데이터가 올 때까지 스레드가 멈춘다. 연결마다 스레드가 필요하고, 연결이 많아지면 스레드가 감당이 안 된다.
NIO는 셋을 바꾼다.
채널(Channel). 양방향 IO 통로. 논블로킹 모드로 둘 수 있고, 그때 read()는 읽을 것이 없으면 0을 돌려주고 즉시 반환한다.
버퍼(Buffer). 스트림이 아니라 버퍼를 읽고 쓴다. position, limit, capacity를 직접 관리해야 하고, 쓰기 모드와 읽기 모드를 flip()으로 전환한다. 이 상태 관리가 NIO를 직접 쓸 때 가장 실수가 잦은 부분이다.
셀렉터(Selector). 여러 채널을 등록해 두고 준비된 것만 골라 온다. 리눅스에서는 epoll로 구현된다(epoll).
1
2
3
4
5
6
7
8
while (true) {
selector.select(); // 준비된 채널이 생길 때까지 대기
for (SelectionKey key : selector.selectedKeys()) {
if (key.isReadable()) { /* 읽기 */ }
if (key.isWritable()) { /* 쓰기 */ }
}
selector.selectedKeys().clear(); // 안 지우면 다음 루프에서 또 나온다
}
다이렉트 버퍼
ByteBuffer.allocate()는 힙 안에, allocateDirect()는 힙 밖(네이티브 메모리)에 잡는다.
차이가 나는 이유는 GC가 힙 객체를 옮기기 때문이다. 커널에 버퍼 주소를 넘겨 IO를 시키는 동안 그 객체가 이동하면 안 되므로, 힙 버퍼를 쓰면 JVM이 내부적으로 다이렉트 버퍼로 복사한 뒤 넘긴다. 다이렉트 버퍼는 그 복사가 없다.
대가가 있다.
- 할당과 해제가 비싸다. 그래서 풀링해서 재사용한다.
- GC가 직접 관리하지 않는다.
Cleaner에 의존하므로 회수 시점이 불확실하고, 누수가 나면 힙 지표에 안 보인다.-XX:MaxDirectMemorySize로 상한을 두고 감시해야 한다. - 작은 IO에는 이득이 없다. 할당 비용이 복사 비용을 넘는다.
즉 크고 반복되는 IO에 풀링해서 쓰는 것이 다이렉트 버퍼의 용법이다.
직접 쓰면 만나는 것들
NIO를 그대로 쓰면 프로토콜 처리를 전부 직접 해야 한다.
메시지 경계가 없다. TCP는 바이트 스트림이라 read() 한 번이 메시지 하나와 대응하지 않는다. 반만 올 수도(부분 읽기), 두 개가 붙어 올 수도(뭉침) 있다. 길이 접두어나 구분자로 경계를 복원하는 코드가 필요하고, 이것이 직접 구현에서 가장 자주 틀리는 부분이다.
쓰기도 부분적이다. write()가 요청한 만큼 다 쓰지 못할 수 있다. 남은 것을 보관했다가 채널이 쓰기 가능해지면 이어 써야 하고, 그 동안 OP_WRITE에 관심을 등록해야 한다. 등록해 둔 채 잊으면 CPU를 태우는 busy loop가 된다.
epoll 버그 우회. 특정 JDK·커널 조합에서 select()가 준비된 채널 없이 즉시 반환해 CPU 100%가 되는 문제가 알려져 있다. 셀렉터를 다시 만드는 우회가 필요하다.
Netty는 이 셋을 전부 다룬다. LengthFieldBasedFrameDecoder 같은 코덱이 경계를, ChannelOutboundBuffer가 부분 쓰기를, 프레임워크가 epoll 버그 우회를 맡는다. Netty를 쓰는 이유는 성능이 아니라 이 정확성이다.
이벤트 루프의 규칙
Netty의 EventLoop는 스레드 하나에 여러 채널을 묶는다. 한 채널의 모든 이벤트는 항상 같은 스레드에서 처리된다. 그래서 핸들러 안에서는 동기화가 필요 없다. 채널 상태는 그 스레드만 만진다.
대신 규칙이 생긴다. 이벤트 루프 스레드에서 블로킹하면 그 루프의 모든 채널이 멈춘다. DB 호출, 파일 IO, 동기 외부 호출을 핸들러에 그대로 넣으면 안 되고, 별도 EventExecutorGroup으로 빼야 한다.
이것이 스레드 기반 모델과의 근본적 교환이다. 스레드 모델은 느린 호출 하나가 스레드 하나를 묶고 풀이 마를 때 무너진다. 이벤트 루프는 느린 호출 하나가 그 루프의 모든 연결을 즉시 묶는다. 전자는 상한에 도달해야 무너지고 후자는 한 번에 무너진다.
가상 스레드가 바꾼 것
Java 21 이후 “높은 동시성”만이 목적이라면 가상 스레드로 블로킹 코드를 그대로 쓰면서 같은 효과를 얻는다(가상 스레드의 내부). 스택 트레이스와 디버거가 정상 동작하고, 버퍼 상태 관리나 부분 읽기 처리를 직접 할 필요가 없다.
NIO와 Netty가 여전히 맞는 곳은 좁아졌다.
- 커스텀 바이너리 프로토콜을 구현할 때
- 연결당 메모리를 극단적으로 아껴야 할 때
- 이미 그 생태계 위에 있을 때(gRPC 자바 구현이 Netty 위에 있다)
요청-응답 HTTP 서버를 NIO로 직접 만들 이유는 거의 없다.
이 설명이 깨지는 곳
- NIO가 항상 빠른 것은 아니다. 연결이 적고 처리량 위주면 블로킹 IO가 단순하고 빠를 수 있다.
- 파일 IO는 이 모델 밖이다. 일반 파일 디스크립터는 항상 “준비됨”으로 보고되므로 셀렉터로 다룰 수 없다.
SelectionKey관리가 누수 지점이다. 채널을 닫고 키를 취소하지 않으면 셀렉터에 남는다.- Netty의
ByteBuf는 참조 카운팅을 쓴다.release()를 잊으면 다이렉트 메모리가 샌다. 힙 덤프에 안 보이는 누수다.
무엇을 재면 확인되는가
- 연결 수를 100 → 10,000으로 늘려 가며 블로킹 모델과 NIO의 메모리·CPU·p99를 비교한다. 연결이 적으면 차이가 없다.
- 이벤트 루프 스레드에서 100ms를 블로킹하고 같은 루프의 다른 연결 지연을 본다.
- 힙 버퍼와 다이렉트 버퍼로 같은 IO를 반복하고 처리량과 GC 부하를 비교한다.
-XX:MaxDirectMemorySize를 작게 두고ByteBuf해제를 빠뜨려 누수가 어떻게 드러나는지 본다.
2번이 이 모델의 성질을 가장 빠르게 보여준다.
실무와의 접점
monticker에서 NioEventLoopGroup으로 다수 연결에 시세를 뿌렸다. 그때 정리한 것은 리액터 패턴의 구조였고, 이 글은 그 아래에서 JVM이 하는 일이다. WebSocket과 폴링 비교에서 잰 수치도 결국 연결 유지 비용을 어느 층이 감당하는가의 결과였다. 다만 같은 머신에서 쟀으므로 네트워크 층의 영향은 그 수치에 들어 있지 않다.
정리
- NIO는 채널, 버퍼, 셀렉터 셋으로 이뤄지고 셀렉터는 리눅스에서
epoll이다. - 버퍼의 모드 전환과 상태 관리가 직접 쓸 때 가장 실수가 잦다.
- 다이렉트 버퍼는 복사를 없애지만 할당이 비싸고 GC 밖에 있다. 크고 반복되는 IO에 풀링해서 쓴다.
- 직접 구현하면 메시지 경계 복원, 부분 쓰기 처리, epoll 버그 우회를 전부 해야 한다. Netty를 쓰는 이유는 성능이 아니라 정확성이다.
- 한 채널은 항상 같은 이벤트 루프 스레드가 처리하므로 핸들러에 동기화가 필요 없다.
- 이벤트 루프에서 블로킹하면 그 루프의 모든 연결이 멈춘다.
- 동시성만이 목적이라면 가상 스레드가 더 단순한 답이다.
댓글
아직 댓글이 없습니다