본문 바로가기

바이트 스트림: InputStream과 OutputStream

InputStream과 OutputStream으로 파일과 메모리의 바이트를 같은 방법으로 읽고 쓰는 법을 익히고, read가 돌려주는 값과 EOF, 일부만 읽히는 경우를 고려한 복사 반복문, 스트림을 누가 닫아야 하는지를 배웁니다.

앞의 두 글에서는 문자열과 바이트 배열을 서로 바꾸는 방법을 보았습니다. 그런데 바이트 배열은 프로그램의 메모리 안에 있을 뿐이라서, 파일에 저장하거나 네트워크로 보내려면 바이트를 밖으로 내보내고 밖에서 들여오는 통로가 필요합니다. Java는 이 통로를 스트림(stream)이라는 하나의 모양으로 다룹니다. 바이트가 들어오는 통로는 InputStream, 나가는 통로는 OutputStream이며, 데이터가 파일에 있든 메모리 배열에 있든 네트워크 연결 너머에 있든 읽을 때는 read를, 쓸 때는 write를 부릅니다. 나중에 배울 스트림 API(java.util.stream)와는 이름만 같을 뿐 다른 개념입니다.

덕분에 바이트를 처리하는 코드를 한 번 작성하면 데이터가 어디서 오든 그대로 쓸 수 있습니다. 대신 지켜야 할 규칙이 몇 가지 있습니다. read가 돌려주는 값을 정확히 해석해야 하고, 요청한 만큼 다 읽힌다고 가정하면 안 되며, 다 쓴 스트림은 정해진 쪽에서 닫아야 합니다. 이 글에서는 이 규칙들을 하나씩 예제로 확인합니다.

바이트를 한 방향으로 차례로 흘려보내는 스트림

스트림은 바이트를 한 방향으로, 앞에서부터 차례로 흘려보냅니다. InputStream에서 바이트를 읽으면 읽는 위치가 그만큼 앞으로 나아가고, 이미 읽은 바이트는 다시 읽을 수 없습니다. 배열처럼 아무 위치나 골라 읽는 것이 아니라 수도꼭지에서 물을 받듯 나오는 순서대로 받는 것입니다. OutputStream도 마찬가지로 write한 바이트가 순서대로 뒤에 이어 붙습니다.

InputStream과 OutputStream은 추상 클래스이고, 실제로는 데이터가 있는 곳에 맞는 하위 클래스를 씁니다. 자주 쓰는 것은 다음과 같습니다.

데이터가 있는 곳읽기쓰기
파일FileInputStream, Files.newInputStreamFileOutputStream, Files.newOutputStream
메모리의 바이트 배열ByteArrayInputStreamByteArrayOutputStream
네트워크 연결Socket의 getInputStream()Socket의 getOutputStream()

어느 것을 쓰든 변수의 타입을 InputStream이나 OutputStream으로 두면 그 뒤의 코드는 똑같습니다. 그래서 파일을 처리하는 코드를 메모리 배열로 시험해 보고, 나중에 25장에서는 같은 코드를 네트워크 연결에 쓸 수 있습니다.

read()가 byte가 아니라 int를 돌려주는 이유

InputStream의 가장 기본 메서드는 바이트 하나를 읽는 read()입니다. 그런데 이 메서드의 반환 타입은 byte가 아니라 int입니다. 읽은 바이트는 0부터 255 사이의 int로 돌려주고, 더 읽을 데이터가 없는 끝(EOF, end of file)에 이르면 -1을 돌려줍니다. 바이트 값 256가지에 '끝'이라는 표시까지 257가지를 구별해야 하는데, byte로는 256가지밖에 나타낼 수 없기 때문입니다.

다음 프로그램은 65, FF, 0 세 바이트가 든 메모리 스트림을 한 바이트씩 읽습니다. 뒷부분은 같은 입력을 잘못된 방법으로 읽어 봅니다.

src/ReadOneByte.java
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;

public final class ReadOneByte {
    public static void main(String[] args) throws IOException {
        byte[] data = {65, (byte) 0xFF, 0};

        try (InputStream input = new ByteArrayInputStream(data)) {
            int value;
            while ((value = input.read()) != -1) {
                System.out.println("read=" + value);
            }
            System.out.println("end=" + input.read());
        }

        try (InputStream input = new ByteArrayInputStream(data)) {
            int count = 0;
            while ((byte) input.read() != -1) { // 잘못: -1과 비교하기 전에 byte로 바꾼다
                count++;
            }
            System.out.println("wrong count=" + count);
        }
    }
}

앞의 네 줄은 올바른 반복문이, 마지막 줄은 byte로 바꿔 비교하는 잘못된 반복문이 출력한 것입니다. 입력이 세 바이트이므로 바르게 셌다면 wrong count도 3이어야 합니다.

read=65
read=255
read=0
end=-1
wrong count=1

첫 번째 반복문은 read()의 결과를 int 변수에 받아 -1과 비교합니다. 바이트 FF가 문자와 바이트에서 본 byte처럼 -1이 아니라 255로 나오므로 데이터와 끝 표시가 섞이지 않고, 세 바이트를 모두 읽은 뒤에야 -1이 나옵니다. 끝에 이른 뒤 다시 read()를 불러도 계속 -1입니다.

두 번째 반복문은 읽은 값을 byte로 바꾼 뒤 -1과 비교합니다. 그러면 데이터 바이트 FF도 byte로는 -1이 되어 끝으로 오인되므로, 세 바이트 가운데 하나만 세고 멈춰 버립니다. 텍스트만 시험할 때는 FF가 잘 나오지 않아 이 버그가 숨어 있다가, 이미지나 압축 파일처럼 모든 바이트 값이 나오는 데이터에서 드러납니다. 그래서 read()의 결과는 반드시 int로 받아 -1인지 먼저 확인하고, 그다음에 필요하면 byte로 바꿉니다.

배열로 읽기: read(byte[])는 일부만 채울 수 있습니다

바이트를 하나씩 읽으면 바이트마다 메서드를 불러야 하므로, 보통은 배열을 넘겨 한 번에 여러 바이트를 읽습니다. read(byte[] buffer)는 데이터를 배열의 앞에서부터 채우고, 실제로 채운 바이트 수를 돌려줍니다. 끝에 이르러 더 읽을 것이 없으면 -1을 돌려줍니다. 길이가 1 이상인 배열을 넘겼다면 데이터가 있는 동안의 반환값은 1부터 배열 길이 사이입니다.

