파일을 네트워크로 보내려면 디스크에서 읽어서 버퍼에 담고, 그걸 다시 소켓에 써야 한다 — 이 과정에서 같은 데이터가 몇 번이나 복사되는 걸까?

이전 글에서 CompositeByteBufslice()를 활용한 JVM 내부의 Zero-Copy 패턴을 다뤘습니다. 이번에는 한 단계 더 내려가서, OS 레벨의 Zero-Copy 와 Netty의 FileRegion을 이용한 대용량 파일 전송 패턴을 정리해 보겠습니다.


Zero-Copy란

Zero-Copy는 커널 공간과 유저 공간 사이의 불필요한 데이터 복사를 제거하는 기법 입니다.

네트워크 프로그래밍에서 왜 중요하냐면, 파일 전송이나 대용량 데이터 처리에서 CPU가 실제로 데이터를 "보는" 필요 없이 단순히 A에서 B로 옮기기만 하는 경우가 많기 때문입니다. 그런데 전통적인 방식에서는 이 단순한 전달에도 여러 번의 메모리 복사와 컨텍스트 스위치가 발생합니다.


전통적 파일 전송 — 4번의 복사

일반적인 read() + write() 방식으로 파일을 네트워크로 전송하는 과정을 보겠습니다.

JAVA
// 전통적 파일 전송 — 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 내부에서 벌어지는 일은 이렇습니다.

PLAINTEXT
① 디스크 → 커널 read 버퍼     (DMA 복사)
② 커널 read 버퍼 → 유저 버퍼   (CPU 복사 + 컨텍스트 스위치)
③ 유저 버퍼 → 소켓 버퍼        (CPU 복사 + 컨텍스트 스위치)
④ 소켓 버퍼 → NIC             (DMA 복사)

총: 데이터 복사 4번 + 컨텍스트 스위치 4번
PLAINTEXT
┌──────────────────────────────────────────────────┐
│              유저 공간 (User Space)                │
│    ┌──────────┐         ┌──────────┐             │
│    │ 유저 버퍼  │ ②→     │          │             │
│    │ (byte[]) │ ←③     │ 애플리케이션│             │
│    └──────────┘         └──────────┘             │
├──────────────────────────────────────────────────┤
│              커널 공간 (Kernel Space)              │
│    ┌──────────┐         ┌──────────┐             │
│    │ read 버퍼 │ ──────→ │ 소켓 버퍼  │             │
│    │  (Page   │         │ (Socket  │             │
│    │  Cache)  │         │  Buffer) │             │
│    └────↑─────┘         └────┬─────┘             │
├─────────┼────────────────────┼───────────────────┤
│    ① DMA 복사               ④ DMA 복사            │
│    디스크                     NIC                  │
└──────────────────────────────────────────────────┘

여기서 ②번과 ③번 복사가 문제입니다. 애플리케이션이 파일 내용을 변환하거나 가공하는 게 아니라면, 데이터가 유저 공간을 거칠 이유가 없습니다. 단지 디스크에서 네트워크로 전달만 하면 되는데, 불필요하게 커널↔유저 사이를 왕복하고 있는 셈입니다.


sendfile — OS 레벨의 Zero-Copy

Linux의 sendfile() 시스템콜은 이 문제를 해결합니다. 커널 공간 안에서 read 버퍼의 데이터를 소켓 버퍼로 직접 전달 하여 유저 공간 복사를 생략합니다.

C
// sendfile 시스템콜 시그니처
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
// out_fd: 소켓 fd, in_fd: 파일 fd
PLAINTEXT
sendfile() 방식:
① 디스크 → 커널 read 버퍼     (DMA 복사)
② 커널 read 버퍼 → 소켓 버퍼   (커널 내부 복사 — 유저 공간 안 거침)
③ 소켓 버퍼 → NIC             (DMA 복사)

총: 데이터 복사 3번 + 컨텍스트 스위치 2번

Linux 2.4+에서는 scatter-gather DMA를 지원하는 NIC라면 ②번마저 최적화됩니다. 커널 read 버퍼의 디스크립터(위치, 길이 정보) 만 소켓 버퍼에 전달하고, NIC가 커널 read 버퍼에서 직접 데이터를 가져갑니다.

