본문 바로가기
빌드노트 검색

빌드노트

UTF-8로 읽었는데 첫 번째 열 이름만 일치하지 않는다

Files.readString으로 읽은 첫 헤더에 UTF-8 BOM이 U+FEFF로 남는 경우를 바이트·코드 포인트·길이·equals로 확인한다. trim·strip의 결과와 입력 시작에서만 표식 하나를 제거할 조건, 중간 U+FEFF를 보존할 이유를 실제 파일 비교로 설명한다.

Java의 Files.readString으로 읽은 첫 열이 화면에서는 name처럼 보이는데 equals("name")가 false라면, 파일 시작의 UTF-8 BOM을 확인한다. 이번 재현에서는 시작 바이트 EF BB BF가 문자열의 U+FEFF 하나로 남아 첫 헤더가 \uFEFFname이 됐다. UTF-8로 디코딩하는 것과 시작 표식을 제거하는 것은 별도 처리였다. 입력 규약에서 시작 BOM을 허용한다면, 필드를 나누기 전에 입력 전체의 맨 앞에서만 하나를 제거한다.

비교에 실패한 파일의 바이트와 문자를 함께 본다

JDK 17의 bin이 PATH에 잡힌 터미널에서 jshell -q를 실행한다. 다음 코드는 파일 앞의 최대 16바이트와 디코딩한 첫 코드 포인트, 첫 열의 길이·비교 결과·코드 포인트 목록을 보여 준다. headers.csv를 읽을 수 있는 작은 파일의 경로로 바꾸고, 예상 열 이름인 name도 실제 값으로 바꾼다. 첫 줄은 따옴표 없이 쉼표로 나눈 단순 헤더라는 전제다.

import java.nio.file.*;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;

Path path = Path.of("headers.csv");
try (var in = Files.newInputStream(path)) {
    System.out.println("hex=" + HexFormat.ofDelimiter(" ").withUpperCase()
        .formatHex(in.readNBytes(16)));
}
String text = Files.readString(path, StandardCharsets.UTF_8);
String header = text.lines().findFirst().orElse("").split(",", -1)[0];
System.out.printf("first=%s headerLength=%d equalsName=%b%n",
    text.isEmpty() ? "EMPTY" : "U+%04X".formatted(text.codePointAt(0)),
    header.length(), header.equals("name"));
System.out.println("headerCodePoints=" + header.codePoints()
    .mapToObj(cp -> "U+%04X".formatted(cp)).toList());

Windows 11의 OpenJDK 17.0.2에서 \uFEFFname,age\n을 UTF-8로 저장한 합성 파일에 실행한 출력이다. 여기서 \uFEFF와 \n은 보이지 않는 문자와 LF를 설명하기 위한 표기이며, 파일 안에 역슬래시와 영문자가 들어 있다는 뜻은 아니다. JShell 프롬프트는 생략했다.

hex=EF BB BF 6E 61 6D 65 2C 61 67 65 0A
first=U+FEFF headerLength=5 equalsName=false
headerCodePoints=[U+FEFF, U+006E, U+0061, U+006D, U+0065]

첫 세 바이트와 첫 코드 포인트가 같은 표식을 가리킨다. name의 네 문자 앞에 U+FEFF가 있어 길이가 5였고 비교가 실패했다. 두 번째 열 age는 그대로 일치했다. 화면에 찍힌 단어만 비교하면 이 추가 문자를 놓칠 수 있다.

Unicode의 BOM FAQ (새 탭에서 열림)는 시작 U+FEFF의 UTF-8 바이트를 EF BB BF로 설명한다. UTF-8에는 바이트 순서를 고르는 문제가 없으며, 이때 BOM은 UTF-8이라는 표식으로 쓰인다. 바이트가 있다는 사실과 그 표식을 수신 코드가 소비한다는 사실은 구분해야 한다.

같은 UTF-8로 읽어도 입력에 따라 결과가 달랐다

새로 만든 세 파일은 모두 끝에 LF 하나가 있고, 같은 StandardCharsets.UTF_8로 읽었다. 길이는 전체 파일 크기가 아니라 첫 헤더의 String.length()다.

  • name,age\n: 전체 바이트는 6E 61 6D 65 2C 61 67 65 0A. 첫 코드 포인트는 U+006E, 헤더 길이는 4, equals("name")는 true였다.
  • \uFEFFname,age\n: 전체 바이트는 EF BB BF 6E 61 6D 65 2C 61 67 65 0A. 첫 코드 포인트는 U+FEFF, 헤더 길이는 5, 비교는 false였다.
  • na\uFEFFme,age\n: 전체 바이트는 6E 61 EF BB BF 6D 65 2C 61 67 65 0A. 첫 코드 포인트는 U+006E지만 헤더 길이는 5, 비교는 false였다. 전체 목록에서 세 번째 코드 포인트가 U+FEFF였다.

