본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
23장 : 문자 인코딩과 스트림

바이트 스트림과 소유권

파일과 메모리를 같은 스트림 추상화로 다루고 부분 읽기, EOF, 예외 시 닫기, 입력 크기 상한을 지키는 바이트 입출력 루프를 설계합니다.

InputStreamOutputStream은 데이터의 출처와 목적지를 바이트 흐름이라는 공통 규칙으로 감춥니다. 파일, 메모리, 소켓이 서로 다른 장치여도 읽는 쪽은 read, 쓰는 쪽은 write를 사용합니다. 이 추상화 덕분에 변환 로직을 장치에서 분리할 수 있지만, 반환값과 자원 소유권을 정확히 지켜야 안전합니다.

입력의 read()는 바이트 하나를 0부터 255 범위의 int로 반환하고 끝에서는 -1을 반환합니다. byte를 반환하지 않는 이유는 256개 바이트 값과 EOF 표식을 동시에 표현해야 하기 때문입니다. 배열 읽기는 실제로 채운 길이를 반환하며 요청한 배열을 매번 가득 채운다는 보장이 없습니다.

available을 전체 길이로 보는 오해

available()은 블로킹 없이 즉시 읽을 수 있다고 추정되는 양이지 전체 스트림 길이가 아닙니다. 메모리 스트림에서는 남은 길이처럼 보여도 네트워크나 압축 스트림에서는 아직 도착하지 않은 데이터가 있습니다. 값이 0이라는 이유로 EOF라고 판단하면 정상 메시지를 중간에서 자릅니다.

bad/AvailableIsNotLength.java
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;

public final class AvailableIsNotLength {
    static byte[] wrongRead(InputStream input) throws IOException {
        byte[] assumed = new byte[input.available()];
        int count = input.read(assumed);
        System.out.println("assumed=" + assumed.length + " actual=" + count);
        return assumed;
    }

    public static void main(String[] args) throws Exception {
        try (InputStream input = new ByteArrayInputStream(new byte[] {10, 20, 30})) {
            wrongRead(input);
        }
    }
}

이 샘플은 우연히 세 바이트를 얻지만 구현을 소켓으로 바꾸면 규칙이 무너집니다. 고정 길이 형식은 헤더의 길이만큼 정확히 읽고, EOF 기반 형식은 read가 -1을 반환할 때까지 반복합니다. 무한 입력에는 별도의 메시지 구분이 반드시 필요합니다.

메모리 스트림의 장치 독립 검사

ByteArrayOutputStream은 쓰인 바이트를 내부 배열에 모으고, ByteArrayInputStream은 배열을 입력처럼 제공합니다. 운영 파일을 만들지 않고도 인코더와 파서의 바이트 규칙을 확인하기 좋습니다. 닫아야 할 외부 자원은 없지만 다른 스트림과 같은 API를 사용하므로 테스트 대역으로 자연스럽습니다.

src/MemoryPacketRoundTrip.java
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;

public final class MemoryPacketRoundTrip {
    static byte[] encode(int version, byte[] payload) throws Exception {
        var bytes = new ByteArrayOutputStream();
        try (var output = new DataOutputStream(bytes)) {
            output.writeInt(version);
            output.writeInt(payload.length);
            output.write(payload);
        }
        return bytes.toByteArray();
    }

    public static void main(String[] args) throws Exception {
        byte[] packet = encode(3, new byte[] {4, 5, 6});
        try (var input = new DataInputStream(new ByteArrayInputStream(packet))) {
            int version = input.readInt();
            int length = input.readInt();
            byte[] payload = input.readNBytes(length);
            System.out.printf("version=%d length=%d first=%d%n",
                    version, payload.length, payload[0]);
        }
    }
}

헤더 길이를 신뢰하기 전에 음수와 허용 최대값을 검사해야 합니다. readNBytes(length)도 EOF에서 요청량보다 짧은 배열을 반환할 수 있으므로 결과 길이를 대조합니다. 프로토콜 파서는 정상 데이터보다 잘린 헤더, 과도한 길이, 뒤에 남은 쓰레기 바이트에서 더 많은 결함이 드러납니다.

