버퍼 스트림과 자원 순서
기본 스트림 위에 버퍼 기능을 겹쳐 시스템 호출을 줄이고, 가장 바깥 자원의 닫기와 `flush` 전파, mark/초기화 한계를 소유권 관점에서 다룹니다.
BufferedInputStream과 BufferedOutputStream은 파일이나 소켓을 직접 열지 않습니다.
생성자에 받은 다른 스트림을 감싸고 내부 바이트 배열을 사용해 작은 읽기와 쓰기를 큰 단위로 묶습니다.
단독으로 장치에 접근하는 기본 스트림과, 기능을 추가하는 보조 스트림을 연결하는 데코레이터 구조입니다.
호출자는 가장 바깥 스트림만 사용하면 됩니다.
바깥 객체의 close()는 남은 출력 버퍼를 비운 뒤 안쪽 스트림의 닫기를 연쇄 호출합니다.
중간 객체를 따로 닫거나 내부 참조를 계속 사용하면 버퍼가 가진 상태와 실제 장치 상태가 어긋납니다.
내부 파일 스트림을 먼저 닫아 버퍼를 잃는 실패
버퍼에 데이터가 아직 가득 차지 않으면 실제 파일에는 쓰이지 않았을 수 있습니다.
안쪽 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에 선언하거나, 여러 자원을 선언한다면 안쪽을 먼저 쓰고 바깥을 나중에 선언해 역순 닫기가 적용되게 하는 것입니다.
보통 바깥 하나만 소유하면 연결 관계가 가장 명확합니다.
출력 버퍼와 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);
}
}
}버퍼 크기를 작게 둔 것은 자동 배출을 쉽게 관찰하려는 학습 설정입니다.
실제 프로그램에서는 기본값도 충분히 합리적이며, 파일 크기와 동시 스트림 수를 근거로 조정합니다.
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 길이보다 충분히 크되 공격자가 과도한 메모리를 요구하지 못하게 고정합니다.
데코레이터 순서와 데이터 변환
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은 출력 순서와 같은 메서드 순서로 읽어야 합니다.
필드를 추가하거나 순서를 바꾸면 기존 파일이 깨질 수 있으므로 헤더 버전으로 분기합니다.
스트림 데코레이터는 편리한 조합 도구이지 장기 저장 형식의 버전 관리를 대신하지 않습니다.
공유와 동시성에 대한 판단
버퍼 스트림 하나를 여러 스레드가 함께 쓰면 각 메시지 바이트가 섞일 수 있습니다. 일부 메서드의 내부 동기화에 기대지 말고 스트림 소유자를 한 스레드로 제한하거나 메시지 전체를 하나의 임계 영역에서 씁니다. 더 나은 구조는 생산자가 완성된 메시지를 큐에 넣고 전담 기록기가 순서대로 기록하는 방식입니다.
동시 파일마다 별도 버퍼를 만들면 총 메모리는 버퍼 크기와 열린 파일 수의 곱입니다. 큰 버퍼가 단일 복사를 조금 빠르게 해도 수천 연결에서는 메모리 압력이 됩니다. 처리량뿐 아니라 열린 스트림 수와 할당량, 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가 짧은 배열을 반환하므로 길이를 대조해야 합니다.
여기서는 정상 파일 왕복에 집중했으며 손상 파일 검증을 추가하는 것이 다음 단계입니다.
바깥 Data 스트림을 닫으면 Buffered와 File 스트림까지 역순으로 닫힙니다.
버퍼 계층을 올바르게 닫았다는 증거
Buffered 스트림은 작은 호출을 묶는 데코레이터이며 데이터 형식과 소유권을 바꾸지 않습니다.
가장 바깥 계층만 사용하고 닫기 전파, 부분 읽기, 메시지 flush 시점, 계층 역순 복원을 확인하면 성능 개선 때문에 끝부분이 사라지거나 형식이 깨지는 일을 막을 수 있습니다.