1. NIO 채널 소개
기존 Java IO(java.io)는 “스레드 하나 — 파일 또는 리소스 하나” 원칙에 가깝게 동작합니다. 읽기나 쓰기가 시작되면 스레드는 해당 작업이 끝날 때까지 블로킹되어 대기합니다. 간단한 경우에는 편리하지만, 부하가 큰 시스템에서는 이런 방식이 병목이 됩니다. 연결이 수천 개라면 수천 개의 스레드가 대기 상태에 빠지게 되기 때문입니다.
NIO(New IO)의 접근 방식은 다릅니다. 여기서는 입출력을 논블로킹으로 만들 수 있어 스레드가 놀 필요가 없습니다. 어떤 데이터가 아직 전송 중인 동안에도 스레드는 다른 작업으로 전환할 수 있습니다. 덕분에 소수의 스레드만으로도 매우 많은 연결을 처리할 수 있습니다.
세부사항에서도 차이가 있습니다. ‘기존’ IO에서는 바이트나 문자를 읽고 쓰는 스트림을 중심으로 작업이 이뤄지며, 연산 중에는 항상 블로킹됩니다. NIO에서는 채널(Channels)과 버퍼(Buffers)가 핵심 개념이 됩니다. 이를 통해 논블로킹 입출력(서버에 중요)을 구현할 수 있고, 데이터가 JVM 버퍼로 불필요하게 복사되지 않도록 직접 전달하는 zero-copy 기법을 적용할 수 있습니다.
비교: 스트림(Streams) vs 채널(Channels)
스트림(InputStream/OutputStream):
- 바이트를 하나씩 또는 배열 단위로 읽고/쓴다.
- 파일 내 위치를 직접 제어할 수 없다.
- 매우 큰 파일을 효율적으로 처리하기 어렵다.
채널(Channel):
- 버퍼(Buffer)를 통해 데이터를 읽고/쓴다.
- 위치를 제어할 수 있다(랜덤 액세스 포함).
- 비동기 및 논블로킹 모드를 지원한다.
- zero-copy로 초고속 복사가 가능하다.
2. FileChannel과 SeekableByteChannel
버퍼를 사용한 데이터 읽기/쓰기
FileChannel은 파일 작업을 위한 기본 채널입니다. FileInputStream, FileOutputStream에서 얻거나 NIO.2의 Files.newByteChannel을 통해 얻을 수 있습니다(반환 타입은 SeekableByteChannel).
예: FileChannel과 ByteBuffer로 파일 읽기
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
public class FileChannelReadExample {
public static void main(String[] args) throws Exception {
try (RandomAccessFile file = new RandomAccessFile("data.txt", "r");
FileChannel channel = file.getChannel()) {
ByteBuffer buffer = ByteBuffer.allocate(1024); // 1 KB 버퍼
int bytesRead = channel.read(buffer); // 버퍼로 읽기
while (bytesRead != -1) {
buffer.flip(); // 버퍼를 읽기 모드로 전환
while (buffer.hasRemaining()) {
System.out.print((char) buffer.get());
}
buffer.clear(); // 다음 읽기를 위해 버퍼 비우기
bytesRead = channel.read(buffer);
}
}
}
}
파일에 쓰기:
try (RandomAccessFile file = new RandomAccessFile("output.txt", "rw");
FileChannel channel = file.getChannel()) {
ByteBuffer buffer = ByteBuffer.wrap("Hello, NIO!\n".getBytes());
channel.write(buffer);
}
NIO.2: Files.newByteChannel로 채널 열기
import java.nio.file.*;
import java.nio.channels.SeekableByteChannel;
import static java.nio.file.StandardOpenOption.*;
Path path = Paths.get("data.txt");
try (SeekableByteChannel ch = Files.newByteChannel(path, READ)) {
ByteBuffer buf = ByteBuffer.allocate(256);
ch.read(buf);
}
포지셔닝(position())과 크기 변경(truncate())
- position() — 파일의 현재 위치를 조회하거나 설정합니다(커서와 유사).
- truncate(long size) — 파일을 지정한 크기로 자릅니다.
channel.position(100); // 100번째 바이트로 이동
channel.truncate(1024); // 파일을 1 KB로 자르기
직접 및 위치 기반 파일 접근
- 직접 접근: 순차에만 국한되지 않고 파일의 임의 위치에 읽기/쓰기가 가능합니다.
- 위치 기반 접근: 채널의 현재 position을 바꾸지 않고 특정 위치에서 읽기/쓰기가 가능합니다.
ByteBuffer buffer = ByteBuffer.allocate(4);
channel.read(buffer, 128); // channel.position()을 바꾸지 않고 128 위치에서 4바이트 읽기
3. ByteBuffer: 동작 방식
핵심 파라미터: capacity, limit, position, mark
- capacity — 버퍼의 최대 크기(생성 시 결정).
- limit — 읽기/쓰기가 가능한 경계(기본값은 capacity).
- position — 현재 위치(쓰는 곳/읽는 곳).
- mark — 나중에 되돌아올 수 있도록 설정하는 북마크.
버퍼의 생애 주기:
- 버퍼에 데이터를 씁니다(예: 채널의 read()로 읽어 채움).
- flip() — 버퍼를 읽기 모드로 전환합니다(position = 0, limit = 현재 position).
- 버퍼에서 데이터를 읽습니다(get()).
- clear() — 다음 쓰기를 위해 버퍼를 초기화합니다(position = 0, limit = capacity).
예:
ByteBuffer buffer = ByteBuffer.allocate(8);
buffer.put((byte) 42);
buffer.flip(); // 이제 읽을 수 있음
byte value = buffer.get(); // 42
buffer.clear(); // 새 쓰기 준비 완료
버퍼 생성: allocate() vs allocateDirect()
ByteBuffer에는 버퍼를 만드는 두 가지 주요 방법이 있으며, 이 차이는 실제 작업에서 분명히 드러납니다. allocate()는 JVM 힙에 버퍼를 할당합니다. 생성이 빠르고 대부분의 작업에 적합하지만, 네이티브 입출력에서는 힙과 OS 메모리 사이의 추가 복사가 발생할 수 있습니다.
allocateDirect()는 JVM 힙 외부(“native memory”)에 메모리를 할당합니다. 생성 비용이 더 크고 관리도 까다롭지만, 큰 파일을 읽고 쓰거나 네트워크 작업을 할 때는 불필요한 복사가 없어 더 빠른 경우가 많습니다.
핵심은 간단합니다. 대용량에서의 성능이 중요하다면 ‘direct’ 버퍼를 사용하세요. 반대로 작은 작업을 자주 수행한다면 생성 오버헤드가 이점을 상쇄할 수 있으니 allocate()를 선택하는 편이 낫습니다.
ByteBuffer directBuffer = ByteBuffer.allocateDirect(4096);
4. 고성능 작업: transferTo()와 transferFrom()
메서드 transferTo()와 transferFrom()
FileChannel에는 ‘zero-copy’ 방식으로 동작하도록 해주는 두 메서드 transferTo()와 transferFrom()가 있습니다. 이들의 핵심은 파일 채널 간 또는 파일과 네트워크 사이에서 데이터를 직접 전달할 수 있다는 점입니다. JVM의 개입은 거의 없고, 운영체제가 작업을 수행하며 Java 내부 버퍼는 건드리지 않습니다.
그 결과 대용량 파일 복사가 눈에 띄게 빨라집니다. 복사가 줄고, user space와 kernel space 간 전환이 감소하며, CPU 부하도 낮아집니다.
예: zero-copy로 파일 복사
import java.nio.channels.FileChannel;
import java.nio.file.*;
public class ZeroCopyExample {
public static void main(String[] args) throws Exception {
try (FileChannel src = FileChannel.open(Paths.get("input.bin"), StandardOpenOption.READ);
FileChannel dst = FileChannel.open(Paths.get("output.bin"), StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
long size = src.size();
long transferred = src.transferTo(0, size, dst);
System.out.println("복사된 바이트: " + transferred);
}
}
}
zero-copy가 진짜로 동작하는 경우
- 같은 디스크 내 파일 간 복사할 때.
- 네트워크로 파일을 전송할 때(예: SocketChannel).
- 운영체제가 zero-copy를 지원할 때(Linux, macOS, Windows는 지원함).
장점:
- 최소한의 복사: 데이터가 JVM 버퍼를 거치지 않습니다.
- 높은 속도: 전환이 줄고 CPU 부하가 낮습니다.
- 메모리 절약: 큰 사용자 버퍼가 필요 없습니다.
예: ‘한 줄’로 파일 복사
Files.copy(Paths.get("input.bin"), Paths.get("output.bin"), StandardCopyOption.REPLACE_EXISTING);
// 내부적으로 가능하다면 zero-copy를 사용할 수 있음
5. 흔한 실수
실수 №1: flip()을 호출하지 않은 채 버퍼에서 읽으려 함. 버퍼에 데이터를 쓴 뒤에는 반드시 flip()을 호출하세요. 그렇지 않으면 position/limit이 ‘쓰기 모드’에 남아 기대대로 읽히지 않습니다.
실수 №2: 작은 작업에 allocateDirect()를 사용함. Direct 버퍼는 대용량에서 유리하지만, 작은 요청에는 생성 비용이 불필요하게 큽니다. 기본적으로는 allocate()를 선택하세요.
실수 №3: 채널을 닫지 않음. 디스크립터 누수를 피하려면 채널과 스트림에 항상 try-with-resources를 사용하세요.
실수 №4: position/limit/capacity를 혼동함. 읽기/쓰기를 하기 전에 버퍼가 어떤 모드인지 확인하세요. 쓰기 후에는 flip(), 읽은 뒤 새로 쓰려면 clear() 또는 compact()가 필요합니다.
실수 №5: zero-copy가 ‘항상 동작한다’고 기대함. 일부 구성(다른 장치/서로 다른 파일 시스템/특수 플래그)에서는 zero-copy가 불가능하여 일반 복사가 수행될 수 있으며, 이때 성능은 달라질 수 있습니다.
GO TO FULL VERSION