| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | |||||
| 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| 10 | 11 | 12 | 13 | 14 | 15 | 16 |
| 17 | 18 | 19 | 20 | 21 | 22 | 23 |
| 24 | 25 | 26 | 27 | 28 | 29 | 30 |
| 31 |
- uv init
- tauri
- docker
- RandomAccessFile
- SharedArrayBuffer
- Python
- secure context
- Docker Compose
- json schema
- yaml
- ndjson
- PowerShell
- curl
- cli
- FileChannel
- Java
- Ollama
- vim
- uv pin
- 이미지
- cross-origin isolated
- io
- Vite
- Typescript
- json
- Webpack
- vscode
- Python Install Manager
- podman
- UV
- Today
- Total
워로디스
FileChannel에 ByteBuffer를 Chunk 단위로 쓰기 본문
최종 코드
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 범위를 제한하는 정책값이다.
사용 전략
핵심 전략은 다음과 같다.
sourceBuffer의 현재 position부터 남은 데이터를 기준으로 slice view를 만든다.- 해당 slice의 limit을
maxChunkBytes이하로 제한한다. - 제한된
chunkBuffer를FileChannel.write()에 넘긴다. - chunk가 완전히 쓰일 때까지 반복한다.
- chunk 쓰기가 끝나면 원본
sourceBuffer.position()을 chunk 길이만큼 전진시킨다. - 원본 버퍼에 남은 데이터가 없어질 때까지 반복한다.
핵심 코드는 이 부분이다.
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 압박을 완화하려는 목적에 부합하는 구현이다.
