청크 입출력과 버퍼
한 바이트씩 파일을 처리할 때 생기는 시스템 호출 비용을 분석하고 부분 청크, 메모리 상한, 측정 가능한 버퍼 전략으로 입출력 성능을 개선합니다.
파일 입출력 속도는 자바 반복문의 횟수만으로 결정되지 않습니다.
사용자 공간에서 커널로 넘어가는 시스템 호출, 파일 시스템 캐시, 저장 장치의 접근 단위가 함께 작동합니다.
한 바이트마다 read와 write를 부르면 아주 작은 데이터를 전달하려고 비싼 구분을 수백만 번 넘습니다.
바이트 배열로 묶으면 같은 양을 훨씬 적은 호출로 옮길 수 있습니다.
버퍼가 클수록 항상 빠른 것은 아닙니다. 일정 크기 이후에는 처리량 증가가 거의 없고, 동시 작업 수만큼 배열 메모리가 늘며 CPU 캐시 효율이 나빠질 수 있습니다. 8KiB나 16KiB 같은 합리적인 시작값을 두고 실제 파일 크기와 저장 환경에서 측정해 선택합니다.
바이트 단위 파일 입출력 비용
다음 코드는 결과 자체는 맞지만 입력 바이트 수만큼 읽기와 쓰기 메서드를 호출합니다. 구현과 운영체제 버퍼가 일부 비용을 흡수할 수 있어도 호출 횟수는 그대로 큽니다. 작은 예제에서 빨라 보이는 결과를 대용량 파일에 일반화하면 처리 시간이 갑자기 늘어납니다.
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
public final class SingleByteFileCopy {
static void copy(Path source, Path target) throws Exception {
try (InputStream input = Files.newInputStream(source);
OutputStream output = Files.newOutputStream(target)) {
int value;
while ((value = input.read()) != -1) {
output.write(value);
}
}
}
public static void main(String[] args) throws Exception {
Path source = Files.createTempFile("one-byte-", ".bin");
Path target = Files.createTempFile("one-byte-copy-", ".bin");
try {
Files.write(source, new byte[4096]);
copy(source, target);
System.out.println(Files.size(target));
} finally {
Files.deleteIfExists(source);
Files.deleteIfExists(target);
}
}
}이 반례를 고칠 때 먼저 반복문을 지우는 것이 아니라 유효한 청크 크기와 마지막 부분 읽기를 처리합니다.
파일 복사만 필요하면 Files.copy 또는 transferTo가 더 분명하지만, 변환·진행률·상한 검사가 있으면 직접 청크 루프를 유지할 이유가 있습니다.
반환된 바이트 수를 반영한 청크 복사
배열 읽기는 버퍼 전체가 아니라 실제 개수를 반환합니다.
마지막 반복에서 3바이트만 읽었는데 8KiB 전체를 쓰면 앞 반복의 잔여 바이트가 파일 뒤에 붙습니다.
write(buffer, 0, count)는 성능뿐 아니라 정확성을 위한 호출입니다.
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
public final class ChunkedFileCopy {
static long copy(Path source, Path target, int bufferSize) throws Exception {
if (bufferSize < 1) throw new IllegalArgumentException("bufferSize");
long total = 0;
byte[] buffer = new byte[bufferSize];
try (InputStream input = Files.newInputStream(source);
OutputStream output = Files.newOutputStream(target)) {
int count;
while ((count = input.read(buffer)) != -1) {
output.write(buffer, 0, count);
total += count;
}
}
return total;
}
public static void main(String[] args) throws Exception {
Path source = Files.createTempFile("chunk-", ".bin");
Path target = Files.createTempFile("chunk-copy-", ".bin");
try {
byte[] data = MessageDigest.getInstance("SHA-256").digest("payload".getBytes());
Files.write(source, data);
System.out.println("copied=" + copy(source, target, 7));
System.out.println(java.util.Arrays.equals(data, Files.readAllBytes(target)));
} finally {
Files.deleteIfExists(source);
Files.deleteIfExists(target);
}
}
}일부 특수 InputStream은 길이가 0인 배열을 제외하면 0을 반환하지 않는 것이 일반 규칙이지만, 비표준 구현을 연결할 수 있다면 0 처리 정책도 검토합니다.
파일 스트림에서는 -1만 종료로 보고 짧은 양수 반환을 정상적인 데이터 조각으로 받아들입니다.
성능 측정의 준비·검사 시간 분리
파일 생성, 워밍업, 복사, 결과 해시를 한 타이머에 넣으면 무엇을 개선했는지 알 수 없습니다. 대상 데이터를 미리 만들고 여러 번 수행하며, 각 실행 뒤 크기와 해시를 확인합니다. 운영체제 캐시 때문에 두 번째 실행이 빨라질 수 있으므로 차가운 캐시와 따뜻한 캐시 중 어떤 시나리오를 재는지도 기록합니다.
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.List;
public final class BufferSizeProbe {
static long drain(byte[] data, int bufferSize) throws IOException {
byte[] buffer = new byte[bufferSize];
long start = System.nanoTime();
try (var input = new ByteArrayInputStream(data)) {
while (input.read(buffer) != -1) {
// 저장 장치 대신 읽기 루프의 호출 비용만 비교한다.
}
}
return System.nanoTime() - start;
}
public static void main(String[] args) throws Exception {
byte[] data = new byte[4 * 1024 * 1024];
for (int size : List.of(1, 512, 4096, 16384)) {
drain(data, size);
System.out.printf("buffer=%d elapsedNanos=%d%n", size, drain(data, size));
}
}
}이 마이크로 실험은 실제 디스크 성능을 대표하지 않습니다. 오직 호출 횟수 차이를 관찰하는 교육용 도구입니다. 운영 결정을 위해서는 대상 파일 시스템, 대표 파일 크기 분포, 동시 복사 수를 포함한 벤치마크가 필요합니다. 짧은 실행 한 번의 숫자보다 반복 분포와 결과 정확성이 중요합니다.
전체 읽기와 스트리밍 사이에서 메모리 예산 계산
10MiB 파일 하나를 readAllBytes로 읽는 것은 단순하지만 동시 요청 200개라면 원본 배열만 약 2GiB입니다.
여기에 변환 결과와 객체 오버헤드가 더해집니다.
파일당 최대 크기와 동시에 처리할 수를 곱해 힙 예산과 비교하면 전체 읽기를 허용할 수 있는지 판단할 수 있습니다.
작은 설정 파일이나 서명 검증처럼 전체 바이트가 필요한 작업은 크기 상한 아래에서 한 번에 읽는 편이 읽기 쉽습니다. 영상 업로드, 백업, 로그 병합은 청크로 처리하고 진행률과 취소 지점을 둡니다. 한 API가 두 범위를 모두 받는다면 임계값에 따라 구현을 나누되 반환 의미는 같게 유지합니다.
청크 변환의 경계에 걸친 상태 보존
암호 해시처럼 각 바이트를 순서대로 소비하는 연산은 청크 경계에 영향을 받지 않습니다.
반면 UTF-8 문자, 압축 프레임, 줄 구분자는 한 단위가 두 청크에 걸칠 수 있습니다.
이 경우 바이트 청크를 각각 new String으로 바꾸면 문자를 반으로 자릅니다.
상태를 유지하는 디코더나 전용 스트림 래퍼를 사용해야 합니다.
진행률은 누적 바이트를 파일 크기로 나눌 수 있지만 크기가 알려지지 않은 스트림에서는 처리량과 누적량만 표시합니다. 매 청크마다 로그를 남기면 입출력보다 로깅이 더 비싸질 수 있으므로 시간 또는 바이트 간격으로 샘플링합니다. 취소 신호는 청크 사이에서 확인하면 최악의 중단 지연이 한 청크 처리 시간으로 제한됩니다.
버퍼 선택 판단표
| 상황 | 시작 전략 | 확인할 값 |
|---|---|---|
| 작은 제한 파일 | 전체 읽기 | 동시 메모리 합계 |
| 대용량 단순 복사 | Files.copy 또는 transferTo | 크기와 해시 |
| 변환이 있는 파일 | 8~16KiB 청크 | 처리량과 p99 |
| 느린 원격 스트림 | 제한된 청크 | 시간 제한과 취소 |
| 다수 동시 작업 | 작은 재사용 버퍼 검토 | 총 힙과 GC |
연습 문제
입력을 4KiB씩 복사하면서 최대 1MiB를 넘으면 중단하고, 성공한 경우 SHA-256 해시와 복사 바이트 수를 반환하세요. 실패했을 때 대상에 부분 결과가 남지 않도록 메모리 스트림으로 확인합니다.
정답과 해설
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.security.MessageDigest;
import java.util.HexFormat;
public final class HashedCopySolution {
record Result(long bytes, String sha256) {}
static Result copy(InputStream input, OutputStream output, long limit) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] buffer = new byte[4096];
long total = 0;
int count;
while ((count = input.read(buffer)) != -1) {
total += count;
if (total > limit) throw new IOException("size limit exceeded");
digest.update(buffer, 0, count);
output.write(buffer, 0, count);
}
return new Result(total, HexFormat.of().formatHex(digest.digest()));
}
public static void main(String[] args) throws Exception {
byte[] source = "chunked-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
var target = new ByteArrayOutputStream();
System.out.println(copy(new ByteArrayInputStream(source), target, 1024));
}
}실제 파일에서는 임시 파일에 복사한 뒤 성공했을 때 최종 경로로 이동해야 부분 결과를 숨길 수 있습니다. 해시는 쓰기 전에 같은 유효 구간을 갱신하므로 대상에 전달한 데이터와 정확히 대응합니다.
청크 복사 전략을 확정하는 기준
청크 입출력의 목표는 큰 배열을 만드는 일이 아니라 장치 경계 호출을 줄이면서 메모리와 정확성에 상한을 두는 것입니다. 부분 읽기, 마지막 조각, 초과 입력, 결과 해시를 확인하고 대표 환경에서 측정했을 때만 선택한 버퍼 크기를 성능 결정으로 인정할 수 있습니다.