본문 바로가기

문자 스트림 Reader·Writer와 Charset

바이트 스트림 위에서 문자 단위로 읽고 쓰는 Reader와 Writer가 Charset으로 바이트를 바꾸는 방식을 보고, 기본 Charset과 콘솔 인코딩, 줄 단위 처리, flush와 PrintStream의 오류 처리를 배웁니다.

청크 단위 파일 복사와 버퍼 크기의 마지막 예제에서는 "가나"의 UTF-8 바이트를 4바이트씩 읽어 청크마다 new String을 만들었다가 '나'가 대체 문자로 깨지는 것을 보았습니다. 텍스트를 바이트 청크로 다루는 한 이 문제는 계속 따라옵니다. 그래서 자바는 바이트 스트림과 별도로 문자를 단위로 다루는 스트림을 둡니다. 읽기용 Reader와 쓰기용 Writer이고, 이 둘을 묶어 문자 스트림이라고 부릅니다.

문자 스트림을 써도 파일이나 소켓에 실제로 오가는 것은 여전히 바이트입니다. 문자 스트림은 바이트와 문자 사이의 변환을 한 객체에 맡길 뿐이므로, 어떤 Charset으로 변환할지 정하는 일이 이 글의 중심이 됩니다. 바이트를 문자로 바꾸는 다리부터 보고, 기본 Charset과 콘솔 인코딩, 잘못된 바이트의 처리, 줄 단위 읽기, flush와 PrintStream의 오류 처리를 차례로 다룹니다. 한편 이미지나 압축 파일처럼 텍스트가 아닌 데이터는 Reader로 읽으면 안 됩니다. 문자로 해석할 수 없는 바이트가 대체 문자로 바뀌면서 원래 데이터가 망가지기 때문입니다.

바이트를 문자로 바꾸는 다리: InputStreamReader

다음 프로그램은 그 예제와 똑같이 "가나"의 바이트가 4바이트와 2바이트로 나뉘어 도착하는 상황을 만듭니다. SequenceInputStream은 두 스트림을 차례로 이어 붙인 스트림인데, 한 번의 read에서는 지금 읽고 있는 스트림의 데이터만 돌려줍니다. 그래서 안쪽에서는 EA B0 80 EB와 82 98이 따로 읽힙니다. 이 바이트 스트림을 InputStreamReader로 감싸고 문자를 하나씩 읽습니다.

src/TextAcrossChunks.java
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.io.SequenceInputStream;
import java.nio.charset.StandardCharsets;

public final class TextAcrossChunks {
    public static void main(String[] args) throws Exception {
        // "가나"의 UTF-8 바이트 EA B0 80 EB 82 98이 4바이트와 2바이트로 나뉘어 도착한다
        byte[] first = {(byte) 0xEA, (byte) 0xB0, (byte) 0x80, (byte) 0xEB};
        byte[] second = {(byte) 0x82, (byte) 0x98};
        InputStream bytes = new SequenceInputStream(
                new ByteArrayInputStream(first), new ByteArrayInputStream(second));
        try (Reader reader = new InputStreamReader(bytes, StandardCharsets.UTF_8)) {
            StringBuilder text = new StringBuilder();
            int ch;
            while ((ch = reader.read()) != -1) {
                text.append((char) ch);
            }
            System.out.println(text + " length=" + text.length());
        }
    }
}
실행 결과
가나 length=2

이번에는 '나'가 깨지지 않았습니다. InputStreamReader는 안에 디코더(decoder)를 두고, 첫 청크를 해석하다가 끝에 남은 EB처럼 아직 완성되지 않은 바이트를 버리지 않고 보관합니다. 다음 청크 82 98이 도착하면 보관해 둔 EB에 이어 붙여 '나'를 완성합니다. 청크가 어디서 나뉘든 디코더가 그 사이의 상태를 이어 가는 것이 청크마다 new String을 만드는 방식과의 차이입니다.

바이트를 문자로 바꾸는 과정을 디코딩(decoding), 문자를 바이트로 바꾸는 과정을 인코딩(encoding)이라고 합니다. InputStreamReader가 디코딩을 맡는 다리라면, 인코딩을 맡는 쓰기 쪽의 다리는 OutputStreamWriter입니다. 두 클래스 모두 바깥에서 보면 Reader와 Writer이고, 안쪽에서는 바이트 스트림과 연결됩니다. 어떤 Charset으로 변환할지는 이 다리를 만들 때 생성자 인자로 정합니다.

쓰고 다시 읽으며 바이트 확인하기

다음 프로그램은 OutputStreamWriter에 UTF-8을 지정해 문자열을 파일에 쓰고, 파일에 실제로 들어간 바이트를 16진수로 출력합니다. 그다음 InputStreamReader로 5개짜리 char 배열을 채워 가며 읽어서 원래 문자열과 비교합니다.

src/ExplicitTextBridge.java
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HexFormat;

