Skip to main content

Command Palette

Search for a command to run...

Java Zero-Copy — FileChannel.transferTo, sendfile, 그리고 Kafka가 디스크를 네트워크로 흘려보내는 방법

Updated
17 min read

"파일을 읽어서 소켓으로 보낸다." 한 줄짜리 요구사항이에요. 그런데 이 한 줄 뒤에서 데이터는 메모리를 네 번이나 복사하고, CPU는 커널과 유저 공간을 네 번이나 들락거려요. Kafka처럼 초당 수십만 건을 흘려보내야 하는 시스템에서 이 비용은 그냥 넘길 수가 없어요. 이 글은 그 복사를 한 겹씩 벗겨내는 zero-copy의 동작 원리를 따라가요. 전통적인 read/write 경로가 왜 네 번 복사하는지부터, mmap·sendfile·scatter-gather DMA가 그 복사를 어떻게 줄이는지, 그리고 Java의 FileChannel.transferTo와 Kafka가 이걸 실제로 어떻게 쓰는지까지를 같은 흐름 안에서 정리해요. 네트워크 I/O가 빠른 시스템을 만들거나, "Kafka는 왜 빠른가"라는 질문에 한 단계 더 깊이 답하고 싶은 분을 위한 글이에요.

들어가기 전에 — 용어 한 가지

먼저 "zero-copy"라는 말부터 짚고 갈게요. 이름만 보면 복사가 0번이라는 뜻 같지만, 사실은 그렇지 않아요. 디스크에서 데이터를 끌어오고 네트워크 카드로 내보내는 DMA 복사는 어떤 방식을 쓰든 남아 있어요. zero-copy가 없애는 건 CPU가 직접 메모리에서 메모리로 옮기는 복사, 그리고 그 과정에서 데이터가 유저 공간을 거치며 생기는 중복 복사예요.

그러니까 이 글에서 "복사를 줄였다"는 말은 정확히는 "CPU가 바이트를 옮기는 횟수와, 유저 공간을 거치는 횟수를 줄였다"는 뜻이에요. 이 구분을 기억하고 가면 뒤의 숫자들이 훨씬 또렷하게 보여요.

용어 하나만 더 정리할게요. 앞으로 DMA 복사CPU 복사를 계속 구분해서 쓸 거예요. DMA 복사는 디스크 컨트롤러나 NIC 같은 장치가 알아서 메모리로 옮기는 거라 그동안 CPU는 다른 일을 할 수 있어요. CPU 복사는 말 그대로 CPU가 메모리 한 곳에서 다른 곳으로 바이트를 직접 나르는 거고요. zero-copy 이야기의 거의 전부는 "이 CPU 복사를 어떻게 한 번씩 덜어내느냐"의 연속이에요.

1. "파일을 읽어서 소켓으로" — 가장 평범한 코드의 진짜 비용

서버가 디스크의 파일을 클라이언트에게 그대로 내려보내는 상황을 생각해 볼게요. 가장 직관적인 코드는 이렇게 생겼어요.

byte[] buffer = new byte[8192];
int n;
while ((n = fileInputStream.read(buffer)) != -1) {
    socketOutputStream.write(buffer, 0, n);
}

read로 파일을 버퍼에 읽고, write로 그 버퍼를 소켓에 쓰는, 더할 나위 없이 평범한 루프예요. 그런데 이 한 번의 read + write 사이클에서 운영체제가 실제로 하는 일을 펼쳐 보면 이래요.

flowchart TD
    Disk[(Disk)] -->|1. DMA copy| PageCache[Kernel: Page Cache]
    PageCache -->|2. CPU copy| UserBuf[User Space: App Buffer]
    UserBuf -->|3. CPU copy| SockBuf[Kernel: Socket Buffer]
    SockBuf -->|4. DMA copy| NIC[NIC]

    subgraph KernelSpace[Kernel Space]
        PageCache
        SockBuf
    end
    subgraph UserSpace[User Space]
        UserBuf
    end

데이터는 총 네 번 복사돼요.

  1. DMA 복사 — 디스크의 데이터를 커널의 페이지 캐시(page cache)로. DMA 엔진이 하므로 CPU는 쉬어요.
  2. CPU 복사 — 페이지 캐시에서 유저 공간의 애플리케이션 버퍼로. read 시스템 콜이 반환될 때 CPU가 직접 옮겨요.
  3. CPU 복사 — 유저 공간 버퍼에서 다시 커널의 소켓 버퍼로. write 시스템 콜이 CPU로 옮겨요.
  4. DMA 복사 — 소켓 버퍼에서 네트워크 카드(NIC)로. 다시 DMA 엔진이 담당해요.

복사만 네 번이 아니에요. 컨텍스트 스위치도 네 번 일어나요. read를 부르며 유저 모드에서 커널 모드로, read가 반환되며 다시 유저 모드로, write를 부르며 커널 모드로, write가 반환되며 유저 모드로. 시스템 콜 두 개에 모드 전환 네 번이에요.

