안동민 개발노트

본문 시작

청크 입출력과 버퍼

한 바이트씩 파일을 처리할 때 생기는 시스템 호출 비용을 분석하고 부분 청크, 메모리 상한, 측정 가능한 버퍼 전략으로 입출력 성능을 개선합니다.

파일 입출력 속도는 자바 반복문의 횟수만으로 결정되지 않습니다.

사용자 공간에서 커널로 넘어가는 시스템 호출, 파일 시스템 캐시, 저장 장치의 접근 단위가 함께 작동합니다.

한 바이트마다 read와 write를 부르면 자바 메서드 호출이 입력 크기만큼 늘어납니다. 실제 시스템 호출 수와 비용은 스트림 구현과 버퍼링에 따라 달라집니다.

바이트 배열로 묶으면 같은 양을 훨씬 적은 호출로 옮길 수 있습니다.

버퍼가 클수록 항상 빠른 것은 아닙니다.

일정 크기 이후에는 처리량 증가가 거의 없고, 동시 작업 수만큼 배열 메모리가 늘며 CPU 캐시 효율이 나빠질 수 있습니다.

8KiB나 16KiB 같은 합리적인 시작값을 두고 실제 파일 크기와 저장 환경에서 측정해 선택합니다.


바이트 단위 파일 입출력 비용

다음 코드는 결과 자체는 맞지만 입력 바이트마다 읽기와 쓰기를 호출하고, EOF 확인을 위해 읽기를 한 번 더 호출합니다.

구현과 운영체제 버퍼가 일부 비용을 흡수할 수 있어도 호출 횟수는 그대로 큽니다.

작은 예제에서 빨라 보이는 결과를 대용량 파일에 일반화하면 처리 시간이 갑자기 늘어납니다.

bad/SingleByteFileCopy.java
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)는 성능뿐 아니라 정확성을 위한 호출입니다.

src/ChunkedFileCopy.java
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.read(byte[])는 비어 있지 않은 배열에 대해 양수, EOF의 -1, 또는 예외를 제공합니다. 계약을 지키지 않는 별도 구현에서 0을 반복 반환하면 이 루프가 진행하지 않을 수 있습니다.

파일 스트림에서는 -1만 종료로 보고 짧은 양수 반환을 정상적인 데이터 조각으로 받아들입니다.


성능 측정의 준비·검사 시간 분리

파일 생성, 워밍업, 복사, 결과 해시를 한 타이머에 넣으면 무엇을 개선했는지 알 수 없습니다.

대상 데이터를 미리 만들고 여러 번 수행하며, 각 실행 뒤 크기와 해시를 확인합니다.

운영체제 캐시 때문에 두 번째 실행이 빨라질 수 있으므로 차가운 캐시와 따뜻한 캐시 중 어떤 시나리오를 재는지도 기록합니다.

src/BufferSizeProbe.java
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));
        }
    }
}

이 마이크로 실험은 실제 디스크 성능을 대표하지 않습니다.

출력하는 값은 메모리 읽기 루프의 경과 시간입니다. 호출 횟수를 직접 계측하지 않으며, 아래 횟수는 4MiB 입력과 ByteArrayInputStream의 읽기 규칙에서 계산한 값입니다.

운영 결정을 위해서는 대상 파일 시스템, 대표 파일 크기 분포, 동시 복사 수를 포함한 벤치마크가 필요합니다.

짧은 실행 한 번의 숫자보다 반복 분포와 결과 정확성이 중요합니다.

4MiB 메모리 입력의 읽기 호출 수

BufferSizeProbe의 drain 한 번마다 성공한 읽기 횟수에 EOF 확인 한 번을 더한 계산입니다.

4MiB 메모리 입력의 읽기 호출 수
버퍼 크기양수 반환 횟수EOF 포함 총 read 호출
1바이트4,194,3044,194,305
512바이트8,1928,193
4KiB · 4,096바이트1,0241,025
16KiB · 16,384바이트256257
1바이트
양수 반환 횟수: 4,194,304
EOF 포함 총 read 호출: 4,194,305
512바이트
양수 반환 횟수: 8,192
EOF 포함 총 read 호출: 8,193
4KiB · 4,096바이트
양수 반환 횟수: 1,024
EOF 포함 총 read 호출: 1,025
16KiB · 16,384바이트
양수 반환 횟수: 256
EOF 포함 총 read 호출: 257