public final class ExplicitTextBridge {
    public static void main(String[] args) throws Exception {
        Path file = Files.createTempFile("bridge-", ".txt");
        String expected = "첫 줄\nemoji 😀\n";
        try {
            try (var writer = new OutputStreamWriter(
                    Files.newOutputStream(file), StandardCharsets.UTF_8)) {
                writer.write(expected);
            }
            byte[] bytes = Files.readAllBytes(file);
            System.out.printf("chars=%d codePoints=%d bytes=%d%n", expected.length(),
                    expected.codePointCount(0, expected.length()), bytes.length);
            System.out.println(HexFormat.ofDelimiter(" ").withUpperCase().formatHex(bytes));

            StringBuilder actual = new StringBuilder();
            try (var reader = new InputStreamReader(
                    Files.newInputStream(file), StandardCharsets.UTF_8)) {
                char[] buffer = new char[5];
                int count;
                while ((count = reader.read(buffer)) != -1) {
                    actual.append(buffer, 0, count);
                }
            }
            System.out.println(expected.equals(actual.toString()));
        } finally {
            Files.deleteIfExists(file);
        }
    }
}
실행 결과
chars=13 codePoints=12 bytes=19
EC B2 AB 20 EC A4 84 0A 65 6D 6F 6A 69 20 F0 9F 98 80 0A
true

같은 문자열을 세는 세 가지 숫자가 모두 다릅니다. 바이트를 앞에서부터 나누면 '첫'이 EC B2 AB, 공백이 20, '줄'이 EC A4 84, 줄바꿈이 0A입니다. 이어서 emoji 다섯 글자와 공백이 한 바이트씩 65 6D 6F 6A 69 20이고, 이모지 😀가 F0 9F 98 80, 마지막 줄바꿈이 0A입니다. UTF-8에서 한글은 3바이트, ASCII 문자는 1바이트, 이모지는 4바이트라서 모두 19바이트가 됩니다. String.length()가 13인 이유는 다음 절에서 보듯 이모지가 char 두 개로 세어지기 때문이고, 사람이 보는 글자 단위에 가까운 코드 포인트 수는 12입니다. 그래서 길이 제한을 정할 때는 바이트, char, 코드 포인트 가운데 무엇을 세는지 분명히 해야 합니다. 이 차이는 문자와 바이트: 유니코드와 UTF-8에서 자세히 다룹니다.

마지막 줄의 true는 char 배열 단위로 읽은 조각들을 순서대로 이어 붙이면 원래 문자열이 그대로 돌아온다는 뜻입니다.

char 하나는 글자 하나가 아니다

자바의 char는 UTF-16 코드 단위(code unit) 하나입니다. 대부분의 글자는 char 하나로 표현되지만, 😀처럼 U+FFFF보다 큰 코드 포인트를 가진 글자는 서로게이트 쌍(surrogate pair)이라는 char 두 개로 표현됩니다. 그래서 Reader.read(char[])가 돌려주는 조각의 끝이 글자의 끝과 맞는다는 보장이 없습니다. 다음 프로그램은 StringReader로 "abcd😀!"를 5개짜리 char 배열에 나눠 읽으면서, 조각마다 담긴 char 값을 16진수로 출력합니다.

src/CharChunks.java
import java.io.Reader;
import java.io.StringReader;

public final class CharChunks {
    public static void main(String[] args) throws Exception {
        String text = "abcd😀!";                    // 😀는 char 두 개(D83D, DE00)
        StringBuilder joined = new StringBuilder();
        try (Reader reader = new StringReader(text)) {
            char[] buffer = new char[5];
            int count;
            while ((count = reader.read(buffer)) != -1) {
                StringBuilder units = new StringBuilder();
                for (int i = 0; i < count; i++) {
                    units.append(String.format(" %04X", (int) buffer[i]));
                }
                System.out.println("count=" + count + ":" + units);
                joined.append(buffer, 0, count);
            }
        }
        System.out.println("joined equals=" + text.equals(joined.toString()));
    }
}
실행 결과
count=5: 0061 0062 0063 0064 D83D
count=2: DE00 0021
joined equals=true

첫 조각은 abcd에 이어 😀의 앞쪽 절반인 D83D에서 끝나고, 두 번째 조각은 뒤쪽 절반 DE00으로 시작합니다. 조각들을 순서대로 이어 붙이면 문자열이 온전히 돌아오지만(joined equals=true), 조각 하나하나를 독립된 문자열로 검사하거나 자르거나 글자 수를 세면 이모지가 반으로 갈라진 채 처리됩니다. 참고로 같은 문자열을 UTF-8 바이트로 바꿔 JDK 21의 InputStreamReader로 같은 크기의 배열에 읽어 보면, 쌍을 나누지 않고 첫 조각에 4개만 돌려줍니다. 하지만 문서가 약속하는 동작은 아니므로 기대면 안 됩니다. 조각 단위로 처리해야 한다면 Character.isHighSurrogate로 끝이 쌍의 앞쪽 절반인지 확인해 다음 조각과 합치거나, 처음부터 readLine이나 Files.readString처럼 더 큰 단위를 돌려주는 API를 씁니다.

FileReader와 FileWriter의 기본 Charset

FileReader와 FileWriter는 파일을 바로 열어 주는 문자 스트림입니다. Charset 인자 없이 만들면 기본 Charset(default charset)을 씁니다. 다음 프로그램은 기본 Charset을 출력하고, Charset을 정하지 않은 FileWriter로 쓴 파일의 바이트를 확인한 뒤 역시 기본값을 쓰는 FileReader로 다시 읽습니다.

bad/ImplicitTextFile.java
import java.io.FileReader;
import java.io.FileWriter;
import java.io.StringWriter;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HexFormat;