여기서 눈에 띄는 건 2번과 3번 복사예요. 페이지 캐시에 이미 있는 데이터를, 유저 공간에 잠깐 들렀다가, 거의 그대로 다시 커널의 소켓 버퍼로 옮겨요. 우리 애플리케이션은 그 데이터를 들여다보지도, 바꾸지도 않았어요. 그냥 통과시켰을 뿐인데 CPU는 두 번이나 메모리를 통째로 복사했어요. zero-copy의 출발점은 바로 이 "쓸데없는 왕복"을 없애는 거예요.

2. 유저 공간과 커널 공간, 그리고 DMA

복사를 줄이려면 왜 복사가 생기는지부터 알아야 해요.

운영체제는 메모리를 커널 공간유저 공간으로 나눠요. 디스크 컨트롤러나 네트워크 카드 같은 하드웨어와 직접 대화하는 코드는 커널 공간에서만 돌 수 있어요. 우리 애플리케이션은 유저 공간에서 돌고요. 그래서 파일을 읽으려면 데이터가 일단 커널 공간(페이지 캐시)에 올라온 다음, 유저 공간으로 한 번 더 넘어와야 해요. 보내는 길도 똑같이 거꾸로 한 번 더 넘어가요. 이 경계를 넘는 게 바로 위에서 본 2번, 3번 CPU 복사예요.

페이지 캐시는 운영체제가 디스크 블록을 메모리에 캐싱해 두는 공간이에요. 한 번 읽은 파일은 페이지 캐시에 남아 있어서, 다음에 같은 데이터를 읽을 때는 디스크까지 안 가도 돼요. 뒤에서 Kafka 이야기를 할 때 이 페이지 캐시가 주인공으로 다시 등장해요.

**DMA(Direct Memory Access)**는 CPU를 거치지 않고 장치가 메모리로 직접 데이터를 옮기는 기능이에요. 디스크에서 페이지 캐시로(1번), 소켓 버퍼에서 NIC로(4번) 가는 복사는 DMA가 담당하니까 CPU는 그동안 다른 일을 할 수 있어요. 문제는 가운데 두 번, CPU가 직접 손으로 옮기는 복사예요. zero-copy 기법들은 전부 "이 가운데 복사를 어떻게 없앨까"에 대한 답이에요.

그 복사가 왜 그렇게 비싼가요

"복사 한 번쯤이야"라고 생각할 수도 있어요. 그런데 데이터가 클수록, 트래픽이 많을수록 이 비용은 눈덩이처럼 불어나요.

먼저 메모리 대역폭이에요. CPU가 1MB를 복사한다는 건 메모리에서 1MB를 읽고 1MB를 쓴다는 뜻이에요. 같은 데이터를 두 번 복사하면 그만큼의 대역폭을 그냥 통과시키는 데 써 버려요. 초당 수 GB를 흘려보내는 시스템에서는 이 "쓸데없는 왕복"이 메모리 버스를 통째로 잡아먹어요.

다음은 CPU 캐시 오염이에요. 통과시키기만 할 데이터로 CPU 캐시가 채워지면, 정작 애플리케이션이 쓰려던 데이터가 캐시에서 밀려나요. 복사 자체의 비용뿐 아니라 그 뒤에 따라오는 캐시 미스까지 대가로 치르는 셈이에요.

마지막은 컨텍스트 스위치예요. 유저 모드와 커널 모드를 오갈 때 CPU는 레지스터 상태를 저장하고 복원하고, 권한 수준을 바꿔요. 시스템 콜 하나를 줄이면 이 왕복이 통째로 사라져요. zero-copy가 복사뿐 아니라 컨텍스트 스위치까지 같이 줄이는 게 이래서 중요해요.

그래서 우리가 줄이려는 건 단순히 "복사 횟수"라는 숫자가 아니에요. 메모리 대역폭, CPU 캐시, 모드 전환이라는 세 가지 실제 자원이에요.

3. mmap + write — CPU 복사 한 번을 덜어내다

첫 번째 아이디어는 mmap이에요. mmap은 파일(정확히는 페이지 캐시)을 유저 공간 주소에 그대로 매핑해요. 복사하는 게 아니라, 같은 물리 메모리를 유저 공간에서도 들여다볼 수 있게 주소만 연결하는 거예요.

flowchart TD
    Disk[(Disk)] -->|1. DMA copy| PageCache[Kernel: Page Cache]
    PageCache -. mmap: address mapping, no copy .-> Mapped[User Space: Mapped Region]
    PageCache -->|2. CPU copy| SockBuf[Kernel: Socket Buffer]
    SockBuf -->|3. DMA copy| NIC[NIC]

    subgraph KernelSpace[Kernel Space]
        PageCache
        SockBuf
    end

