목차
Java NIO: 채널, 버퍼, 셀렉터
Java의 전통적인 InputStream·OutputStream은 바이트를 읽고 쓰는 스트림 API다. NIO의 Channel은 버퍼와 함께 바이트를 주고받고, 파일 위치 제어와 네트워크 준비 상태 감시 같은 기능을 제공한다. IO가 항상 버퍼를 사용하지 않고 NIO가 항상 빠르다는 식으로 비교하면 실제 설계를 놓치기 쉽다. 스트림에도 BufferedInputStream을 붙일 수 있고, 채널도 사용 방식에 따라 블로킹으로 동작한다.
| 작업 | 단순한 출발점 | NIO가 유리한 요구 |
|---|---|---|
| 작은 파일 전체 읽기 | Files.readString·Files.readAllBytes | 위치별 읽기, 큰 데이터의 청크 처리 |
| 파일 쓰기 | Files.writeString | 채널 위치·잠금·전송 제어 |
| 소수 연결의 서버 | 블로킹 소켓과 작업 스레드 | 많은 연결의 준비 상태를 한 스레드에서 감시 |
| 화면을 막지 않는 파일 I/O | 별도 작업 스레드 | AsynchronousFileChannel의 완료 신호 처리 |
표에서 어느 방식이 빠른지는 파일 크기, 연결 수, 스레드 수, 플랫폼에 따라 달라진다. NIO에는 블로킹 채널, 비블로킹 채널, 비동기 채널이 함께 있으므로 NIO라는 이름만으로 논블로킹을 뜻하지 않는다.
ByteBuffer의 네 값
버퍼에서 capacity는 할당한 총 크기, limit는 읽기·쓰기 가능한 상한, position은 다음 접근 위치다. mark는 필요할 때 저장한 position이다. 항상 0 <= position <= limit <= capacity를 유지한다. mark가 있으면 mark <= position이어야 한다.
import java.nio.ByteBuffer;
ByteBuffer buffer = ByteBuffer.allocate(8);
buffer.put((byte) 'A');
buffer.put((byte) 'B');
System.out.println(buffer.position()); // 2
buffer.flip(); // 읽기 준비: limit=2, position=0
System.out.println((char) buffer.get()); // A
System.out.println((char) buffer.get()); // B
buffer.clear(); // 다시 쓰기 준비
flip()은 지금까지 쓴 위치를 읽기 limit로 만들고 position을 0으로 돌린다. clear()는 내용을 0으로 지우는 함수가 아니다. 단지 버퍼 전체를 다시 쓸 수 있게 위치를 바꾼다. 읽지 못한 데이터를 앞쪽에 남기고 이어서 쓰려면 compact()를 검토한다. 이 세 메서드를 잘못 섞으면 지난 데이터가 다시 읽히거나 새 데이터가 누락된다.
힙 버퍼와 다이렉트 버퍼
ByteBuffer.allocate(n)은 일반적으로 힙 버퍼를, ByteBuffer.allocateDirect(n)은 다이렉트 버퍼를 만든다. wrap(byte[])은 기존 배열을 버퍼로 감싼다. 다이렉트 버퍼는 네이티브 I/O에서 중간 복사를 줄일 가능성이 있지만 할당·해제 비용과 힙 바깥 메모리 사용이 있다. 다이렉트 버퍼가 항상 더 빠르거나 더 크게 할당할 수 있다는 단정은 틀리다. 작고 수명이 짧은 작업은 힙 버퍼가 단순하고, 반복되는 대용량 I/O에서는 재사용 가능한 다이렉트 버퍼를 실제로 측정해 본다.
ByteBuffer heap = ByteBuffer.allocate(1024);
ByteBuffer direct = ByteBuffer.allocateDirect(1024);
System.out.println(heap.isDirect()); // false
System.out.println(direct.isDirect()); // true
텍스트를 쓰려면 문자열을 문자 인코딩으로 바이트로 바꿔야 한다. UTF-8 한글의 바이트 수는 글자 수와 같지 않으므로 text.length()만큼 버퍼를 잡으면 부족할 수 있다. StandardCharsets.UTF_8.encode(text) 같은 API를 사용한다.
비블로킹 소켓과 Selector
SocketChannel을 configureBlocking(false)로 바꾸면 준비되지 않은 read가 바로 0을 돌려줄 수 있다. Selector에는 비블로킹 채널을 등록하고 읽기·쓰기 준비 상태를 감시한다. 준비 신호를 받았어도 한 번의 read나 write가 모든 데이터를 처리한다는 보장은 없다. 연결마다 남은 버퍼와 프로토콜 상태를 유지해야 한다.
flowchart LR
A[SocketChannel을 비블로킹으로 설정] --> B[Selector에 관심 이벤트 등록]
B --> C[select로 준비된 키 받기]
C --> D[채널별 읽기·쓰기 가능한 만큼 처리]
D --> E[남은 버퍼와 관심 이벤트 갱신]
E --> C
그림의 select() 자체는 기본적으로 준비된 이벤트가 생길 때까지 대기할 수 있다. “논블로킹 I/O”는 채널의 개별 읽기·쓰기 호출이 준비되지 않았을 때 스레드를 묶어두지 않는다는 뜻이지 서버 루프에 대기가 전혀 없다는 뜻이 아니다. 준비된 키를 처리한 뒤 선택 집합에서 제거하고, 닫힌 채널의 키를 취소하는 수명 관리가 필요하다. 파일용 FileChannel을 같은 방식으로 셀렉터에 등록할 수는 없다.
블로킹 I/O도 스레드 인터럽트와 채널 닫기의 동작이 API에 따라 다르다. 예전 InputStream 호출은 일괄적으로 “인터럽트 불가”라고 할 수 없고, InterruptibleChannel의 블로킹 동작에는 별도 규칙이 있다. 호출한 구체 API의 취소 계약을 확인한다.
참고: Oracle ByteBuffer API, SelectableChannel API, Selector API