public final class ImplicitTextFile {
    public static void main(String[] args) throws Exception {
        Path file = Files.createTempFile("implicit-text-", ".txt");
        try {
            System.out.println("default=" + Charset.defaultCharset());
            try (var writer = new FileWriter(file.toFile())) {          // Charset을 정하지 않았다
                writer.write("한글과 café");
            }
            System.out.println(HexFormat.ofDelimiter(" ").withUpperCase()
                    .formatHex(Files.readAllBytes(file)));
            try (var reader = new FileReader(file.toFile())) {          // 여기도 기본값
                var text = new StringWriter();
                reader.transferTo(text);
                System.out.println(text);
            }
        } finally {
            Files.deleteIfExists(file);
        }
    }
}
실행 결과
default=UTF-8
ED 95 9C EA B8 80 EA B3 BC 20 63 61 66 C3 A9
한글과 café

JDK 18부터는 따로 설정하지 않는 한 기본 Charset이 UTF-8입니다(JEP 400). 이 트랙의 기준인 Java 25도 마찬가지입니다. 그래서 위 프로그램은 '한'을 ED 95 9C, '글'을 EA B8 80, '과'를 EA B3 BC, 'é'를 C3 A9로 쓰고 문제없이 다시 읽습니다. 하지만 JDK 17까지는 운영체제의 지역 설정에서 기본 Charset을 골랐습니다. 예를 들어 한국어 Windows에서는 MS949였습니다. 그래서 JDK 17 이하의 프로그램이 한국어 Windows에서 기본값으로 쓴 파일을 JDK 18 이후의 기본값으로 읽으면 한글이 깨지고, 반대 방향도 마찬가지입니다.

JEP 400은 예전 방식이 꼭 필요한 경우를 위해 -Dfile.encoding=COMPAT 옵션을 마련해 두었습니다. 같은 프로그램을 지역 설정이 POSIX인 Linux 컨테이너에서 이 옵션으로 실행하면(JDK 21) 결과가 이렇게 바뀝니다.

default=US-ASCII
3F 3F 3F 20 63 61 66 3F
??? caf?

예전 방식은 POSIX 지역 설정에서 US-ASCII를 고릅니다. ASCII에는 한글도 'é'도 없으므로 FileWriter가 이 글자들을 모두 ?(3F)로 바꿔서 썼습니다. 화면에만 잘못 보이는 것이 아니라 파일에 원래 글자가 아예 남아 있지 않고, 그런데도 예외는 나지 않습니다. 같은 코드가 실행 환경에 따라 다른 바이트를 만든다는 것이 기본값에 기대는 코드의 문제입니다.

그래서 프로그램 밖으로 나가는 텍스트에는 Charset을 항상 적습니다. new FileWriter(file, StandardCharsets.UTF_8)처럼 Java 11부터 생긴, Charset을 받는 생성자를 쓰거나, new InputStreamReader(input, StandardCharsets.UTF_8), Files.newBufferedReader(path, StandardCharsets.UTF_8)를 씁니다. java.nio.file.Files의 메서드는 Charset 인자를 생략해도 처음부터 UTF-8을 쓰도록 정해져 있습니다. 같은 환경에서 쓰고 읽어 성공했다는 사실만으로는 다른 프로그램이 만든 파일과 호환된다고 말할 수 없다는 점도 기억해 둡니다.

콘솔에 ??가 찍힐 때

이 장의 예제를 실행하다 보면 한글이 ??로 찍히는 일이 있습니다. 그렇다고 파일이 깨졌다고 단정하면 안 됩니다. 다음 프로그램은 기본 Charset과 표준 출력의 인코딩을 출력하고 한글 한 줄을 찍습니다.

src/ConsoleEncoding.java
import java.nio.charset.Charset;

public final class ConsoleEncoding {
    public static void main(String[] args) {
        System.out.println("default=" + Charset.defaultCharset());
        System.out.println("stdout=" + System.getProperty("stdout.encoding"));
        System.out.println("한글");
    }
}

UTF-8로 설정된 터미널에서 실행한 결과입니다. 첫 두 줄은 각각 기본 Charset과 표준 출력이 쓰는 인코딩이고, 셋째 줄이 실제로 화면에 찍힌 한글입니다.

default=UTF-8
stdout=UTF-8
한글

같은 프로그램을 지역 설정이 POSIX인 Linux 환경에서 실행하면 다음과 같습니다. 프로그램은 한 글자도 바꾸지 않았으니, 앞의 결과와 세 줄을 하나씩 비교해 보세요.

default=UTF-8
stdout=ANSI_X3.4-1968
??

두 환경 모두 기본 Charset은 UTF-8인데 결과가 다릅니다. System.out과 System.err는 기본 Charset을 따르지 않고 콘솔의 인코딩을 따르기 때문입니다. JDK 19부터는 이 값을 stdout.encoding과 stderr.encoding 시스템 속성으로 확인할 수 있는데, 기본값은 플랫폼과 터미널 설정에 따라 정해집니다. 두 번째 환경의 ANSI_X3.4-1968은 Java에서 US-ASCII의 별칭 가운데 하나이고, ASCII로 인코딩할 수 없는 한글이 ?로 바뀌었습니다. 이 경우 프로그램이나 파일에는 문제가 없고 콘솔 출력만 깨진 것입니다. 터미널을 UTF-8로 설정하거나 java -Dstdout.encoding=UTF-8 ConsoleEncoding처럼 실행하면 두 번째 환경에서도 한글이 제대로 찍힙니다. 그래서 파일 내용을 확인할 때는 콘솔 출력보다 이 장의 예제들처럼 바이트를 16진수로 보거나 코드에서 문자열을 비교하는 편이 확실합니다.

잘못된 바이트를 만났을 때: 대체 문자와 예외