여기서 중요한 점은 배열을 가득 채운다는 보장이 없다는 것입니다. 끝에 가까운 마지막 조각만 짧은 것이 아니라, 중간에도 지금 당장 읽을 수 있는 만큼만 채우고 돌아올 수 있습니다. 네트워크에서는 데이터가 여러 조각으로 나뉘어 도착하므로 이런 일이 흔합니다. 다음 프로그램은 두 메모리 스트림을 이어 붙인 SequenceInputStream으로 이 상황을 재현합니다. SequenceInputStream은 한 번의 read에서 지금 읽고 있는 스트림의 데이터만 돌려주므로, 네트워크처럼 짧은 읽기가 일어납니다.

src/ShortReadDemo.java
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.SequenceInputStream;
import java.util.Arrays;

public final class ShortReadDemo {
    static InputStream twoParts() {
        return new SequenceInputStream(
                new ByteArrayInputStream(new byte[] {1, 2}),
                new ByteArrayInputStream(new byte[] {3, 4, 5}));
    }

    public static void main(String[] args) throws IOException {
        byte[] buffer = new byte[8];
        try (InputStream input = twoParts()) {
            int count;
            while ((count = input.read(buffer)) != -1) {
                System.out.println("count=" + count + " "
                        + Arrays.toString(Arrays.copyOf(buffer, count)));
            }
        }

        try (InputStream input = twoParts()) {
            System.out.println("readNBytes=" + Arrays.toString(input.readNBytes(8)));
        }
    }
}

count= 두 줄은 길이 8인 배열로 read를 반복한 결과이고, 마지막 줄은 같은 입력을 readNBytes(8)로 읽은 결과입니다. 한 번의 read가 배열을 얼마나 채우는지 보세요.

count=2 [1, 2]
count=3 [3, 4, 5]
readNBytes=[1, 2, 3, 4, 5]

배열 길이는 8이고 전체 데이터는 5바이트이지만, 첫 read는 2바이트만 채우고 돌아왔습니다. 두 번째 read가 나머지 3바이트를 읽고, 세 번째 read가 -1을 돌려주어 반복이 끝났습니다. Arrays.copyOf(buffer, count)로 반환값만큼만 잘라 출력한 데 주목하세요. 배열의 나머지 칸에는 의미 없는 값이 남아 있으므로, 언제나 반환값이 알려 준 앞부분만 데이터로 써야 합니다.

정확히 n바이트가 필요하다면 readNBytes(n)을 씁니다. 이 메서드는 n바이트를 모두 읽거나 끝에 이를 때까지 read를 되풀이하므로, 마지막 줄처럼 짧은 읽기와 상관없이 5바이트를 한 번에 돌려줍니다. 다만 끝에 먼저 이르면 n보다 짧은 배열을 돌려주므로, 결과의 길이가 n인지는 직접 확인해야 합니다. 메모리 배열만 읽는 ByteArrayInputStream은 언제나 가능한 만큼 가득 채워 주기 때문에, 메모리 스트림으로만 시험하면 짧은 읽기를 처리하지 않는 코드도 통과한다는 점을 기억해 두세요.

available()은 남은 전체 길이가 아닙니다

available()은 이름 때문에 '남은 바이트 수'로 오해하기 쉽습니다. 실제로는 기다리지 않고 지금 바로 읽을 수 있다고 추정되는 바이트 수일 뿐, 스트림에 앞으로 들어올 전체 길이가 아닙니다. 다음 프로그램은 available()만큼 배열을 만들어 한 번에 다 읽었다고 가정하는 흔한 실수를 보여 줍니다.

bad/AvailableIsNotLength.java
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.SequenceInputStream;
import java.util.Arrays;

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("available=" + assumed.length + " read=" + count);
        return assumed;
    }

    public static void main(String[] args) throws IOException {
        try (InputStream input = new SequenceInputStream(
                new ByteArrayInputStream(new byte[] {10, 20}),
                new ByteArrayInputStream(new byte[] {30, 40, 50}))) {
            byte[] first = wrongRead(input);
            System.out.println("got=" + Arrays.toString(first));
            System.out.println("left=" + Arrays.toString(input.readAllBytes()));
        }
    }
}

첫 줄은 wrongRead가 배열을 만들 때 쓴 available() 값과 실제로 읽은 바이트 수이고, 둘째 줄은 그렇게 읽은 데이터입니다. 마지막 줄은 그 뒤에도 스트림에 남아 있던 바이트입니다.

available=2 read=2
got=[10, 20]
left=[30, 40, 50]

전체 데이터는 5바이트인데 available()은 2를 돌려주었습니다. SequenceInputStream의 available()은 지금 읽고 있는 첫 스트림의 남은 양만 알려 주기 때문입니다. wrongRead가 이것을 전체 데이터로 믿었다면 뒤의 세 바이트를 잃었을 것이고, 실제로 left를 보면 아직 읽지 않은 데이터가 남아 있습니다. 네트워크 스트림이라면 아직 도착하지 않은 데이터 때문에 같은 일이 생기고, 값이 0이라고 끝이라고 판단하면 정상 메시지를 중간에서 잘라 버립니다. ByteArrayInputStream이었다면 우연히 남은 길이와 같은 값이 나와 이 버그가 드러나지 않았을 것입니다.

그러면 데이터를 어디까지 읽어야 하는지는 무엇으로 알까요? 형식마다 정해 두어야 합니다. 앞쪽에 길이를 적어 두는 형식이라면 그 길이만큼 readNBytes로 읽고 길이를 확인합니다. 파일처럼 끝이 있는 입력을 통째로 처리한다면 read가 -1을 돌려줄 때까지 반복합니다. 네트워크 연결처럼 메시지가 끝없이 이어지는 입력이라면 메시지마다 길이나 구분 기호를 두어 어디까지가 한 메시지인지 알 수 있게 해야 합니다. 이 글의 연습 문제가 길이를 앞에 적는 방식을 다룹니다.

메모리를 스트림처럼 쓰기: ByteArrayInputStream과 ByteArrayOutputStream

