이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.
공부 › Java › 입출력
입출력 스트림과 버퍼
write를 했는데 왜 파일이 비어 있나
파일에 쓸 때 out.write를 불렀다고 해서 그 데이터가 곧바로 파일에 들어가는 것은 아닙니다. 버퍼를 쓰는 스트림은 데이터를 메모리에 모아 두었다가 한꺼번에 내려보냅니다. "가나다" 9바이트를 한 바이트씩 쓰면서 아래 스트림으로 실제로 몇 번 내려가는지 세어 보고, 그 파일을 바이트 단위와 글자 단위로 다시 읽어 봅니다.
실행으로 대조 · JDK 21에서 마흔여덟 가지 조합을 실행해 대조
이 실습에 나오는 개념
- 바이트 스트림과 문자 스트림
- 스트림은 데이터를 한쪽에서 다른 쪽으로 차례로 흘려보내는 통로이고, 자바에는 바이트 단위로 다루는 것과 글자 단위로 다루는 것 두 종류가 있다. InputStream과 OutputStream 계열이 바이트 스트림으로, 이미지나 압축 파일처럼 내용을 그대로 옮길 때 쓴다. Reader와 Writer 계열이 문자 스트림으로, 바이트를 정해진 인코딩에 따라 글자로 바꾸어 준다. 한글처럼 한 글자가 여러 바이트인 글을 바이트 스트림으로 한 바이트씩 글자로 바꾸면 깨지므로, 글을 읽고 쓸 때는 인코딩을 지정한 문자 스트림을 쓴다.
- 버퍼와 flush
- 버퍼는 데이터를 바로 내보내지 않고 잠시 모아 두는 메모리 공간이다. BufferedOutputStream이나 BufferedWriter는 write로 받은 데이터를 버퍼에 모았다가 버퍼가 가득 차면 한꺼번에 아래 스트림으로 내려보내, 파일이나 네트워크에 쓰는 횟수를 줄인다. flush는 버퍼가 차지 않았어도 지금 모인 것을 내려보내라는 요청이고, close는 닫기 전에 flush를 먼저 한다. 그래서 flush도 close도 하지 않으면 버퍼에 남아 있던 데이터는 목적지에 쓰이지 않는다.
- 문자 인코딩과 UTF-8
- 문자 인코딩은 글자를 어떤 바이트로 바꾸어 저장할지 정한 규칙이다. UTF-8은 가장 널리 쓰이는 인코딩으로, 글자에 따라 1바이트에서 4바이트까지 길이가 달라진다. 영문자와 숫자는 1바이트, 한글 한 글자는 3바이트다. 쓸 때와 읽을 때의 인코딩이 다르거나 글자의 중간에서 바이트를 끊어 읽으면 글자가 깨지므로, 글을 읽고 쓰는 코드에는 인코딩을 명시하는 것이 안전하다.
- finally와 try-with-resources
- finally는 try 블록이 정상적으로 끝나든 예외가 발생하든 실행되는 블록으로(try 도중 System.exit() 등으로 JVM이 종료되는 경우는 예외다) 자원 정리에 쓴다. try-with-resources는 AutoCloseable을 구현한 자원을 try 괄호 안에 선언하면 블록이 끝날 때 자동으로 close()를 호출해 주는 구문으로, finally에서 직접 닫는 것보다 누락과 예외 처리 실수를 줄인다.
이렇게 바꿔 보세요
- 처음 설정으로 — 버퍼로 감싸고 닫지 않음. write를 9번 했는데 파일은 0바이트입니다.
- close로 닫으면 — 버퍼에 모인 9바이트가 한 번에 내려가 파일에 쓰입니다.
- 버퍼 없이 쓰면 — write 9번이 그대로 아래로 9번 내려갑니다.
- 버퍼가 4바이트라면 — 가득 찰 때마다 내려가고, 닫지 않으면 마지막 1바이트가 남습니다.
- close 전에 예외가 나면 — close가 실행되지 못해 파일이 비어 있습니다.
- try-with-resources라면 — 예외가 나도 자동으로 닫혀 데이터가 파일에 쓰입니다.
- 바이트 하나를 글자 하나로 읽으면 — 한글 세 글자가 깨진 글자 아홉 개가 됩니다.
처음 설정으로 실행하면
- new FileOutputStream("out.txt"): 파일을 쓰기용으로 엽니다. 그 위에 write 호출을 세는 CountingOutputStream을 씌웠습니다. 그 위에 다시 8192바이트짜리 버퍼를 가진 BufferedOutputStream을 씌웠으므로, out.write는 버퍼를 거쳐서 내려갑니다.
- "가"의 3바이트(EA B0 80)를 out.write(b)로 하나씩: 세 번 모두 버퍼에 담기기만 하고 아래로는 내려가지 않습니다. 버퍼에 3바이트가 모였고, 파일에는 0바이트가 있습니다.
- "나"의 3바이트(EB 82 98)를 out.write(b)로 하나씩: 세 번 모두 버퍼에 담기기만 하고 아래로는 내려가지 않습니다. 버퍼에 6바이트가 모였고, 파일에는 0바이트가 있습니다.
- "다"의 3바이트(EB 8B A4)를 out.write(b)로 하나씩: 세 번 모두 버퍼에 담기기만 하고 아래로는 내려가지 않습니다. 버퍼에 9바이트가 모였고, 파일에는 0바이트가 있습니다.
- (닫는 코드 없음): flush도 close도 부르지 않고 다음으로 넘어갑니다. 버퍼에 남은 9바이트는 메모리에만 있고 파일에는 없습니다. 프로그램이 이대로 끝나면 그 데이터는 사라집니다. 스트림은 열린 채로 남아 파일을 계속 붙잡고 있습니다.
- counting.calls와 Files.size: 아래 스트림으로 내려간 write는 0번이고, 파일 크기는 0바이트입니다. 9바이트를 썼지만 9바이트가 파일에 없습니다.
- 파일 다시 읽기: 파일이 비어 있어서 in.read()가 처음부터 -1(끝)을 돌려줍니다. 읽은 글자가 없습니다.
실행 결과
- BufferedOutputStream은 데이터를 메모리의 버퍼에 모았다가 한꺼번에 내려보냅니다. 내려가는 때는 버퍼가 가득 찼을 때와 flush 또는 close를 불렀을 때뿐입니다. 그래서 닫지 않으면 마지막에 버퍼에 남은 데이터가 파일에 쓰이지 않습니다.
코드
class CountingOutputStream extends OutputStream { // 아래로 내려온 write 호출을 세는 스트림
private final OutputStream out;
int calls = 0;
boolean closed = false;
CountingOutputStream(OutputStream out) { this.out = out; }
@Override public void write(int b) throws IOException { calls++; out.write(b); }
@Override public void write(byte[] b, int off, int len) throws IOException { calls++; out.write(b, off, len); }
@Override public void close() throws IOException { closed = true; out.close(); }
}
byte[] data = "가나다".getBytes(StandardCharsets.UTF_8); // 9바이트
CountingOutputStream counting = new CountingOutputStream(new FileOutputStream("out.txt"));
OutputStream out = new BufferedOutputStream(counting); // 버퍼로 감싼다
for (byte b : data) out.write(b); // 한 바이트씩 9번
System.out.println(counting.calls); // 아래로 내려간 write 횟수
System.out.println(Files.size(Path.of("out.txt"))); // 파일 크기
StringBuilder sb = new StringBuilder(); // 파일을 다시 읽는다
try (Reader in = new InputStreamReader(new FileInputStream("out.txt"), StandardCharsets.UTF_8)) {
int ch;
while ((ch = in.read()) != -1) sb.append((char) ch); // 글자 하나씩
}
System.out.println(sb);
이 모형의 전제
마흔여덟 가지 조합의 결과(아래 스트림으로 내려간 write 횟수와 한 번에 내려간 바이트 수, 파일 크기, 닫혔는지, 다시 읽은 글자)를 이 PC의 JDK 21에서 실행한 결과와 대조했습니다. 크기를 주지 않은 BufferedOutputStream이 8192바이트를 모은 뒤에 내려보낸다는 것, 닫지 않고 프로그램이 정상 종료해도 버퍼에 남은 데이터가 파일에 쓰이지 않는다는 것, 영문 abc는 바이트 하나씩 글자로 바꿔도 그대로라는 것도 같은 PC에서 따로 실행해 확인했습니다. 8192는 이 JDK에서 본 값이며 자바 문서가 정해 둔 수치는 아닙니다. 그림의 "파일"은 프로그램이 운영체제에 쓰기를 넘긴 것까지를 뜻합니다. 운영체제도 자기 버퍼를 따로 가지고 있어서 디스크에 실제로 기록되는 때는 그보다 늦을 수 있는데, 이것은 다루지 않았습니다. 바이트 방식으로 읽은 결과에서 화면에 보일 수 없는 글자는 □로 표시했습니다. 버퍼가 있을 때와 없을 때의 속도 차이는 재지 않았습니다. 쓰는 쪽의 문자 스트림(Writer)과 큰 덩어리를 한 번에 쓰는 write(byte[])는 다루지 않았습니다.
앞 실습: 클래스 로딩과 초기화 · 다음 실습: 직렬화