UTF-8 규칙에 맞지 않는 바이트를 만났을 때 어떻게 하는지는 API마다 다릅니다. 다음 프로그램은 o, k 다음에 UTF-8로는 잘못된 바이트 조합을 넣은 파일을 만들고, 두 가지 방법으로 읽습니다.

src/ReplaceOrReport.java
import java.io.InputStreamReader;
import java.nio.charset.MalformedInputException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

public final class ReplaceOrReport {
    public static void main(String[] args) throws Exception {
        Path file = Files.createTempFile("broken-", ".txt");
        try {
            // 0xC3은 뒤에 바이트 하나가 더 와야 하는 UTF-8 시작 바이트인데 '('가 왔다
            Files.write(file, new byte[] {'o', 'k', (byte) 0xC3, '('});

            try (var reader = new InputStreamReader(Files.newInputStream(file), StandardCharsets.UTF_8)) {
                StringBuilder codes = new StringBuilder();
                int ch;
                while ((ch = reader.read()) != -1) {
                    codes.append(String.format(" U+%04X", ch));
                }
                System.out.println("InputStreamReader:" + codes);
            }

            try {
                Files.readString(file, StandardCharsets.UTF_8);
            } catch (MalformedInputException e) {
                System.out.println("Files.readString: " + e);
            }
        } finally {
            Files.deleteIfExists(file);
        }
    }
}
실행 결과
InputStreamReader: U+006F U+006B U+FFFD U+0028
Files.readString: java.nio.charset.MalformedInputException: Input length = 1

Charset을 받아 만든 InputStreamReader는 잘못된 바이트 C3을 대체 문자 U+FFFD로 바꾸고 예외 없이 읽기를 계속합니다. 반면 Files.readString과 Files.newBufferedReader는 잘못된 입력을 만나면 MalformedInputException을 던집니다. 쓰는 쪽도 같은 차이가 있습니다. OutputStreamWriter에 MS949를 지정해 "café"를 쓰면 MS949에 없는 'é'가 ?(3F)로 바뀌어 63 61 66 3F가 기록되지만, Files.writeString에 같은 Charset을 주면 UnmappableCharacterException이 납니다.

외부에서 받은 텍스트를 조용히 바꿔 버리면 데이터가 손상된 사실을 나중에야 알게 됩니다. 그래서 손상을 알아채야 하는 입력에는 Files의 메서드를 쓰거나, 다리 클래스에 Charset 대신 디코더를 넘깁니다. new InputStreamReader(input, StandardCharsets.UTF_8.newDecoder())처럼 새로 만든 디코더는 기본으로 오류를 보고하므로, 잘못된 바이트에서 MalformedInputException이 납니다. 디코더의 오류 처리 방식은 Charset과 인코딩 오류 처리에서 자세히 다룹니다.

줄 단위로 읽기: readLine과 줄 구분자

텍스트 파일은 줄 단위로 처리하는 경우가 많습니다. BufferedReader는 문자 스트림에 버퍼를 더하면서 한 줄을 통째로 돌려주는 readLine()을 제공합니다. 다음 프로그램은 Files.newBufferedReader와 Files.newBufferedWriter로 UTF-8 파일을 열고, 각 줄 앞에 번호를 붙여 다른 파일에 씁니다.

src/NumberedUtf8Lines.java
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

public final class NumberedUtf8Lines {
    static void number(Path source, Path target) throws Exception {
        try (BufferedReader reader = Files.newBufferedReader(source, StandardCharsets.UTF_8);
             BufferedWriter writer = Files.newBufferedWriter(target, StandardCharsets.UTF_8)) {
            String line;
            int number = 1;
            while ((line = reader.readLine()) != null) {
                writer.write(number++ + ": " + line);
                writer.newLine();
            }
        }
    }

    public static void main(String[] args) throws Exception {
        Path source = Files.createTempFile("lines-", ".txt");
        Path target = Files.createTempFile("numbered-", ".txt");
        try {
            Files.writeString(source, "alpha\n한글\n", StandardCharsets.UTF_8);
            number(source, target);
            System.out.print(Files.readString(target, StandardCharsets.UTF_8));
        } finally {
            Files.deleteIfExists(source);
            Files.deleteIfExists(target);
        }
    }
}
실행 결과
1: alpha
2: 한글

readLine()은 줄 끝 문자를 뺀 문자열을 돌려주고, 더 읽을 줄이 없으면 null을 돌려줍니다. 줄 끝으로는 LF(\n), CR(\r), CRLF(\r\n)를 모두 인정합니다. 그래서 편리하지만 정보가 사라집니다. 결과만 보고는 원본이 LF였는지 CRLF였는지, 마지막 줄 뒤에 줄바꿈이 있었는지 알 수 없습니다. 설정 파일처럼 논리적인 줄만 필요할 때는 알맞지만, 원본과 바이트까지 같은 복사본을 만들어야 한다면 쓸 수 없습니다.

쓰는 쪽의 newLine()은 현재 플랫폼의 줄 구분자인 System.lineSeparator()를 씁니다. Linux와 macOS에서는 \n, Windows에서는 \r\n입니다. 사람이 읽는 로컬 보고서라면 이것이 자연스럽지만, 네트워크 프로토콜이나 저장 형식처럼 명세가 줄 구분자를 정해 둔 출력이라면 writer.write("\n")처럼 명세의 구분자를 직접 씁니다. 줄 구분자도 Charset과 마찬가지로 파일 형식의 일부입니다.

전체를 읽을까, 한 줄씩 흘려 읽을까

