워로디스

FileChannel에 ByteBuffer를 Chunk 단위로 쓰기 본문

개발/Kotlin

FileChannel에 ByteBuffer를 Chunk 단위로 쓰기

워로디스 2026. 5. 27. 22:38

최종 코드

import java.io.IOException
import java.nio.ByteBuffer
import java.nio.channels.FileChannel

private const val MAX_ZERO_WRITE_RETRIES = 16

/**
 * [sourceBuffer]의 현재 position부터 limit까지 남아 있는 데이터를 모두 [fileChannel]에 쓴다.
 *
 * 단, [FileChannel.write]에 너무 큰 ByteBuffer 범위를 한 번에 넘기지 않도록,
 * 최대 [maxChunkBytes] 크기의 slice view로 나누어 순차적으로 쓴다.
 *
 * 이 함수는 chunk 데이터를 복사하지 않는다.
 * 각 chunk는 [sourceBuffer]의 데이터를 공유하는 slice view다.
 *
 * 주의:
 * 이 함수는 [sourceBuffer]가 이미 점유하고 있는 메모리를 줄이지 않는다.
 * 특히 [sourceBuffer]가 큰 DirectByteBuffer라면, 해당 native memory는
 * 이 함수가 호출되기 전에 이미 할당된 상태다.
 */
@Throws(IOException::class)
fun writeBuffer(
    fileChannel: FileChannel,
    sourceBuffer: ByteBuffer,
    maxChunkBytes: Int
) {
    require(maxChunkBytes > 0) { "maxChunkBytes must be positive" }

    while (sourceBuffer.hasRemaining()) {
        val chunkLength = minOf(sourceBuffer.remaining(), maxChunkBytes)

        val chunkBuffer = sourceBuffer.slice()
        chunkBuffer.limit(chunkLength)

        var zeroWriteCount = 0

        while (chunkBuffer.hasRemaining()) {
            val writtenBytes = fileChannel.write(chunkBuffer)

            if (writtenBytes == 0) {
                if (++zeroWriteCount >= MAX_ZERO_WRITE_RETRIES) {
                    throw IOException("FileChannel.write made no progress")
                }

                Thread.yield()
            } else {
                zeroWriteCount = 0
            }
        }

        sourceBuffer.position(sourceBuffer.position() + chunkLength)
    }
}

목적

이 함수의 목적은 ByteBuffer에 남아 있는 데이터를 모두 FileChannel에 쓰되, 한 번에 너무 큰 버퍼 범위를 FileChannel.write()에 넘기지 않도록 제한하는 것이다.

즉, sourceBuffer.position()부터 sourceBuffer.limit()까지의 데이터를 전부 쓰지만, 쓰기 대상 범위는 maxChunkBytes 이하로 나누어 순차적으로 처리한다.

sourceBuffer remaining:
[ chunk 1 ][ chunk 2 ][ chunk 3 ][ last chunk ]
   write      write      write       write

호출자 관점에서 이 함수의 책임은 단순하다.

writeBuffer(fileChannel, sourceBuffer, maxChunkBytes)

즉, 주어진 sourceBuffer의 remaining 데이터를 fileChannel에 쓴다.

maxChunkBytes는 그 과정에서 한 번에 FileChannel.write()에 넘길 수 있는 최대 byte 범위를 제한하는 정책값이다.

사용 전략

핵심 전략은 다음과 같다.

  1. sourceBuffer의 현재 position부터 남은 데이터를 기준으로 slice view를 만든다.
  2. 해당 slice의 limit을 maxChunkBytes 이하로 제한한다.
  3. 제한된 chunkBufferFileChannel.write()에 넘긴다.
  4. chunk가 완전히 쓰일 때까지 반복한다.
  5. chunk 쓰기가 끝나면 원본 sourceBuffer.position()을 chunk 길이만큼 전진시킨다.
  6. 원본 버퍼에 남은 데이터가 없어질 때까지 반복한다.

핵심 코드는 이 부분이다.

val chunkLength = minOf(sourceBuffer.remaining(), maxChunkBytes)

val chunkBuffer = sourceBuffer.slice()
chunkBuffer.limit(chunkLength)

slice()는 데이터를 복사하지 않고 원본 ByteBuffer의 현재 position부터 시작하는 view를 만든다. 따라서 chunk마다 별도의 byte array나 direct buffer를 새로 만들지 않는다.

쓰기 정확성

이 함수는 sourceBuffer의 remaining 데이터를 모두 쓴다.