각 크기에서 drain을 준비용 1회와 측정용 1회 호출합니다. 위 수는 drain 1회의 계산이며, 디스크 시스템 호출 수나 경과 시간의 순위를 뜻하지 않습니다.


전체 읽기와 스트리밍 사이에서 메모리 예산 계산

10MiB 파일 하나를 readAllBytes로 읽는 것은 단순하지만 동시 요청 200개라면 원본 배열만 약 2GiB입니다.

여기에 변환 결과와 객체 오버헤드가 더해집니다.

파일당 최대 크기와 동시에 처리할 수를 곱해 힙 예산과 비교하면 전체 읽기를 허용할 수 있는지 판단할 수 있습니다.

작은 설정 파일이나 서명 검증처럼 전체 바이트가 필요한 작업은 크기 상한 아래에서 한 번에 읽는 편이 읽기 쉽습니다.

영상 업로드, 백업, 로그 병합은 청크로 처리하고 진행률과 취소 지점을 둡니다.

한 API가 두 범위를 모두 받는다면 임계값에 따라 구현을 나누되 반환 의미는 같게 유지합니다.


청크 변환의 경계에 걸친 상태 보존

암호 해시처럼 각 바이트를 순서대로 소비하는 연산은 청크 경계에 영향을 받지 않습니다.

반면 UTF-8 문자, 압축 프레임, 줄 구분자는 한 단위가 두 청크에 걸칠 수 있습니다.

이 경우 바이트 청크를 각각 new String으로 바꾸면 문자를 반으로 자릅니다.

상태를 유지하는 디코더나 전용 스트림 래퍼를 사용해야 합니다.

진행률은 누적 바이트를 파일 크기로 나눌 수 있지만 크기가 알려지지 않은 스트림에서는 처리량과 누적량만 표시합니다.

매 청크마다 로그를 남기면 입출력보다 로깅이 더 비싸질 수 있으므로 시간 또는 바이트 간격으로 샘플링합니다.

취소 신호를 청크 사이에서 확인하면 다음 검사 지점에서 중단할 수 있습니다. 다만 현재의 read나 write가 오래 블로킹될 수 있어 청크 크기만으로 중단 시간의 상한을 보장하지는 못합니다.


버퍼 선택 판단표

상황시작 전략확인할 값
작은 제한 파일전체 읽기동시 메모리 합계
대용량 단순 복사Files.copy 또는 transferTo크기와 해시
변환이 있는 파일8~16KiB 청크처리량과 p99
느린 원격 스트림제한된 청크시간 제한과 취소
다수 동시 작업작은 재사용 버퍼 검토총 힙과 GC

연습 문제

입력을 4KiB씩 복사하면서 지정한 상한(예: 1MiB)을 넘으면 중단하고, 성공한 경우 SHA-256 해시와 복사 바이트 수를 반환하세요.

메모리 스트림으로 정상 복사를 확인하고, 실패한 부분 결과를 공개하지 않기 위해 호출자가 해야 할 일도 설명하세요.

정답과 해설
exercise/HashedCopySolution.java
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));
    }
}

main은 15바이트 입력에 1024바이트 상한을 적용한 정상 경로입니다. 상한 초과 경로나 부분 결과 폐기를 실행하는 코드는 없습니다.

copy는 이미 쓴 앞부분을 되돌리지 않습니다. 호출자는 메모리 버퍼나 임시 파일을 별도로 소유하고, 성공한 뒤에만 결과를 공개하며 실패하면 그 임시 결과를 폐기해야 합니다.

성공해 Result를 반환한 경우 해시와 바이트 수는 출력에 전달한 유효 구간에 대응합니다. 쓰기 중 실패하면 Result는 반환되지 않으며 출력의 일부가 남을 수 있습니다.


청크 복사 전략을 확정하는 기준

청크 입출력의 목표는 큰 배열을 만드는 일이 아니라 장치 경계 호출을 줄이면서 메모리와 정확성에 상한을 두는 것입니다.

부분 읽기, 마지막 조각, 초과 입력, 결과 해시를 확인하고 대표 환경에서 측정했을 때만 선택한 버퍼 크기를 성능 결정으로 인정할 수 있습니다.