Files.readString은 파일 전체를 문자열 하나로 읽습니다. 파일이 작고 크기 상한이 분명할 때 가장 간결합니다. Files.readAllLines는 모든 줄을 List에 담으므로 편집하거나 아무 줄이나 골라 보기에는 좋지만, 파일 크기만큼 메모리를 쓰므로 큰 로그 파일에는 맞지 않습니다. 반면 Files.lines와 BufferedReader의 readLine 반복은 모든 줄을 동시에 들고 있지 않습니다. 다만 한 줄 자체가 아주 길 수 있으므로, 줄 단위로 처리한다고 해서 메모리 사용량에 엄격한 상한이 생기는 것은 아닙니다.

Files.lines가 돌려주는 Stream<String>은 안에서 파일을 열어 둔 채로 줄을 하나씩 읽습니다. 그래서 다 쓴 뒤에는 닫아야 하고, 보통 try-with-resources 안에서 소비합니다.

try (Stream<String> lines = Files.lines(path, StandardCharsets.UTF_8)) {
    long errors = lines.filter(line -> line.contains("ERROR")).count();
}

이 스트림을 메서드 밖으로 돌려주면 누가 닫아야 하는지가 흐려집니다. 처리할 동작을 인자로 받거나 결과를 완성해서 돌려주면, 파일을 여는 곳과 닫는 곳을 한 메서드 안에 둘 수 있습니다.

Writer의 flush와 PrintStream의 오류 처리

문자 스트림의 Writer도 버퍼를 가집니다. OutputStreamWriter 안의 인코더와 BufferedWriter가 문자를 모아 두었다가 바이트로 바꿔 내보내고, close()는 닫기 전에 남은 문자와 인코더의 상태를 모두 비웁니다. 그래서 파일에 쓸 때는 줄마다 flush할 필요가 없고, 그렇게 하면 작은 시스템 호출만 늘어 느려집니다. 반대로 상대가 한 줄을 받아야 응답하는 대화형 네트워크 프로토콜에서는 메시지가 끝날 때마다 flush해야, 메시지가 버퍼에 머문 채 양쪽이 서로를 기다리는 일을 막을 수 있습니다.

PrintStream은 OutputStream을 감싸 print, println, printf 같은 형식화 출력을 더하는 보조 스트림입니다. 늘 쓰는 System.out과 System.err도 PrintStream입니다. 문자를 위한 짝으로는 Writer를 감싸는 PrintWriter가 있습니다. 다음 프로그램은 PrintStream에 UTF-8을 지정해 보고서 파일을 만들고, 쓰기에 실패하지 않았는지 checkError()로 확인합니다.

src/Utf8PrintStreamReport.java
import java.io.IOException;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

public final class Utf8PrintStreamReport {
    public static void main(String[] args) throws Exception {
        Path report = Files.createTempFile("board-report-", ".txt");
        try {
            try (PrintStream output = new PrintStream(
                    Files.newOutputStream(report), false, StandardCharsets.UTF_8)) {
                output.println("제목=문자 스트림");
                output.printf("views=%d%n", 45);
                if (output.checkError()) throw new IOException("report write failed");
            }
            System.out.print(Files.readString(report, StandardCharsets.UTF_8));
        } finally {
            Files.deleteIfExists(report);
        }
    }
}
실행 결과
제목=문자 스트림
views=45

생성자의 두 번째 인자 false는 자동 flush를 끄고, 세 번째 인자로 Charset을 정합니다. checkError()가 필요한 이유는 PrintStream과 PrintWriter의 출력 메서드가 IOException을 던지지 않기 때문입니다. 쓰기 중에 오류가 나면 예외 대신 내부 오류 표시만 남기고, 호출자는 checkError()로 그 표시를 확인해야 합니다. 이 동작은 PrintWriter 공식 문서에도 적혀 있습니다. 확인하지 않으면 디스크가 가득 찬 것 같은 실패를 모르고 지나칩니다. 일반 Writer와 OutputStream은 오류를 IOException으로 바로 던진다는 점과 구별해 두세요.

checkError()는 스트림이 열려 있으면 먼저 flush한 뒤 오류 표시를 확인합니다. 그래서 위 예제처럼 닫기 전에 부르면 그때까지 쓴 내용의 실패는 잡지만, 그 뒤 close()에서만 생기는 실패는 잡지 못합니다. 실제로 close()에서만 실패하는 출력 스트림으로 시험해 보면, 닫기 전의 checkError()는 false, 닫은 뒤에는 true를 돌려줍니다. 결과가 꼭 기록되어야 하는 저장 로직이라면 닫은 뒤에도 확인하거나, 예외를 그대로 전달하는 Writer와 OutputStream을 직접 쓰는 편이 낫습니다.

두 출력 클래스의 자동 비우기 계약

PrintWriter와 PrintStream은 autoFlush 조건이 다르며 checkError는 열린 스트림을 비운 뒤 오류 상태를 확인합니다.

출력 계약 · 자동 flush 조건

두 출력 클래스의 자동 비우기 계약

PrintWriter는 형식 출력 메서드에서, PrintStream은 바이트 배열 쓰기와 개행에서도 자동으로 비웁니다.

PrintWriter

autoFlush = true
println · printf · format 호출
개행 없는 write
자동 flush하지 않음
checkError()
열려 있으면 flush 후 오류 상태 확인

PrintStream

autoFlush = true
바이트 배열 쓰기 · println · 개행 문자/바이트 쓰기
개행 없는 write
바이트 배열 write는 자동 flush 대상
checkError()
열려 있으면 flush 후 오류 상태 확인