ByteArrayOutputStream은 쓰인 바이트를 내부 배열에 모아 두었다가 toByteArray()로 돌려주고, ByteArrayInputStream은 주어진 배열을 입력 스트림처럼 읽게 해 줍니다. 파일을 만들지 않고도 바이트를 만들고 읽는 코드를 시험할 수 있어서 테스트에 자주 씁니다. 다음 프로그램은 버전 번호, 본문 길이, 본문으로 이루어진 작은 패킷을 메모리에 만들고 다시 읽습니다. DataOutputStream과 DataInputStream은 다른 스트림을 감싸서 int 같은 기본형 값을 바이트로 쓰고 읽게 해 주는 클래스입니다.

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

public final class MemoryPacketRoundTrip {
    static byte[] encode(int version, byte[] payload) throws IOException {
        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 IOException {
        byte[] packet = encode(3, new byte[] {4, 5, 6});
        System.out.println("packet=" + HexFormat.ofDelimiter(" ").formatHex(packet));

        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]);
        }
    }
}

첫 줄은 만든 패킷을 16진수로 출력한 것이고, 둘째 줄은 그 패킷을 다시 읽어 얻은 값입니다. 패킷의 바이트를 버전, 길이, 본문으로 나눠 보면서 읽어 보세요.

packet=00 00 00 03 00 00 00 03 04 05 06
version=3 length=3 first=4

패킷은 11바이트입니다. writeInt는 int 하나를 큰 자리부터 4바이트로 쓰므로 버전 3은 00 00 00 03, 본문 길이 3도 00 00 00 03이 되고, 그 뒤에 본문 04 05 06이 붙었습니다. 읽는 쪽은 같은 순서로 readInt 두 번과 readNBytes(3)을 불러 값을 되찾습니다. toByteArray()는 try 블록이 끝나 DataOutputStream이 닫힌 뒤에 부릅니다. 이 조합에서는 닫기 전에 불러도 결과가 같지만, 중간에 버퍼가 있는 스트림을 끼우면 닫거나 flush해야 마지막 바이트까지 배열에 들어가므로 이 순서를 습관으로 들이는 것이 좋습니다. 이런 형식을 설계하는 방법은 DataOutputStream 파일 형식과 버전 관리에서 자세히 다룹니다.

이 예제는 자기가 만든 올바른 패킷만 읽습니다. 외부에서 받은 패킷을 읽는 코드라면 헤더의 길이를 그대로 믿으면 안 됩니다. 길이가 음수이면 readNBytes가 IllegalArgumentException을 던지고, 터무니없이 크면 그만큼의 메모리를 쓰려다 프로그램이 위험해질 수 있으므로 먼저 허용 범위를 검사해야 합니다. 또 readNBytes(length)도 끝에 먼저 이르면 짧은 배열을 돌려주므로 결과 길이를 확인해야 합니다. 이런 파서는 올바른 데이터보다 헤더가 잘렸거나, 길이가 지나치게 크거나, 형식에 없는 바이트가 뒤에 남은 데이터에서 결함이 더 잘 드러나므로 그런 입력으로도 시험해 봅니다.

파일에서 읽고 쓰기: FileInputStream과 Files

FileInputStream은 파일의 바이트를 읽는 InputStream 구현입니다. 파일 경로 문자열이나 File 객체로 열 수 있고, 일단 열면 read, readNBytes, readAllBytes 같은 InputStream의 공통 메서드로 읽습니다. 다음 프로그램은 임시 파일에 다섯 바이트를 쓴 뒤, FileInputStream으로 앞 두 바이트를 머리 부분(header)으로 읽고 나머지를 읽습니다.

