본문 바로가기

청크 단위 파일 복사와 버퍼 크기

한 바이트씩 복사하면 왜 느린지 시스템 호출 수로 확인하고, 바이트 배열에 여러 바이트를 모아 옮기는 청크 복사와 버퍼 크기, 메모리 사용량을 정하는 방법을 배웁니다.

앞 글에서는 read가 돌려준 개수만큼만 쓰는 복사 루프를 만들었습니다. 이 글에서는 같은 루프를 속도와 메모리의 관점에서 다시 봅니다. 파일을 한 바이트씩 읽고 쓰는 코드는 결과가 맞더라도 파일이 커지면 눈에 띄게 느려집니다. 반복문이 많이 돌아서라기보다, 반복 한 번마다 운영체제를 부르는 비싼 호출이 따라붙기 때문입니다.

해결책은 여러 바이트를 바이트 배열에 모아 한 번에 옮기는 것입니다. 한 번에 옮기는 덩어리를 청크(chunk)라 하고, 청크를 담아 두는 배열을 버퍼(buffer)라고 부릅니다. 아래에서는 청크 복사가 왜 빠른지 확인하고, 마지막 조각을 올바르게 처리하는 방법과 버퍼 크기를 정하는 방법을 살펴봅니다. 끝으로 UTF-8 글자처럼 여러 바이트로 이루어진 단위가 청크 경계에 걸칠 때 생기는 문제도 봅니다.

한 바이트씩 복사하는 코드

다음 프로그램은 4,096바이트짜리 임시 파일을 만들고, read()로 한 바이트를 읽을 때마다 write()로 한 바이트를 쓰는 방식으로 복사합니다. read()는 읽은 바이트를 0부터 255 사이의 int로 돌려주고, 파일 끝에서는 -1을 돌려줍니다.

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);
        }
    }
}
실행 결과
4096

복사본의 크기가 원본과 같은 4,096바이트이므로 결과만 보면 문제가 없습니다. 문제는 결과가 아니라 그 결과를 얻는 과정에 있습니다.

파일 읽기 한 번은 운영체제 호출 한 번

자바 프로그램은 디스크를 직접 다루지 못합니다. 파일에서 바이트를 가져오거나 파일에 쓰려면 운영체제 커널에 요청해야 하는데, 이 요청을 시스템 호출(system call)이라고 합니다. 시스템 호출을 하면 CPU가 일반 프로그램의 실행 모드에서 커널 모드로 넘어갔다가 돌아옵니다. 이 전환과 커널 내부 처리에 드는 고정 비용은 메모리에서 바이트 하나를 복사하는 비용보다 훨씬 큽니다. 그래서 한 번에 1바이트를 옮기든 8,192바이트를 옮기든, 호출 한 번의 기본 비용은 거의 같습니다.

Files.newInputStream이 돌려주는 스트림이나 FileInputStream 같은 파일 스트림은 자체 버퍼를 두지 않습니다. 따라서 read()를 부를 때마다 시스템 호출이 한 번씩 일어납니다. Linux에서 시스템 호출을 기록해 주는 strace로 위 프로그램을 추적해 보면(JDK 21), 원본 파일에 대한 read 시스템 호출이 4,097번, 복사본에 대한 write 시스템 호출이 4,096번 나옵니다. 바이트마다 한 번씩이고, read가 한 번 더 많은 것은 마지막에 파일 끝(EOF)을 확인하는 호출 때문입니다.

같은 방법으로 100,000바이트 파일을 두 방식으로 복사하며 세어 보면 차이가 분명해집니다. 두 번째 줄은 다음 절에서 만들 청크 복사에 8,192바이트 배열을 쓴 경우입니다.

복사 방식원본 read 호출복사본 write 호출
read()와 write(int)로 한 바이트씩100,001100,000
8,192바이트 배열로 청크 복사1413

청크 복사에서 read는 8,192바이트를 12번, 남은 1,696바이트를 1번 읽은 뒤 EOF 확인을 한 번 더 합니다. write는 읽은 청크마다 한 번씩이므로 13번입니다. 옮긴 바이트 수는 같은데 운영체제를 부른 횟수는 7,000분의 1 수준으로 줄었습니다.

