Devin.KR
로그인

자바 입출력 - File Path 스트림과 버퍼 그리고 한글이 깨지는 세 지점 (자바 중급 15단원)

개발자 조회 1

이 단원에서 배우는 것

11단원에서 CatalogLoader 로 소장자료 CSV를 읽으면서 Files.newBufferedReader(csv, StandardCharsets.UTF_8) 를 썼다. 왜 FileReader 가 아니라 Files 인지, StandardCharsets.UTF_8 을 왜 명시했는지는 넘어갔다. 이번 단원이 그 답이고, 실무에서 한글이 깨지는 사고의 거의 전부가 이 안에 있다.

입출력은 API가 많아서 외울 것이 많아 보이지만 축은 셋뿐이다. 무엇을 가리키는가(Path) / 바이트인가 문자인가(Stream vs Reader) / 버퍼를 거치는가. 이 셋으로 정리하면 나머지는 조합이다. 도서관의 소장자료 목록 파일을 다룬다.

  • File 대신 Path/Files 를 쓰고 그 이유를 안다
  • 버퍼가 성능에 미치는 영향을 실측으로 이해한다
  • 한글이 깨지는 지점 세 곳을 각각 짚어낸다

왜 필요한가

메모리 안의 데이터는 프로그램이 끝나면 사라진다. 파일에 남겨야 다음 실행에서 쓸 수 있고, 다른 시스템에 넘길 수 있고, 배치가 읽어갈 수 있다. 실무에서 파일 입출력이 나오는 자리는 대개 정해져 있다.

  • 납품처나 상호대차 기관이 보내주는 목록 파일(CSV/엑셀)을 읽어 DB에 넣는다
  • 월간 대출 통계를 파일로 만들어 상급 기관에 제출한다
  • 설정 파일을 읽는다
  • 로그를 남긴다

그리고 이 자리에서 가장 자주 나는 사고가 두 가지다. 한글이 깨지는 것파일 핸들을 안 닫아 서버가 죽는 것. 후자는 11단원의 try-with-resources 로 해결했으니, 이번엔 전자를 확실히 잡는다.

문법과 예제

Path — 경로를 다루는 타입 (Java 7+)

java.io.File 은 Java 1.0부터 있던 클래스인데 문제가 많다. 삭제 실패 시 그냥 false 를 반환해 이유를 알 수 없고, 심볼릭 링크를 제대로 다루지 못하고, 메타데이터 조회가 빈약하다. Java 7의 java.nio.file.PathFiles 가 대체재이며 새 코드에서는 File 을 쓰지 않는다.

import java.nio.file.Path;

public class PathDemo {
    public static void main(String[] args) {
        Path rel = Path.of("data", "catalog.csv");        // OS별 구분자를 알아서 쓴다
        System.out.println(rel);                          // data/catalog.csv
        System.out.println("절대경로인가? " + rel.isAbsolute());   // false

        Path abs = Path.of("/tmp").resolve("catalog.csv");
        System.out.println(abs);                          // /tmp/catalog.csv
        System.out.println(abs.getFileName());            // catalog.csv
        System.out.println(abs.getParent());              // /tmp
        System.out.println(abs.toAbsolutePath().normalize());
    }
}

Path.of("data" + "/" + "catalog.csv") 처럼 문자열을 직접 이어붙이지 않는다. 윈도우와 리눅스의 구분자가 다르고, resolve() 는 앞부분이 이미 구분자로 끝나는지까지 알아서 처리한다.

Files — 파일 작업의 진입점

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;

public class FilesDemo {
    public static void main(String[] args) throws IOException {
        Path dir = Files.createTempDirectory("library");
        Path csv = dir.resolve("catalog.csv");

        // 작은 파일 — 통째로 읽고 쓴다
        Files.writeString(csv,
                "B-001,자바의 정석,컴퓨터\n" +
                "D-014,매트릭스,영상\n",
                StandardCharsets.UTF_8);

        System.out.println(Files.exists(csv) + " / " + Files.size(csv) + " bytes");

        List<String> lines = Files.readAllLines(csv, StandardCharsets.UTF_8);
        for (String line : lines) {
            String[] col = line.split(",", -1);
            System.out.println(col[1] + " (" + col[2] + ")");
        }

        Files.deleteIfExists(csv);
        Files.deleteIfExists(dir);
    }
}
true / 59 bytes
자바의 정석 (컴퓨터)
매트릭스 (영상)