src/FileInputStreamHeader.java
import java.io.EOFException;
import java.io.FileInputStream;
import java.io.IOException;
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 FileInputStreamHeader {
    public static void main(String[] args) throws IOException {
        Path file = Files.createTempFile("header-", ".bin");
        try {
            try (OutputStream output = Files.newOutputStream(file)) {
                output.write(new byte[] {(byte) 0xCA, (byte) 0xFE, 1, 2, 3});
            }

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

header=는 앞 두 바이트를 16진수로 출력한 것이고, remaining=은 그 뒤에 남아 있던 바이트 수입니다. 파일에 쓴 바이트는 모두 다섯 개입니다.

header=cafe
remaining=3

쓰는 쪽은 Files.newOutputStream으로 파일을 열어 다섯 바이트를 썼습니다. 읽는 쪽은 FileInputStream을 InputStream 타입 변수에 담았으므로, 그 아래 코드는 메모리 스트림을 읽을 때와 똑같습니다. readNBytes(2)가 CA FE를 읽은 뒤 읽는 위치가 2로 나아갔기 때문에, 이어서 부른 readAllBytes()는 나머지 세 바이트만 돌려줍니다. 머리 부분이 두 바이트보다 짧은 파일이라면 EOFException을 던져 잘린 파일임을 알립니다.

FileInputStream은 파일이 없을 때뿐 아니라 경로가 디렉터리이거나 권한이 없어 열 수 없을 때도 FileNotFoundException을 던집니다. 새 코드에서 Path로 파일을 다룬다면 Files.newInputStream(path)를 주로 씁니다. 같은 InputStream을 돌려주면서 파일이 없으면 NoSuchFileException처럼 원인이 더 분명한 예외를 던지고, 열기 옵션도 지정할 수 있습니다. Path와 Files는 24장에서 자세히 다룹니다. 파일을 열었다고 내용이 고정되는 것은 아니라서 다른 프로세스가 그 사이에 파일을 바꿀 수 있고, available()이 파일의 남은 크기를 알려 준다고 기대해서도 안 됩니다.

스트림은 누가 닫아야 할까

파일이나 네트워크 스트림은 운영체제의 자원을 붙잡고 있으므로 다 쓰면 close()로 닫아야 합니다. 운영체제는 한 프로세스가 동시에 열 수 있는 파일 수를 제한하므로, 닫지 않은 스트림이 쌓이면 어느 순간 파일을 더 열 수 없게 됩니다. 기본 원칙은 '스트림을 연 쪽이 닫는다'입니다. 앞 예제들처럼 스트림을 연 메서드 안에서 try-with-resources를 쓰면, 블록을 정상적으로 빠져나가든 예외로 빠져나가든 close()가 자동으로 호출됩니다. 본문과 close()에서 모두 예외가 나면 본문의 예외가 던져지고 닫기 예외는 억제된 예외로 함께 남는데, 자세한 내용은 try-with-resources와 공통 예외 처리에서 다루었습니다.

반대로 다른 메서드에서 넘겨받은 스트림은 빌려 쓰는 것이므로 함부로 닫으면 안 됩니다. 호출한 쪽이 그 스트림에 이어서 더 쓰거나 읽을 수 있기 때문입니다. 다음 프로그램은 넘겨받은 스트림을 닫아 버리는 잘못된 메서드에 System.out을 넘깁니다.

bad/BorrowedStream.java
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;

public final class BorrowedStream {
    static void writeHeader(OutputStream output) throws IOException {
        try (output) { // 잘못: 빌려 온 스트림을 닫는다
            output.write("header\n".getBytes(StandardCharsets.UTF_8));
        }
    }

    public static void main(String[] args) throws IOException {
        writeHeader(System.out);
        System.out.println("body");
    }
}

writeHeader는 header를 쓰고, main은 그다음 줄에서 body를 출력합니다. 두 줄이 모두 나오는지 보세요.

header

try (output)은 이미 있는 변수를 try-with-resources로 닫게 하는 문법인데, 여기서는 그 대상이 빌려 온 System.out이었습니다. writeHeader가 끝나면서 System.out이 닫혔으므로 main의 body는 출력되지 않습니다. 더 곤란한 점은 아무 예외도 나지 않는다는 것입니다. System.out 같은 PrintStream은 쓰기 오류를 예외로 던지지 않고 내부에 표시만 해 두며, 그 표시는 checkError()로 확인할 수 있습니다. main 끝에서 System.out.checkError()의 결과를 System.err로 출력해 보면 true가 나옵니다.

그래서 스트림을 매개변수로 받는 메서드는 쓰거나 읽는 일만 하고, 닫는 일은 스트림을 연 호출자에게 맡깁니다. 넘겨받은 출력 스트림에 쓴 내용을 확실히 내보내야 한다면 닫는 대신 flush()를 부릅니다. 반대로 스트림의 소유권까지 넘겨받아 닫는 것이 맞는 API라면, 그 사실을 메서드 이름이나 문서에 분명히 적어 두어야 호출자가 같은 스트림을 다시 쓰는 실수를 하지 않습니다.

일부만 읽혀도 안전한 복사 반복문

이제 앞의 규칙들을 모아 입력 스트림의 내용을 출력 스트림으로 복사하는 반복문을 만들어 보겠습니다. 다음 copy는 배열 단위로 읽어서 쓰고, 복사한 양이 limit를 넘으면 중단합니다. 동작을 눈으로 따라가기 쉽게 배열 크기를 4로 아주 작게 잡았습니다.

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 IOException {
        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()));

        var partial = new ByteArrayOutputStream();
        try {
            copy(new ByteArrayInputStream(source), partial, 6);
        } catch (IOException e) {
            System.out.println(e.getMessage() + ", written=" + Arrays.toString(partial.toByteArray()));
        }
    }
}

첫 두 줄은 상한 10으로 7바이트를 복사한 결과이고, 마지막 줄은 상한 6으로 같은 입력을 복사하다 중단된 결과입니다. 중단되기 전까지 출력 스트림에 몇 바이트가 쓰였는지 보세요.

count=7
true
input limit exceeded, written=[1, 2, 3, 4]

반복문의 핵심은 write(buffer, 0, count)입니다. 상한 10으로 7바이트를 복사하는 첫 호출에서, 첫 read는 배열 네 칸을 모두 채워 count가 4이고 1, 2, 3, 4를 씁니다. 두 번째 read는 남은 세 바이트만 읽어 count가 3입니다. 이때 배열은 [5, 6, 7, 4]인데, 마지막 칸의 4는 첫 번째 읽기에서 남은 옛 값입니다. count만큼만 쓰므로 5, 6, 7만 출력되고, 세 번째 read가 -1을 돌려주면 반복이 끝납니다. 그래서 count=7과 원본과 같다는 true가 출력됩니다.

마지막 읽기의 유효 구간

같은 buffer의 read 4→read 3→EOF에서 유효 범위·누적 출력 4/7이 바뀝니다. 마지막의 옛 4를 쓰지 않는 지점을 고정된 슬롯에서 추적합니다.

마지막 읽기의 유효 구간

BoundedStreamCopy의 7바이트 메모리 입력과 4바이트 버퍼에서 반환된 개수만 출력하는 이유입니다.

부분 읽기 · 유효 바이트만 쓰기

마지막 읽기의 유효 구간

두 번째 읽기 뒤 버퍼의 마지막 4는 이전 데이터이며, count=3이면 쓰지 않습니다.

버퍼의 유효 구간과 출력량같은 버퍼의 배열 위치 0~3이다. 첫 read는 네 칸, 다음 read는 세 칸만 유효하다. 마지막 4는 이전 값이므로 출력하지 않는다. EOF에서는 배열과 출력이 그대로다.01122334출력에 1·2·3·4누적 4바이트05162734출력에 5·6·7누적 7바이트05162734EOF: write 없이 종료누적 7바이트 유지첫 read · count=4write(buffer, 0, 4)둘째 read · count=3write(buffer, 0, 3) · 마지막 4 제외다음 read · count=−1인덱스는 같은 배열 위치 · 화살표는 유효 구간의 출력
버퍼의 유효 구간과 출력량같은 버퍼의 배열 위치 0~3이다. 첫 read는 네 칸, 다음 read는 세 칸만 유효하다. 마지막 4는 이전 값이므로 출력하지 않는다. EOF에서는 배열과 출력이 그대로다.01122334출력에 1·2·3·4누적 4바이트05162734출력에 5·6·7누적 7바이트05162734write 없이 종료누적 7바이트 유지첫 read · count=4write(buffer, 0, 4)둘째 read · count=3마지막 4는 이전 값선택: 인덱스 0~2다음 read · count=−1EOF · 배열은 그대로이 원 메모리 입력의 값
  1. 첫 번째 · count = 4

    읽은 뒤 버퍼
    [1, 2, 3, 4]
    출력에 전달하는 값
    인덱스 0~3 · 1, 2, 3, 4
  2. 두 번째 · count = 3

    읽은 뒤 버퍼
    [5, 6, 7, 4]
    출력에 전달하는 값
    인덱스 0~2 · 5, 6, 7
  3. 세 번째 · count = −1

    읽은 뒤 버퍼
    [5, 6, 7, 4] 그대로
    출력에 전달하는 값
    EOF이므로 write 없이 종료