마지막 파일처럼 첫 문자만 정상이라고 해서 문자열 전체가 같지는 않다. 파일 중간에 있는 U+FEFF는 시작 BOM과 같은 위치의 문제가 아니다. 첫 바이트가 EF BB BF가 아니거나 첫 코드 포인트가 U+FEFF가 아니라면, 시작 BOM 제거부터 추가하지 말고 비교 직전 헤더의 코드 포인트 목록을 확인한다. 위 목록 출력은 실제 파서에서 꺼낸 첫 열 값에도 적용할 수 있다.

Java SE 21의 Files.readString 문서 (새 탭에서 열림)에서 문자셋 없는 호출은 readString(path, StandardCharsets.UTF_8)과 같다. StandardCharsets 문서 (새 탭에서 열림)의 UTF_8은 이 디코딩에 사용할 문자셋 상수다. 실제 OpenJDK 17.0.2 실험에서도 두 호출이 같은 문자열을 반환했다. 이미 Files.readString(path)을 쓰고 있었다면 UTF-8 인자를 추가하는 것만으로 이번 첫 헤더는 바뀌지 않았다.

trim과 strip으로는 사라지지 않았다

BOM이 붙은 헤더에 header.trim().equals("name")와 header.strip().equals("name")를 각각 실행했지만 둘 다 false였다. 같은 실행에서 Character.isWhitespace(0xFEFF)도 false였다.

String.trim 문서 (새 탭에서 열림)가 정의하는 제거 범위는 양끝의 U+0020 이하 문자다. String.strip 문서 (새 탭에서 열림)는 Character.isWhitespace 기준을 사용한다. 보이지 않는 문자라고 해서 두 메서드의 공백 처리 대상이 되는 것은 아니다.

시작 표식을 허용하는 입력에서 한 번만 제거한다

입력 규약이 UTF-8 파일의 시작 BOM을 허용하고 이를 데이터로 쓰지 않는다면, 앞에서 읽은 text에 다음 처리를 적용한다. 이 코드는 입력 맨 앞의 U+FEFF 하나만 제거한 뒤 첫 헤더의 비교 결과를 확인한다. 실제 CSV 파서를 쓴다면 정리한 입력을 파서에 넘기는 지점에 적용한다.

String cleaned = text.startsWith("\uFEFF") ? text.substring(1) : text;
String cleanedHeader = cleaned.lines().findFirst().orElse("").split(",", -1)[0];
System.out.println("cleanedEqualsName=" + cleanedHeader.equals("name"));

위의 시작 BOM 파일에서는 cleanedEqualsName=true가 나왔고 헤더 길이가 5에서 4로 줄었다. BOM 없는 파일은 그대로였으며, na\uFEFFme는 바뀌지 않아 비교가 계속 false였다. 시작과 중간에 U+FEFF를 모두 넣은 \uFEFFna\uFEFFme,age\n도 첫 표식만 빠지고 중간 문자는 남았다. 빈 파일은 빈 문자열을 유지했고, BOM만 든 파일은 제거 뒤 빈 문자열이 됐다. 표식을 처리했다고 유효한 헤더까지 생기는 것은 아니므로 필수 헤더 검사는 이어서 해야 한다.

제거 위치는 각 줄이나 각 필드의 시작이 아니라 원래 입력의 시작이다. 전역 replace로 U+FEFF를 지우면 중간 내용까지 바뀐다. Unicode FAQ도 별도의 프로토콜 규칙이 없는 중간 U+FEFF를 내용의 일부로 다룬다. 자신의 형식이 이 문자를 금지한다면 오류로 거부할 수 있지만, 시작 BOM 처리에 섞어 조용히 삭제할 이유는 없다. 시작 BOM 자체를 금지하는 형식이라면 이 코드로 받아들이기보다 입력 오류로 판정한다.

이번 결과는 OpenJDK 17.0.2에서 작은 합성 파일을 직접 읽은 관찰이다. 실제 업로드 경로와 CSV 라이브러리, 다른 JDK의 실행 결과는 확인하지 않았다. 본문의 쉼표 분할은 단순 헤더를 관찰하기 위한 것이며 따옴표·필드 안 쉼표·여러 줄 필드가 있는 CSV를 처리하지 않는다. 이미 입력 계층이 BOM을 소비한다면 그 뒤 문자열에는 표식이 없을 수 있으므로, 제거 코드를 더하기 전에 실제 비교 직전의 값을 확인한다. JShell 확인을 마치면 /exit로 종료한다.

← 전체 글 보기