운영체제는 최근에 읽고 쓴 파일 내용을 메모리에 캐시해 두므로, 시스템 호출마다 실제 디스크까지 가지는 않습니다. 하지만 캐시가 디스크 접근을 줄여 줄 뿐, 호출 자체의 비용은 그대로 남습니다. 작은 파일에서는 이 차이가 잘 드러나지 않아서, 테스트에서는 괜찮던 코드가 큰 파일에서 갑자기 느려지는 일이 생깁니다.

더 깊이: 시스템 호출을 직접 세어 보기

Linux라면 위 숫자를 직접 확인할 수 있습니다. -f는 JVM이 만든 모든 스레드를 따라가고, -yy는 파일 디스크립터 번호 옆에 파일 경로를 적어 줍니다. 그래서 임시 파일 이름으로 걸러 내면 JVM이 클래스를 읽느라 한 호출과 섞이지 않습니다.

strace -f -yy -e trace=read,write -o trace.txt java SingleByteFileCopy
grep -c 'read(.*one-byte-[0-9]*\.bin' trace.txt
grep -c 'write(.*one-byte-copy-' trace.txt

첫 번째 grep이 4097, 두 번째가 4096을 출력합니다. 이 숫자는 JDK 21이 Linux에서 파일 스트림을 구현하는 방식에서 나온 것입니다. 스트림 문서가 'read() 한 번에 시스템 호출 한 번'을 약속하지는 않으므로, 다른 운영체제나 버전에서는 달라질 수 있습니다.

바이트 배열로 청크 복사하기

청크 복사는 read(byte[])로 배열을 채우고, 채워진 만큼을 write(byte[], int, int)로 내보냅니다. 다음 프로그램은 버퍼 크기를 인자로 받는데, 일부러 7바이트라는 작은 버퍼로 32바이트 파일을 복사합니다. 32는 7로 나누어떨어지지 않으므로 마지막에 짧은 조각이 생깁니다. 원본 데이터로는 문자열 "payload"의 SHA-256 해시 값을 씁니다. SHA-256 해시는 항상 32바이트이므로, 값이 제각각인 32바이트를 간단히 만들 수 있습니다.

src/ChunkedFileCopy.java
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.Arrays;

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(StandardCharsets.UTF_8));
            Files.write(source, data);
            System.out.println("copied=" + copy(source, target, 7));
            System.out.println(Arrays.equals(data, Files.readAllBytes(target)));
        } finally {
            Files.deleteIfExists(source);
            Files.deleteIfExists(target);
        }
    }
}
실행 결과
copied=32
true

read(buffer)는 배열을 채운 뒤 실제로 채운 바이트 수를 돌려주고, 더 읽을 것이 없으면 -1을 돌려줍니다. 이 실행에서 반복문이 돈 과정을 표로 따라가 보겠습니다.

회차read(buffer) 반환값write로 내보내는 부분누적 total
17버퍼 앞 7바이트7
27버퍼 앞 7바이트14
37버퍼 앞 7바이트21
47버퍼 앞 7바이트28
54버퍼 앞 4바이트32
6-1반복 종료32

다섯 번째 read는 남은 4바이트만 채우고 4를 돌려줍니다. 이때 buffer[4]부터 buffer[6]까지는 네 번째 회차에 읽은 바이트가 그대로 남아 있습니다. read는 배열을 비우지 않고 앞에서부터 덮어쓸 뿐이기 때문입니다. 그래서 write(buffer, 0, count)로 앞 count바이트만 내보내야 하고, 그 결과 copied=32와 원본과 같은 내용(true)이 나옵니다.

마지막 조각에서 배열 전체를 쓰면

write(buffer, 0, count) 대신 write(buffer)를 쓰는 실수가 어떤 결과를 내는지 직접 보겠습니다. 다음 코드는 1부터 10까지의 10바이트를 4바이트 버퍼로 복사하면서, 읽은 개수와 상관없이 배열 전체를 씁니다.

bad/WholeBufferWriteBug.java
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.util.Arrays;