Utf8PrintStreamReport는 autoFlush=false로 생성합니다. 닫기 전 checkError는 그 뒤 close에서만 생기는 실패까지 확인하지 못합니다.

자동 flush를 켰을 때 비우는 시점은 두 클래스가 다릅니다. PrintWriter는 println, printf, format을 부를 때만 비우고, 줄바꿈 없는 write로는 비우지 않습니다. PrintStream은 이에 더해 바이트 배열을 쓸 때와 줄바꿈 문자나 바이트를 쓸 때도 비웁니다. 마지막으로 System.out과 System.err는 프로세스 전체가 함께 쓰는 스트림입니다. 라이브러리 코드에서 이것을 닫으면 프로그램의 다른 부분이 더는 출력하지 못하므로 닫지 말고, 꼭 필요한 지점에서만 flush합니다.

텍스트 입출력 API 고르기

하려는 일API주의할 점
작은 문서를 통째로 읽고 쓰기Files.readString, Files.writeString크기 상한
줄 단위로 변환하기BufferedReader, BufferedWriter줄 끝을 보존해야 하는지
이미 있는 바이트 스트림과 연결하기InputStreamReader, OutputStreamWriterCharset 명시, 잘못된 바이트는 대체 문자로 바뀜
많은 줄을 흘려 읽기Files.linesStream 닫기
프로토콜 메시지 보내기Writer와 flush명세가 정한 줄 구분자
콘솔 진단과 형식화 출력PrintStream, PrintWritercheckError, 누가 닫는지

정리

  • Reader와 Writer는 문자를 단위로 읽고 씁니다. InputStreamReader와 OutputStreamWriter가 바이트 스트림과 문자 스트림을 잇는 다리이고, 디코더가 청크 사이의 상태를 이어 가므로 여러 바이트로 된 글자가 청크에 걸려도 깨지지 않습니다.
  • char는 UTF-16 코드 단위입니다. 이모지 같은 글자는 char 두 개이므로, read(char[])의 조각이 글자 중간에서 끝날 수 있습니다.
  • JDK 18부터 기본 Charset은 UTF-8이지만, 기본값에 기대는 코드는 실행 환경에 따라 다른 바이트를 만들 수 있습니다. 프로그램 밖으로 나가는 텍스트에는 Charset을 적습니다.
  • System.out은 기본 Charset이 아니라 콘솔 인코딩(stdout.encoding)을 따릅니다. 콘솔의 ??만 보고 파일이 깨졌다고 판단하지 않습니다.
  • Charset을 받은 다리 클래스는 잘못된 바이트를 대체 문자로 바꾸고, Files의 메서드는 예외를 던집니다.
  • readLine()은 줄 끝 정보를 버리고, newLine()은 플랫폼 구분자를 씁니다. PrintStream과 PrintWriter는 오류를 checkError()로 확인해야 합니다.

연습 문제

1. 공백을 다듬고 원래 줄 번호 붙이기

UTF-8 입력을 한 줄씩 읽어 앞뒤 공백을 없애고, 공백을 없앤 결과가 빈 줄은 건너뛴 뒤 원래 줄 번호와 내용을 탭으로 구분해 출력하세요. 입력 전체를 메모리에 담지 말고, 출력의 줄 구분자는 플랫폼과 상관없이 LF로 씁니다.

정답과 해설
exercise/NormalizedLineWriterSolution.java
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.StringReader;
import java.io.StringWriter;

public final class NormalizedLineWriterSolution {
    static void transform(BufferedReader reader, BufferedWriter writer) throws Exception {
        String line;
        int sourceLine = 0;
        while ((line = reader.readLine()) != null) {
            sourceLine++;
            String normalized = line.strip();
            if (normalized.isEmpty()) continue;
            writer.write(Integer.toString(sourceLine));
            writer.write('\t');
            writer.write(normalized);
            writer.write('\n');
        }
        writer.flush();
    }

    public static void main(String[] args) throws Exception {
        var target = new StringWriter();
        try (var reader = new BufferedReader(new StringReader(" alpha \n\n 한글  \n"));
             var writer = new BufferedWriter(target)) {
            transform(reader, writer);
        }
        System.out.print(target);
    }
}
실행 결과
1	alpha
3	한글

줄 번호와 내용 사이는 탭 문자입니다. 입력의 첫 줄 " alpha "는 strip()으로 alpha가 되어 1과 함께 기록됩니다. 두 번째 줄은 빈 문자열이므로 기록하지 않지만, 번호는 strip()보다 먼저 늘렸으므로 이 줄도 세어집니다. 그래서 세 번째 줄 " 한글 "은 출력의 두 번째 줄인데도 원래 번호 3을 유지합니다. 입력이 끝나 readLine()이 null을 돌려주면 strip()을 부르지 않고 반복을 끝낸 뒤 flush()합니다. 빈 문자열인 줄과 입력의 끝을 뜻하는 null은 서로 다른 경우라는 점이 중요합니다.

빈 줄을 건너뛰어도 원래 번호를 유지

줄 1 정규화 기록→빈 줄 2 건너뜀→줄 3의 원래 번호 유지→EOF flush입니다. 번호·출력 누적·미호출 strip의 연속 변화를 단계별로 확인할 수 있습니다.

빈 줄을 건너뛰어도 원래 번호를 유지

