문자 스트림과 Charset
바이트·문자 스트림을 Charset으로 연결하고 버퍼링·flush·close를 적용해 텍스트를 안전하게 변환합니다.
Reader와 Writer는 바이트가 아니라 char와 문자열을 다루는 추상화입니다.
파일이나 소켓의 실제 데이터는 여전히 바이트이므로 InputStreamReader가 디코딩을, OutputStreamWriter가 인코딩을 담당합니다.
어떤 Charset을 쓰는지가 이 브리지 생성 시점에 결정됩니다.
문자 스트림을 선택하면 멀티바이트 문자가 청크 경계에 걸쳐도 디코더가 상태를 유지합니다.
바이트 배열 조각마다 new String을 호출하는 구현보다 안전합니다.
반대로 이미지와 압축 데이터처럼 텍스트가 아닌 형식은 Reader로 읽으면 안 됩니다.
FileReader의 기본 문자 집합
인자를 단순화한 FileReader와 FileWriter 사용은 코드가 실행되는 환경의 기본 Charset에 의존할 수 있습니다.
Java 25의 기본 Charset은 구현별 설정으로 바꾸지 않았다면 UTF-8입니다. 이전 환경이나 기본값을 변경한 실행 환경에 기대지 않고 파일 사양을 드러내려면 Charset을 명시합니다.
새 버전 생성자에서 Charset을 받을 수 있어도 코드 리뷰에서 명시 여부를 확인하는 습관이 필요합니다.
import java.io.FileReader;
import java.io.FileWriter;
import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.Path;
public final class ImplicitTextFile {
public static void main(String[] args) throws Exception {
Path file = Files.createTempFile("implicit-text-", ".txt");
try {
try (var writer = new FileWriter(file.toFile())) {
writer.write("한글과 café");
}
try (var reader = new FileReader(file.toFile())) {
var text = new StringWriter();
reader.transferTo(text);
System.out.println(text);
}
} finally {
Files.deleteIfExists(file);
}
}
}이 예제는 같은 기본 Charset으로 쓰고 읽습니다. 같은 환경의 왕복 성공만으로 외부 파일과의 인코딩 호환성까지 확인한 것은 아닙니다.
외부로 전달되는 텍스트에는 Files.newBufferedReader(path, UTF_8) 같은 명시적 API를 사용합니다.
콘솔 입출력도 배포 터미널의 인코딩 규칙이 다를 수 있으므로 파일 결과 검증 수단으로만 믿지 않습니다.
브리지의 양쪽에 같은 Charset 규칙을 배치
문자열을 쓰는 접점에서는 OutputStreamWriter, 읽는 접점에서는 InputStreamReader를 사용합니다.
둘은 바깥쪽에서 Writer와 Reader로 보이고 안쪽에서는 바이트 스트림과 연결됩니다.
StandardCharsets.UTF_8을 전달하면 실행 환경과 무관한 파일을 만듭니다.
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
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);
}
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);
}
}
}char 버퍼 경계가 이모지의 UTF-16 서로게이트 쌍 사이에 놓여도 이 예제는 유효 구간을 순서대로 StringBuilder에 이어 붙여 전체 문자열을 복원합니다.
그러나 각 조각을 독립 문자열로 검증하거나 잘라 내는 작업은 완성된 코드 포인트 구분을 고려해야 합니다.
텍스트 파서가 필요한 단위를 제공하도록 더 높은 수준의 API를 선택합니다.
readLine과 줄 구분자
readLine()은 줄 끝 문자를 제거한 문자열을 반환하고 입력 끝에서 null을 반환합니다.
원본이 LF였는지 CRLF였는지, 마지막 줄에 구분자가 있었는지는 결과만으로 알 수 없습니다.
설정 파일처럼 논리적 줄만 필요하면 편리하지만 바이트 단위 동일 복사가 목적이라면 사용할 수 없습니다.
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);
}
}
}newLine()은 현재 플랫폼의 줄 구분자를 씁니다.
출력 형식이 네트워크 프로토콜이나 저장소 규격이라면 writer.write("\n")처럼 사양의 구분자를 명시합니다.
사람이 읽는 로컬 보고서라면 플랫폼 구분자가 자연스러울 수 있습니다.
줄 규칙도 Charset과 마찬가지로 형식의 일부입니다.
전체 문자열과 줄 스트림의 메모리 특성 비교
Files.readString은 파일 전체가 작고 상한이 분명할 때 가장 간결합니다.
readAllLines는 모든 줄을 List로 보관하므로 편집과 무작위 접근에는 좋지만 대용량 로그에 부적합합니다.
Files.lines와 BufferedReader 반복은 모든 줄을 동시에 보관하지 않아도 됩니다. 다만 한 줄 자체가 매우 길 수 있으므로 줄 단위 처리만으로 입력 메모리의 엄격한 상한이 생기는 것은 아닙니다.
Files.lines가 반환하는 Stream<String>도 열린 파일 자원을 보유하므로 try-with-resources 안에서 소비해야 합니다.
반환 스트림을 메서드 밖으로 넘기면 누가 닫는지 불명확해집니다.
처리 콜백을 받거나 결과를 완성해서 반환하면 자원 범위를 메서드 안에 가둘 수 있습니다.
Writer의 명시적·자동 flush
파일 Writer는 닫을 때 인코더의 남은 상태와 내부 버퍼를 비웁니다.
매 줄마다 flush하면 작은 시스템 호출이 늘어 성능이 떨어집니다.
네트워크 대화형 프로토콜에서는 상대가 한 줄을 기다리므로 메시지 경계마다 flush해야 교착처럼 보이는 대기를 피할 수 있습니다.
오류도 검사 예외 대신 내부 플래그로 보관할 수 있으므로 checkError를 무시하면 쓰기 실패를 놓칩니다.
오류를 명시적으로 전달해야 하는 저장 로직에는 Writer와 IOException 경로를 직접 사용하는 편이 적합합니다.
PrintStream의 출력 계층
PrintStream은 OutputStream을 감싸고 print, println, printf 같은 형식화 출력을 더합니다.
자주 사용하는 System.out과 System.err도 PrintStream입니다.
콘솔 진단이나 기존 바이트 출력 대상에 간단한 텍스트를 섞어 쓸 때 편리하며, 문자 중심 변환 파이프라인에는 Writer가 더 직접적인 추상화입니다.
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=45PrintStream은 쓰기 중 발생한 IOException을 대부분 호출자에게 직접 던지지 않고 내부 오류 상태로 기록합니다.
결과 보장이 필요한 파일에서는 checkError()를 확인하거나 예외를 직접 전달하는 Writer·OutputStream을 사용합니다.
프로세스가 소유한 System.out과 System.err는 라이브러리 코드에서 닫지 말고, 정말 필요한 메시지 접점에서만 flush합니다.
PrintWriter와 PrintStream은 autoFlush 조건이 다르며 checkError는 열린 스트림을 비운 뒤 오류 상태를 확인합니다.
| 확인 항목 | PrintWriter | PrintStream |
|---|---|---|
| autoFlush = true | println · printf · format 호출 | 바이트 배열 쓰기 · println · 개행 문자/바이트 쓰기 |
| 개행 없는 write | 자동 flush하지 않음 | 바이트 배열 write는 자동 flush 대상 |
| checkError() | 열려 있으면 flush 후 오류 상태 확인 | 열려 있으면 flush 후 오류 상태 확인 |
- autoFlush = true
- PrintWriter: println · printf · format 호출PrintStream: 바이트 배열 쓰기 · println · 개행 문자/바이트 쓰기
- 개행 없는 write
- PrintWriter: 자동 flush하지 않음PrintStream: 바이트 배열 write는 자동 flush 대상
- checkError()
- PrintWriter: 열려 있으면 flush 후 오류 상태 확인PrintStream: 열려 있으면 flush 후 오류 상태 확인
Utf8PrintStreamReport는 autoFlush=false로 생성합니다. 닫기 전 checkError는 그 뒤 close에서만 생기는 실패까지 확인하지 못합니다.
텍스트 입출력 선택 기준
| 요구 | API | 주의점 |
|---|---|---|
| 작은 전체 문서 | readString/writeString | 크기 상한 |
| 줄 단위 변환 | BufferedReader/Writer | 줄 끝 보존 여부 |
| 바이트 스트림 연결 | InputStreamReader/OutputStreamWriter | Charset 명시 |
| 지연 소비 | Files.lines | Stream 닫기 |
| 프로토콜 메시지 | Writer와 flush | 구분자 사양 |
| 콘솔·형식화 진단 | PrintStream | checkError와 소유권 |
연습 문제
UTF-8 입력을 한 줄씩 읽어 앞뒤 공백을 제거하고 빈 줄은 건너뛴 뒤, 원래 줄 번호와 내용을 탭으로 구분해 출력하세요.
입력 전체를 메모리에 보관하지 않고 LF를 출력 규격으로 사용합니다.
정답과 해설
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);
}
}NormalizedLineWriterSolution의 메모리 입력에서 strip한 결과와 LF로 끝나는 출력 행을 대응시킵니다.
| 입력의 원래 줄 | strip 결과 | 기록하는 내용 |
|---|---|---|
| 1 · ␠alpha␠ | alpha | 1 + 탭 + alpha + LF |
| 2 · 빈 줄 | 빈 문자열 | 기록하지 않음 |
| 3 · ␠한글␠␠ | 한글 | 3 + 탭 + 한글 + LF |
| EOF · readLine() = null | strip 호출 없음 | 반복 종료 후 flush |
- 1 · ␠alpha␠
- strip 결과: alpha기록하는 내용: 1 + 탭 + alpha + LF
- 2 · 빈 줄
- strip 결과: 빈 문자열기록하는 내용: 기록하지 않음
- 3 · ␠한글␠␠
- strip 결과: 한글기록하는 내용: 3 + 탭 + 한글 + LF
- EOF · readLine() = null
- strip 결과: strip 호출 없음기록하는 내용: 반복 종료 후 flush
␠는 공백을 뜻합니다.
변환 함수는 Reader와 Writer를 인자로 받아 파일 경로와 분리되므로 메모리 문자열로 빠르게 확인할 수 있습니다.
운영 호출자는 UTF-8 브리지와 자원 소유권을 구성하고, 함수는 줄 단위 업무 규칙에만 집중합니다.
텍스트 입출력 규칙의 종료 기준
문자 스트림은 인코딩을 없애는 도구가 아니라 인코딩 구분을 한 객체에 맡기는 도구입니다.
Charset, 줄 끝, flush 시점, 전체 크기, 닫기 소유자를 명시하고 다국어와 보조 문자를 포함한 왕복을 확인하면 환경에 의존하지 않는 텍스트 처리가 됩니다.