public final class WholeBufferWriteBug {
    public static void main(String[] args) throws Exception {
        byte[] source = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
        InputStream input = new ByteArrayInputStream(source);
        var output = new ByteArrayOutputStream();
        byte[] buffer = new byte[4];
        while (input.read(buffer) != -1) {
            output.write(buffer);          // 잘못: 읽은 개수와 상관없이 4바이트를 모두 쓴다
        }
        System.out.println(Arrays.toString(output.toByteArray()));
    }
}
실행 결과
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 7, 8]

read는 차례로 4, 4, 2를 돌려준 뒤 -1을 돌려줍니다. 세 번째 read는 buffer[0]과 buffer[1]에 9와 10을 채우지만, buffer[2]와 buffer[3]에는 두 번째 회차의 7과 8이 남아 있습니다. write(buffer)는 배열 길이인 4바이트를 모두 쓰므로 원본에 없던 7과 8이 끝에 붙습니다. 원본보다 2바이트 큰 결과가 만들어지는데도 예외는 나지 않습니다. 그래서 크기나 해시를 비교하지 않으면 알아채기 어렵습니다.

짧은 조각은 마지막에만 오는 것도 아닙니다. InputStream.read(byte[])의 규칙은 '길이가 0이 아닌 배열이면 적어도 1바이트를 읽고 그 개수를 돌려준다, 끝이면 -1을 돌려준다'까지입니다. 배열을 가득 채운다는 약속은 없어서, 네트워크나 파이프에서는 중간에도 짧은 조각이 자주 옵니다. 반대로 0은 길이 0인 배열을 넘겼을 때만 돌아옵니다. 그래서 반복 조건은 -1만 검사하면 됩니다. 이 규칙을 어기고 0을 계속 돌려주는 잘못된 스트림 구현을 만나면 이 루프는 끝나지 않습니다.

단순 복사라면 Files.copy와 transferTo

파일을 그대로 옮기기만 한다면 루프를 직접 쓸 필요가 없습니다. 경로에서 경로로는 Files.copy(source, target)을 쓰면 되는데, 대상 파일이 이미 있으면 FileAlreadyExistsException이 나므로 덮어쓰려면 StandardCopyOption.REPLACE_EXISTING을 함께 넘깁니다. 스트림에서 스트림으로는 input.transferTo(output)이 같은 청크 루프를 대신 돌려 줍니다. InputStream이 기본으로 제공하는 transferTo는 JDK 21에서 16,384바이트짜리 버퍼를 쓰고, 스트림 종류에 따라서는 더 효율적인 방법으로 따로 구현하기도 합니다.

루프를 직접 쓰는 것은 청크마다 할 일이 있을 때입니다. 복사하면서 해시를 계산하거나, 진행률을 표시하거나, 크기 상한을 검사하거나, 데이터를 변환해야 한다면 청크 루프가 그 작업을 끼워 넣을 자리가 됩니다.

버퍼는 클수록 좋을까

버퍼를 키울수록 호출 횟수는 줄어듭니다. 그렇다면 버퍼는 무조건 클수록 좋을까요? 다음 프로그램은 디스크의 영향을 빼고 읽기 호출의 비용만 보기 위해, 4MiB 바이트 배열을 ByteArrayInputStream으로 감싸서 끝까지 읽습니다. 버퍼 크기를 1, 512, 4,096, 16,384바이트로 바꿔 가며 read 호출 횟수와 걸린 시간을 출력합니다. 크기마다 첫 실행은 준비용으로 버리고 두 번째 실행만 잽니다. 처음 실행하는 코드는 JIT 컴파일러가 최적화하기 전이라 느리기 때문입니다.

src/BufferSizeProbe.java
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.List;

public final class BufferSizeProbe {
    record Run(long calls, long nanos) {}

    static Run drain(byte[] data, int bufferSize) throws IOException {
        byte[] buffer = new byte[bufferSize];
        long calls = 0;
        long start = System.nanoTime();
        try (var input = new ByteArrayInputStream(data)) {
            int count;
            do {
                count = input.read(buffer);   // 저장 장치 없이 읽기 호출의 비용만 잰다
                calls++;
            } while (count != -1);
        }
        return new Run(calls, System.nanoTime() - start);
    }