흐름은 이렇게 바뀌어요.

  1. DMA 복사 — 디스크에서 페이지 캐시로. 그대로예요.
  2. mmap — 페이지 캐시를 유저 공간 주소에 매핑. 복사가 아니에요. 그래서 전통적 경로의 "2번 CPU 복사"가 사라져요.
  3. CPU 복사write를 부르면 커널이 매핑된 영역(= 페이지 캐시)에서 소켓 버퍼로 한 번 복사해요.
  4. DMA 복사 — 소켓 버퍼에서 NIC로.

총 복사가 4번에서 3번으로 줄었어요. CPU 복사가 2번에서 1번으로 줄어든 거예요. 컨텍스트 스위치는 여전히 4번이고요(mmapwrite 두 시스템 콜).

Java에서는 FileChannel.map으로 이 매핑을 만들 수 있어요. 반환되는 건 MappedByteBuffer예요.

try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) {
    MappedByteBuffer mapped =
        channel.map(FileChannel.MapMode.READ_ONLY, 0, channel.size());
    socketChannel.write(mapped);
}

MapMode는 세 가지가 있어요. 읽기 전용 READ_ONLY, 읽고 쓰기 READ_WRITE, 그리고 변경이 파일에 반영되지 않는 copy-on-write 방식의 PRIVATE예요.

다만 mmap에는 함정이 하나 있어요. 매핑한 파일을 다른 프로세스가 중간에 줄여 버리면(truncate), 사라진 영역에 접근하는 순간 SIGBUS가 떨어져요. Java에서는 이게 그대로 노출되거나 예외로 바뀌어 올라올 수 있어요. 그래서 mmap은 "내가 통제하는, 크기가 안정적인 파일"에 쓰는 게 안전해요. 그리고 매핑을 푸는 시점이 GC에 묶여 있어서 파일 핸들이 예상보다 오래 잡혀 있을 수 있다는 점도 알아 두면 좋아요.

4. sendfile — 커널 밖으로 한 발도 안 나가기

mmap은 복사를 하나 줄였지만, 데이터가 여전히 유저 공간 주소를 거쳐 가요(매핑이라 복사는 아니지만요). 그러면 아예 유저 공간을 건드리지 말고 커널 안에서 끝내면 어떨까요? 그게 sendfile이에요.

Linux의 시그니처는 이렇게 생겼어요.

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

in_fd에서 읽어 out_fd로 바로 보내요. 핵심은 **"이 복사가 커널 안에서 일어난다"**는 거예요. Linux man page도 이렇게 설명해요. "이 복사가 커널 안에서 이뤄지기 때문에, sendfile()은 유저 공간으로 데이터를 주고받아야 하는 read(2)write(2)의 조합보다 효율적이다."

flowchart TD
    Disk[(Disk)] -->|1. DMA copy| PageCache[Kernel: Page Cache]
    PageCache -->|2. CPU copy| SockBuf[Kernel: Socket Buffer]
    SockBuf -->|3. DMA copy| NIC[NIC]

    subgraph KernelSpace[Kernel Space all inside kernel]
        PageCache
        SockBuf
    end

흐름을 보면 유저 공간이 아예 그림에서 사라졌어요.

  1. DMA 복사 — 디스크에서 페이지 캐시로.
  2. CPU 복사 — 페이지 캐시에서 소켓 버퍼로. 아직 한 번 남아 있어요.
  3. DMA 복사 — 소켓 버퍼에서 NIC로.

복사는 mmap과 같은 3번이지만, 컨텍스트 스위치가 2번으로 줄었어요. 시스템 콜이 sendfile 하나뿐이라 모드 전환이 들어갈 때 한 번, 나올 때 한 번이면 끝이에요. 그리고 데이터가 유저 공간 버퍼를 아예 안 거치니까, 애플리케이션 입장에서는 메모리도 아끼고 코드도 단순해져요.

sendfile에는 알아 둘 제약이 몇 가지 있어요.

  • in_fdmmap이 가능한 파일이어야 해요. 그래서 보통 정규 파일이고, 소켓은 in_fd로 못 써요.
  • out_fd는 역사적으로 제약이 바뀌었어요. Linux 2.4 이전에는 정규 파일도 됐지만, 2.6 초기 커널에서는 소켓만 가능했어요. 그러다 Linux 2.6.33부터 다시 아무 파일이나 가능해졌어요.

이 "out_fd는 소켓이어야 한다"는 제약이, sendfile이 오랫동안 "파일을 네트워크로 보내는" 용도와 딱 붙어 다닌 이유예요.

한 가지 더, 정확성에 관한 주의사항이 있어요. out_fd가 zero-copy를 지원하는 소켓이나 파이프라면, 전송 중인 파일 영역을 다른 곳에서 건드리면 안 돼요. Linux man page는 "out_fd 반대편의 리더가 전송된 데이터를 다 소비할 때까지 in_fd의 해당 영역이 변경되지 않도록 호출자가 보장해야 한다"고 명시해요. 데이터를 복사하지 않고 페이지를 그대로 참조해서 보내기 때문에, 보내는 도중에 그 페이지가 바뀌면 깨진 데이터가 나가요. Kafka가 로그 세그먼트를 추가 전용으로만 다루고 이미 쓴 영역을 건드리지 않는 설계가, 이 제약과도 자연스럽게 맞아떨어져요.

