버퍼 스트림으로 감싸기와 닫는 순서
BufferedInputStream과 BufferedOutputStream으로 기존 스트림을 감싸 시스템 호출을 줄이고, flush와 close가 안쪽 스트림으로 전달되는 방식과 mark·reset으로 앞부분을 미리 읽는 방법을 배웁니다.
앞 글에서는 바이트 배열을 직접 관리하며 청크 단위로 복사했습니다. 그런데 파일 형식을 해석하는 파서처럼, 한 바이트씩 읽어야 코드가 자연스러운 경우도 많습니다. 이런 코드를 배열과 인덱스를 직접 다루도록 고치면 코드가 복잡해지고, 마지막 조각을 잘못 쓰는 것 같은 실수도 끼어들기 쉽습니다. BufferedInputStream과 BufferedOutputStream은 이 문제를 다른 방법으로 풉니다. 기존 스트림을 감싸서 안쪽에서 청크를 모아 주므로, 바깥 코드는 한 바이트씩 읽고 쓰는 모양을 그대로 유지할 수 있습니다.
대신 새로 신경 쓸 것이 생깁니다. 출력 버퍼에 모인 데이터가 언제 실제 파일로 가는지, 여러 겹으로 감싼 스트림 가운데 무엇을 닫아야 하는지입니다. 이 글은 감싸기의 효과를 확인한 뒤 flush와 close의 동작과 닫는 순서를 보고, mark와 reset을 이용한 미리 읽기와 여러 스트림을 겹쳐 쌓는 순서를 차례로 다룹니다.
감싸기만 해도 시스템 호출이 줄어든다
다음 프로그램의 copy는 앞 글의 SingleByteFileCopy와 같은 한 바이트 루프를 씁니다. 달라진 점은 Files.newInputStream과 Files.newOutputStream이 돌려준 파일 스트림을 각각 BufferedInputStream과 BufferedOutputStream으로 한 번 감쌌다는 것 하나뿐입니다. 원본은 고정된 시드(seed)로 만든 난수 100,000바이트입니다.
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Random;
public final class BufferedByteCopy {
static void copy(Path source, Path target) throws Exception {
try (InputStream input = new BufferedInputStream(Files.newInputStream(source));
OutputStream output = new BufferedOutputStream(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("buffered-", ".bin");
Path target = Files.createTempFile("buffered-copy-", ".bin");
try {
byte[] data = new byte[100_000];
new Random(23).nextBytes(data);
Files.write(source, data);
copy(source, target);
System.out.println(Files.size(target) + " " + Arrays.equals(data, Files.readAllBytes(target)));
} finally {
Files.deleteIfExists(source);
Files.deleteIfExists(target);
}
}
}100000 true복사본의 크기와 내용이 원본과 같습니다. 앞 글과 같은 방법으로 Linux에서 strace로 시스템 호출을 세어 보면(JDK 21) 감싸기의 효과가 드러납니다.
| 복사 방식 | 원본 read 호출 | 복사본 write 호출 |
|---|---|---|
| 파일 스트림에 한 바이트씩 | 100,001 | 100,000 |
| 8,192바이트 배열로 직접 청크 복사 | 14 | 13 |
| 버퍼 스트림으로 감싸고 한 바이트씩 | 14 | 13 |
세 번째 줄의 코드는 첫 번째 줄과 똑같이 한 바이트씩 읽고 쓰는데도, 시스템 호출 수는 배열을 직접 관리한 두 번째 줄과 같습니다. 복사본에 대한 write 13번은 8,192바이트씩 12번과 마지막 1,696바이트 1번입니다.
버퍼 스트림이 하는 일
BufferedInputStream은 파일을 직접 열지 않습니다. 생성자로 다른 InputStream을 받아 그 앞에 끼어들 뿐입니다. 처음 read()가 불리면 안쪽 스트림에 한 번에 최대 8,192바이트(JDK 21의 기본 크기)를 요청해 내부 배열을 채우고, 그중 1바이트를 돌려줍니다. 그다음 read()부터는 안쪽 스트림을 부르지 않고 내부 배열에서 바로 꺼내 줍니다. 배열이 바닥나야 다시 안쪽 스트림에 청크를 요청합니다. 그래서 호출자 쪽의 read() 100,001번(마지막 한 번은 EOF 확인)이 안쪽에서는 청크 요청 14번으로 바뀝니다.
BufferedOutputStream은 반대 방향으로 같은 일을 합니다. write로 받은 바이트를 내부 배열에 모아 두었다가, 배열이 차면 한꺼번에 안쪽 스트림에 넘깁니다. 이처럼 파일이나 네트워크에 실제로 연결된 스트림을 기반 스트림이라 하고, 다른 스트림을 감싸서 기능을 더하는 스트림을 보조 스트림이라고 부릅니다. 보조 스트림도 InputStream이나 OutputStream이므로 또 다른 보조 스트림으로 다시 감쌀 수 있습니다. 같은 타입의 객체를 감싸며 기능을 덧붙이는 이런 구조를 데코레이터(decorator) 패턴이라고 합니다.
감싸도 호출자에게 보이는 동작은 바뀌지 않습니다. 바이트의 순서도, 끝에서 -1을 돌려주는 규칙도 그대로입니다. 그래서 한 바이트씩 읽는 기존 파서를 고치지 않고 스트림을 만드는 한 줄만 바꿔 시스템 호출을 줄일 수 있습니다. 반대로 이미 큰 배열로 직접 읽고 쓰는 코드는 시스템 호출이 충분히 적으므로, 버퍼 스트림을 더해서 얻을 것이 별로 없습니다.
출력 버퍼의 데이터는 언제 파일로 가는가
출력 쪽 버퍼에는 '아직 파일에 가지 않은 데이터'가 머뭅니다. 다음 프로그램은 버퍼 크기를 4바이트로 작게 잡고, 쓰기 호출을 하나 할 때마다 실제 파일 크기를 출력합니다. 파일 크기는 운영체제가 받은 바이트 수이므로, 버퍼에서 언제 데이터가 빠져나가는지를 그대로 보여 줍니다.
import java.io.BufferedOutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
public final class FlushTiming {
public static void main(String[] args) throws Exception {
Path file = Files.createTempFile("flush-", ".bin");
try {
try (var output = new BufferedOutputStream(Files.newOutputStream(file), 4)) {
output.write(new byte[] {1, 2, 3});
System.out.println("write 1,2,3 -> file " + Files.size(file));
output.write(4);
System.out.println("write 4 -> file " + Files.size(file));
output.write(5);
System.out.println("write 5 -> file " + Files.size(file));
output.flush();
System.out.println("flush -> file " + Files.size(file));
output.write(new byte[] {6, 7, 8, 9, 10});
System.out.println("write 6-10 -> file " + Files.size(file));
output.write(11);
System.out.println("write 11 -> file " + Files.size(file));
}
System.out.println("close -> file " + Files.size(file));
} finally {
Files.deleteIfExists(file);
}
}
}write 1,2,3 -> file 0
write 4 -> file 0
write 5 -> file 4
flush -> file 5
write 6-10 -> file 10
write 11 -> file 10
close -> file 11호출마다 버퍼와 파일의 상태를 따라가 보겠습니다.
| 호출 | 호출 뒤 버퍼 | 파일 크기 | 일어난 일 |
|---|---|---|---|
write 1, 2, 3 | 1 2 3 | 0 | 버퍼에 자리가 있으므로 모아 둔다 |
write(4) | 1 2 3 4 | 0 | 버퍼가 가득 찼지만 아직 넘기지 않는다 |
write(5) | 5 | 4 | 자리가 없으므로 먼저 4바이트를 넘기고 5를 담는다 |
flush() | 비어 있음 | 5 | 남은 1바이트를 강제로 넘긴다 |
write 6부터 10까지 | 비어 있음 | 10 | 버퍼보다 긴 배열이라 버퍼를 거치지 않고 곧바로 넘긴다 |
write(11) | 11 | 10 | 다시 버퍼에 모은다 |
close() | 없음 | 11 | 닫기 전에 남은 1바이트를 넘긴다 |
두 번째 줄을 눈여겨볼 만합니다. 버퍼가 가득 찬 순간에 바로 넘기는 것이 아니라, 다음 바이트가 들어올 자리가 없을 때 넘깁니다. 다섯 번째 줄은 BufferedOutputStream 문서에 정해진 동작입니다. 요청한 길이가 버퍼 크기 이상이면 버퍼를 먼저 비운 뒤 배열을 안쪽 스트림에 직접 씁니다. 큰 배열을 작은 버퍼에 잘게 나눠 담아 봐야 복사만 늘기 때문입니다.
마지막 줄처럼 close()는 닫기 전에 flush()를 합니다. 그래서 파일에 쓰는 코드라면 write마다 flush를 부를 필요가 없고, 오히려 자주 부르면 버퍼를 둔 의미가 사라집니다. 반대로 네트워크 연결처럼 오래 열어 두는 스트림에서 상대가 메시지 하나를 다 받아야 응답하는 경우에는, 메시지 끝에서 flush해야 합니다. 그러지 않으면 메시지가 버퍼에 머문 채로 양쪽이 서로를 기다리게 됩니다.
더 깊이: flush는 디스크에 기록했다는 뜻이 아니다
flush()는 자바 버퍼의 내용을 다음 계층으로 넘긴다는 뜻입니다. 파일이라면 다음 계층은 파일 스트림이고, 파일 스트림의 write는 write 시스템 호출을 합니다. 이 시점에 데이터는 운영체제의 메모리에 있을 뿐, 디스크에 기록됐다는 보장은 없습니다. OutputStream.flush의 문서도 대상이 파일 같은 운영체제의 추상화라면 운영체제에 넘겨졌다는 것까지만 보장한다고 적고 있습니다. 그래서 flush 직후에 전원이 나가면 데이터를 잃을 수 있습니다. 저장 장치까지 기록해야 한다면 FileChannel.force(true)나 FileOutputStream.getFD().sync()를 쓰고, 운영체제와 파일 시스템이 그 요청을 어떻게 처리하는지도 따로 확인합니다.
안쪽 스트림을 먼저 닫으면 생기는 일
스트림을 겹쳐 쌓으면 어느 것을 닫아야 하는지가 문제가 됩니다. 다음 코드는 일부러 잘못된 순서로 닫습니다. 64바이트 버퍼에 3바이트를 쓰면 그 3바이트는 버퍼에 머물러 있습니다. 이 상태에서 안쪽 FileOutputStream을 먼저 닫고 나서 바깥 BufferedOutputStream을 닫습니다.
import java.io.BufferedOutputStream;
import java.io.FileOutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
public final class CloseInnerFirst {
public static void main(String[] args) throws Exception {
Path file = Files.createTempFile("wrong-close-", ".bin");
FileOutputStream fileOutput = new FileOutputStream(file.toFile());
BufferedOutputStream buffered = new BufferedOutputStream(fileOutput, 64);
try {
buffered.write(new byte[] {1, 2, 3});
fileOutput.close();
buffered.close();
} finally {
try { buffered.close(); } catch (Exception ignored) { }
Files.deleteIfExists(file);
}
}
}JDK 21에서 실행한 결과입니다. JDK 내부 클래스의 줄 번호는 버전에 따라 다를 수 있습니다.
Exception in thread "main" java.io.IOException: Stream Closed
at java.base/java.io.FileOutputStream.writeBytes(Native Method)
at java.base/java.io.FileOutputStream.write(FileOutputStream.java:367)
at java.base/java.io.BufferedOutputStream.flushBuffer(BufferedOutputStream.java:125)
at java.base/java.io.BufferedOutputStream.implFlush(BufferedOutputStream.java:252)
at java.base/java.io.BufferedOutputStream.flush(BufferedOutputStream.java:240)
at java.base/java.io.FilterOutputStream.close(FilterOutputStream.java:184)
at CloseInnerFirst.main(CloseInnerFirst.java:14)스택 트레이스는 아래에서 위로 읽습니다. 14번째 줄의 buffered.close()가 닫기 전에 flush를 부르고, flush는 남은 3바이트를 안쪽 FileOutputStream에 쓰려 합니다. 하지만 그 스트림은 이미 닫혔으므로 IOException: Stream Closed가 납니다. finally의 두 번째 buffered.close()는 이미 닫기를 시도한 스트림이라 아무 일도 하지 않고, 임시 파일을 지운 뒤 예외가 main 밖으로 나갑니다. 만약 이 예외를 잡아서 무시했다면, 파일은 정상적으로 만들어진 것처럼 보이지만 3바이트는 하나도 들어 있지 않았을 것입니다.
안쪽을 먼저 닫은 뒤의 출력 상태
buffer 3바이트 보관→안쪽 close→바깥 close 전달 실패의 실제 소유 과정입니다. 버퍼와 하위 스트림 상태를 단계로 추적해 닫기 순서 문제를 설명합니다.
CloseInnerFirst의 64바이트 출력 버퍼에 3바이트를 쓴 뒤 안쪽 파일 스트림을 먼저 닫는 순서입니다.
닫기 순서 · 남은 버퍼의 전달
안쪽을 먼저 닫은 뒤의 출력 상태
안쪽 파일을 먼저 닫으면, 바깥 버퍼가 남은 3바이트를 전달할 때 실패합니다.
남은 출력 버퍼의 전달 대상이 먼저 닫힌다
64바이트 버퍼에 1·2·3이 남아 있는 상태에서 안쪽 파일을 먼저 닫는다. buffered.close의 전송 시도는 닫힌 파일에서 IOException이 된다. 원문과 API에서 분석한 실패 경로다.
상태별 상세 해설
buffered.write({1, 2, 3})
- 출력 버퍼
- 3바이트 보관
- 하위 파일 스트림
- 아직 이 3바이트를 받지 않음
fileOutput.close()
- 출력 버퍼
- 3바이트가 남음
- 하위 파일 스트림
- 닫힘
buffered.close()
- 출력 버퍼
- 남은 바이트를 전달하려 시도
- 하위 파일 스트림
- 닫힌 대상에 쓰므로 IOException
원문과 API에서 분석한 실패 경로입니다. finally는 재차 닫기에서 발생한 예외를 무시하고 임시 파일 삭제를 시도합니다.
이 예의 조건
- 64바이트 버퍼에 3바이트를 써 아직 하위로 전달되지 않은 상태입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 안쪽을 먼저 닫은 뒤의 출력 상태 안쪽을 먼저 닫은 뒤의 출력 상태 CloseInnerFirst의 64바이트 출력 버퍼에 3바이트를 쓴 뒤 안쪽 파일 스트림을 먼저 닫는 순서입니다. 닫기 순서 · 남은 버퍼의 전달 안쪽을 먼저 닫은 뒤의 출력 상태 안쪽 파일을 먼저 닫으면, 바깥 버퍼가 남은 3바이트를 전달할 때 실패합니다. buffered.write({1, 2, 3}) 출력 버퍼 3바이트 보관 하위 파일 스트림 아직 이 3바이트를 받지 않음 fileOutput.close() 출력 버퍼 3바이트가 남음 하위 파일 스트림 닫힘 buffered.close() 출력 버퍼 남은 바이트를 전달하려 시도 하위 파일 스트림 닫힌 대상에 쓰므로 IOException 원문과 API에서 분석한 실패 경로입니다. finally는 재차 닫기에서 발생한 예외를 무시하고 임시 파일 삭제를 시도합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 버퍼 보관 buffered.write가 1,2,3을 출력 버퍼에 둡니다. |
|
|
| 안쪽 먼저 닫기 fileOutput.close가 하위 스트림을 닫습니다. |
|
|
| 바깥 닫기 실패 buffered.close가 남은 바이트를 전달하려 합니다. |
|
|
안쪽을 먼저 닫은 뒤의 출력 상태
buffer 3바이트 보관→안쪽 close→바깥 close 전달 실패의 실제 소유 과정입니다. 버퍼와 하위 스트림 상태를 단계로 추적해 닫기 순서 문제를 설명합니다.
CloseInnerFirst의 64바이트 출력 버퍼에 3바이트를 쓴 뒤 안쪽 파일 스트림을 먼저 닫는 순서입니다.
닫기 순서 · 남은 버퍼의 전달
안쪽을 먼저 닫은 뒤의 출력 상태
안쪽 파일을 먼저 닫으면, 바깥 버퍼가 남은 3바이트를 전달할 때 실패합니다.
남은 출력 버퍼의 전달 대상이 먼저 닫힌다
64바이트 버퍼에 1·2·3이 남아 있는 상태에서 안쪽 파일을 먼저 닫는다. buffered.close의 전송 시도는 닫힌 파일에서 IOException이 된다. 원문과 API에서 분석한 실패 경로다.
상태별 상세 해설
buffered.write({1, 2, 3})
- 출력 버퍼
- 3바이트 보관
- 하위 파일 스트림
- 아직 이 3바이트를 받지 않음
fileOutput.close()
- 출력 버퍼
- 3바이트가 남음
- 하위 파일 스트림
- 닫힘
buffered.close()
- 출력 버퍼
- 남은 바이트를 전달하려 시도
- 하위 파일 스트림
- 닫힌 대상에 쓰므로 IOException
원문과 API에서 분석한 실패 경로입니다. finally는 재차 닫기에서 발생한 예외를 무시하고 임시 파일 삭제를 시도합니다.
이 예의 조건
- 64바이트 버퍼에 3바이트를 써 아직 하위로 전달되지 않은 상태입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 안쪽을 먼저 닫은 뒤의 출력 상태 안쪽을 먼저 닫은 뒤의 출력 상태 CloseInnerFirst의 64바이트 출력 버퍼에 3바이트를 쓴 뒤 안쪽 파일 스트림을 먼저 닫는 순서입니다. 닫기 순서 · 남은 버퍼의 전달 안쪽을 먼저 닫은 뒤의 출력 상태 안쪽 파일을 먼저 닫으면, 바깥 버퍼가 남은 3바이트를 전달할 때 실패합니다. buffered.write({1, 2, 3}) 출력 버퍼 3바이트 보관 하위 파일 스트림 아직 이 3바이트를 받지 않음 fileOutput.close() 출력 버퍼 3바이트가 남음 하위 파일 스트림 닫힘 buffered.close() 출력 버퍼 남은 바이트를 전달하려 시도 하위 파일 스트림 닫힌 대상에 쓰므로 IOException 원문과 API에서 분석한 실패 경로입니다. finally는 재차 닫기에서 발생한 예외를 무시하고 임시 파일 삭제를 시도합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 버퍼 보관 buffered.write가 1,2,3을 출력 버퍼에 둡니다. |
|
|
| 안쪽 먼저 닫기 fileOutput.close가 하위 스트림을 닫습니다. |
|
|
| 바깥 닫기 실패 buffered.close가 남은 바이트를 전달하려 합니다. |
|
|
올바른 방법은 가장 바깥 스트림 하나만 try-with-resources로 선언하고 그것만 닫는 것입니다.
import java.io.BufferedOutputStream;
import java.io.FileOutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
public final class CloseOuterOnly {
public static void main(String[] args) throws Exception {
Path file = Files.createTempFile("right-close-", ".bin");
try {
try (var buffered = new BufferedOutputStream(new FileOutputStream(file.toFile()), 64)) {
buffered.write(new byte[] {1, 2, 3});
} // 바깥만 닫는다: 남은 3바이트를 flush한 뒤 안쪽 FileOutputStream도 닫는다
System.out.println("file size=" + Files.size(file));
} finally {
Files.deleteIfExists(file);
}
}
}file size=3BufferedOutputStream의 close()는 먼저 flush()로 남은 데이터를 안쪽 스트림에 넘기고, 그다음 안쪽 스트림의 close()를 부릅니다. 닫기가 바깥에서 안쪽으로 차례로 전달되므로 안쪽 스트림을 따로 닫을 필요가 없습니다. BufferedInputStream의 close()도 감싼 입력 스트림을 닫지만, 입력에는 내보낼 데이터가 없으므로 flush 같은 단계는 없습니다. 정리하면 스트림을 연 쪽은 가장 바깥 스트림의 참조만 들고 그것만 닫으면 되고, 안쪽 스트림을 따로 쓰거나 닫지 않아야 버퍼의 상태와 실제 파일의 상태가 어긋나지 않습니다.
더 깊이: 두 스트림을 모두 try 자원으로 선언할 때
다음처럼 안쪽과 바깥을 각각 자원으로 선언하는 코드도 자주 봅니다. file과 buffered가 세미콜론으로 나뉘어 try 괄호 안에 따로 선언되어 있으므로, 블록이 끝나면 두 변수 모두에 close()가 호출됩니다.
try (var file = new FileOutputStream(path.toFile());
var buffered = new BufferedOutputStream(file)) {
buffered.write(data);
}try-with-resources는 선언의 역순으로 닫으므로 buffered가 먼저 닫히면서 flush와 안쪽 닫기를 마칩니다. 이어서 file.close()가 한 번 더 불리지만, 이미 닫힌 스트림의 close()는 아무 일도 하지 않으므로 문제가 없습니다. 이렇게 쓰는 이유는 바깥 스트림의 생성자가 예외를 던질 때를 대비하기 위해서입니다. new BufferedOutputStream(new FileOutputStream(...))을 자원 하나로 선언했는데 바깥 생성자가 실패하면, 이미 열린 FileOutputStream은 아무도 닫지 않습니다. BufferedOutputStream의 생성자는 크기가 잘못됐을 때만 예외를 던지므로 자원 하나로 충분합니다. 하지만 생성자에서 입출력을 하는 보조 스트림도 있습니다. 예를 들어 GZIPOutputStream은 생성자에서 GZIP 헤더를 쓰므로 IOException을 던질 수 있고, 이런 경우에는 따로 선언하는 편이 안전합니다.
mark와 reset으로 앞부분 미리 읽기
파일 형식을 판별할 때는 앞부분 몇 바이트를 먼저 들여다본 뒤, 알맞은 파서에 스트림을 처음부터 넘기고 싶을 때가 있습니다. 하지만 스트림은 원래 한 방향으로만 읽혀서 이미 읽은 바이트를 되돌릴 수 없습니다. BufferedInputStream은 읽은 바이트를 내부 배열에 갖고 있으므로, 그 범위 안에서는 되돌아갈 수 있습니다. mark(readlimit)로 지금 위치를 표시해 두고, 나중에 reset()을 부르면 표시한 위치로 돌아갑니다.
import java.io.BufferedInputStream;
import java.io.ByteArrayInputStream;
public final class BufferedPeek {
static String classify(BufferedInputStream input) throws Exception {
input.mark(4);
int first = input.read();
int second = input.read();
input.reset();
return first == 0x50 && second == 0x4B ? "zip-like" : "other";
}
public static void main(String[] args) throws Exception {
byte[] data = {0x50, 0x4B, 3, 4};
try (var input = new BufferedInputStream(new ByteArrayInputStream(data), 8)) {
System.out.println(classify(input));
System.out.println("firstAfterReset=" + input.read());
}
}
}zip-like
firstAfterReset=80ZIP 파일은 문자 'P'와 'K'에 해당하는 0x50, 0x4B로 시작합니다. classify는 위치 0에 표시를 하고 두 바이트를 읽어 80(0x50)과 75(0x4B)를 얻은 뒤 reset()으로 위치 0에 돌아갑니다. 그래서 main의 read()가 첫 바이트 80을 다시 읽습니다. 이때 안쪽의 ByteArrayInputStream이 되감기는 것은 아닙니다. BufferedInputStream이 자기 배열 안에서 다음에 돌려줄 위치만 뒤로 옮깁니다.
미리 읽기 뒤의 논리적 위치
mark 위치 0→두 read→reset 0→main read 80의 논리 커서 변화입니다. 하위 스트림의 되감기가 아니라는 조건을 유지하며 상태를 추적합니다.
BufferedPeek가 입력 50 4B 03 04를 두 바이트 미리 읽고 reset한 뒤 다시 첫 바이트를 읽는 순서입니다.
미리 읽기 · mark와 reset
미리 읽기 뒤의 논리적 위치
두 바이트를 확인한 뒤 reset하면, main은 다시 첫 바이트 0x50을 읽습니다.
바이트 배열 위에서 논리 커서를 되돌린다
같은 50·4B·03·04 버퍼에서 mark 0, 두 read 뒤 2, reset 뒤 0, main read 뒤 1의 논리 커서를 표시한다. 하위 스트림 자체의 되감기가 아니다.
상태별 상세 해설
mark(4)
- 반환 또는 효과
- 현재 위치 0 저장
- 다음 읽기 위치
- 0 · 50
첫 read()
- 반환 또는 효과
- 0x50 = 80
- 다음 읽기 위치
- 1 · 4B
둘째 read()
- 반환 또는 효과
- 0x4B = 75
- 다음 읽기 위치
- 2 · 03
reset()
- 반환 또는 효과
- 표식 위치로 복귀
- 다음 읽기 위치
- 0 · 50
main의 read()
- 반환 또는 효과
- 0x50 = 80
- 다음 읽기 위치
- 1 · 4B
바이트 표기는 16진수입니다. reset은 호출자가 보는 위치를 되돌리며, 하위 스트림 자체를 되감는다는 뜻은 아닙니다.
이 예의 조건
- 바이트 50 4B 03 04의 표식·논리 위치이며 하위 스트림 되감기가 아닙니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 미리 읽기 뒤의 논리적 위치 미리 읽기 뒤의 논리적 위치 BufferedPeek가 입력 50 4B 03 04를 두 바이트 미리 읽고 reset한 뒤 다시 첫 바이트를 읽는 순서입니다. 미리 읽기 · mark와 reset 미리 읽기 뒤의 논리적 위치 두 바이트를 확인한 뒤 reset하면, main은 다시 첫 바이트 0x50을 읽습니다. mark(4) 반환 또는 효과 현재 위치 0 저장 다음 읽기 위치 0 · 50 첫 read() 반환 또는 효과 0x50 = 80 다음 읽기 위치 1 · 4B 둘째 read() 반환 또는 효과 0x4B = 75 다음 읽기 위치 2 · 03 reset() 반환 또는 효과 표식 위치로 복귀 다음 읽기 위치 0 · 50 main의 read() 반환 또는 효과 0x50 = 80 다음 읽기 위치 1 · 4B 바이트 표기는 16진수입니다. reset은 호출자가 보는 위치를 되돌리며, 하위 스트림 자체를 되감는다는 뜻은 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 표식 설정 mark(4)가 위치 0을 저장합니다. |
|
|
| 두 바이트 미리 읽기 read가 80과 75를 순서대로 반환합니다. |
|
|
| reset과 재읽기 reset으로 0에 돌아가 main read가 80을 반환합니다. |
|
|
미리 읽기 뒤의 논리적 위치
mark 위치 0→두 read→reset 0→main read 80의 논리 커서 변화입니다. 하위 스트림의 되감기가 아니라는 조건을 유지하며 상태를 추적합니다.
BufferedPeek가 입력 50 4B 03 04를 두 바이트 미리 읽고 reset한 뒤 다시 첫 바이트를 읽는 순서입니다.
미리 읽기 · mark와 reset
미리 읽기 뒤의 논리적 위치
두 바이트를 확인한 뒤 reset하면, main은 다시 첫 바이트 0x50을 읽습니다.
바이트 배열 위에서 논리 커서를 되돌린다
같은 50·4B·03·04 버퍼에서 mark 0, 두 read 뒤 2, reset 뒤 0, main read 뒤 1의 논리 커서를 표시한다. 하위 스트림 자체의 되감기가 아니다.
상태별 상세 해설
mark(4)
- 반환 또는 효과
- 현재 위치 0 저장
- 다음 읽기 위치
- 0 · 50
첫 read()
- 반환 또는 효과
- 0x50 = 80
- 다음 읽기 위치
- 1 · 4B
둘째 read()
- 반환 또는 효과
- 0x4B = 75
- 다음 읽기 위치
- 2 · 03
reset()
- 반환 또는 효과
- 표식 위치로 복귀
- 다음 읽기 위치
- 0 · 50
main의 read()
- 반환 또는 효과
- 0x50 = 80
- 다음 읽기 위치
- 1 · 4B
바이트 표기는 16진수입니다. reset은 호출자가 보는 위치를 되돌리며, 하위 스트림 자체를 되감는다는 뜻은 아닙니다.
이 예의 조건
- 바이트 50 4B 03 04의 표식·논리 위치이며 하위 스트림 되감기가 아닙니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 미리 읽기 뒤의 논리적 위치 미리 읽기 뒤의 논리적 위치 BufferedPeek가 입력 50 4B 03 04를 두 바이트 미리 읽고 reset한 뒤 다시 첫 바이트를 읽는 순서입니다. 미리 읽기 · mark와 reset 미리 읽기 뒤의 논리적 위치 두 바이트를 확인한 뒤 reset하면, main은 다시 첫 바이트 0x50을 읽습니다. mark(4) 반환 또는 효과 현재 위치 0 저장 다음 읽기 위치 0 · 50 첫 read() 반환 또는 효과 0x50 = 80 다음 읽기 위치 1 · 4B 둘째 read() 반환 또는 효과 0x4B = 75 다음 읽기 위치 2 · 03 reset() 반환 또는 효과 표식 위치로 복귀 다음 읽기 위치 0 · 50 main의 read() 반환 또는 효과 0x50 = 80 다음 읽기 위치 1 · 4B 바이트 표기는 16진수입니다. reset은 호출자가 보는 위치를 되돌리며, 하위 스트림 자체를 되감는다는 뜻은 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 표식 설정 mark(4)가 위치 0을 저장합니다. |
|
|
| 두 바이트 미리 읽기 read가 80과 75를 순서대로 반환합니다. |
|
|
| reset과 재읽기 reset으로 0에 돌아가 main read가 80을 반환합니다. |
|
|
mark에 넘기는 readlimit는 '표시한 뒤 최대 이만큼만 읽고 돌아오겠다'는 약속입니다. 이 약속을 넘겨 읽으면 어떻게 되는지 확인해 보겠습니다. 다음 프로그램은 버퍼 크기 4, readlimit 2로 표시한 뒤 2바이트, 3바이트, 6바이트를 읽고 각각 reset()을 시도합니다.
import java.io.BufferedInputStream;
import java.io.ByteArrayInputStream;
import java.io.IOException;
public final class MarkLimit {
static void readThenReset(int readCount) {
byte[] data = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
try (var input = new BufferedInputStream(new ByteArrayInputStream(data), 4)) {
input.mark(2); // 최대 2바이트 읽은 뒤 돌아오겠다는 표시
for (int i = 0; i < readCount; i++) {
input.read();
}
input.reset();
System.out.println("read " + readCount + " -> reset ok, next=" + input.read());
} catch (IOException e) {
System.out.println("read " + readCount + " -> " + e.getMessage());
}
}
public static void main(String[] args) {
readThenReset(2);
readThenReset(3);
readThenReset(6);
}
}read 2 -> reset ok, next=0
read 3 -> reset ok, next=0
read 6 -> Resetting to invalid mark2바이트를 읽은 경우는 약속을 지켰으니 당연히 돌아갑니다. 3바이트를 읽은 경우는 약속을 넘겼는데도 돌아갔습니다. 표시 이후의 바이트가 아직 4바이트 버퍼 안에 모두 남아 있었기 때문입니다. 6바이트를 읽을 때는 버퍼를 새로 채워야 했고, 그 시점에 이미 readlimit보다 많이 읽었으므로 BufferedInputStream이 표시를 버렸습니다. 그래서 reset()이 IOException을 던집니다. InputStream.mark의 문서도 readlimit를 넘겨 읽으면 표시가 무효가 될 수 있다고만 말합니다. 3바이트 경우처럼 운 좋게 돌아가는 일에 기대지 말고, readlimit를 실제로 미리 읽을 바이트 수 이상으로 잡아야 합니다.
실제로 쓸 때 알아 둘 점이 몇 가지 더 있습니다. 먼저 Files.newInputStream이 돌려주는 스트림은 markSupported()가 false이고, 이런 스트림에서 reset()을 부르면 IOException이 납니다. 파일의 앞부분을 미리 읽으려면 BufferedInputStream으로 감싸야 합니다. 또 BufferedInputStream은 표시한 바이트를 지키려고 내부 배열을 readlimit까지 키울 수 있습니다. 그러니 readlimit를 입력 데이터의 길이 필드처럼 외부에서 온 값으로 정하면, 공격자가 큰 메모리 할당을 일으킬 수 있습니다. 미리 읽을 길이는 작은 상수로 고정합니다. 마지막으로, 위 예제는 표시와 되돌리기의 흐름만 보여 줍니다. 실제 파일 형식은 두 바이트만 보고 확정하지 말고, 형식 명세가 정한 전체 시그니처와 버전, 길이까지 검사해야 합니다.
여러 겹 쌓기: DataOutputStream과 버퍼
보조 스트림은 버퍼만 있는 것이 아닙니다. DataOutputStream은 int, long, double, String 같은 값을 정해진 바이트 형식으로 바꿔 쓰고, DataInputStream은 그 바이트를 다시 값으로 읽습니다. 다음 프로그램은 파일 스트림을 버퍼로 감싸고, 그것을 다시 DataOutputStream으로 감싸서 int 하나와 문자열 하나를 씁니다. 그리고 파일에 실제로 들어간 바이트를 16진수로 출력한 뒤 같은 순서로 다시 읽습니다.
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HexFormat;
public final class LayeredDataStream {
public static void main(String[] args) throws Exception {
Path file = Files.createTempFile("layered-", ".dat");
try {
try (var output = new DataOutputStream(
new BufferedOutputStream(Files.newOutputStream(file)))) {
output.writeInt(7);
output.writeUTF("서울");
}
byte[] bytes = Files.readAllBytes(file);
System.out.println(HexFormat.ofDelimiter(" ").withUpperCase().formatHex(bytes));
try (var input = new DataInputStream(
new BufferedInputStream(Files.newInputStream(file)))) {
System.out.printf("version=%d city=%s%n", input.readInt(), input.readUTF());
}
} finally {
Files.deleteIfExists(file);
}
}
}00 00 00 07 00 06 EC 84 9C EC 9A B8
version=7 city=서울12바이트를 앞에서부터 나눠 읽어 보겠습니다. 00 00 00 07은 writeInt(7)이 쓴 4바이트로, 큰 자리부터 차례로 적는 빅엔디언(big-endian) 순서입니다. 00 06은 writeUTF가 앞에 붙이는 2바이트 길이로, 뒤에 6바이트가 이어진다는 뜻입니다. EC 84 9C는 '서', EC 9A B8은 '울'입니다. 파일에는 필드 이름이나 타입 정보 없이 바이트만 들어 있습니다. 그래서 읽을 때도 readInt, readUTF를 쓸 때와 같은 순서로 불러야 하고, 순서가 바뀌면 다른 필드의 바이트를 엉뚱한 값으로 해석하게 됩니다.
쌓는 순서에도 이유가 있습니다. 값을 쓰는 메서드는 DataOutputStream에 있으므로 이것이 가장 바깥이어야 호출자가 writeInt를 부를 수 있습니다. 가운데의 버퍼는 DataOutputStream이 값마다 내보내는 작은 쓰기들을 모아 파일 스트림에 큰 청크로 넘깁니다. 압축이나 암호화 같은 보조 스트림을 더하면 순서가 파일 형식 자체를 바꿉니다. 암호화된 데이터는 무작위처럼 보여 잘 압축되지 않으므로 보통은 압축한 뒤 암호화하지만, 보안 명세가 순서를 정해 두었다면 그것을 따릅니다. 읽을 때는 쓸 때의 역순으로 복호화하고, 압축을 풀고, 값을 읽습니다. 나중에 다른 코드가 같은 순서를 재현할 수 있도록 형식 버전과 알고리즘 식별자를 헤더에 둡니다. 위 예제에서 맨 앞의 int 7이 버전 번호 역할을 합니다. 스트림 클래스들이 문제없이 컴파일된다고 해서 형식이 호환되는 것은 아닙니다.
필드를 추가하거나 순서를 바꾸면 기존 파일을 읽지 못하게 됩니다. 그래서 장기간 보관하는 파일이라면 헤더의 버전을 먼저 읽고, 버전에 따라 읽는 방법을 나눕니다. 보조 스트림은 편리한 조합 도구일 뿐, 저장 형식의 버전 관리를 대신해 주지는 않습니다.
더 깊이: writeUTF가 쓰는 modified UTF-8
writeUTF가 쓰는 문자열 형식은 표준 UTF-8이 아니라 자바가 정의한 modified UTF-8입니다. 대부분의 글자에서는 같은 바이트가 나오지만, 두 가지 경우에 달라집니다. 다음 프로그램은 'A', NUL 문자(U+0000), 이모지로 된 문자열을 두 방식으로 인코딩해 비교합니다.
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
public final class ModifiedUtf8Dump {
public static void main(String[] args) throws Exception {
HexFormat hex = HexFormat.ofDelimiter(" ").withUpperCase();
String text = "A\u0000😀"; // 'A', NUL 문자, 이모지
var memory = new ByteArrayOutputStream();
try (var output = new DataOutputStream(memory)) {
output.writeUTF(text);
}
System.out.println("writeUTF: " + hex.formatHex(memory.toByteArray()));
System.out.println("UTF-8 : " + hex.formatHex(text.getBytes(StandardCharsets.UTF_8)));
}
}writeUTF: 00 09 41 C0 80 ED A0 BD ED B8 80
UTF-8 : 41 00 F0 9F 98 8000 09는 길이 접두사이고, 'A'는 두 방식 모두 41입니다. NUL 문자는 표준 UTF-8에서 00이지만 modified UTF-8에서는 C0 80이 됩니다. 인코딩한 결과에 0바이트가 나오지 않게 하려는 규칙입니다. 이모지는 표준 UTF-8에서 4바이트(F0 9F 98 80)인데, modified UTF-8은 자바 문자열 안의 UTF-16 서로게이트 두 개(D83D, DE00)를 각각 3바이트로 인코딩해 6바이트를 씁니다. 표준 UTF-8 디코더는 이 바이트들을 잘못된 입력으로 봅니다. 엄격한 디코더는 MalformedInputException을 던지고, new String으로 읽으면 'A' 뒤가 모두 대체 문자 U+FFFD로 바뀝니다.
길이 접두사가 2바이트라서 인코딩한 결과가 65,535바이트를 넘으면 writeUTF는 UTFDataFormatException을 던집니다. 그래서 writeUTF와 readUTF는 자바 프로그램끼리 주고받는 데이터에만 씁니다. 다른 언어의 프로그램도 읽을 파일이라면, 아래 연습 문제처럼 getBytes(StandardCharsets.UTF_8)로 만든 바이트와 길이를 직접 씁니다.
여러 스레드가 같은 버퍼 스트림을 쓸 때
버퍼 스트림 하나를 여러 스레드가 함께 쓰면 서로 다른 메시지의 바이트가 섞일 수 있습니다. JDK 구현은 write 호출 하나하나를 내부에서 잠그지만, 여러 번의 write로 이루어진 메시지 전체를 하나로 묶어 주지는 않습니다. 이런 부분적인 동기화에 기대지 말고, 스트림을 한 스레드만 쓰게 하거나 메시지 전체를 하나의 임계 영역 안에서 씁니다. 더 나은 구조는 여러 생산자 스레드가 완성된 메시지를 큐에 넣고, 기록을 맡은 스레드 하나가 큐에서 꺼내 차례로 쓰는 방식입니다.
메모리도 생각해야 합니다. 열린 스트림마다 버퍼가 따로 있으므로, 전체 메모리는 버퍼 크기에 열린 스트림 수를 곱한 만큼입니다. 8KiB 버퍼라도 연결이 10,000개면 80,000KiB, 약 78MiB가 됩니다. 큰 버퍼가 복사 한 번을 조금 빠르게 해도 연결 수천 개에서는 메모리 부담이 되므로, 버퍼 크기는 처리량과 함께 열린 스트림 수, 메모리 할당량, GC를 같이 재면서 정합니다.
정리
BufferedInputStream과BufferedOutputStream은 다른 스트림을 감싸서 작은 읽기와 쓰기를 청크로 묶습니다. 한 바이트씩 다루는 코드를 그대로 둔 채로 시스템 호출을 청크 복사 수준까지 줄일 수 있습니다.- 출력 버퍼의 데이터는 버퍼에 자리가 없을 때,
flush()를 부를 때,close()할 때 안쪽 스트림으로 넘어갑니다.flush는 운영체제에 넘긴다는 뜻이지 디스크에 기록했다는 뜻이 아닙니다. - 가장 바깥 스트림 하나만 닫습니다. 바깥의
close()가 flush한 뒤 안쪽 스트림을 닫아 줍니다. 안쪽을 먼저 닫으면 버퍼에 남은 데이터를 잃습니다. mark(readlimit)와reset()은 버퍼 안에서만 되돌아가는 기능입니다.readlimit를 넘겨 읽으면 표시가 무효가 될 수 있습니다.- 보조 스트림을 쌓는 순서는 호출할 수 있는 메서드와 파일 형식을 정합니다. 읽을 때는 쓸 때와 같은 순서로 값을 읽고, 계층은 역순으로 풉니다.
연습 문제
1. 길이와 본문으로 된 메시지 파일
UTF-8 문자열 메시지 여러 개를 파일에 이어서 기록한 뒤 모두 읽어 보세요. 메시지 하나는 본문의 바이트 수를 담은 int와 UTF-8 본문으로 이루어집니다. 가장 바깥 스트림 하나만 닫고, 읽을 때는 각 길이가 0 이상 4,096 이하인지 확인합니다. 빈 메시지도 정상적인 메시지로 처리해야 합니다.
정답과 해설
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.EOFException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
public final class BufferedFrameFileSolution {
public static void main(String[] args) throws Exception {
Path file = Files.createTempFile("frames-", ".dat");
try {
try (var output = new DataOutputStream(
new BufferedOutputStream(Files.newOutputStream(file)))) {
for (String value : List.of("", "첫째", "second")) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
output.writeInt(bytes.length);
output.write(bytes);
}
}
List<String> restored = new ArrayList<>();
try (var input = new DataInputStream(
new BufferedInputStream(Files.newInputStream(file)))) {
while (true) {
try {
int length = input.readInt();
if (length < 0 || length > 4096) throw new IllegalStateException("length");
restored.add(new String(input.readNBytes(length), StandardCharsets.UTF_8));
} catch (EOFException end) {
break;
}
}
}
System.out.println(restored);
} finally {
Files.deleteIfExists(file);
}
}
}[, 첫째, second]첫 번째 원소가 빈 문자열이라 [ 바로 뒤에 쉼표가 나옵니다. 길이 0인 메시지도 readInt로 0을 읽고 readNBytes(0)이 빈 배열을 돌려주므로 정상적으로 복원됩니다. 쓰는 쪽과 읽는 쪽 모두 가장 바깥의 DataOutputStream과 DataInputStream만 try-with-resources로 닫고, 그 닫기가 버퍼와 파일 스트림까지 차례로 전달됩니다. readInt는 4바이트를 채우기 전에 파일이 끝나면 EOFException을 던지므로, 이것을 반복의 끝으로 씁니다.
이 정답은 정상 파일을 읽는 데 집중했고, 손상된 파일은 제대로 걸러 내지 못합니다. 본문이 잘려도 readNBytes는 예외 없이 짧은 배열을 돌려주는데, 위 코드는 그 길이를 확인하지 않습니다. 또 readInt 도중에 헤더가 잘려 생긴 EOFException도 메시지 사이의 정상적인 끝과 똑같이 처리합니다. 잘못된 UTF-8 바이트도 new String이 대체 문자로 바꿔 버립니다.
이 세 가지를 모두 걸러 내는 판독기는 다음과 같습니다. 첫 헤더 바이트를 읽기 전에 만난 EOF만 정상 종료로 인정하고, 첫 바이트를 읽었다면 나머지 세 바이트가 반드시 있어야 합니다. 길이를 검사한 뒤에는 본문의 바이트 수를 확인하고, 잘못된 UTF-8이면 예외를 던지는 디코더로 변환합니다. Optional.empty()는 메시지 사이에서 파일이 끝났다는 뜻이고, Optional.of("")는 길이 0인 정상 메시지입니다.
import java.io.*;
import java.nio.ByteBuffer;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public final class StrictFrameReaderSolution {
static Optional<String> readOne(InputStream input) throws IOException {
int first = input.read();
if (first == -1) return Optional.empty();
byte[] rest = input.readNBytes(3);
if (rest.length != 3) throw new EOFException("short int header");
int length = (first << 24) | ((rest[0] & 0xff) << 16)
| ((rest[1] & 0xff) << 8) | (rest[2] & 0xff);
if (length < 0 || length > 4096) throw new IOException("invalid length: " + length);
byte[] bytes = input.readNBytes(length);
if (bytes.length != length) throw new EOFException("short frame body");
String text = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes)).toString();
return Optional.of(text);
}
static List<String> readSample(byte[] wire) throws IOException {
var values = new ArrayList<String>();
try (var input = new BufferedInputStream(new ByteArrayInputStream(wire))) {
while (true) {
var message = readOne(input);
if (message.isEmpty()) return List.copyOf(values);
values.add(message.get());
}
}
}
static void report(byte[] wire) {
try { System.out.println(readSample(wire)); }
catch (IOException e) { System.out.println(e.getClass().getSimpleName()); }
}
public static void main(String[] args) throws IOException {
var memory = new ByteArrayOutputStream();
try (var output = new DataOutputStream(memory)) {
for (String value : List.of("", "첫째", "second")) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
output.writeInt(bytes.length);
output.write(bytes);
}
}
report(memory.toByteArray());
report(new byte[] {0, 0}); // 짧은 헤더
report(new byte[] {0, 0, 0, 3, 9}); // 길이 3인데 본문은 1바이트
report(new byte[] {0, 0, 0, 2, (byte) 0xC3, 0x28}); // 잘못된 UTF-8
}
}[, 첫째, second]
EOFException
EOFException
MalformedInputException정상 입력은 빈 메시지를 포함한 세 메시지를 복원합니다. 두 번째 입력은 헤더가 2바이트뿐이라 short int header, 세 번째 입력은 길이가 3인데 본문이 1바이트뿐이라 short frame body로 EOFException이 납니다. 마지막 입력의 C3은 뒤에 이어지는 바이트가 와야 하는 UTF-8 시작 바이트인데 28(문자 '(')이 왔으므로 MalformedInputException으로 거부됩니다.
닫는 쪽도 눈여겨보세요. readOne은 스트림을 빌려 쓰기만 하므로 닫지 않고, 스트림을 만든 readSample이 가장 바깥의 BufferedInputStream을 닫습니다. 실제 파일이라면 Files.newInputStream을 BufferedInputStream으로 감싸서 같은 readOne에 넘기면 됩니다. 메시지 하나의 상한 4,096바이트가 파일 전체의 크기나 메시지 개수까지 제한하지는 않는다는 점도 기억해 두세요. readSample은 작은 입력을 목록에 모으는 확인용 함수입니다. 큰 파일이라면 readOne의 결과를 한 건씩 처리하면서 전체 입력량에 대한 상한을 따로 둡니다.
2. 버퍼 크기를 8로 바꾸면
FlushTiming에서 버퍼 크기만 4에서 8로 바꾸면 일곱 줄의 파일 크기는 각각 어떻게 될까요? 실행하기 전에 앞의 표처럼 버퍼 상태를 따라가 보세요.
해설
차례로 0, 0, 0, 5, 5, 5, 11입니다. 버퍼에 8바이트가 들어가므로 write 1, 2, 3과 write(4), write(5)까지 모두 버퍼에 모이고 파일은 0바이트로 남습니다. flush()가 이 5바이트를 넘겨 파일이 5바이트가 됩니다. 이번에는 6부터 10까지의 배열이 5바이트로 버퍼 크기 8보다 짧으므로, 곧바로 넘어가지 않고 버퍼에 담깁니다. write(11)까지 버퍼에 6바이트가 모이고, close()가 그 6바이트를 넘겨서 파일은 11바이트가 됩니다. 버퍼 크기 4일 때 write 6부터 10까지가 바로 파일에 간 것은 배열 길이가 버퍼 크기 이상이었기 때문입니다.
3. close를 잊으면
다음 프로그램을 실행하면 written이 출력되고 프로그램이 정상 종료됩니다. 실행이 끝난 뒤 forgot.bin의 크기는 몇 바이트일까요? 그 이유와 고치는 방법을 설명하세요.
import java.io.BufferedOutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
public final class ForgotClose {
public static void main(String[] args) throws Exception {
Path file = Path.of("forgot.bin");
var output = new BufferedOutputStream(Files.newOutputStream(file));
output.write(new byte[] {1, 2, 3});
System.out.println("written");
} // close도 flush도 하지 않고 끝난다
}해설
0바이트입니다. 3바이트는 BufferedOutputStream의 내부 버퍼에 들어간 채로 남아 있었습니다. 버퍼가 찰 만큼 쓰지도 않았고 flush()나 close()도 부르지 않았으니, 안쪽 파일 스트림에는 한 바이트도 넘어가지 않았습니다. JVM은 종료할 때 이런 자바 버퍼를 대신 비워 주지 않으므로, 파일은 만들어졌지만 아무 내용도 없습니다. 예외도 나지 않아서 쉽게 놓치는 실수입니다. try (var output = new BufferedOutputStream(Files.newOutputStream(file))) { ... }처럼 try-with-resources로 선언하면 블록이 끝날 때 close()가 flush까지 해 줍니다.
다음 글
지금까지는 바이트를 그대로 옮겼습니다. 문자 스트림 Reader·Writer와 Charset에서는 바이트 스트림 위에 문자 단위로 읽고 쓰는 Reader와 Writer를 얹습니다. 청크 단위 파일 복사와 버퍼 크기에서 본, 청크가 나뉘는 자리에서 글자가 잘리는 문제를 이 계층이 어떻게 해결하는지도 확인합니다.