    public static void main(String[] args) throws Exception {
        byte[] data = new byte[4 * 1024 * 1024];   // 4MiB
        for (int size : List.of(1, 512, 4096, 16384)) {
            drain(data, size);                      // 준비용 1회
            Run run = drain(data, size);            // 측정용 1회
            System.out.printf("buffer=%5d calls=%,9d elapsed=%,8dus%n",
                    size, run.calls(), run.nanos() / 1000);
        }
    }
}

실행 결과의 한 예입니다. us는 마이크로초입니다.

buffer=    1 calls=4,194,305 elapsed= 129,371us
buffer=  512 calls=    8,193 elapsed=     379us
buffer= 4096 calls=    1,025 elapsed=     174us
buffer=16384 calls=      257 elapsed=     148us
4MiB 메모리 입력의 읽기 호출 수

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

메모리 읽기 · 호출 횟수 계산

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

4MiB를 버퍼 크기로 나눈 읽기 횟수에, 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
4,194,304
EOF 포함 총 read 호출: 4,194,305
4,194,305
512바이트
양수 반환 횟수: 8,192
8,192
EOF 포함 총 read 호출: 8,193
8,193
4KiB · 4,096바이트
양수 반환 횟수: 1,024
1,024
EOF 포함 총 read 호출: 1,025
1,025
16KiB · 16,384바이트
양수 반환 횟수: 256
256
EOF 포함 총 read 호출: 257
257

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

calls 열은 실행할 때마다 같습니다. 4MiB(4,194,304바이트)를 버퍼 크기로 나눈 횟수만큼 데이터를 읽고, EOF를 확인하는 호출이 한 번 더 붙기 때문입니다. 반면 elapsed 열은 컴퓨터와 실행 시점에 따라 달라집니다. 그래도 모양은 비슷하게 나옵니다. 1바이트에서 512바이트로 키우면 호출이 512분의 1로 줄면서 시간도 크게 줄어듭니다. 하지만 4,096바이트에서 16,384바이트로 키우면 호출은 4분의 1로 줄어도 시간은 거의 그대로입니다.

이유는 걸리는 시간을 두 부분으로 나눠 보면 보입니다. 하나는 '호출 횟수 × 호출 한 번의 고정 비용'이고, 다른 하나는 '전체 바이트 수 × 바이트 하나를 복사하는 비용'입니다. 두 번째 부분은 버퍼 크기와 상관없이 4MiB를 모두 복사해야 하므로 줄지 않습니다. 호출 횟수가 충분히 작아지면 첫 번째 부분은 전체에서 차지하는 몫이 미미해지고, 그때부터는 버퍼를 키워도 얻는 것이 거의 없습니다. 그런데 버퍼를 키우는 비용은 계속 늘어납니다. 동시에 도는 작업마다 버퍼 배열이 하나씩 필요하므로 메모리 사용량이 늘고, 너무 큰 배열은 CPU 캐시에 들어가지 않아 오히려 느려질 수도 있습니다.

이 실험은 메모리를 읽는 루프만 잰 것이라 실제 디스크나 네트워크 성능을 대표하지 않습니다. 실제 서비스의 버퍼 크기를 정하려면 대상 파일 시스템과 대표적인 파일 크기 분포, 동시에 복사하는 수를 반영해 여러 번 재고, 한 번의 숫자보다 반복 측정의 분포를 봐야 합니다. 운영체제 캐시 때문에 같은 파일의 두 번째 복사가 더 빠를 수 있으므로, 캐시가 빈 상태와 찬 상태 중 어느 쪽을 재는지도 정해 둡니다. 측정 전에는 8KiB나 16KiB에서 시작하면 무난합니다. JDK 21도 BufferedInputStream의 기본 버퍼에 8,192바이트를, InputStream의 기본 transferTo에 16,384바이트를 씁니다.

전부 읽을까, 청크로 흘려보낼까