FileChannel.write(chunkBuffer)는 한 번 호출했다고 반드시 chunkBuffer 전체를 쓰는 것이 아니므로, 내부에서 다음처럼 반복한다.

while (chunkBuffer.hasRemaining()) {
    val writtenBytes = fileChannel.write(chunkBuffer)
    ...
}

이 반복이 끝나면 현재 chunk는 모두 기록된 상태다.

그 다음 원본 버퍼의 position을 전진시킨다.

sourceBuffer.position(sourceBuffer.position() + chunkLength)

이 처리가 있기 때문에 다음 루프의 sourceBuffer.slice()는 방금 쓴 chunk 다음 위치부터 시작한다.

따라서 데이터는 다음 조건을 만족한다.

누락 없이 쓰인다.
중복 없이 쓰인다.
원래 ByteBuffer의 순서대로 쓰인다.
sourceBuffer의 position은 최종적으로 limit까지 이동한다.

메모리 관점

이 구현은 chunk 데이터를 새로 복사하지 않는다.

각 chunk는 다음 코드로 만들어진다.

val chunkBuffer = sourceBuffer.slice()

chunkBuffer는 원본 sourceBuffer와 같은 데이터를 바라보는 view다. 따라서 chunk마다 큰 메모리를 새로 할당하지 않는다.

다만 이 함수의 메모리 효과는 정확히 구분해야 한다.

효과가 있는 부분

이 함수는 FileChannel.write()에 한 번에 전달되는 ByteBuffer의 remaining 크기를 maxChunkBytes 이하로 제한한다.

따라서 큰 heap ByteBuffer를 파일에 쓸 때, 내부적으로 native/direct 임시 버퍼가 사용되는 경로라면 한 번에 큰 native 메모리가 잡히는 상황을 완화하는 데 도움이 될 수 있다.

즉, 이 함수의 목적은 다음에 가깝다.

큰 sourceBuffer 전체를 한 번에 FileChannel.write()에 넘기지 않는다.
대신 maxChunkBytes 이하의 작은 view로 나누어 순차적으로 write한다.

효과가 없는 부분

이미 sourceBuffer 자체가 큰 DirectByteBuffer라면, 그 direct buffer의 native memory는 이 함수가 호출되기 전에 이미 할당되어 있다.

따라서 이 함수는 다음을 해결하지 않는다.

이미 할당된 큰 DirectByteBuffer의 native memory를 줄이는 것
sourceBuffer 자체의 크기를 줄이는 것
sourceBuffer 데이터를 작은 버퍼로 재구성하는 것

즉, 이 함수는 원본 버퍼의 메모리 점유량을 줄이는 함수가 아니라, FileChannel.write()에 전달되는 쓰기 범위를 제한하는 함수다.

writtenBytes == 0 처리

FileChannel.write()는 이론적으로 0을 반환할 수 있다. 이때 chunkBuffer.hasRemaining()이 계속 true라면 무한 루프가 발생할 수 있다.

그래서 이 구현은 0-byte write가 발생했을 때 즉시 실패하지 않고, 제한된 횟수만 재시도한다.

if (writtenBytes == 0) {
    if (++zeroWriteCount >= MAX_ZERO_WRITE_RETRIES) {
        throw IOException("FileChannel.write made no progress")
    }

    Thread.yield()
} else {
    zeroWriteCount = 0
}

이 전략은 다음을 모두 만족한다.

일시적인 0-byte write는 허용한다.
계속 progress가 없으면 무한 루프를 방지한다.
정상적으로 write가 진행되면 zeroWriteCount를 초기화한다.

요약

이 구현은 다음 목적에 적합하다.

ByteBuffer의 remaining 데이터를 모두 FileChannel에 기록한다.
한 번에 큰 ByteBuffer 범위를 넘기지 않고 maxChunkBytes 단위로 나누어 쓴다.
chunk 데이터 복사를 피한다.
FileChannel.write()의 partial write를 처리한다.
0-byte write로 인한 무한 루프를 방지한다.

다만 다음 한계는 명확히 이해해야 한다.

이미 큰 DirectByteBuffer가 만들어진 상태라면 그 native memory 자체를 줄이지는 못한다.
메모리 절감 효과는 주로 FileChannel.write()에 한 번에 전달되는 범위를 제한하는 데 있다.

따라서 이 코드는 ByteBuffer를 파일에 쓸 때, 쓰기 단위를 제한해서 native memory 압박을 완화하려는 목적에 부합하는 구현이다.

반응형