5. Scatter-Gather DMA — 마지막 CPU 복사마저 없애기

sendfile을 써도 2번 CPU 복사(페이지 캐시에서 소켓 버퍼로 가는 그 복사)는 남아 있었죠. 이마저 없애려면 하드웨어의 도움이 필요해요. 바로 scatter-gather DMA예요.

Linux 2.4부터, NIC가 scatter-gather DMA를 지원하면 동작이 또 바뀌어요. 페이지 캐시의 데이터를 소켓 버퍼로 실제로 복사하는 대신, 데이터가 어디에 얼마만큼 있는지를 가리키는 디스크립터(주소 + 길이)만 소켓 버퍼에 추가해요. 그러면 NIC가 그 디스크립터를 보고 페이지 캐시에서 직접 데이터를 긁어모아(gather) 전송해요.

flowchart TD
    Disk[(Disk)] -->|1. DMA copy| PageCache[Kernel: Page Cache]
    PageCache -. only descriptor: address + length .-> SockBuf[Kernel: Socket Buffer]
    PageCache -->|2. DMA gather copy| NIC[NIC]

    subgraph KernelSpace[Kernel Space]
        PageCache
        SockBuf
    end

흐름을 보면 이래요.

  1. DMA 복사 — 디스크에서 페이지 캐시로.
  2. 디스크립터만 소켓 버퍼에 추가. 데이터 복사가 아니에요.
  3. DMA gather 복사 — NIC가 디스크립터를 보고 페이지 캐시에서 직접 데이터를 모아 전송.

이제 CPU 복사가 0번이에요. 남은 복사 2번은 전부 DMA가 담당하고요. 컨텍스트 스위치도 2번 그대로예요. 비로소 "CPU는 데이터를 한 바이트도 안 옮기는" 진짜 zero-copy가 완성됐어요.

네 가지 경로를 한 표로 정리하면 이래요.

방식 컨텍스트 스위치 DMA 복사 CPU 복사 총 복사
read + write 4 2 2 4
mmap + write 4 2 1 3
sendfile 2 2 1 3
sendfile + SG-DMA 2 2 0 2

위에서 아래로 내려갈수록 CPU가 할 일이 줄어요. 그리고 우리가 코드에서 신경 쓸 건 사실상 하나예요. "유저 공간을 거치지 않게 만드는 것." 그 다음 단계(scatter-gather)는 커널과 NIC가 알아서 해 줘요.

6. splice — sendfile의 일반화

sendfile에는 태생적인 제약이 하나 있었어요. in_fdmmap이 가능한 파일이어야 한다는 점이에요. 그래서 "소켓에서 받은 걸 다른 소켓으로" 같은 조합은 sendfile로 못 했어요. 이 제약을 풀어 준 게 splice예요.

ssize_t splice(int fd_in, off_t *off_in, int fd_out, off_t *off_out,
               size_t len, unsigned int flags);

splice의 핵심 아이디어는 커널 안의 파이프 버퍼예요. 두 파일 디스크립터 사이에서 데이터를 옮길 때, 데이터를 실제로 복사하는 대신 페이지를 가리키는 참조를 파이프 버퍼를 통해 넘겨요. 그래서 한쪽 끝이 파이프이기만 하면, 파일이든 소켓이든 자유롭게 이어 붙일 수 있어요. sendfile이 "파일에서 소켓으로"라는 한 가지 경로에 특화돼 있었다면, splice는 그걸 임의의 디스크립터 쌍으로 일반화한 셈이에요.

Linux 커널 문서를 보면 sendfilesplice는 남남이 아니에요. do_sendfile() 내부가 do_splice_direct()를 거치도록 정리돼 있어서, 우리가 sendfile을 부르든 FileChannel.transferTo를 부르든 그 아래에서는 결국 같은 커널 경로로 모이는 거예요. 그러니 "어떤 시스템 콜을 골라야 하나"를 너무 고민할 필요는 없어요. 우리가 할 일은 여전히 "유저 공간을 거치지 않게 만드는 것" 하나뿐이고, 어떤 커널 경로를 탈지는 커널이 알아서 정해요.

Java에서 splice를 직접 부르는 표준 API는 없어요. 하지만 FileChannel.transferTo/transferFrom이 운영체제가 제공하는 가장 좋은 경로(sendfile이든 그 아래의 splice든)로 알아서 연결해 주기 때문에, 우리는 이 메서드만 잘 쓰면 돼요.

7. Java에서 zero-copy 쓰기 — FileChannel.transferTo

이 모든 걸 Java에서 직접 시스템 콜로 부를 필요는 없어요. java.nio.channels.FileChanneltransferTotransferFrom이 운영체제의 zero-copy 경로로 연결해 줘요.