Files.readAllBytes처럼 파일 전체를 배열 하나로 읽는 방법은 코드가 가장 단순합니다. 하지만 메모리를 파일 크기만큼 씁니다. 10MiB 파일 하나는 문제가 없어 보여도, 서버가 이런 요청 200개를 동시에 처리하면 원본 배열만 10MiB × 200 = 2,000MiB, 1GiB를 1,024MiB로 환산해 약 1.95GiB가 필요합니다. 여기에 변환 결과와 객체 오버헤드가 더해집니다.

청크로 처리하면서 작업마다 64KiB 배열 하나만 쓴다면 64KiB × 200 = 12,800KiB, 즉 12.5MiB입니다. 이 비교는 배열만 센 것이고 스트림 객체나 해시 상태, 출력 쪽 버퍼는 뺀 값입니다. 또 버퍼를 작게 했다고 해서 처리량이나 응답 시간이 같게 유지되는 것은 아니므로, 메모리를 줄인 대가는 따로 재야 합니다.

판단 기준은 '파일당 최대 크기 × 동시에 처리할 파일 수'를 힙 예산과 비교하는 것입니다. 크기 상한이 확실한 작은 설정 파일이나, 서명 검증처럼 전체 바이트가 있어야 하는 작업은 한 번에 읽는 편이 읽기 쉽습니다. 영상 업로드, 백업, 로그 병합처럼 크기를 장담할 수 없는 작업은 청크로 처리하고 진행률과 취소 지점을 둡니다. 한 API가 두 경우를 모두 받아야 한다면 크기 임계값에 따라 구현을 나누되, 호출자가 받는 결과의 의미는 같게 유지합니다.

청크 경계에 걸치는 데이터

청크로 나눠도 결과가 같은 작업이 있습니다. SHA-256 같은 해시는 바이트를 순서대로 넣기만 하면 되므로, 7바이트씩 넣든 4,096바이트씩 넣든 같은 값이 나옵니다. 반면 UTF-8로 인코딩한 글자, 압축 데이터의 한 블록, CRLF 같은 줄 구분자는 하나의 단위가 여러 바이트로 이루어져 있어 두 청크에 걸칠 수 있습니다. 이런 데이터를 청크마다 따로 해석하면 단위가 반으로 잘립니다.

다음 프로그램은 "가나"의 UTF-8 바이트를 4바이트 버퍼로 읽으면서, 청크마다 new String으로 문자열을 만들고 그 코드 포인트를 출력합니다.

bad/SplitUtf8Chunks.java
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
import java.util.stream.Collectors;

public final class SplitUtf8Chunks {
    static final HexFormat HEX = HexFormat.ofDelimiter(" ").withUpperCase();

    public static void main(String[] args) throws Exception {
        byte[] data = "가나".getBytes(StandardCharsets.UTF_8);
        System.out.println("all bytes: " + HEX.formatHex(data));
        try (InputStream input = new ByteArrayInputStream(data)) {
            byte[] buffer = new byte[4];
            int count;
            while ((count = input.read(buffer)) != -1) {
                String piece = new String(buffer, 0, count, StandardCharsets.UTF_8);
                System.out.println(HEX.formatHex(buffer, 0, count) + " -> " + codePoints(piece));
            }
        }
    }

    static String codePoints(String text) {
        return text.codePoints()
                .mapToObj(cp -> String.format("U+%04X", cp))
                .collect(Collectors.joining(" "));
    }
}
실행 결과
all bytes: EA B0 80 EB 82 98
EA B0 80 EB -> U+AC00 U+FFFD
82 98 -> U+FFFD U+FFFD

'가'(U+AC00)는 EA B0 80, '나'(U+B098)는 EB 82 98로 각각 3바이트입니다. 4바이트 버퍼는 '나'의 첫 바이트 EB 뒤에서 잘립니다. 첫 청크에서 '가'는 온전하지만, 홀로 남은 EB는 완성되지 않은 글자라서 대체 문자 U+FFFD가 됩니다. 두 번째 청크의 82와 98은 글자의 첫 바이트 없이 이어지는 바이트뿐이라 각각 U+FFFD가 됩니다. 결국 '나'는 사라지고 대체 문자 세 개가 남는데, 예외는 하나도 나지 않습니다.