NormalizedLineWriterSolution의 메모리 입력에서 strip한 결과와 LF로 끝나는 출력 행을 대응시킵니다.

줄 변환 · 원래 줄 번호

빈 줄을 건너뛰어도 원래 번호를 유지

빈 두 번째 줄은 출력하지 않아도, 다음 한글 줄의 번호는 3으로 남습니다.

null과 빈 문자열은 다른 출구다

null과 빈 문자열은 다른 출구다readLine null은 strip 없이 flush로 종료한다. 실제 줄은 원래 번호를 먼저 늘리고 strip하며, 빈 결과는 기록하지 않아도 3번 한글 줄의 번호를 유지한다.null줄예아니오readLine 결과null · EOFflush 종료줄 번호 +1strip 실행빈 값인가?빈 값기록 생략남길 줄번호·LF기록
null과 빈 문자열은 다른 출구다readLine null은 strip 없이 flush로 종료한다. 실제 줄은 원래 번호를 먼저 늘리고 strip하며, 빈 결과는 기록하지 않아도 3번 한글 줄의 번호를 유지한다.null줄예아니오readLine 결과null · EOFflush 종료줄 번호 +1strip 실행빈 값인가?빈 값기록 생략남길 줄번호·LF기록

readLine null은 strip 없이 flush로 종료한다. 실제 줄은 원래 번호를 먼저 늘리고 strip하며, 빈 결과는 기록하지 않아도 3번 한글 줄의 번호를 유지한다.

상태별 상세 해설
  1. 1 · ␠alpha␠

    strip 결과
    alpha
    기록하는 내용
    1 + 탭 + alpha + LF
  2. 2 · 빈 줄

    strip 결과
    빈 문자열
    기록하는 내용
    기록하지 않음
  3. 3 · ␠한글␠␠

    strip 결과
    한글
    기록하는 내용
    3 + 탭 + 한글 + LF
  4. EOF · readLine() = null

    strip 결과
    strip 호출 없음
    기록하는 내용
    반복 종료 후 flush

␠는 공백을 뜻합니다.

이 예의 조건

  • 공백 표시 ␠는 실제 입력의 공백이며 출력 줄끝은 LF입니다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보빈 줄을 건너뛰어도 원래 번호를 유지 빈 줄을 건너뛰어도 원래 번호를 유지 NormalizedLineWriterSolution의 메모리 입력에서 strip한 결과와 LF로 끝나는 출력 행을 대응시킵니다. 줄 변환 · 원래 줄 번호 빈 줄을 건너뛰어도 원래 번호를 유지 빈 두 번째 줄은 출력하지 않아도, 다음 한글 줄의 번호는 3으로 남습니다. 1 · ␠alpha␠ strip 결과 alpha 기록하는 내용 1 + 탭 + alpha + LF 2 · 빈 줄 strip 결과 빈 문자열 기록하는 내용 기록하지 않음 3 · ␠한글␠␠ strip 결과 한글 기록하는 내용 3 + 탭 + 한글 + LF EOF · readLine() = null strip 결과 strip 호출 없음 기록하는 내용 반복 종료 후 flush ␠는 공백을 뜻합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 줄 정규화

1번 줄의 양쪽 공백을 strip합니다.

  • 결과: alpha
  • 출력: 1+탭+alpha+LF
  • 원래 줄 번호를 출력 접두로 유지합니다.
빈 줄 건너뜀

2번 빈 줄은 기록하지 않고 3번 줄을 처리합니다.

  • 2번: 출력 없음
  • 3번: 3+탭+한글+LF
  • 건너뛴 줄도 원래 번호 계산에 포함됩니다.
EOF 종료

readLine=null이면 strip 없이 반복을 끝내고 flush합니다.

  • strip: 호출 없음
  • 완료 동작: flush
  • EOF null과 빈 문자열 줄을 구별합니다.

빈 줄을 건너뛰어도 원래 번호를 유지

줄 1 정규화 기록→빈 줄 2 건너뜀→줄 3의 원래 번호 유지→EOF flush입니다. 번호·출력 누적·미호출 strip의 연속 변화를 단계별로 확인할 수 있습니다.

빈 줄을 건너뛰어도 원래 번호를 유지

NormalizedLineWriterSolution의 메모리 입력에서 strip한 결과와 LF로 끝나는 출력 행을 대응시킵니다.

줄 변환 · 원래 줄 번호

빈 줄을 건너뛰어도 원래 번호를 유지

빈 두 번째 줄은 출력하지 않아도, 다음 한글 줄의 번호는 3으로 남습니다.

null과 빈 문자열은 다른 출구다

null과 빈 문자열은 다른 출구다readLine null은 strip 없이 flush로 종료한다. 실제 줄은 원래 번호를 먼저 늘리고 strip하며, 빈 결과는 기록하지 않아도 3번 한글 줄의 번호를 유지한다.null줄예아니오readLine 결과null · EOFflush 종료줄 번호 +1strip 실행빈 값인가?빈 값기록 생략남길 줄번호·LF기록
null과 빈 문자열은 다른 출구다readLine null은 strip 없이 flush로 종료한다. 실제 줄은 원래 번호를 먼저 늘리고 strip하며, 빈 결과는 기록하지 않아도 3번 한글 줄의 번호를 유지한다.null줄예아니오readLine 결과null · EOFflush 종료줄 번호 +1strip 실행빈 값인가?빈 값기록 생략남길 줄번호·LF기록

readLine null은 strip 없이 flush로 종료한다. 실제 줄은 원래 번호를 먼저 늘리고 strip하며, 빈 결과는 기록하지 않아도 3번 한글 줄의 번호를 유지한다.