transferTo의 JavaDoc은 이렇게 설명해요. "이 메서드는 이 채널에서 읽어 대상 채널에 쓰는 단순한 루프보다 잠재적으로 훨씬 효율적일 수 있다. 많은 운영체제가 바이트를 실제로 복사하지 않고 파일 시스템 캐시에서 대상 채널로 직접 전송할 수 있다." 여기서 말하는 "직접 전송"이 바로 Linux에서는 sendfile이에요.

1절의 평범한 루프를 transferTo로 바꾸면 이렇게 돼요.

try (FileChannel fileChannel = FileChannel.open(path, StandardOpenOption.READ)) {
    long position = 0;
    long count = fileChannel.size();
    while (position < count) {
        long transferred =
            fileChannel.transferTo(position, count - position, socketChannel);
        if (transferred == 0) {
            break;
        }
        position += transferred;
    }
}

여기서 꼭 기억할 게 하나 있어요. transferTo요청한 만큼을 한 번에 다 보낸다고 보장하지 않아요. 실제로 전송된 바이트 수를 반환하는데, 그게 요청한 count보다 적을 수 있어요(소켓 버퍼가 가득 찼다든가 하는 이유로요). 그래서 반환값만큼 position을 옮겨 가며 다 보낼 때까지 반복해야 해요. 한 번 부르고 끝내면 데이터가 잘려 나가는 버그가 생겨요. 이건 zero-copy의 한계가 아니라 논블로킹 I/O의 본질이에요.

여기엔 커널 차원의 상한도 있어요. Linux의 sendfile은 한 번 호출에 최대 0x7ffff000바이트(약 2,147,479,552바이트, 2GiB 바로 아래)까지만 전송하고 실제 전송한 바이트 수를 반환해요. 32비트든 64비트든 동일한 상한이에요. 그러니 2GB가 넘는 파일을 보낼 때 위의 루프가 단지 좋은 습관이 아니라 반드시 필요한 구조라는 뜻이에요. 어차피 반환값 기준으로 반복하도록 짜 두면, 부분 전송이든 이 상한이든 한 번에 자연스럽게 처리돼요.

JavaDoc이 "잠재적으로", "많은 운영체제가"라고 조심스럽게 표현한 데에도 이유가 있어요. transferTo는 zero-copy를 보장하는 게 아니라, 운영체제가 지원하면 그 경로를 쓰려고 시도하는 메서드예요. zero-copy 경로가 없는 환경이거나 조건이 안 맞으면, JVM이 내부적으로 평범한 유저 공간 복사로 조용히 폴백해요. 즉 transferTo로 바꾼다고 항상 빨라진다고 단정할 수는 없고, "가능하면 가장 빠른 길로 가 달라"는 요청에 가까워요. 다행히 우리가 주로 돌리는 Linux 서버 환경에서는 이 길이 거의 항상 열려 있어요.

반대 방향도 있어요. transferFrom은 어떤 채널에서 읽어 이 파일 채널로 옮겨요. JavaDoc 설명도 대칭이에요. "많은 운영체제가 바이트를 실제로 복사하지 않고 소스 채널에서 파일 시스템 캐시로 직접 전송할 수 있다." 네트워크에서 받은 데이터를 디스크에 떨굴 때 쓰면 좋아요.

try (FileChannel fileChannel =
         FileChannel.open(path, StandardOpenOption.WRITE, StandardOpenOption.CREATE)) {
    long position = 0;
    long expected = contentLength;
    while (position < expected) {
        long transferred =
            fileChannel.transferFrom(socketChannel, position, expected - position);
        if (transferred == 0) {
            break;
        }
        position += transferred;
    }
}

여기서도 반환값을 기준으로 반복하는 구조는 똑같아요. 한 번에 다 옮겨진다고 가정하지 않는 게 핵심이에요. transferTo가 "파일을 소켓으로 흘려보내는" 길이라면, transferFrom은 "소켓에서 받은 걸 파일로 떨구는" 반대 방향의 같은 길이에요. 업로드 받은 파일을 디스크에 저장하거나, 복제 데이터를 로그 파일에 적재하는 경로에서 유저 공간 복사를 통째로 건너뛸 수 있어요.

transferTommap을 언제 쓰는지 감을 잡자면 이래요.

FileChannel.transferTo FileChannel.map (mmap)
데이터를 들여다보나? 아니요, 그대로 통과 예, 유저 공간에서 읽고 쓸 수 있음
유저 공간 복사 없음 없음(매핑), 단 접근 시 페이지 폴트
대표 용도 파일을 그대로 네트워크로 전송 파일을 메모리처럼 랜덤 액세스
주의점 부분 전송 루프 필요 SIGBUS, 매핑 해제 타이밍

데이터를 바꾸지 않고 그대로 흘려보내기만 한다면 transferTo가 거의 항상 정답이에요.

8. Kafka는 이걸 어떻게 쓰나

zero-copy가 빛을 발하는 가장 유명한 사례가 Kafka예요. Kafka 공식 문서의 Efficiency 절이 이걸 직접 설명해요.