이 문제를 피하려면 청크 사이에서 '아직 완성되지 않은 바이트'를 기억해 두었다가 다음 청크와 이어 붙여 해석하는 디코더가 필요합니다. 텍스트라면 문자 스트림 Reader·Writer와 Charset에서 다루는 InputStreamReader가 이 일을 대신해 주고, 직접 제어해야 할 때는 Charset과 인코딩 오류 처리의 CharsetDecoder를 씁니다. 압축 데이터나 줄 단위 처리도 마찬가지로 상태를 이어 가는 전용 스트림이나 파서를 씁니다.

청크 사이에서 진행률과 취소 처리하기

청크 루프는 한 청크를 옮길 때마다 제어가 돌아오므로, 그 사이에 진행 상황을 알리거나 작업을 멈출 수 있습니다. 진행률은 누적 바이트를 전체 파일 크기로 나누면 됩니다. 크기를 미리 알 수 없는 스트림이라면 누적량과 초당 처리량만 보여 줍니다. 다만 청크마다 로그를 남기면 로깅이 입출력보다 비싸질 수 있으므로, 1초마다 또는 몇 MiB마다처럼 간격을 두고 기록합니다.

취소 신호를 청크 사이에서 확인하면 다음 확인 지점에서 작업을 멈출 수 있습니다. 하지만 지금 실행 중인 read나 write는 느린 네트워크에서 오래 멈춰 있을 수 있습니다. 그래서 청크를 작게 한다고 취소까지 걸리는 시간에 상한이 생기지는 않으며, 그런 상한이 필요하면 시간 제한을 따로 둡니다.

버퍼 전략 고르기

상황시작 전략확인할 값
크기 상한이 확실한 작은 파일readAllBytes로 전체 읽기최대 크기 × 동시 처리 수
변환 없는 대용량 복사Files.copy 또는 transferTo복사본의 크기와 해시
청크마다 처리가 필요한 파일8KiB나 16KiB 청크 루프처리량과 응답 시간 분포
느린 원격 스트림청크 루프와 시간 제한시간 제한과 취소 동작
동시 작업이 많은 서버작은 버퍼 재사용 검토전체 힙 사용량과 GC

정리

  • 파일 스트림의 read()와 write(int)는 JDK 21의 Linux 구현에서 호출마다 시스템 호출을 한 번씩 일으킵니다. 바이트 배열로 청크를 옮기면 같은 양을 훨씬 적은 호출로 옮깁니다.
  • read(buffer)는 채운 개수를 돌려주며 배열을 가득 채운다는 약속이 없습니다. 쓸 때는 항상 write(buffer, 0, count)로 유효한 부분만 내보냅니다.
  • 버퍼를 키우는 효과는 호출의 고정 비용이 무시할 만해지는 지점에서 멈춥니다. 8KiB나 16KiB에서 시작하고, 실제 환경에서 재서 정합니다.
  • 전체 읽기는 '최대 크기 × 동시 처리 수'가 힙 예산 안에 들 때만 씁니다.
  • UTF-8 글자처럼 여러 바이트로 된 단위는 청크가 나뉘는 자리에서 잘릴 수 있으므로, 상태를 이어 가는 디코더나 스트림으로 해석합니다.

연습 문제

1. 상한을 지키며 해시와 함께 복사하기

입력을 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.nio.charset.StandardCharsets;
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(StandardCharsets.UTF_8);
        var target = new ByteArrayOutputStream();
        System.out.println(copy(new ByteArrayInputStream(source), target, 1024));
    }
}
실행 결과
Result[bytes=15, sha256=31bd79a95a625f2d21d6e7b948699b9ccbd9bdda66eb1ca533eccb28d84038cf]

main은 15바이트 입력에 1,024바이트 상한을 적용한 정상 경로만 실행합니다. 출력된 해시는 Linux의 printf 'chunked-content' | sha256sum으로 구한 값과 같습니다. 상한 검사를 digest.update와 write보다 앞에 둔 것이 핵심입니다. 그래야 상한을 넘긴 청크는 해시에도, 출력에도 들어가지 않습니다.