ByteArrayInputStream과 이 입력에서 도출한 값입니다. 두 번째 읽기 뒤의 마지막 4는 이전 데이터이므로 다시 쓰지 않습니다.

이 예의 조건

  • 같은 4바이트 배열을 재사용하며 read count만 유효합니다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
전체 읽기 정보마지막 읽기의 유효 구간 마지막 읽기의 유효 구간 BoundedStreamCopy의 7바이트 메모리 입력과 4바이트 버퍼에서 반환된 개수만 출력하는 이유입니다. 부분 읽기 · 유효 바이트만 쓰기 마지막 읽기의 유효 구간 두 번째 읽기 뒤 버퍼의 마지막 4는 이전 데이터이며, count=3이면 쓰지 않습니다. 첫 번째 · count = 4 읽은 뒤 버퍼 [1, 2, 3, 4] 출력에 전달하는 값 인덱스 0~3 · 1, 2, 3, 4 두 번째 · count = 3 읽은 뒤 버퍼 [5, 6, 7, 4] 출력에 전달하는 값 인덱스 0~2 · 5, 6, 7 세 번째 · count = −1 읽은 뒤 버퍼 [5, 6, 7, 4] 그대로 출력에 전달하는 값 EOF이므로 write 없이 종료 ByteArrayInputStream과 이 입력에서 도출한 값입니다. 두 번째 읽기 뒤의 마지막 4는 이전 데이터이므로 다시 쓰지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 read

count 4인 1,2,3,4를 모두 씁니다.

  • 배열: [1,2,3,4]
  • 누적 출력: 4바이트
  • 유효 구간 0..3만 write합니다.
둘째 read

count 3인 5,6,7을 쓰고 마지막의 옛 4는 제외합니다.

  • 배열: [5,6,7,4]
  • 누적 출력: 7바이트
  • 배열 길이가 아닌 이번 count가 쓰기 범위를 정합니다.
EOF

read가 −1이면 write 없이 종료합니다.

  • 배열: [5,6,7,4] 유지
  • 출력: 7바이트 유지
  • EOF에서 이전 버퍼를 다시 쓰지 않습니다.

마지막 읽기의 유효 구간

같은 buffer의 read 4→read 3→EOF에서 유효 범위·누적 출력 4/7이 바뀝니다. 마지막의 옛 4를 쓰지 않는 지점을 고정된 슬롯에서 추적합니다.

마지막 읽기의 유효 구간

BoundedStreamCopy의 7바이트 메모리 입력과 4바이트 버퍼에서 반환된 개수만 출력하는 이유입니다.

부분 읽기 · 유효 바이트만 쓰기

마지막 읽기의 유효 구간

두 번째 읽기 뒤 버퍼의 마지막 4는 이전 데이터이며, count=3이면 쓰지 않습니다.

버퍼의 유효 구간과 출력량같은 버퍼의 배열 위치 0~3이다. 첫 read는 네 칸, 다음 read는 세 칸만 유효하다. 마지막 4는 이전 값이므로 출력하지 않는다. EOF에서는 배열과 출력이 그대로다.01122334출력에 1·2·3·4누적 4바이트05162734출력에 5·6·7누적 7바이트05162734EOF: write 없이 종료누적 7바이트 유지첫 read · count=4write(buffer, 0, 4)둘째 read · count=3write(buffer, 0, 3) · 마지막 4 제외다음 read · count=−1인덱스는 같은 배열 위치 · 화살표는 유효 구간의 출력
버퍼의 유효 구간과 출력량같은 버퍼의 배열 위치 0~3이다. 첫 read는 네 칸, 다음 read는 세 칸만 유효하다. 마지막 4는 이전 값이므로 출력하지 않는다. EOF에서는 배열과 출력이 그대로다.01122334출력에 1·2·3·4누적 4바이트05162734출력에 5·6·7누적 7바이트05162734write 없이 종료누적 7바이트 유지첫 read · count=4write(buffer, 0, 4)둘째 read · count=3마지막 4는 이전 값선택: 인덱스 0~2다음 read · count=−1EOF · 배열은 그대로이 원 메모리 입력의 값
  1. 첫 번째 · count = 4

    읽은 뒤 버퍼
    [1, 2, 3, 4]
    출력에 전달하는 값
    인덱스 0~3 · 1, 2, 3, 4
  2. 두 번째 · count = 3

    읽은 뒤 버퍼
    [5, 6, 7, 4]
    출력에 전달하는 값
    인덱스 0~2 · 5, 6, 7
  3. 세 번째 · count = −1

    읽은 뒤 버퍼
    [5, 6, 7, 4] 그대로
    출력에 전달하는 값
    EOF이므로 write 없이 종료

ByteArrayInputStream과 이 입력에서 도출한 값입니다. 두 번째 읽기 뒤의 마지막 4는 이전 데이터이므로 다시 쓰지 않습니다.

이 예의 조건

  • 같은 4바이트 배열을 재사용하며 read count만 유효합니다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
전체 읽기 정보마지막 읽기의 유효 구간 마지막 읽기의 유효 구간 BoundedStreamCopy의 7바이트 메모리 입력과 4바이트 버퍼에서 반환된 개수만 출력하는 이유입니다. 부분 읽기 · 유효 바이트만 쓰기 마지막 읽기의 유효 구간 두 번째 읽기 뒤 버퍼의 마지막 4는 이전 데이터이며, count=3이면 쓰지 않습니다. 첫 번째 · count = 4 읽은 뒤 버퍼 [1, 2, 3, 4] 출력에 전달하는 값 인덱스 0~3 · 1, 2, 3, 4 두 번째 · count = 3 읽은 뒤 버퍼 [5, 6, 7, 4] 출력에 전달하는 값 인덱스 0~2 · 5, 6, 7 세 번째 · count = −1 읽은 뒤 버퍼 [5, 6, 7, 4] 그대로 출력에 전달하는 값 EOF이므로 write 없이 종료 ByteArrayInputStream과 이 입력에서 도출한 값입니다. 두 번째 읽기 뒤의 마지막 4는 이전 데이터이므로 다시 쓰지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 read