Kafka 브로커가 컨슈머에게 메시지를 보낼 때, 만약 평범한 read/write를 썼다면 1절에서 본 그 네 번 복사가 메시지마다 반복됐을 거예요. 대신 Kafka는 이렇게 해요.

"데이터는 페이지 캐시에 정확히 한 번 복사되고, 읽을 때마다 유저 공간으로 복사해 내보내는 대신 매 소비마다 재사용된다."

그리고 실제 전송에는 sendfile을 써요. 공식 문서의 표현을 그대로 옮기면 이래요.

"현대 Unix 운영체제는 페이지 캐시에서 소켓으로 데이터를 옮기는 고도로 최적화된 경로를 제공한다. Linux에서는 이것이 sendfile 시스템 콜로 이뤄진다. JVM 기반 애플리케이션은 FileChanneltransferTo() 메서드를 사용해야 하며, 이는 zero copy가 지원되는 경우 내부적으로 그 시스템 콜을 호출한다."

여기서 페이지 캐시와 sendfile의 조합이 만들어 내는 결과가 인상적이에요. 공식 문서는 이렇게 말해요.

"컨슈머들이 대체로 따라잡은 상태인 Kafka 클러스터에서는 디스크에서 읽기 활동이 전혀 보이지 않을 것이다. 데이터를 전부 캐시에서 제공하기 때문이다."

방금 쓴 메시지는 페이지 캐시에 따끈하게 남아 있고, 컨슈머가 그걸 바로 따라 읽으면 디스크까지 갈 일이 없어요. 거기에 sendfile까지 더해지니, "디스크 기반 시스템인데 메모리처럼 빠른" 게 가능해지는 거예요. Kafka가 파티션을 추가 전용(append-only) 로그 세그먼트 파일로 단순하게 유지하는 것도, 이 transferTo 한 방으로 파일을 그대로 소켓에 흘려보내기 위한 설계예요. (참고로 Kafka의 오프셋·타임 인덱스 파일은 메모리 맵 파일로 다뤄요. 6절에서 본 mmap이 여기서 쓰여요.)

그런데 중요한 단서가 하나 있어요. 암호화를 켜면 zero-copy가 깨져요. TLS/SSL 라이브러리는 유저 공간에서 동작하기 때문에, 데이터를 암호화하려면 어쩔 수 없이 유저 공간으로 한 번 끌어와야 해요. Kafka 공식 문서도 in-kernel SSL_sendfile을 현재 지원하지 않아서, SSL이 켜져 있으면 sendfile을 쓰지 않는다고 명시해요. 즉, "데이터를 통과시키기만 한다"는 zero-copy의 전제가 깨지는 순간(암호화, 압축 변환 등) 이 최적화는 무력해져요. 이건 Kafka만의 이야기가 아니라 zero-copy의 본질적인 경계예요.

쓰는 길도 같은 철학이에요

지금까지는 "읽어서 보내는" 길만 봤지만, Kafka는 쓰는 길에도 같은 철학을 적용해요. Kafka 공식 설계 문서의 첫 마디가 "파일 시스템을 두려워하지 마라(Don't fear the file system!)"예요. 디스크는 느리다는 통념을 정면으로 반박하는 거예요.

프로듀서가 보낸 메시지를 Kafka는 곧바로 파일 시스템의 로그에 추가 기록(append) 해요. 굳이 디스크에 강제로 내려쓰지(flush) 않고요. 그러면 그 데이터는 사실상 커널의 페이지 캐시에 올라간 상태가 돼요. 그리고 이 추가 기록은 파일 끝에 순차적으로 붙이는 선형 쓰기(linear write) 라서, 운영체제가 가장 잘 최적화하는 패턴이에요. 방금 쓴 데이터가 페이지 캐시에 따끈하게 올라와 있으니, 곧이어 컨슈머가 그걸 sendfile로 읽어 갈 때 디스크를 안 건드려도 되는 그림이 자연스럽게 완성돼요. 쓰는 쪽과 읽는 쪽이 같은 페이지 캐시를 공유하는 거예요.

페이지 캐시를 신뢰하는 설계

zero-copy는 페이지 캐시가 받쳐 줄 때만 제 위력을 내요. 그래서 Kafka는 아예 페이지 캐시를 1차 캐시로 삼는 쪽으로 설계를 밀어붙였어요.

많은 시스템이 자주 쓰는 데이터를 애플리케이션 메모리(JVM 힙) 안에 따로 캐싱해요. 그런데 그러면 같은 데이터가 페이지 캐시에도 있고 힙에도 있는 이중 버퍼링이 생기고, 힙에 쌓인 캐시는 GC 부담으로 돌아와요. Kafka는 이 길을 일부러 피해요. 데이터를 힙에 들고 있지 않고, 운영체제의 페이지 캐시에 맡겨요. 그 결과 힙은 가벼워지고, 캐시 적중 여부는 OS가 관리하고, transferTo 한 번이면 그 캐시를 그대로 소켓으로 흘려보낼 수 있어요.