그래도 copy는 이미 내보낸 앞부분을 되돌리지 못합니다. 상한을 넘기 전의 청크들은 이미 출력에 쓰였기 때문입니다. 그래서 호출자는 결과를 임시 파일이나 메모리 버퍼에 먼저 받고, copy가 정상적으로 돌아온 뒤에만 그 결과를 최종 위치로 옮기거나 공개해야 합니다. 예외가 나면 임시 결과는 버립니다. 쓰기 도중 예외가 나도 Result는 반환되지 않고 출력에는 일부가 남을 수 있으니, 이 경우도 같은 방식으로 처리합니다.

2. 상한을 14로 줄이면

1번 정답의 15바이트 입력은 그대로 두고 상한만 14로 줄이면, 출력에는 몇 바이트가 남고 Result는 반환될까요? 같은 방식의 복사를 4바이트 버퍼로 하면서 7바이트 입력에 상한 6을 둔 경우(앞 글의 BoundedStreamCopy와 같은 조건)와 결과가 왜 다른지도 설명하세요.

해설

버퍼가 4,096바이트이므로 첫 read가 15바이트를 한꺼번에 읽습니다. total이 15가 되어 상한 14를 넘으므로, digest.update와 write보다 먼저 예외가 납니다. 따라서 출력은 0바이트이고 Result는 반환되지 않습니다. 실제로 상한을 14로 바꿔 실행하고 예외를 잡아 출력 크기를 찍어 보면 size limit exceeded와 함께 0이 나옵니다.

4바이트 버퍼의 경우에는 첫 청크 4바이트가 상한 6 아래이므로 그대로 출력에 쓰입니다. 두 번째 read가 남은 3바이트를 읽어 total이 7이 되면 그제서야 예외가 나므로, 출력에는 4바이트가 남습니다. 즉 실패했을 때 출력에 남는 양은 버퍼 크기와 상한의 관계, 그리고 스트림이 한 번에 몇 바이트를 돌려주느냐에 따라 달라집니다. '상한을 넘으면 출력이 비어 있다'고 일반화할 수 없으므로, 1번 해설처럼 성공한 뒤에만 결과를 공개하는 방식이 필요합니다.

3. 마지막 조각 예측하기

WholeBufferWriteBug의 원본을 1부터 9까지의 9바이트로 바꾸면 무엇이 출력될까요? 실행하기 전에 회차마다 버퍼의 상태를 적어 보세요. 또 10,000바이트 파일을 4,096바이트 버퍼로 청크 복사하면 read는 몇 번 호출되고 각각 무엇을 돌려줄까요?

해설

9바이트를 4바이트 버퍼로 읽으면 반환값은 4, 4, 1입니다. 첫 회차 뒤 버퍼는 [1, 2, 3, 4], 두 번째 뒤에는 [5, 6, 7, 8]입니다. 세 번째 read는 buffer[0]만 9로 덮어쓰므로 버퍼는 [9, 6, 7, 8]이 됩니다. 매번 배열 전체를 쓰므로 출력은 [1, 2, 3, 4, 5, 6, 7, 8, 9, 6, 7, 8]이고, 원본보다 3바이트가 많습니다.

10,000바이트 파일은 read가 네 번 호출됩니다. 반환값은 4,096, 4,096, 1,808, -1입니다. 10,000 = 4,096 × 2 + 1,808이고, 마지막 -1은 EOF 확인입니다. 일반 파일에서는 이렇게 버퍼를 꽉 채워 읽는 경우가 보통이지만, 앞에서 본 대로 스트림 규칙이 이를 약속하지는 않습니다. 그래서 코드는 이 숫자가 아니라 반환값에 맞춰 동작해야 합니다.

다음 글

버퍼 스트림으로 감싸기와 닫는 순서에서는 버퍼를 직접 관리하는 대신 BufferedInputStream과 BufferedOutputStream으로 기존 스트림을 감쌉니다. 한 바이트씩 읽는 코드를 그대로 두고도 시스템 호출이 청크 복사만큼 줄어드는 것을 확인하고, 버퍼에 남은 데이터를 잃지 않도록 스트림을 닫는 순서를 다룹니다.