split(",", -1) 의 두 번째 인자를 눈여겨본다. 이걸 생략하면 뒤쪽의 빈 문자열이 잘려나간다. 분류가 비어 있는 "B-001,자바의 정석,"split(",") 하면 배열 길이가 2가 되어 col[2] 에서 ArrayIndexOutOfBoundsException 이 난다. 실제 납품 파일에는 이런 행이 반드시 섞여 있다. CSV를 다룰 때는 항상 -1 을 준다.

readAllLines/readString 은 파일 전체를 메모리에 올린다. 수십 MB 이상이면 OutOfMemoryError 가 나므로 한 줄씩 읽어야 한다.

바이트 스트림과 문자 스트림

구분단위최상위 타입쓰는 대상
바이트 스트림1바이트InputStream / OutputStream표지 이미지, PDF, 압축파일 등 이진 데이터
문자 스트림1문자(char)Reader / Writer텍스트

결정적인 차이는 문자 스트림에만 문자셋 개념이 있다는 것이다. 바이트를 문자로 바꾸려면 "이 바이트들이 어떤 규칙으로 인코딩됐는지"를 알아야 하고, 그것이 문자셋이다. 텍스트를 InputStream 으로 읽고 new String(bytes) 하는 코드가 한글 깨짐의 출발점이다.

버퍼 — 있고 없고의 차이

버퍼가 없으면 read() 한 번마다 운영체제 호출이 일어난다. 7.3MB 파일을 복사하며 Java 17에서 재봤다.

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;

public class BufferBenchmark {
    public static void main(String[] args) throws IOException {
        Path dir = Files.createTempDirectory("io");
        Path src = dir.resolve("big.txt");
        try (BufferedWriter w = Files.newBufferedWriter(src, StandardCharsets.UTF_8)) {
            for (int i = 0; i < 200_000; i++) w.write("행 " + i + ",자바의 정석,컴퓨터\n");
        }
        System.out.println("파일 크기 " + Files.size(src) / 1024 + "KB");

        Path a = dir.resolve("a.txt");
        long t = System.nanoTime();
        try (InputStream in = Files.newInputStream(src);
             OutputStream out = Files.newOutputStream(a)) {
            int c;
            while ((c = in.read()) != -1) out.write(c);
        }
        System.out.println("버퍼 없이 1바이트씩 : " + (System.nanoTime() - t) / 1_000_000 + "ms");

        Path b = dir.resolve("b.txt");
        t = System.nanoTime();
        try (InputStream in = new BufferedInputStream(Files.newInputStream(src));
             OutputStream out = new BufferedOutputStream(Files.newOutputStream(b))) {
            int c;
            while ((c = in.read()) != -1) out.write(c);
        }
        System.out.println("버퍼 경유          : " + (System.nanoTime() - t) / 1_000_000 + "ms");
    }
}
파일 크기 7313KB
버퍼 없이 1바이트씩 : 15421ms
버퍼 경유          : 93ms

165배 차이다. 코드는 BufferedInputStream 으로 감싼 것 하나뿐인데 15초가 0.09초가 된다. 버퍼는 선택 사항이 아니다. Files.newBufferedReader(), Files.newBufferedWriter() 는 이름 그대로 이미 버퍼가 붙어 있다.

Writer 쪽에는 함정이 하나 더 있다. 버퍼에 쌓아뒀다가 한 번에 내보내므로 close()flush() 를 하지 않으면 마지막 내용이 파일에 안 써진다. try-with-resources 를 쓰면 close() 가 보장되므로 이 문제가 구조적으로 사라진다.

대용량 파일은 한 줄씩

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.stream.Stream;