이 설계에는 또 하나의 이점이 있어요. JVM 프로세스가 재시작돼도 페이지 캐시는 OS가 들고 있으니 캐시가 따뜻한 채로 남아 있어요. 인메모리 캐시였다면 재시작과 함께 전부 차갑게 비워졌을 거예요. Kafka가 디스크 기반인데도 빠른 이유의 절반이 zero-copy라면, 나머지 절반은 이렇게 페이지 캐시를 정면으로 신뢰하는 설계 결정이에요. 둘은 사실 한 몸이에요. 페이지 캐시에 데이터가 있어야 sendfile이 디스크를 안 건드리고, sendfile이 있어야 그 페이지 캐시를 복사 없이 내보낼 수 있으니까요.

여기에 자료 구조까지 더해져요. Kafka 공식 설계 문서는 큐의 모든 연산이 데이터 크기와 무관하게 상수 시간(O(1))이고, 읽기가 쓰기를 막지 않으며 읽기끼리도 서로를 막지 않는다고 설명해요. 그래서 성능이 데이터 크기에서 분리돼요. 로그가 수 테라바이트로 불어나도 메시지 한 건을 추가하고 읽어 내보내는 비용은 그대로예요. 선형 추가 기록, 페이지 캐시, sendfile이라는 세 톱니가 맞물려, "디스크가 느리다"는 통념이 적어도 이 워크로드에서는 성립하지 않게 만든 거예요.

9. 흔한 오해 세 가지

이 주제는 이름 때문에 오해를 사기 쉬워요. 정리하는 차원에서 세 가지만 짚을게요.

"zero-copy면 복사가 0번이다." 아니에요. 디스크에서 페이지 캐시로, 페이지 캐시에서 NIC로 가는 DMA 복사는 어떤 방식을 써도 남아요. zero-copy가 0으로 만드는 건 CPU 복사와 유저 공간을 거치는 복사예요. 앞의 비교 표에서 가장 좋은 경우도 DMA 복사는 2번이었던 걸 떠올리면 돼요.

"transferTo로 바꾸면 무조건 빨라진다." 아니에요. JavaDoc이 "잠재적으로", "많은 운영체제가"라고 말한 그대로, 보장이 아니라 시도예요. zero-copy 경로가 없으면 평범한 복사로 폴백하고, 파일이 아주 작으면 시스템 콜 한 번 줄인 차이가 잘 안 보여요. 이득은 데이터가 크고 트래픽이 많을수록 또렷해져요.

"데이터를 가공해도 zero-copy가 유지된다." 아니에요. 압축하거나 암호화하거나 내용을 조금이라도 바꾸려면 데이터를 유저 공간으로 가져와야 하고, 그 순간 zero-copy의 전제가 깨져요. Kafka가 SSL을 켜면 sendfile을 못 쓰는 게 정확히 이 이유예요. zero-copy는 "그대로 통과"에만 적용되는 최적화예요.

데이터의 여정을 한 문장으로

지금까지 그린 다이어그램들을 한 줄로 압축하면 이래요. 데이터는 항상 디스크에서 페이지 캐시로 한 번(DMA), 페이지 캐시에서 NIC로 한 번(DMA) 움직인다. 우리가 줄일 수 있는 건 그 사이에 끼어드는, CPU가 직접 하는 복사와 유저 공간 왕복뿐이다. read/write는 그 사이에 CPU 복사 두 번과 모드 전환 네 번을 끼워 넣고, mmap은 한 번을 빼고, sendfile은 유저 공간을 통째로 들어내고, scatter-gather DMA는 마지막 CPU 복사마저 디스크립터로 대체해요. 방향이 하나라서 외우기 쉬워요. "건드리지 않을 데이터일수록, 손을 덜 대는 길로." 이 한 문장이 이 글 전체의 요약이에요.

10. 정리 — zero-copy가 통할 때와 안 통할 때

긴 이야기를 한 문장으로 줄이면 이래요. 데이터를 바꾸지 않고 그대로 흘려보낼 때, 유저 공간을 거치지 않게 만들면 복사와 컨텍스트 스위치가 확 줄어든다.

언제 효과가 큰지 정리하면 이래요.

  • 잘 통하는 경우: 정적 파일 서버, Kafka처럼 로그를 그대로 전송하는 시스템, 프록시처럼 받은 걸 그대로 넘기는 경우. 데이터가 클수록, 트래픽이 많을수록 이득이 커져요.
  • 효과가 작거나 안 통하는 경우: 데이터를 압축하거나 암호화해서 보내야 할 때(유저 공간을 거쳐야 하므로), 보낼 내용을 애플리케이션이 직접 가공할 때, 파일이 아주 작아서 시스템 콜 한 번 차이가 의미 없을 때.

고민이 될 때는 이 흐름 하나로 정리할 수 있어요.