파일 스트림 소유권

스트림을 연 메서드가 닫는 책임도 가지는 것이 기본입니다. try-with-resources는 정상 반환과 예외 모두에서 close를 호출하고, 본문 예외와 닫기 예외가 동시에 나면 닫기 예외를 억제됨으로 보존합니다. finally에서 수동으로 닫는 코드보다 원래 실패를 잃을 가능성이 작습니다.

src/FileSignature.java
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HexFormat;

public final class FileSignature {
    public static void main(String[] args) throws Exception {
        Path file = Files.createTempFile("signature-", ".bin");
        try {
            try (OutputStream output = Files.newOutputStream(file)) {
                output.write(new byte[] {(byte) 0xCA, (byte) 0xFE, 1, 2});
            }
            try (InputStream input = Files.newInputStream(file)) {
                byte[] signature = input.readNBytes(2);
                if (signature.length != 2) throw new IllegalStateException("short header");
                System.out.println(HexFormat.of().formatHex(signature));
            }
        } finally {
            Files.deleteIfExists(file);
        }
    }
}

상위 메서드가 전달한 스트림은 일반적으로 빌린 자원입니다. 변환 함수가 임의로 닫으면 호출자가 뒤이어 읽거나 여러 데이터를 이어 쓸 수 없습니다. 소유권을 받는 API라면 이름이나 문서에 닫기 책임을 드러내고, 그렇지 않으면 flushclose를 호출자에게 맡깁니다.

FileInputStream의 역할

FileInputStream은 파일의 바이트를 읽는 구체적인 InputStream 구현입니다. 파일 경로나 File을 열어도 호출 코드는 InputStreamread, readNBytes, transferTo 같은 공통 규칙으로 처리할 수 있습니다. 기존 API가 File을 받거나 파일 스트림 자체가 필요할 때 직접 사용하고, 새 코드에서 Path와 열기 옵션을 다룬다면 Files.newInputStream을 우선합니다.

src/FileInputStreamHeader.java
import java.io.FileInputStream;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HexFormat;

public final class FileInputStreamHeader {
    public static void main(String[] args) throws Exception {
        Path file = Files.createTempFile("file-input-", ".bin");
        try {
            Files.write(file, new byte[] {(byte) 0xCA, (byte) 0xFE, 1, 2, 3});

            try (FileInputStream fileInput = new FileInputStream(file.toFile())) {
                InputStream input = fileInput;
                byte[] header = input.readNBytes(2);
                if (header.length != 2) throw new IllegalStateException("short header");
                System.out.println("header=" + HexFormat.of().formatHex(header));
                System.out.println("remaining=" + input.readAllBytes().length);
            }
        } finally {
            Files.deleteIfExists(file);
        }
    }
}
header=cafe
remaining=3

생성자는 파일이 없을 때뿐 아니라 디렉터리이거나 운영체제가 열기를 거부할 때도 FileNotFoundException 계열 실패를 낼 수 있습니다. 성공적으로 열었다고 파일 내용이 고정되는 것은 아니며 다른 프로세스가 동시에 바꿀 수 있습니다. available()은 남은 파일 전체 길이를 보장하는 API가 아니므로 크기나 EOF 판단에 사용하지 않습니다. 스트림을 다른 계층에 넘길 때는 누가 닫는지 명시하고, 빌린 FileDescriptor로 만든 스트림을 닫아 다른 소유자의 자원까지 무효화하지 않도록 주의합니다.

부분 읽기를 전제로 한 복사 루프

read(buffer)는 1부터 배열 길이 사이의 값을 반환할 수 있습니다. 마지막 조각뿐 아니라 중간에도 짧은 읽기가 가능합니다. 쓰기는 항상 write(buffer, 0, count)처럼 유효 구간만 전달해야 합니다. 배열 전체를 쓰면 이전 반복에서 남은 데이터가 결과 뒤에 섞입니다.