public class LineByLine {
    public static void main(String[] args) throws IOException {
        Path src = Path.of("catalog.csv");

        // 방법 1: BufferedReader
        try (var br = Files.newBufferedReader(src, StandardCharsets.UTF_8)) {
            String line;
            while ((line = br.readLine()) != null) {
                if (line.isBlank()) continue;
                // 한 줄 처리
            }
        }

        // 방법 2: Files.lines — 반드시 try-with-resources 로 감싼다
        try (Stream<String> lines = Files.lines(src, StandardCharsets.UTF_8)) {
            long count = lines.filter(l -> !l.isBlank()).count();
            System.out.println("유효 행 수 " + count);
        }
    }
}

Files.lines() 는 파일을 연 채로 Stream 을 돌려준다. try-with-resources 없이 쓰면 파일 핸들이 계속 열려 있고, 배치가 파일 수만 개를 돌면 "too many open files"로 프로세스가 죽는다. StreamAutoCloseable 이다는 사실을 기억한다. 스트림 자체는 16단원에서 다룬다.

한글이 깨지는 지점

한글 깨짐은 원인이 세 군데 있고, 각각 고치는 방법이 다르다. 하나씩 본다.

지점 1 — 파일을 쓴 문자셋과 읽는 문자셋이 다르다

납품처가 보내주는 목록 파일이 MS949(윈도우 한국어 인코딩, CP949·euc-kr과 사실상 같은 계열)인데 UTF-8로 읽으면 이렇게 된다.

import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;

public class EncodingDemo {
    public static void main(String[] args) throws Exception {
        Path dir = Files.createTempDirectory("enc");
        Path legacy = dir.resolve("legacy.csv");

        // 납품처가 MS949 로 만든 파일이라고 가정
        Files.write(legacy, "자바의 정석,컴퓨터\n".getBytes(Charset.forName("MS949")));

        System.out.println("UTF-8 로 읽으면 : "
                + new String(Files.readAllBytes(legacy), StandardCharsets.UTF_8).trim());
        System.out.println("MS949 로 읽으면 : "
                + new String(Files.readAllBytes(legacy), Charset.forName("MS949")).trim());
    }
}
UTF-8 로 읽으면 : ?ڹ??? ????,??ǻ??
MS949 로 읽으면 : 자바의 정석,컴퓨터

파일 자체는 멀쩡하다. 해석 규칙이 틀린 것이다. 그래서 "깨진 문자열을 고치는 코드"를 짜려는 시도는 대체로 실패한다. UTF-8로 잘못 디코딩하는 순간 원래 바이트 중 매핑 불가능한 것들이 U+FFFD(치환 문자)로 뭉개져 정보가 영구히 사라지기 때문이다. 읽는 시점에 올바른 문자셋을 지정하는 것 말고는 답이 없다.

바이트 수도 다르다는 점을 알아두면 판단에 도움이 된다.

System.out.println("UTF-8  '가' : " + "가".getBytes(StandardCharsets.UTF_8).length + "바이트");
System.out.println("MS949 '가' : " + "가".getBytes(Charset.forName("MS949")).length + "바이트");
UTF-8  '가' : 3바이트
MS949 '가' : 2바이트

받은 파일을 헥사 에디터로 열어 한글 한 글자가 몇 바이트인지 보면 문자셋을 거의 확정할 수 있다.

지점 2 — 문자셋을 지정하지 않아 실행 환경에 좌우된다

여기가 Java 17에서 특히 조심해야 하는 지점이다.

// 문자셋을 안 쓴 코드 — 개발 PC 에서는 되고 서버에서 깨진다
new FileReader("catalog.csv")
new FileWriter("out.csv")
new String(bytes)
new InputStreamReader(inputStream)

이 API들은 문자셋을 생략하면 플랫폼 기본 문자셋을 쓴다. 그리고 기본 문자셋은 OS와 로케일 설정에 따라 바뀐다.

$ java Demo
Charset.defaultCharset() = UTF-8

$ LANG=C java Demo
Charset.defaultCharset() = US-ASCII

같은 JAR 파일인데 실행 환경의 LANG 하나로 기본 문자셋이 바뀐다. 개발자 맥북에서는 UTF-8, 도커 컨테이너 안에서는 POSIX 로케일이라 US-ASCII, 이 조합에서 "로컬에서는 되는데 배포하면 깨진다"가 나온다.

