버퍼 스트림과 자원 순서
기본 스트림 위에 버퍼 기능을 겹쳐 시스템 호출을 줄이고 가장 바깥 자원의 닫기와 flush 전파, mark·reset 한계를 소유권 관점에서 다룹니다.
BufferedInputStream과 BufferedOutputStream은 파일이나 소켓을 직접 열지 않습니다.
생성자에 받은 다른 스트림을 감싸고 내부 바이트 배열을 사용해 작은 읽기와 쓰기를 큰 단위로 묶습니다.
단독으로 장치에 접근하는 기본 스트림과, 기능을 추가하는 보조 스트림을 연결하는 데코레이터 구조입니다.
호출자는 가장 바깥 스트림만 사용하면 됩니다.
바깥 BufferedOutputStream의 close()는 남은 출력 버퍼를 비우고 안쪽 스트림의 닫기를 호출합니다. 입력 버퍼의 close()도 감싼 입력을 닫지만 출력처럼 flush하지는 않습니다.
중간 객체를 따로 닫거나 내부 참조를 계속 사용하면 버퍼가 가진 상태와 실제 장치 상태가 어긋납니다.
내부 파일 스트림을 먼저 닫아 버퍼를 잃는 실패
버퍼에 데이터가 아직 가득 차지 않으면 실제 파일에는 쓰이지 않았을 수 있습니다.
안쪽 FileOutputStream을 먼저 닫은 뒤 바깥 버퍼를 닫으면 flush가 닫힌 대상에 쓰려고 하며 예외가 발생합니다.
예외를 무시하면 파일이 정상 생성된 것처럼 남지만 끝부분이 빠집니다.
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);
}
}
}이 코드는 의도적인 실패 예제입니다.
올바른 구조는 가장 바깥 BufferedOutputStream만 try-with-resources에 선언하거나, 여러 자원을 선언한다면 안쪽을 먼저 쓰고 바깥을 나중에 선언해 역순 닫기가 적용되게 하는 것입니다.
보통 바깥 하나만 소유하면 연결 관계가 가장 명확합니다.
안쪽을 먼저 닫은 뒤의 출력 상태
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가 남은 바이트를 전달하려 합니다. |
|
|
출력 버퍼와 flush 시점
버퍼가 가득 차면 자동으로 안쪽 스트림에 전달됩니다.
flush()는 가득 차지 않은 데이터도 아래 계층으로 밀어냅니다.
파일을 닫을 때는 close()가 flush를 포함하므로 매 쓰기 뒤 호출할 필요가 없습니다.
반대로 장시간 유지하는 네트워크 연결에서 한 메시지를 즉시 보내야 한다면 메시지 끝에서 flush가 필요할 수 있습니다.
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
public final class BufferedFileRoundTrip {
public static void main(String[] args) throws Exception {
Path file = Files.createTempFile("buffered-", ".dat");
byte[] expected = {10, 20, 30, 40, 50};
try {
try (var output = new BufferedOutputStream(Files.newOutputStream(file), 4)) {
output.write(expected);
}
byte[] actual;
try (var input = new BufferedInputStream(Files.newInputStream(file), 3)) {
actual = input.readAllBytes();
}
System.out.println(Arrays.equals(expected, actual));
} finally {
Files.deleteIfExists(file);
}
}
}이 예제의 배열 길이 5는 출력 버퍼 크기 4보다 큽니다. BufferedOutputStream.write(byte[])는 이 경우 배열을 내부 버퍼에 나누어 채우지 않고 하위 스트림에 직접 전달합니다. 왕복 결과는 비교하지만 자동 배출 횟수를 관측하는 코드는 아닙니다.
실제 프로그램에서는 기본값도 충분히 합리적이며, 파일 크기와 동시 스트림 수를 근거로 조정합니다.
flush는 자바 버퍼를 다음 계층에 전달한다는 뜻이지 저장 장치의 물리 기록까지 항상 보장한다는 뜻은 아닙니다.
강한 영속성 요구에는 파일 채널의 force와 운영체제·파일 시스템 규칙을 별도로 검토합니다.
입력 버퍼의 미리 읽기와 소비 위치 보존
BufferedInputStream.read()가 한 바이트만 반환하더라도 내부에서는 큰 청크를 기본 스트림에서 가져올 수 있습니다.
나머지는 버퍼에 남아 다음 호출에 제공됩니다.
호출자에게 보이는 순서와 EOF 규칙은 그대로이므로 기존 단일 바이트 파서를 바꾸지 않고 장치 호출을 줄일 수 있습니다.
mark와 reset은 제한된 되돌리기를 제공하지만 무한한 탐색 기능이 아닙니다.
mark(readLimit) 뒤 readLimit보다 멀리 읽으면 표식이 무효화될 수 있습니다.
프로토콜 전체를 임의 탐색해야 한다면 명확한 프레임 파서나 제한된 메모리 버퍼를 설계하는 편이 낫습니다.
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());
}
}
}파일 형식 판단은 두 바이트만 보고 확정하지 말고 필요한 전체 마법 값과 버전, 길이를 검사해야 합니다.
여기서는 표식을 보존하는 흐름만 보여 줍니다.
읽기 제한은 실제 peek 길이보다 충분히 크되 공격자가 과도한 메모리를 요구하지 못하게 고정합니다.
미리 읽기 뒤의 논리적 위치
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을 반환합니다. |
|
|
데코레이터 순서와 데이터 변환
DataOutputStream(new BufferedOutputStream(file))은 타입 값을 바이트로 바꾼 뒤 버퍼에 모아 파일로 보냅니다.
압축과 암호화가 추가되면 어떤 계층을 먼저 적용하는지가 파일 형식을 바꿉니다.
일반적으로 평문을 압축한 뒤 암호화해야 압축 효율을 얻지만 보안 사양이 정한 순서를 우선합니다.
입력은 출력의 역순으로 해제합니다.
파일에서 읽은 암호문을 복호화하고 압축을 풀어 타입 파서에 전달합니다.
계층을 변수 이름으로 드러내고 형식 버전과 알고리즘 식별자를 헤더에 둬야 미래 구현이 같은 순서를 재현할 수 있습니다.
단순히 스트림 클래스가 모두 컴파일된다는 사실은 호환성을 보장하지 않습니다.
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;
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("서울");
}
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);
}
}
}DataInputStream은 출력 순서와 같은 메서드 순서로 읽어야 합니다. writeUTF와 readUTF는 길이 접두사가 있는 modified UTF-8을 사용하므로 일반 UTF-8 텍스트 파일과 구별합니다.
필드를 추가하거나 순서를 바꾸면 기존 파일이 깨질 수 있으므로 헤더 버전으로 분기합니다.
스트림 데코레이터는 편리한 조합 도구이지 장기 저장 형식의 버전 관리를 대신하지 않습니다.
공유와 동시성에 대한 판단
버퍼 스트림 하나를 여러 스레드가 함께 쓰면 각 메시지 바이트가 섞일 수 있습니다.
일부 메서드의 내부 동기화에 기대지 말고 스트림 소유자를 한 스레드로 제한하거나 메시지 전체를 하나의 임계 영역에서 씁니다.
더 나은 구조는 생산자가 완성된 메시지를 큐에 넣고 전담 기록기가 순서대로 기록하는 방식입니다.
동시 파일마다 별도 버퍼를 만들면 총 메모리는 버퍼 크기와 열린 파일 수의 곱입니다.
큰 버퍼가 단일 복사를 조금 빠르게 해도 수천 연결에서는 메모리 압력이 됩니다.
처리량뿐 아니라 열린 스트림 수와 할당량, GC를 함께 측정해 조정합니다.
연습 문제
UTF-8 메시지를 int 길이와 본문으로 파일에 연속 기록한 뒤 모두 읽으세요.
가장 바깥 스트림 하나만 닫고, 각 길이는 4096바이트 이하인지 확인합니다.
빈 메시지도 정상 프레임으로 처리합니다.
정답과 해설
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);
}
}
}운영 코드에서는 본문이 잘려도 readNBytes가 짧은 배열을 반환하므로 길이를 대조해야 합니다. 위 코드는 이 검사가 없으며, readInt 도중 헤더가 잘려 발생한 EOFException도 정상 종료처럼 처리합니다.
손상 파일을 거부하려면 프레임 사이의 정상 EOF와 불완전한 4바이트 헤더를 구분해야 합니다. 잘못된 UTF-8까지 검출해야 한다면 new String의 대체 문자 처리 대신 오류를 보고하는 디코더도 필요합니다.
여기서는 정상 파일 왕복에 집중했으며 손상 파일 검증을 추가하는 것이 다음 단계입니다.
아래 판독기는 같은 int 길이 + UTF-8 본문 형식을 한 프레임씩 읽습니다. 첫 헤더 바이트 전에 만난 EOF만 정상 종료로 인정합니다. 첫 바이트를 읽었다면 남은 세 헤더 바이트가 반드시 있어야 하고, 길이 검사 뒤에는 본문 개수와 UTF-8 변환도 검증합니다. Optional.empty는 프레임 사이 EOF이며 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
}
}정상 입력은 빈 메시지를 포함한 세 메시지를 복원합니다. 다음 두 입력은 EOFException, 마지막 입력은 MalformedInputException으로 거부됩니다. InputStream을 빌리는 readOne은 닫지 않고, input을 만든 readSample이 가장 바깥 버퍼를 닫습니다. 실제 파일에도 Files.newInputStream을 바깥 BufferedInputStream으로 감싸 같은 readOne을 적용할 수 있습니다.
한 프레임 상한 4,096바이트가 파일 전체 길이나 프레임 개수의 상한을 대신하지는 않습니다. readSample은 작은 확인 입력을 목록에 모으는 함수이고, 대량 파일은 readOne 결과를 한 건씩 처리하면서 별도의 전체 입력 예산을 둡니다. 이 추가 예제의 입력은 메모리 바이트이며 실제 소켓/손상파일 전수 시험은 아닙니다.
바깥 Data 스트림을 닫으면 Buffered와 File 스트림까지 역순으로 닫힙니다.
버퍼 계층을 올바르게 닫았다는 증거
Buffered 스트림은 작은 호출을 묶는 데코레이터이며 데이터 형식과 소유권을 바꾸지 않습니다.
가장 바깥 계층만 사용하고 닫기 전파, 부분 읽기, 메시지 flush 시점, 계층 역순 복원을 확인하면 성능 개선 때문에 끝부분이 사라지거나 형식이 깨지는 일을 막을 수 있습니다.