상태별 상세 해설
  1. 1 · ␠alpha␠

    strip 결과
    alpha
    기록하는 내용
    1 + 탭 + alpha + LF
  2. 2 · 빈 줄

    strip 결과
    빈 문자열
    기록하는 내용
    기록하지 않음
  3. 3 · ␠한글␠␠

    strip 결과
    한글
    기록하는 내용
    3 + 탭 + 한글 + LF
  4. EOF · readLine() = null

    strip 결과
    strip 호출 없음
    기록하는 내용
    반복 종료 후 flush

␠는 공백을 뜻합니다.

이 예의 조건

  • 공백 표시 ␠는 실제 입력의 공백이며 출력 줄끝은 LF입니다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보빈 줄을 건너뛰어도 원래 번호를 유지 빈 줄을 건너뛰어도 원래 번호를 유지 NormalizedLineWriterSolution의 메모리 입력에서 strip한 결과와 LF로 끝나는 출력 행을 대응시킵니다. 줄 변환 · 원래 줄 번호 빈 줄을 건너뛰어도 원래 번호를 유지 빈 두 번째 줄은 출력하지 않아도, 다음 한글 줄의 번호는 3으로 남습니다. 1 · ␠alpha␠ strip 결과 alpha 기록하는 내용 1 + 탭 + alpha + LF 2 · 빈 줄 strip 결과 빈 문자열 기록하는 내용 기록하지 않음 3 · ␠한글␠␠ strip 결과 한글 기록하는 내용 3 + 탭 + 한글 + LF EOF · readLine() = null strip 결과 strip 호출 없음 기록하는 내용 반복 종료 후 flush ␠는 공백을 뜻합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 줄 정규화

1번 줄의 양쪽 공백을 strip합니다.

  • 결과: alpha
  • 출력: 1+탭+alpha+LF
  • 원래 줄 번호를 출력 접두로 유지합니다.
빈 줄 건너뜀

2번 빈 줄은 기록하지 않고 3번 줄을 처리합니다.

  • 2번: 출력 없음
  • 3번: 3+탭+한글+LF
  • 건너뛴 줄도 원래 번호 계산에 포함됩니다.
EOF 종료

readLine=null이면 strip 없이 반복을 끝내고 flush합니다.

  • strip: 호출 없음
  • 완료 동작: flush
  • EOF null과 빈 문자열 줄을 구별합니다.

transform이 파일 경로 대신 BufferedReader와 BufferedWriter를 인자로 받으므로, 위처럼 StringReader와 StringWriter로 파일 없이 빠르게 확인할 수 있습니다. 실제 프로그램에서는 호출하는 쪽이 Files.newBufferedReader(path, StandardCharsets.UTF_8)처럼 Charset을 정해 스트림을 열고 닫으며, transform은 줄을 다루는 규칙에만 집중합니다. transform이 마지막에 flush()만 하고 close()하지 않는 것도 같은 이유입니다. 스트림을 연 쪽이 닫습니다.

2. CRLF와 마지막 줄바꿈

1번 정답에 " one\r\n \r\n two"를 입력하면 출력의 줄 번호와 구분자는 어떻게 될까요? 입력의 마지막 줄에 줄바꿈이 없다는 사실이 출력에 그대로 남는지도 설명하세요.

해설

입력은 " one", " ", " two" 세 줄로 읽힙니다. readLine()은 \r\n을 줄 끝으로 인정해 제거하고, 마지막 줄처럼 줄바꿈 없이 입력이 끝난 줄도 한 줄로 돌려줍니다. 첫 줄은 1, 탭, one, LF로 기록됩니다. 공백뿐인 둘째 줄은 strip() 결과가 빈 문자열이라 건너뜁니다. 셋째 줄은 3, 탭, two, LF로 기록됩니다.

원래 마지막 줄에 줄바꿈이 없었다는 정보는 남지 않습니다. transform은 기록하는 모든 줄 뒤에 LF를 쓰기 때문입니다. 마찬가지로 원본의 CRLF도 LF로 바뀝니다. 원래 줄 번호는 지키지만, 원본과 바이트까지 같은 결과를 만드는 변환은 아닙니다.

3. 세 가지 길이

문자열 "Java 한글 😀"에서 length(), 코드 포인트 수, UTF-8 바이트 수는 각각 얼마일까요? 계산한 뒤 length(), codePointCount(0, s.length()), getBytes(StandardCharsets.UTF_8).length로 확인해 보세요.

해설

차례로 10, 9, 16입니다. J, a, v, a와 공백 두 개, 한글 두 글자는 모두 char 하나씩이라 8개이고, 😀는 서로게이트 쌍이라 char 두 개이므로 length()는 10입니다. 코드 포인트로는 😀가 하나이므로 9입니다. UTF-8 바이트는 ASCII 문자 6개(Java와 공백 두 개)가 1바이트씩 6바이트, 한글 두 글자가 3바이트씩 6바이트, 😀가 4바이트라서 모두 16바이트입니다.

다음 글

이 장에서는 바이트와 문자가 스트림 위에서 어떻게 오가는지를 보았습니다. 다음 장의 Path와 Files로 텍스트 파일 저장소 만들기에서는 Path와 Files로 파일과 디렉터리를 다루면서, 여기서 배운 Charset 지정과 스트림 닫기를 바탕으로 데이터를 파일에 저장하는 프로그램을 만듭니다.