버전 주의: Java 18부터는 JEP 400에 의해 기본 문자셋이 플랫폼과 무관하게 UTF-8로 고정됐다. 하지만 Java 17은 그 이전이므로 여전히 플랫폼에 좌우된다. Java 17 프로젝트에서는 문자셋을 생략한 API를 쓰지 않는 것이 규칙이다.

// 항상 이렇게 쓴다
Files.newBufferedReader(path, StandardCharsets.UTF_8)
Files.newBufferedWriter(path, StandardCharsets.UTF_8)
Files.readString(path, StandardCharsets.UTF_8)
new String(bytes, StandardCharsets.UTF_8)
new InputStreamReader(in, StandardCharsets.UTF_8)

지점 3 — 소스 파일과 javac 인코딩이 다르다

파일을 읽기도 전에, 소스 코드 안의 문자열 리터럴 자체가 깨지는 경우다.

public class Literal {
    public static void main(String[] args) {
        String s = "자바의 정석";
        System.out.println("length=" + s.length());
        System.out.println("codePoint(0)=" + Integer.toHexString(s.codePointAt(0)));
    }
}
$ java Literal.java
length=6
codePoint(0)=c790

$ LANG=C java Literal.java
length=16
codePoint(0)=fffd

소스 파일은 UTF-8인데 javac 가 기본 문자셋(US-ASCII)으로 읽어서, 한글 한 글자의 UTF-8 3바이트가 각각 U+FFFD 치환 문자 3개가 됐다. 6자여야 할 문자열이 16자다. 실행 환경이 아니라 빌드 환경의 문제라 앞의 두 지점과 원인이 완전히 다르고, 파일 입출력을 아무리 고쳐도 해결되지 않는다.

$ javac -encoding UTF-8 Literal.java     # 항상 명시한다

Maven이라면 <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>, Gradle이라면 tasks.withType(JavaCompile) { options.encoding = 'UTF-8' } 로 고정한다. 빌드 스크립트에 이 설정이 없는 프로젝트는 CI 서버 환경이 바뀌는 순간 터진다.

실무에서 자주 틀리는 것

1. 문자열 길이와 바이트 수를 혼동한다

String s = "가나다";
System.out.println(s.length());                                  // 3
System.out.println(s.getBytes(StandardCharsets.UTF_8).length);   // 9

DB의 자료 제목 컬럼이 VARCHAR(10)(바이트 기준)인데 s.length() <= 10 으로 검증하면 한글 4글자에서 이미 12바이트가 되어 저장 시점에 잘리거나 에러가 난다. 바이트 제한을 검사할 때는 getBytes(문자셋).length 를 쓴다.

더 나아가 length()문자 수도 아니다. char 개수다.

String emoji = "👍";
System.out.println(emoji.length());                          // 2
System.out.println(emoji.codePointCount(0, emoji.length())); // 1

이모지 같은 보충 문자는 char 두 개(서로게이트 쌍)로 표현된다. substring() 으로 이 쌍의 중간을 자르면 깨진 문자가 생긴다. 회원이 남긴 서평을 글자 수로 자르는 기능이 있다면 codePointCount()offsetByCodePoints() 를 쓴다.

2. 엑셀에서 CSV를 열면 한글이 깨진다고 UTF-8을 포기한다

UTF-8로 저장한 대출 통계 CSV를 윈도우 엑셀에서 더블클릭하면 깨진다. 엑셀이 BOM 없는 UTF-8 파일을 MS949로 추정하기 때문이다. 이때 파일 전체를 MS949로 만들면 일부 한자나 특수문자를 표현할 수 없어 다른 문제가 생긴다. 해법은 UTF-8 BOM(EF BB BF) 3바이트를 파일 맨 앞에 붙이는 것이다.

try (OutputStream out = Files.newOutputStream(csv)) {
    out.write(new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF});   // UTF-8 BOM
    out.write("등록번호,제목,분류\n".getBytes(StandardCharsets.UTF_8));
    out.write("B-001,자바의 정석,컴퓨터\n".getBytes(StandardCharsets.UTF_8));
}