src/BoundedStreamCopy.java
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Arrays;

public final class BoundedStreamCopy {
    static long copy(InputStream input, OutputStream output, long limit)
            throws IOException {
        byte[] buffer = new byte[4];
        long total = 0;
        int count;
        while ((count = input.read(buffer)) != -1) {
            total = Math.addExact(total, count);
            if (total > limit) throw new IOException("input limit exceeded");
            output.write(buffer, 0, count);
        }
        return total;
    }

    public static void main(String[] args) throws Exception {
        byte[] source = {1, 2, 3, 4, 5, 6, 7};
        var target = new ByteArrayOutputStream();
        System.out.println("count=" + copy(new ByteArrayInputStream(source), target, 10));
        System.out.println(Arrays.equals(source, target.toByteArray()));
    }
}

입력 상한은 메모리 고갈과 무제한 업로드를 막는 제품 규칙입니다. 복사 중간에 넘는 즉시 중단해야 하며, 부분 파일을 남길 수 없다면 임시 경로에 쓴 뒤 성공했을 때 원자적으로 이동합니다. 제한을 초과한 요청의 스트림을 어디까지 비울지 여부는 연결 재사용 정책에 맞춰 정합니다.

readAllBytes를 선택할 수 있는 범위

작고 크기 상한이 확실한 설정 파일은 readAllBytes가 가장 읽기 쉬울 수 있습니다. 하지만 사용자 업로드나 압축 해제 결과처럼 크기를 신뢰할 수 없는 입력을 모두 메모리에 올리면 힙을 소진합니다. 파일 크기 메타데이터도 읽는 동안 바뀔 수 있고 압축 데이터의 해제 크기를 알려주지 않으므로 스트리밍 상한을 별도로 둡니다.

transferTo는 단순 복사를 간결하게 표현하지만 업무별 제한, 진행률, 해시 계산이 필요하면 명시적 루프가 더 적합합니다. 도구 선택은 줄 수가 아니라 관찰해야 할 중간 조건에 달려 있습니다. 어떤 구현을 써도 입력과 출력의 닫기 순서를 호출 규칙에 맞게 유지합니다.

연습 문제

두 바이트 길이 헤더와 본문으로 구성된 프레임을 읽으세요. 최대 본문은 1024바이트이며, 헤더나 본문이 잘리면 예외를 발생시켜야 합니다. 한 프레임 뒤의 바이트는 다음 판독을 위해 남겨 둡니다.

정답과 해설
exercise/ExactFrameReaderSolution.java
import java.io.ByteArrayInputStream;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;

public final class ExactFrameReaderSolution {
    static byte[] readFrame(InputStream input) throws IOException {
        int high = input.read();
        int low = input.read();
        if (high == -1 || low == -1) throw new EOFException("short length header");
        int length = (high << 8) | low;
        if (length > 1024) throw new IOException("frame too large: " + length);
        byte[] payload = input.readNBytes(length);
        if (payload.length != length) throw new EOFException("short payload");
        return payload;
    }

    public static void main(String[] args) throws Exception {
        byte[] wire = {0, 3, 9, 8, 7, 99};
        var input = new ByteArrayInputStream(wire);
        System.out.println(Arrays.toString(readFrame(input)));
        System.out.println("next=" + input.read());
    }
}

두 헤더 바이트는 read가 반환한 부호 없음 범위의 int이므로 바로 비트 조합할 수 있습니다. readNBytes 결과 길이를 다시 확인해 조기 EOF를 검출하고, readAllBytes를 쓰지 않아 다음 프레임 바이트를 소비하지 않습니다.

스트림 구분을 안전하다고 판단하는 조건

안전한 바이트 입출력은 EOF, 부분 읽기, 크기 제한, 닫기 책임을 코드로 표현합니다. 같은 복사 함수를 메모리와 파일 입력에 적용해 보고 잘린 데이터와 초과 입력까지 실패시키면 스트림 추상화의 편의와 장치 구분의 안전성을 함께 얻을 수 있습니다.