문자 스트림과 Charset
바이트 스트림과 문자 스트림을 Charset 브리지로 연결하고 BufferedReader의 줄 규칙, Writer의 `flush`·`close`, 대용량 텍스트 변환을 안전하게 구현합니다.
Reader와 Writer는 바이트가 아니라 char와 문자열을 다루는 추상화입니다.
파일이나 소켓의 실제 데이터는 여전히 바이트이므로 InputStreamReader가 디코딩을, OutputStreamWriter가 인코딩을 담당합니다.
어떤 Charset을 쓰는지가 이 브리지 생성 시점에 결정됩니다.
문자 스트림을 선택하면 멀티바이트 문자가 청크 경계에 걸쳐도 디코더가 상태를 유지합니다.
바이트 배열 조각마다 new String을 호출하는 구현보다 안전합니다.
반대로 이미지와 압축 데이터처럼 텍스트가 아닌 형식은 Reader로 읽으면 안 됩니다.
FileReader의 기본 문자 집합
인자를 단순화한 FileReader와 FileWriter 사용은 코드가 실행되는 환경의 기본 Charset에 의존할 수 있습니다.
파일 사양이 UTF-8인데 개발 PC가 다른 기본값을 사용하면 읽은 문자열이 깨집니다.
새 버전 생성자에서 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);
}
}
}이 예제는 같은 환경 안에서 우연히 성공할 수 있다는 점이 더 위험합니다.
외부로 전달되는 텍스트에는 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 쌍 구분과 맞지 않더라도 Writer로 그대로 전달하는 복사에서는 순서가 유지됩니다.
그러나 각 조각을 독립 문자열로 검증하거나 잘라 내는 작업은 완성된 코드 포인트 구분을 고려해야 합니다.
텍스트 파서가 필요한 단위를 제공하도록 더 높은 수준의 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해야 교착처럼 보이는 대기를 피할 수 있습니다.
PrintWriter의 autoFlush는 모든 write 호출 뒤에 작동하는 옵션이 아닙니다.
특정 println, printf, 형식 호출에서만 조건이 적용됩니다.
오류도 검사 예외 대신 내부 플래그로 보관할 수 있으므로 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("study-report-", ".txt");
try {
try (PrintStream output = new PrintStream(
Files.newOutputStream(report), false, StandardCharsets.UTF_8)) {
output.println("주제=문자 스트림");
output.printf("minutes=%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);
}
}
}주제=문자 스트림
minutes=45PrintStream은 쓰기 중 발생한 IOException을 대부분 호출자에게 직접 던지지 않고 내부 오류 상태로 기록합니다.
결과 보장이 필요한 파일에서는 checkError()를 확인하거나 예외를 직접 전달하는 Writer·OutputStream을 사용합니다.
autoFlush를 켜도 모든 write 뒤에 비워지는 것은 아니며 줄 출력과 일부 형식화 호출에서만 동작합니다.
프로세스가 소유한 System.out과 System.err는 라이브러리 코드에서 닫지 말고, 정말 필요한 메시지 접점에서만 flush합니다.
텍스트 입출력 선택 기준
| 요구 | API | 주의점 |
|---|---|---|
| 작은 전체 문서 | readString/writeString | 크기 상한 |
| 줄 단위 변환 | BufferedReader/Writer | 줄 끝 보존 여부 |
| 바이트 스트림 연결 | InputStreamReader/Writer | Charset 명시 |
| 지연 소비 | Files.줄 | 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);
}
}변환 함수는 Reader와 Writer를 인자로 받아 파일 경로와 분리되므로 메모리 문자열로 빠르게 확인할 수 있습니다.
운영 호출자는 UTF-8 브리지와 자원 소유권을 구성하고, 함수는 줄 단위 업무 규칙에만 집중합니다.
텍스트 입출력 규칙의 종료 기준
문자 스트림은 인코딩을 없애는 도구가 아니라 인코딩 구분을 한 객체에 맡기는 도구입니다.
Charset, 줄 끝, flush 시점, 전체 크기, 닫기 소유자를 명시하고 다국어와 보조 문자를 포함한 왕복을 확인하면 환경에 의존하지 않는 텍스트 처리가 됩니다.