PLAINTEXT
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
// 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 레벨FileRegionsendfile()로 유저 공간 복사 완전 생략

이전 글에서 다룬 CompositeByteBufslice()는 JVM 내부 레벨이었습니다. 이번 글의 핵심인 FileRegion은 OS 레벨의 Zero-Copy입니다.


FileRegion — 파일 직접 전송

FileRegion은 Netty가 파일 데이터를 유저 공간 버퍼로 읽지 않고 OS의 sendfile()을 통해 직접 소켓으로 전송 하는 인터페이스입니다.

DefaultFileRegion

FileRegion의 기본 구현체가 DefaultFileRegion입니다.

JAVA
// 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()을 활용합니다.

JAVA
// 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을 사용하는 패턴입니다.

JAVA
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을 활용하는 패턴입니다.

JAVA
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);
        }
    }
}

파이프라인 구성

JAVA
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 — 청크 단위 파일 전송

JAVA
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 추가

JAVA
ChannelPipeline p = ch.pipeline();
p.addLast(new ChunkedWriteHandler()); // 청크 전송 지원 핸들러 — 반드시 추가
p.addLast(new ChunkedFileHandler());

ChunkedWriteHandlerChunkedInput 인터페이스를 구현한 객체(ChunkedFile, ChunkedNioFile 등)를 받아서, 내부적으로 작은 청크로 쪼개어 비동기적으로 전송합니다. 한 번에 모든 데이터를 메모리에 올리지 않으므로, 수 GB짜리 파일도 안정적으로 전송할 수 있습니다.

FileRegion vs ChunkedFile

구분FileRegionChunkedFile
Zero-CopyO (sendfile 활용)X (유저 공간 버퍼 사용)
SSL/TLS 지원XO
데이터 가공불가 (원본 그대로 전송)가능 (ByteBuf로 읽으므로)
메모리 사용거의 없음청크 크기만큼 사용
성능 (비암호화)우수보통
적용 대상정적 파일, 비암호화 전송SSL/TLS, 데이터 변환 필요 시

SSL/TLS 사용 시 FileRegion 제약

가장 자주 만나는 함정이 이 부분입니다. SSL/TLS가 적용된 채널에서는 FileRegion이 동작하지 않습니다.

이유는 간단합니다. SSL/TLS는 데이터를 암호화해서 보내야 하는데, sendfile()은 커널이 파일 데이터를 직접 소켓으로 넘기기 때문에 암호화할 기회가 없습니다. 데이터가 유저 공간을 거쳐야 SslHandler가 암호화를 적용할 수 있습니다.

JAVA
// 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 사용
    }
}
JAVA
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 여부를 런타임에 판단해서 분기하는 패턴을 자주 씁니다.

JAVA
// 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로 정리하는 것이 안전합니다.

JAVA
// 안전한 리소스 정리 패턴
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에서는 내부적으로 일반 복사로 폴백합니다.


전체 흐름 정리

PLAINTEXT
파일을 네트워크로 전송해야 한다

      ├── SSL/TLS 사용? ──── Yes ──→ ChunkedFile + ChunkedWriteHandler
      │                                (유저 공간 경유, SslHandler가 암호화)

      └── No

           ├── 데이터 가공 필요? ── Yes ──→ ChunkedFile + ChunkedWriteHandler
           │                                (ByteBuf로 읽어서 가공 가능)

           └── No ──→ DefaultFileRegion
                       (sendfile()로 Zero-Copy 전송)

기억할 포인트를 요약하면 이렇습니다.

  • 전통적 파일 전송은 4번의 복사와 4번의 컨텍스트 스위치 가 발생한다
  • sendfile()은 유저 공간 복사를 생략하여 실질 복사를 2번(DMA만) 으로 줄인다
  • Netty의 FileRegionsendfile()을 추상화한 것으로, 대용량 파일을 메모리 부담 없이 전송한다
  • SSL/TLS 환경에서는 FileRegion을 쓸 수 없다ChunkedFile로 대체해야 한다
  • SslHandler 존재 여부로 런타임 분기하는 패턴이 실무 표준이다
댓글 로딩 중...