count 4인 1,2,3,4를 모두 씁니다.

  • 배열: [1,2,3,4]
  • 누적 출력: 4바이트
  • 유효 구간 0..3만 write합니다.
둘째 read

count 3인 5,6,7을 쓰고 마지막의 옛 4는 제외합니다.

  • 배열: [5,6,7,4]
  • 누적 출력: 7바이트
  • 배열 길이가 아닌 이번 count가 쓰기 범위를 정합니다.
EOF

read가 −1이면 write 없이 종료합니다.

  • 배열: [5,6,7,4] 유지
  • 출력: 7바이트 유지
  • EOF에서 이전 버퍼를 다시 쓰지 않습니다.

write(buffer, 0, count) 대신 write(buffer)로 배열 전체를 쓰면 어떻게 될까요? 두 번째 반복에서 옛 값 4까지 함께 쓰여 결과가 [1, 2, 3, 4, 5, 6, 7, 4]로 8바이트가 됩니다. 짧은 읽기는 마지막 조각뿐 아니라 중간에도 일어날 수 있으므로, 반환값만큼만 쓰는 것은 성능 문제가 아니라 결과가 맞느냐의 문제입니다.

limit는 크기를 알 수 없는 입력이 메모리나 디스크를 끝없이 차지하지 못하게 막는 상한입니다. total을 더할 때 Math.addExact를 쓴 것은 아주 긴 입력에서 long이 넘쳐 음수가 되면 상한 검사를 그냥 통과해 버리기 때문이며, 넘치는 순간 예외를 던지게 합니다. 상한 검사는 쓰기 전에 하므로, 상한을 넘게 만든 조각은 출력에 쓰이지 않습니다.

출력의 셋째 줄이 그 모습을 보여 줍니다. 상한 6으로 같은 입력을 복사하면 첫 read의 4바이트는 상한 아래라 출력에 쓰이고, 두 번째 read에서 total이 7이 되어 쓰기 전에 예외가 납니다. 입력에서는 7바이트를 모두 읽었지만 출력에는 [1, 2, 3, 4]만 남았습니다. 예외가 났다고 이미 쓴 앞부분이 저절로 지워지지는 않는다는 뜻입니다. 이 숫자는 ByteArrayInputStream이 배열을 가득 채워 주기 때문에 나온 것이고, 짧은 읽기가 일어나는 입력이라면 출력에 남는 양도 달라집니다. 반쯤 쓰다 만 결과가 다른 사람 눈에 보이면 안 된다면, 임시 파일에 먼저 쓰고 복사가 성공했을 때만 원래 이름으로 옮깁니다. 이 방법은 24장에서 다룹니다.

readAllBytes와 transferTo는 언제 쓸까

InputStream에는 반복문을 직접 쓰지 않아도 되는 메서드가 있습니다. readAllBytes()는 스트림의 끝까지 읽어 바이트 배열 하나로 돌려줍니다. 크기가 작고 상한이 분명한 설정 파일 같은 입력이라면 가장 읽기 쉬운 방법입니다. 하지만 사용자가 올린 파일이나 압축을 푼 결과처럼 크기를 믿을 수 없는 입력을 통째로 메모리에 올리면 힙을 다 써 버릴 수 있습니다. 파일 크기를 미리 확인하는 방법도 완전하지 않습니다. 읽는 동안 파일이 바뀔 수 있고, 압축된 데이터는 풀었을 때의 크기를 미리 알려 주지 않기 때문입니다.

Java 9부터 생긴 transferTo(OutputStream)는 입력의 끝까지 읽어 출력에 쓰고 옮긴 바이트 수를 돌려주므로, 단순한 복사를 한 줄로 쓸 수 있습니다. 반면 앞의 copy처럼 상한을 검사하거나, 진행률을 보여 주거나, 복사하면서 해시를 계산해야 한다면 반복문을 직접 쓰는 편이 맞습니다. 즉 어떤 방법을 고를지는 코드 줄 수가 아니라 복사하는 도중에 무엇을 확인해야 하는지로 정합니다. 배열 크기를 얼마로 잡아야 빠른지는 다음 글에서 측정해 봅니다.

정리

  • InputStream과 OutputStream은 파일, 메모리, 네트워크의 바이트를 같은 방법으로 읽고 쓰게 해 줍니다. 스트림은 바이트를 앞에서부터 차례로 흘려보내며, 읽은 바이트는 다시 읽을 수 없습니다.
  • read()는 0부터 255 사이의 int나 끝을 뜻하는 -1을 돌려줍니다. 결과를 byte로 바꾸기 전에 -1인지 먼저 확인해야 FF를 끝으로 오인하지 않습니다.
  • read(byte[])는 배열을 가득 채운다는 보장이 없고 실제로 읽은 바이트 수를 돌려줍니다. 정확히 n바이트가 필요하면 readNBytes(n)을 쓰고 결과 길이를 확인합니다.
  • available()은 남은 전체 길이가 아닙니다. 어디까지 읽을지는 길이 정보, 끝 표시(-1), 메시지 구분 방식으로 정합니다.
  • 스트림은 연 쪽이 try-with-resources로 닫고, 넘겨받은 스트림은 닫지 않습니다.
  • 복사 반복문에서는 write(buffer, 0, count)로 읽은 만큼만 쓰고, 크기를 믿을 수 없는 입력에는 상한을 둡니다. 실패해도 이미 쓴 출력은 남는다는 점을 기억합니다.

연습 문제

1. 길이 헤더가 있는 프레임 읽기

앞쪽 두 바이트에 본문 길이를 적고 그 뒤에 본문이 오는 형식을 프레임(frame)이라고 부르겠습니다. 입력 스트림에서 프레임 하나를 읽어 본문을 돌려주는 메서드를 작성하세요. 본문은 최대 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());
    }
}

실행 결과:

[9, 8, 7]
next=99

