Zero-Copy & FileRegion
파일을 네트워크로 보내려면 디스크에서 읽어서 버퍼에 담고, 그걸 다시 소켓에 써야 한다 — 이 과정에서 같은 데이터가 몇 번이나 복사되는 걸까?
이전 글에서 CompositeByteBuf와 slice()를 활용한 JVM 내부의 Zero-Copy 패턴을 다뤘습니다. 이번에는 한 단계 더 내려가서, OS 레벨의 Zero-Copy 와 Netty의 FileRegion을 이용한 대용량 파일 전송 패턴을 정리해 보겠습니다.
Zero-Copy란
Zero-Copy는 커널 공간과 유저 공간 사이의 불필요한 데이터 복사를 제거하는 기법 입니다.
네트워크 프로그래밍에서 왜 중요하냐면, 파일 전송이나 대용량 데이터 처리에서 CPU가 실제로 데이터를 "보는" 필요 없이 단순히 A에서 B로 옮기기만 하는 경우가 많기 때문입니다. 그런데 전통적인 방식에서는 이 단순한 전달에도 여러 번의 메모리 복사와 컨텍스트 스위치가 발생합니다.
전통적 파일 전송 — 4번의 복사
일반적인 read() + write() 방식으로 파일을 네트워크로 전송하는 과정을 보겠습니다.
// 전통적 파일 전송 — Java 코드
FileInputStream fis = new FileInputStream("large-file.dat");
SocketOutputStream sos = socket.getOutputStream();
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = fis.read(buffer)) != -1) {
sos.write(buffer, 0, bytesRead); // 유저 공간 버퍼를 거쳐서 전송
}
이 코드가 실행될 때 OS 내부에서 벌어지는 일은 이렇습니다.
① 디스크 → 커널 read 버퍼 (DMA 복사)
② 커널 read 버퍼 → 유저 버퍼 (CPU 복사 + 컨텍스트 스위치)
③ 유저 버퍼 → 소켓 버퍼 (CPU 복사 + 컨텍스트 스위치)
④ 소켓 버퍼 → NIC (DMA 복사)
총: 데이터 복사 4번 + 컨텍스트 스위치 4번
┌──────────────────────────────────────────────────┐
│ 유저 공간 (User Space) │
│ ┌──────────┐ ┌──────────┐ │
│ │ 유저 버퍼 │ ②→ │ │ │
│ │ (byte[]) │ ←③ │ 애플리케이션│ │
│ └──────────┘ └──────────┘ │
├──────────────────────────────────────────────────┤
│ 커널 공간 (Kernel Space) │
│ ┌──────────┐ ┌──────────┐ │
│ │ read 버퍼 │ ──────→ │ 소켓 버퍼 │ │
│ │ (Page │ │ (Socket │ │
│ │ Cache) │ │ Buffer) │ │
│ └────↑─────┘ └────┬─────┘ │
├─────────┼────────────────────┼───────────────────┤
│ ① DMA 복사 ④ DMA 복사 │
│ 디스크 NIC │
└──────────────────────────────────────────────────┘
여기서 ②번과 ③번 복사가 문제입니다. 애플리케이션이 파일 내용을 변환하거나 가공하는 게 아니라면, 데이터가 유저 공간을 거칠 이유가 없습니다. 단지 디스크에서 네트워크로 전달만 하면 되는데, 불필요하게 커널↔유저 사이를 왕복하고 있는 셈입니다.
sendfile — OS 레벨의 Zero-Copy
Linux의 sendfile() 시스템콜은 이 문제를 해결합니다. 커널 공간 안에서 read 버퍼의 데이터를 소켓 버퍼로 직접 전달 하여 유저 공간 복사를 생략합니다.
// sendfile 시스템콜 시그니처
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
// out_fd: 소켓 fd, in_fd: 파일 fd
sendfile() 방식:
① 디스크 → 커널 read 버퍼 (DMA 복사)
② 커널 read 버퍼 → 소켓 버퍼 (커널 내부 복사 — 유저 공간 안 거침)
③ 소켓 버퍼 → NIC (DMA 복사)
총: 데이터 복사 3번 + 컨텍스트 스위치 2번
Linux 2.4+에서는 scatter-gather DMA를 지원하는 NIC라면 ②번마저 최적화됩니다. 커널 read 버퍼의 디스크립터(위치, 길이 정보) 만 소켓 버퍼에 전달하고, NIC가 커널 read 버퍼에서 직접 데이터를 가져갑니다.
scatter-gather DMA 지원 시:
① 디스크 → 커널 read 버퍼 (DMA 복사)
② 디스크립터만 소켓 버퍼에 전달 (복사 아님, 참조 전달)
③ 커널 read 버퍼 → NIC (DMA 복사)
총: 실질 복사 2번 + 컨텍스트 스위치 2번
전통적 방식 대비 CPU가 관여하는 복사가 2번에서 0번으로 줄어든다. CPU는 데이터를 한 번도 만지지 않고, DMA 컨트롤러가 전부 처리한다. 이게 "Zero-Copy"라는 이름의 유래이다.
Java NIO의 Zero-Copy 지원
Java NIO의 FileChannel.transferTo()가 OS의 sendfile()을 활용합니다.
// Java NIO의 Zero-Copy 파일 전송
FileChannel fileChannel = new FileInputStream("large-file.dat").getChannel();
SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("server", 8080));
// transferTo — 내부적으로 sendfile() 시스템콜 호출
long transferred = fileChannel.transferTo(0, fileChannel.size(), socketChannel);
transferTo()를 호출하면 JVM은 OS에 sendfile()을 요청하고, 파일 데이터는 유저 공간을 거치지 않고 커널에서 직접 소켓으로 전달됩니다.
Netty의 Zero-Copy — 세 가지 레벨
Netty에서 말하는 "Zero-Copy"는 실제로 세 가지 레벨에 걸쳐 있습니다.
| 레벨 | 도구 | 복사를 줄이는 방식 |
|---|---|---|
| JVM 내부 | CompositeByteBuf, slice() | 버퍼 간 데이터 복사 없이 참조로 합성/분리 |
| JVM ↔ 커널 | Direct ByteBuf | 힙 → 네이티브 메모리 복사 생략 |
| OS 레벨 | FileRegion | sendfile()로 유저 공간 복사 완전 생략 |
이전 글에서 다룬 CompositeByteBuf와 slice()는 JVM 내부 레벨이었습니다. 이번 글의 핵심인 FileRegion은 OS 레벨의 Zero-Copy입니다.
FileRegion — 파일 직접 전송
FileRegion은 Netty가 파일 데이터를 유저 공간 버퍼로 읽지 않고 OS의 sendfile()을 통해 직접 소켓으로 전송 하는 인터페이스입니다.
DefaultFileRegion
FileRegion의 기본 구현체가 DefaultFileRegion입니다.
// DefaultFileRegion 생성
FileChannel fileChannel = new RandomAccessFile("video.mp4", "r").getChannel();
// 파일 전체를 FileRegion으로 감싸기
DefaultFileRegion region = new DefaultFileRegion(
fileChannel,
0, // offset — 시작 위치
fileChannel.size() // count — 전송할 바이트 수
);
transferTo() — 핵심 메서드
DefaultFileRegion의 핵심은 transferTo() 메서드입니다. 내부적으로 FileChannel.transferTo()를 호출하여 OS의 sendfile()을 활용합니다.
// DefaultFileRegion.transferTo() 내부 (간략화)
public long transferTo(WritableByteChannel target, long position) throws IOException {
long count = this.count - position;
if (count < 0 || position < 0) {
throw new IllegalArgumentException();
}
if (count == 0) {
return 0L;
}
// FileChannel.transferTo() → OS의 sendfile() 시스템콜
return this.file.transferTo(this.position + position, count, target);
}
파일 전송 핸들러 예시
실제 Netty 파이프라인에서 FileRegion을 사용하는 패턴입니다.
public class FileServerHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
String filePath = (String) msg;
File file = new File(filePath);
if (!file.exists()) {
ctx.writeAndFlush(Unpooled.copiedBuffer("파일 없음\n", StandardCharsets.UTF_8));
return;
}
RandomAccessFile raf = new RandomAccessFile(file, "r");
long fileLength = raf.length();
// 파일 길이 정보를 먼저 전송 (헤더)
ByteBuf header = ctx.alloc().buffer(8);
header.writeLong(fileLength);
ctx.write(header);
// FileRegion으로 파일 본문 전송 — Zero-Copy
FileRegion region = new DefaultFileRegion(raf.getChannel(), 0, fileLength);
ctx.writeAndFlush(region).addListener(future -> {
raf.close(); // 전송 완료 후 파일 닫기
if (!future.isSuccess()) {
future.cause().printStackTrace();
}
});
}
}
ctx.write(region)을 호출하면 Netty는 내부적으로transferTo()를 반복 호출하여 파일 데이터를 소켓으로 전송한다. 애플리케이션 코드에서ByteBuf를 할당하거나 파일을read()할 필요가 전혀 없다.
정적 파일 서버 구현 — 실전 패턴
HTTP 정적 파일 서버를 구현할 때 FileRegion을 활용하는 패턴입니다.
public class StaticFileHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
private final String baseDir;
public StaticFileHandler(String baseDir) {
this.baseDir = baseDir;
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) throws Exception {
String path = sanitizePath(request.uri()); // 경로 검증
File file = new File(baseDir, path);
if (!file.exists() || !file.isFile()) {
sendError(ctx, HttpResponseStatus.NOT_FOUND);
return;
}
RandomAccessFile raf = new RandomAccessFile(file, "r");
long fileLength = raf.length();
// HTTP 응답 헤더
HttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
HttpUtil.setContentLength(response, fileLength);
response.headers().set(HttpHeaderNames.CONTENT_TYPE, detectMimeType(file));
ctx.write(response);
// 파일 본문 — FileRegion으로 Zero-Copy 전송
ctx.write(new DefaultFileRegion(raf.getChannel(), 0, fileLength));
// 마지막 빈 청크 — HTTP 응답 완료 표시
ChannelFuture future = ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT);
future.addListener(f -> raf.close());
// keep-alive가 아니면 전송 후 연결 종료
if (!HttpUtil.isKeepAlive(request)) {
future.addListener(ChannelFutureListener.CLOSE);
}
}
}
파이프라인 구성
public class FileServerInitializer extends ChannelInitializer<SocketChannel> {
@Override
protected void initChannel(SocketChannel ch) {
ChannelPipeline p = ch.pipeline();
p.addLast(new HttpServerCodec()); // HTTP 인코더/디코더
p.addLast(new HttpObjectAggregator(65536)); // HTTP 메시지 합치기
p.addLast(new StaticFileHandler("/var/www/static")); // 파일 서버 핸들러
}
}
ChunkedWriteHandler와의 조합
FileRegion이 항상 최선은 아닙니다. 파일을 전송하면서 데이터를 가공해야 하거나, SSL/TLS가 적용된 환경에서는 ChunkedWriteHandler가 대안입니다.
ChunkedFile — 청크 단위 파일 전송
public class ChunkedFileHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
File file = new File((String) msg);
RandomAccessFile raf = new RandomAccessFile(file, "r");
// ChunkedFile — 8KB 단위로 나눠서 전송
// 대용량 파일도 메모리에 전부 올리지 않음
ctx.writeAndFlush(new ChunkedFile(raf, 0, file.length(), 8192))
.addListener(future -> raf.close());
}
}
파이프라인에 ChunkedWriteHandler 추가
ChannelPipeline p = ch.pipeline();
p.addLast(new ChunkedWriteHandler()); // 청크 전송 지원 핸들러 — 반드시 추가
p.addLast(new ChunkedFileHandler());
ChunkedWriteHandler는 ChunkedInput 인터페이스를 구현한 객체(ChunkedFile, ChunkedNioFile 등)를 받아서, 내부적으로 작은 청크로 쪼개어 비동기적으로 전송합니다. 한 번에 모든 데이터를 메모리에 올리지 않으므로, 수 GB짜리 파일도 안정적으로 전송할 수 있습니다.
FileRegion vs ChunkedFile
| 구분 | FileRegion | ChunkedFile |
|---|---|---|
| Zero-Copy | O (sendfile 활용) | X (유저 공간 버퍼 사용) |
| SSL/TLS 지원 | X | O |
| 데이터 가공 | 불가 (원본 그대로 전송) | 가능 (ByteBuf로 읽으므로) |
| 메모리 사용 | 거의 없음 | 청크 크기만큼 사용 |
| 성능 (비암호화) | 우수 | 보통 |
| 적용 대상 | 정적 파일, 비암호화 전송 | SSL/TLS, 데이터 변환 필요 시 |
SSL/TLS 사용 시 FileRegion 제약
가장 자주 만나는 함정이 이 부분입니다. SSL/TLS가 적용된 채널에서는 FileRegion이 동작하지 않습니다.
이유는 간단합니다. SSL/TLS는 데이터를 암호화해서 보내야 하는데, sendfile()은 커널이 파일 데이터를 직접 소켓으로 넘기기 때문에 암호화할 기회가 없습니다. 데이터가 유저 공간을 거쳐야 SslHandler가 암호화를 적용할 수 있습니다.
// SSL/TLS 환경 — FileRegion 대신 ChunkedFile 사용
public class SslFileServerInitializer extends ChannelInitializer<SocketChannel> {
private final SslContext sslCtx;
public SslFileServerInitializer(SslContext sslCtx) {
this.sslCtx = sslCtx;
}
@Override
protected void initChannel(SocketChannel ch) {
ChannelPipeline p = ch.pipeline();
p.addLast(sslCtx.newHandler(ch.alloc())); // SSL 핸들러
p.addLast(new HttpServerCodec());
p.addLast(new HttpObjectAggregator(65536));
p.addLast(new ChunkedWriteHandler()); // ChunkedFile 지원
p.addLast(new SslAwareFileHandler()); // FileRegion 대신 ChunkedFile 사용
}
}
public class SslAwareFileHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) throws Exception {
File file = resolveFile(request.uri());
RandomAccessFile raf = new RandomAccessFile(file, "r");
long fileLength = raf.length();
HttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
HttpUtil.setContentLength(response, fileLength);
ctx.write(response);
// SSL 환경 → ChunkedFile로 전송 (SslHandler가 암호화 처리)
ctx.write(new ChunkedFile(raf, 0, fileLength, 8192));
ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT)
.addListener(f -> raf.close());
}
}
SSL 여부에 따른 자동 분기 패턴
실무에서는 SSL 여부를 런타임에 판단해서 분기하는 패턴을 자주 씁니다.
// SSL 여부에 따라 FileRegion 또는 ChunkedFile 선택
private void sendFile(ChannelHandlerContext ctx, RandomAccessFile raf, long fileLength) {
if (ctx.pipeline().get(SslHandler.class) != null) {
// SSL 적용됨 → ChunkedFile
ctx.write(new ChunkedFile(raf, 0, fileLength, 8192));
} else {
// SSL 없음 → FileRegion (Zero-Copy)
ctx.write(new DefaultFileRegion(raf.getChannel(), 0, fileLength));
}
}
SslHandler가 파이프라인에 있는지 확인하는 한 줄로 분기할 수 있다. 이 패턴은 Netty 공식 예제에서도 사용하는 권장 방식이다.
주의사항 정리
1. FileRegion은 파일에서 소켓으로의 직접 전송만 지원
FileRegion은 파일 → 소켓 경로에서만 동작합니다. 파일 데이터를 읽어서 ByteBuf로 가공하고 싶다면 FileRegion이 아니라 ChunkedFile이나 직접 FileChannel.read()를 사용해야 합니다.
2. 전송 완료 후 반드시 리소스 정리
DefaultFileRegion은 내부적으로 FileChannel을 들고 있습니다. 전송이 완료되면 release()가 호출되면서 FileChannel이 닫히지만, 예외 상황에서 누수될 수 있으므로 ChannelFutureListener로 정리하는 것이 안전합니다.
// 안전한 리소스 정리 패턴
DefaultFileRegion region = new DefaultFileRegion(raf.getChannel(), 0, fileLength);
ctx.writeAndFlush(region).addListener(future -> {
// 성공/실패 관계없이 파일 닫기
try {
raf.close();
} catch (IOException e) {
// 로그 기록
}
});
3. 대용량 파일 전송 시 offset 관리
FileChannel.transferTo()는 OS에 따라 한 번에 전송 가능한 최대 크기가 제한될 수 있습니다. Netty의 DefaultFileRegion은 이를 내부적으로 처리하여 여러 번 나눠서 전송하지만, 직접 FileChannel을 다룰 때는 주의가 필요합니다.
4. NIO Transport에서만 동작
FileRegion의 Zero-Copy는 Java NIO의 FileChannel.transferTo()에 의존하므로, NIO 기반 Transport(NioSocketChannel, EpollSocketChannel 등)에서만 동작 합니다. OIO(Old Blocking I/O) Transport에서는 내부적으로 일반 복사로 폴백합니다.
전체 흐름 정리
파일을 네트워크로 전송해야 한다
│
├── SSL/TLS 사용? ──── Yes ──→ ChunkedFile + ChunkedWriteHandler
│ (유저 공간 경유, SslHandler가 암호화)
│
└── No
│
├── 데이터 가공 필요? ── Yes ──→ ChunkedFile + ChunkedWriteHandler
│ (ByteBuf로 읽어서 가공 가능)
│
└── No ──→ DefaultFileRegion
(sendfile()로 Zero-Copy 전송)
기억할 포인트를 요약하면 이렇습니다.
- 전통적 파일 전송은 4번의 복사와 4번의 컨텍스트 스위치 가 발생한다
sendfile()은 유저 공간 복사를 생략하여 실질 복사를 2번(DMA만) 으로 줄인다- Netty의
FileRegion은sendfile()을 추상화한 것으로, 대용량 파일을 메모리 부담 없이 전송한다 - SSL/TLS 환경에서는
FileRegion을 쓸 수 없다 —ChunkedFile로 대체해야 한다 SslHandler존재 여부로 런타임 분기하는 패턴이 실무 표준이다