flowchart TD
    Start[Need to move file data] --> Q1{Transform the data?<br/>encrypt / compress / edit}
    Q1 -->|Yes| Plain[Plain read/write<br/>zero-copy not applicable]
    Q1 -->|No| Q2{Need random access<br/>in user space?}
    Q2 -->|Yes| Mmap[FileChannel.map<br/>mmap]
    Q2 -->|No| Transfer[FileChannel.transferTo<br/>or transferFrom]
    Transfer --> Loop[Wrap in a loop<br/>on the returned byte count]

그리고 우리가 코드에서 할 일은 의외로 단순해요. 직접 sendfile을 부르는 게 아니라, FileChannel.transferTo를 쓰고 부분 전송을 루프로 감싸는 것 정도예요. 그 아래의 mmap이냐 sendfile이냐 scatter-gather냐는 커널과 하드웨어가 알아서 가장 좋은 경로를 골라 줘요. "Kafka는 왜 빠른가"라는 질문의 답 중 절반은, 결국 이 한 줄짜리 메서드와 페이지 캐시의 협업에 있어요. 나머지 절반은 그 캐시를 정면으로 신뢰한 설계 결정이고요. 결국 zero-copy는 화려한 기교가 아니라, "데이터를 안 건드릴 거면 굳이 들고 오지도 말자"는 단순한 원칙을 커널과 하드웨어까지 밀어붙인 결과예요.

참고자료

More from this blog

JVM 객체 할당의 비밀 — TLAB, Bump-the-Pointer, 그리고 할당이 거의 공짜인 이유

Java에서 new를 호출하면 무슨 일이 벌어질까요? "힙에 메모리를 잡는다"는 한 문장 뒤에는 스레드마다 자기만의 분양 구역을 나눠 갖는 정교한 설계가 숨어 있어요. 이 글은 HotSpot JVM이 객체 할당을 어떻게 "거의 공짜"로 만드는지 그 내부를 따라가 보려는 글이에요. JVM 메모리 동작 원리에 관심 있는 분께 권해요. 자바를 쓰다 보면 객체를

May 15, 202614 min read

Java Zero-Copy — FileChannel.transferTo, sendfile, 그리고 Kafka가 디스크를 네트워크로 흘려보내는 방법

"파일을 읽어서 소켓으로 보낸다." 한 줄짜리 요구사항이에요. 그런데 이 한 줄 뒤에서 데이터는 메모리를 네 번이나 복사하고, CPU는 커널과 유저 공간을 네 번이나 들락거려요. Kafka처럼 초당 수십만 건을 흘려보내야 하는 시스템에서 이 비용은 그냥 넘길 수가 없어요. 이 글은 그 복사를 한 겹씩 벗겨내는 zero-copy의 동작 원리를 따라가요. 전통

May 15, 202617 min read

Git merge 내부 동작 — 3-way merge, merge base, 그리고 recursive에서 ort로

git merge를 매일 쓰지만, 그 한 줄이 안에서 무슨 일을 하는지 들여다본 적은 드물어요. 이 글은 merge가 두 갈래의 변경을 어떻게 합치는지, merge base가 왜 필요한지, 그리고 Git이 기본 전략을 recursive에서 ort로 갈아치운 이유를 따라가요. Git을 쓰는 백엔드 개발자를 대상으로 해요. 브랜치 두 개를 합치는 일은 겉보기

May 15, 202612 min read

Java NIO ByteBuffer 내부 구조 — Direct vs Heap, Cleaner, 그리고 off-heap 메모리가 GC를 우회하는 방법

Netty가 빠른 이유, Kafka 클라이언트가 직렬화에 신경 쓰는 이유, MappedByteBuffer로 수 GB짜리 파일을 다루는 이유. 그 한가운데에는 ByteBuffer가 있어요. 이번 글에서는 ByteBuffer의 두 얼굴 — heap과 direct — 가 어떻게 다른지, off-heap 메모리는 어떻게 잡고 어떻게 풀리는지, JVM과 운영체제 사

May 15, 202612 min read

Java Flight Recorder 내부 구조 — Thread-Local Buffer부터 Disk Repository까지

JFR을 켜면 1% 미만 오버헤드로 JVM 내부가 그대로 기록돼요. 어떻게 이렇게 가벼울 수 있는지, 그리고 그 데이터가 어떤 경로를 거쳐 디스크에 쌓이는지 한 번 따라가 봐요. 이 글은 JFR을 "그냥 잘 쓰는 도구"에서 "내부 동작을 아는 도구"로 끌어올리고 싶은 분을 위한 글이에요. 운영 중인 서버에서 갑자기 응답 시간이 튀어요. 메트릭 그래프는 분

May 15, 202614 min read

끄적끄적 테크 블로그

162 posts

물류 회사에 다니고 있는 개발자 블로그입니다. 개발을 너무 좋아해서 정신없이 작업하다가 중간에 끄적거리며 내용들을 몇개 적어봅니다 ㅎㅎ