한 프레임의 소비 범위

길이 00 03→payload 3바이트→현재 위치 5→뒤 read 99의 소비 범위입니다. 위치와 미소비 바이트를 단계로 구분하면 프레임 경계가 보입니다.

한 프레임의 소비 범위

ExactFrameReaderSolution의 wire에서 길이 헤더와 본문만 읽고 다음 바이트를 남깁니다.

프레임 경계 · 헤더와 본문

한 프레임의 소비 범위

길이 헤더 00 03과 본문 09 08 07만 읽고, 다음 바이트 63은 남겨 둡니다.

헤더와 본문을 소비하고 다음 바이트를 남긴다wire의 바이트0~1은00 03,2~4는09 08 07,5는63이다.헤더에서길이3을정한뒤본문3바이트만읽어반환한다.다음바이트63은readFrame가소비하지않고다음input.read가99로반환한다.wire[0..1]00 03 · 길이 3크기 검사 통과readNBytes(3)wire[2..4]09 08 07 반환다음 위치: 5readFrame 종료wire[5] · 남은 바이트63 (16진수) = 99아직 읽지 않음다음 input.read() → 99실선: 이번 readFrame가 처리한 범위 · 남은 바이트로 가는 소비 간선 없음
헤더와 본문을 소비하고 다음 바이트를 남긴다wire의 바이트0~1은00 03,2~4는09 08 07,5는63이다.헤더에서길이3을정한뒤본문3바이트만읽어반환한다.다음바이트63은readFrame가소비하지않고다음input.read가99로반환한다.wire[0..1] · 헤더00 03 → 길이 3readNBytes(3)길이 상한 검사를 통과wire[2..4] · 본문09 08 07세 바이트를 반환wire[5] · 남은 바이트63 (16진수) = 99아직 읽지 않음다음 위치: 5readFrame 종료위치 5의 값은 아래에 남음그다음 input.read() → 99

0~1 · 헤더

값
00 03 (16진수)
readFrame의 처리
본문 길이 3으로 조합

2~4 · 본문

값
09 08 07 (16진수)
readFrame의 처리
3바이트를 읽어 반환

5 · 다음 데이터

값
63 (16진수) = 99
readFrame의 처리
남겨 둠 · 다음 input.read()가 반환

길이가 1024를 넘으면 본문을 읽기 전에 실패하며, 헤더 또는 본문이 부족하면 EOFException을 던집니다.

이 예의 조건

  • wire=00 03 09 08 07 63이며 16진수 63은 십진수 99입니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2wire의 바이트 0~1은 00 03, 2~4는 09 08 07, 5는 63이다. 헤더에서 길이 3을 정한 뒤 본문 3바이트만 읽어 반환한다. 다음 바이트 63은 readFrame이 소비하지 않고 다음 input.read가 99로 반환한다.
15실선: 이번 readFrame이 처리한 범위 · 남은 바이트로 가는 소비 간선 없음
원 도표의 설명과 주석
구분내용
설명wire의 바이트 0~1은 00 03, 2~4는 09 08 07, 5는 63이다. 헤더에서 길이 3을 정한 뒤 본문 3바이트만 읽어 반환한다. 다음 바이트 63은 readFrame이 소비하지 않고 다음 input.read가 99로 반환한다.
전체 읽기 정보한 프레임의 소비 범위 한 프레임의 소비 범위 ExactFrameReaderSolution의 wire에서 길이 헤더와 본문만 읽고 다음 바이트를 남깁니다. 프레임 경계 · 헤더와 본문 한 프레임의 소비 범위 길이 헤더 00 03과 본문 09 08 07만 읽고, 다음 바이트 63은 남겨 둡니다. 0~1 · 헤더 값 00 03 (16진수) readFrame의 처리 본문 길이 3으로 조합 2~4 · 본문 값 09 08 07 (16진수) readFrame의 처리 3바이트를 읽어 반환 5 · 다음 데이터 값 63 (16진수) = 99 readFrame의 처리 남겨 둠 · 다음 input.read()가 반환 길이가 1024를 넘으면 본문을 읽기 전에 실패하며, 헤더 또는 본문이 부족하면 EOFException을 던집니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
헤더 소비

wire 0..1의 00 03으로 길이 3을 읽습니다.

  • 길이: 3
  • 검사: 크기 통과
  • 본문 읽기량은 헤더가 정한 3입니다.
본문 반환

readNBytes(3)가 wire 2..4를 읽습니다.

  • 반환: 09 08 07
  • readFrame은 다음 프레임 앞 바이트까지 소비하지 않습니다.
남은 바이트

종료 뒤 별도 input.read가 wire 5를 읽습니다.

  • readFrame 뒤: 63 남음
  • 다음 read: 99 반환
  • 프레임 함수의 소비 범위와 후속 읽기를 구별합니다.

한 프레임의 소비 범위

길이 00 03→payload 3바이트→현재 위치 5→뒤 read 99의 소비 범위입니다. 위치와 미소비 바이트를 단계로 구분하면 프레임 경계가 보입니다.

한 프레임의 소비 범위

ExactFrameReaderSolution의 wire에서 길이 헤더와 본문만 읽고 다음 바이트를 남깁니다.

프레임 경계 · 헤더와 본문

한 프레임의 소비 범위

길이 헤더 00 03과 본문 09 08 07만 읽고, 다음 바이트 63은 남겨 둡니다.

헤더와 본문을 소비하고 다음 바이트를 남긴다wire의 바이트0~1은00 03,2~4는09 08 07,5는63이다.헤더에서길이3을정한뒤본문3바이트만읽어반환한다.다음바이트63은readFrame가소비하지않고다음input.read가99로반환한다.wire[0..1]00 03 · 길이 3크기 검사 통과readNBytes(3)wire[2..4]09 08 07 반환다음 위치: 5readFrame 종료wire[5] · 남은 바이트63 (16진수) = 99아직 읽지 않음다음 input.read() → 99실선: 이번 readFrame가 처리한 범위 · 남은 바이트로 가는 소비 간선 없음
헤더와 본문을 소비하고 다음 바이트를 남긴다wire의 바이트0~1은00 03,2~4는09 08 07,5는63이다.헤더에서길이3을정한뒤본문3바이트만읽어반환한다.다음바이트63은readFrame가소비하지않고다음input.read가99로반환한다.wire[0..1] · 헤더00 03 → 길이 3readNBytes(3)길이 상한 검사를 통과wire[2..4] · 본문09 08 07세 바이트를 반환wire[5] · 남은 바이트63 (16진수) = 99아직 읽지 않음다음 위치: 5readFrame 종료위치 5의 값은 아래에 남음그다음 input.read() → 99