반대로 BOM이 붙은 파일을 읽을 때도 주의해야 한다. 자바의 StandardCharsets.UTF_8 디코더는 BOM을 제거해주지 않아서 첫 줄 맨 앞에 눈에 보이지 않는 U+FEFF 문자가 남는다. 아래처럼 검증이 이유 없이 실패한다.

String first = Files.readAllLines(bom, StandardCharsets.UTF_8).get(0);
String[] col = first.split(",", -1);
System.out.println("B-001".equals(col[0]));                 // false
System.out.println(col[0].length());                        // 6  (5여야 하는데)
System.out.println(Integer.toHexString(first.charAt(0)));   // feff

첫 줄에서만 걷어내면 된다.

if (!line.isEmpty() && line.charAt(0) == '\uFEFF') {
    line = line.substring(1);
}

3. Files.lines() 를 try-with-resources 없이 쓴다

// 파일 핸들이 닫히지 않는다
long count = Files.lines(path, StandardCharsets.UTF_8).count();

한 번은 문제가 없어 보인다. 배치가 파일 수천 개를 처리하면 열린 핸들이 쌓여 Too many open files 로 죽는다. 더 나쁜 것은 개발 중에는 절대 재현되지 않는다는 점이다. Files.lines, Files.walk, Files.list, Files.newDirectoryStream 은 전부 닫아야 한다.

4. 파일 삭제 결과를 확인하지 않는다

file.delete();                    // File — 실패해도 false 만 반환하고 조용히 넘어간다
Files.delete(path);               // 실패하면 이유가 담긴 예외를 던진다
Files.deleteIfExists(path);       // 없으면 false, 다른 실패는 예외

File.delete() 는 권한이 없어서 실패했는지, 파일이 열려 있어서 실패했는지, 애초에 없었는지를 구분해주지 않는다. 업로드된 임시 파일이 안 지워지고 쌓여 디스크가 가득 차는 장애의 흔한 원인이다.

스스로 확인하기

  1. 납품처에서 받은 catalog.csv 를 읽었더니 한글이 ?ڹ??? 처럼 나온다. 확인해야 할 것을 순서대로 세 가지 적어라.
  2. 다음 코드의 문제를 두 가지 지적하고 고쳐라.
    BufferedWriter w = new BufferedWriter(new FileWriter("stat.csv"));
    for (LibraryItem item : items) {
        w.write(item.isbn() + "," + item.title() + "\n");
    }
  3. "안녕👋" 에 대해 length(), codePointCount(0, length()), getBytes(StandardCharsets.UTF_8).length 는 각각 얼마인가?

정답

  1. (1) 파일이 실제로 어떤 문자셋인지 확인한다. file -I catalog.csv 나 헥사 에디터로 한글 한 글자가 2바이트인지 3바이트인지 본다. 2바이트면 MS949 계열이다. (2) 읽는 코드가 문자셋을 명시했는지 본다. new FileReader(...)new String(bytes) 를 쓰고 있으면 플랫폼 기본값에 좌우된다. (3) 콘솔이나 로그 출력 자체의 인코딩을 확인한다. 파일은 제대로 읽었는데 출력 단계에서만 깨지는 경우도 있으므로, s.length()codePointAt(0) 을 찍어 문자열이 정말 깨졌는지 구분한다.
  2. 첫째, FileWriter 에 문자셋을 지정하지 않아 Java 17에서는 실행 환경의 기본 문자셋이 쓰인다. 둘째, try-with-resources 가 없어 close() 가 보장되지 않고, 버퍼에 남은 마지막 내용이 파일에 기록되지 않을 수 있다.
    try (BufferedWriter w = Files.newBufferedWriter(
            Path.of("stat.csv"), StandardCharsets.UTF_8)) {
        for (LibraryItem item : items) {
            w.write(item.isbn() + "," + item.title());
            w.newLine();
        }
    }
  3. length() = 4 (한글 2 + 이모지가 서로게이트 쌍 2), codePointCount = 3, UTF-8 바이트 수 = 10 (한글 3바이트 × 2 + 이모지 4바이트). 사람이 세는 글자 수는 3인데 자바의 length() 는 4를 준다는 점이 핵심이다.

공식 문서: java.nio.file.Files (Java 17 API), JEP 400: UTF-8 by Default