0~1 · 헤더

값
00 03 (16진수)
readFrame의 처리
본문 길이 3으로 조합

2~4 · 본문

값
09 08 07 (16진수)
readFrame의 처리
3바이트를 읽어 반환

5 · 다음 데이터

값
63 (16진수) = 99
readFrame의 처리
남겨 둠 · 다음 input.read()가 반환

길이가 1024를 넘으면 본문을 읽기 전에 실패하며, 헤더 또는 본문이 부족하면 EOFException을 던집니다.

이 예의 조건

  • wire=00 03 09 08 07 63이며 16진수 63은 십진수 99입니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2wire의 바이트 0~1은 00 03, 2~4는 09 08 07, 5는 63이다. 헤더에서 길이 3을 정한 뒤 본문 3바이트만 읽어 반환한다. 다음 바이트 63은 readFrame이 소비하지 않고 다음 input.read가 99로 반환한다.
15실선: 이번 readFrame이 처리한 범위 · 남은 바이트로 가는 소비 간선 없음
원 도표의 설명과 주석
구분내용
설명wire의 바이트 0~1은 00 03, 2~4는 09 08 07, 5는 63이다. 헤더에서 길이 3을 정한 뒤 본문 3바이트만 읽어 반환한다. 다음 바이트 63은 readFrame이 소비하지 않고 다음 input.read가 99로 반환한다.
전체 읽기 정보한 프레임의 소비 범위 한 프레임의 소비 범위 ExactFrameReaderSolution의 wire에서 길이 헤더와 본문만 읽고 다음 바이트를 남깁니다. 프레임 경계 · 헤더와 본문 한 프레임의 소비 범위 길이 헤더 00 03과 본문 09 08 07만 읽고, 다음 바이트 63은 남겨 둡니다. 0~1 · 헤더 값 00 03 (16진수) readFrame의 처리 본문 길이 3으로 조합 2~4 · 본문 값 09 08 07 (16진수) readFrame의 처리 3바이트를 읽어 반환 5 · 다음 데이터 값 63 (16진수) = 99 readFrame의 처리 남겨 둠 · 다음 input.read()가 반환 길이가 1024를 넘으면 본문을 읽기 전에 실패하며, 헤더 또는 본문이 부족하면 EOFException을 던집니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
헤더 소비

wire 0..1의 00 03으로 길이 3을 읽습니다.

  • 길이: 3
  • 검사: 크기 통과
  • 본문 읽기량은 헤더가 정한 3입니다.
본문 반환

readNBytes(3)가 wire 2..4를 읽습니다.

  • 반환: 09 08 07
  • readFrame은 다음 프레임 앞 바이트까지 소비하지 않습니다.
남은 바이트

종료 뒤 별도 input.read가 wire 5를 읽습니다.

  • readFrame 뒤: 63 남음
  • 다음 read: 99 반환
  • 프레임 함수의 소비 범위와 후속 읽기를 구별합니다.

헤더의 두 바이트는 read()로 하나씩 읽습니다. read()는 0부터 255 사이의 int를 돌려주므로 부호를 걱정할 필요 없이 (high << 8) | low로 바로 조합할 수 있습니다. 앞 바이트가 큰 자리이므로 00 03은 길이 3입니다. 어느 한쪽이라도 -1이면 헤더가 잘린 것이므로 EOFException을 던집니다. 그다음 길이가 1024를 넘는지 본문을 읽기 전에 검사하고, readNBytes(length)의 결과 길이를 다시 확인해 본문이 잘린 경우를 찾아냅니다.

main의 wire에서는 헤더 00 03과 본문 09 08 07까지 다섯 바이트만 읽고, 여섯째 바이트 99는 그대로 남깁니다. readAllBytes()를 쓰지 않고 정확히 length만큼만 읽었기 때문이며, 그래서 이어서 부른 input.read()가 99를 돌려줍니다. 같은 스트림에서 readFrame을 다시 부르면 다음 프레임을 읽을 수 있다는 뜻입니다. DataInputStream의 readUnsignedShort()도 두 바이트를 같은 방식으로 조합해 주므로, 헤더를 읽는 부분을 그것으로 바꿔도 됩니다.

2. 프레임 읽기를 손으로 따라가기

1번의 readFrame에 다음 입력을 넣으면 어떻게 될까요? 숫자는 모두 바이트의 부호 없는 값입니다.

  1. [0, 0, 99]를 넣었을 때 돌려받는 본문과, 이어서 input.read()가 돌려주는 값은 무엇인가요?
  2. [4, 1, 9, 8]은 어느 단계에서 실패하나요? 본문 9, 8까지 읽나요?
  3. [0]과 [0, 3, 9, 8]은 각각 어떤 예외로 실패하나요?
정답과 해설
  1. 헤더 00 00은 길이 0이므로 빈 배열 []을 정상적으로 돌려줍니다. readNBytes(0)은 아무것도 읽지 않으므로 다음 input.read()는 99를 돌려줍니다.
  2. 헤더 04 01은 4 × 256 + 1 = 1025바이트를 뜻하므로, 헤더 두 바이트만 읽은 뒤 크기 검사에서 frame too large: 1025로 실패합니다. 본문을 읽기 전에 검사하므로 9, 8은 스트림에 그대로 남아 있습니다.
  3. [0]은 두 번째 read()가 -1을 돌려주므로 short length header로 실패합니다. [0, 3, 9, 8]은 헤더가 길이 3을 요구하지만 본문이 두 바이트뿐이라 readNBytes(3)이 길이 2인 배열을 돌려주고, short payload로 실패합니다.

다음 글

청크 단위 파일 복사와 버퍼 크기에서는 바이트를 하나씩 옮길 때와 배열 단위로 옮길 때의 비용을 비교하고, 배열 크기를 어떻게 정할지 측정해 봅니다.