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

빌드노트

같은 UTF-8인데 new String은 대체하고 Files.readString은 실패한다

같은 UTF-8 바이트를 String 생성자와 Files.readString으로 읽었을 때 대체 문자와 예외로 갈리는 이유를 비교한다. REPORT와 REPLACE를 직접 적용하고, 손실을 허용할 조건과 대체 뒤 원본을 복구할 수 없는 이유를 확인한다.

Java에서 new String(Files.readAllBytes(path), StandardCharsets.UTF_8)은 문자열을 반환하는데 Files.readString(path, StandardCharsets.UTF_8)은 MalformedInputException을 던질 수 있다. 같은 파일에 같은 UTF-8을 지정해도 잘못된 바이트를 만났을 때의 처리 정책이 다르기 때문이다. 문자열이 나왔다는 사실만으로 입력이 정상 UTF-8이었다고 판단하면 안 된다.

여기서 비교하는 생성자는 문자셋 이름 문자열이 아니라 Charset 객체를 받는 String(byte[], Charset)이다. Java SE 21의 String 문서 (새 탭에서 열림)는 이 생성자가 잘못된 입력을 해당 문자셋의 기본 대체 문자열로 바꾼다고 명시한다. 반면 Files.readString 문서 (새 탭에서 열림)는 잘못되거나 매핑할 수 없는 바이트열을 읽으면 IOException을 던지는 계약이다. 아래 실험에서 관찰한 구체적인 예외가 그 하위 유형인 MalformedInputException이었다.

같은 파일을 어떤 코드로 읽는지 확인한다

먼저 UTF-8로 받기로 한 작은 파일을 두 호출로 비교한다. JDK 21의 bin이 PATH에 있는 터미널에서 jshell -q를 실행한 뒤 다음 코드를 붙여 넣는다. input.txt는 실제 확인할 파일의 경로로 바꾼다. Windows 경로도 C:/data/input.txt처럼 쓸 수 있다. 비교 중 파일 내용이 바뀌지 않는 조건이며, 코드는 파일을 수정하지 않는다.

이 코드는 원본 바이트, 생성자가 반환한 문자열의 코드 포인트, readString의 성공 또는 디코딩 예외를 함께 보여 준다.

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

Path path = Path.of("input.txt");
byte[] bytes = Files.readAllBytes(path);
System.out.println("hex=" + HexFormat.ofDelimiter(" ").withUpperCase().formatHex(bytes));
String replaced = new String(bytes, StandardCharsets.UTF_8);
System.out.println("codePoints=" + replaced.codePoints()
    .mapToObj(cp -> "U+%04X".formatted(cp)).toList());
try {
    System.out.println("readString=" + Files.readString(path, StandardCharsets.UTF_8));
} catch (MalformedInputException ex) {
    System.out.println("readString=" + ex.getClass().getSimpleName());
}

Windows 11, OpenJDK 21.0.1에서 합성 바이트 41 C3 28 42를 새 임시 파일에 저장하고 실행한 출력이다. JShell의 변수 표시와 프롬프트는 생략했다.

hex=41 C3 28 42
codePoints=[U+0041, U+FFFD, U+0028, U+0042]
readString=MalformedInputException

생성자의 결과를 이스케이프 표기로 적으면 A\uFFFD(B였다. 원래 없던 대체 문자 U+FFFD가 들어간 것이며, 원본 바이트를 읽어 정상 문자를 찾아낸 결과가 아니다. readAllBytes가 바이트를 가져오는 데 성공한 것과 그 바이트가 UTF-8로 유효한 것은 서로 다른 판단이다.

두 호출이 모두 성공하면 해당 입력에서는 이 오류 정책 차이가 드러나지 않은 것이다. 파일을 찾지 못하거나 읽기 권한이 없어 실패했다면 우선 경로와 접근 조건을 해결한다. 모든 IOException을 인코딩 오류로 취급해서는 안 된다. 또한 Files.readString(path)는 이미 UTF-8을 쓰므로, 인자를 생략했던 코드에 UTF_8을 덧붙이는 것만으로 잘못된 바이트가 고쳐지지는 않는다.

정상 입력과 잘못된 입력을 나란히 읽었다

별도 실험에서는 정상 UTF-8과 비정상 바이트를 각각 새 파일에 저장했다. 각 파일에 기본 readString, UTF-8을 지정한 readString, 위 String 생성자, 오류 정책을 명시한 두 디코더를 적용했다. 실제 결과는 다음과 같았다.

  • 41 EA B0 80 42는 A가B의 UTF-8이다. 다섯 호출이 모두 같은 문자열을 반환했다.
  • 41 C3 28 42는 잘못된 UTF-8이다. 두 readString과 REPORT 디코더는 MalformedInputException을 던졌고, 생성자와 REPLACE 디코더는 A\uFFFD(B를 반환했다.
  • 끝이 잘린 41 E2 82에서도 두 readString과 REPORT는 예외를 던졌다. 생성자와 REPLACE는 A\uFFFD를 반환했다.

여기서 \uFFFD는 실제 대체 문자 하나를 보이게 적은 표기다. 역슬래시부터 시작하는 여섯 글자가 결과에 들어 있다는 뜻은 아니다. 이 비교에서는 문자셋은 고정했고 입력과 오류 처리 정책만 바꿨다. UTF-8 지정은 해석 규칙을 정하지만, 해석에 실패한 부분을 버릴지 거부할지까지 같게 만들지는 않는다.

손실을 허용할 때만 REPLACE를 선택한다

입력이 유효한 UTF-8이어야 하는 가져오기 작업이라면, 예외를 없애려고 readString을 new String으로 바꾸지 않는다. 파일 읽기에서는 Files.readString(path, StandardCharsets.UTF_8)을 유지하고 실패를 입력 오류로 처리할 수 있다. 이미 바이트 배열을 받은 코드에서는 디코더에 오류 정책을 명시한다.

앞의 JShell에서 다음 코드는 같은 bytes를 엄격하게 디코딩한다. 일반 Java 메서드에 옮길 때는 decode가 던질 수 있는 CharacterCodingException을 처리하거나 호출자에게 전달해야 한다.

CodingErrorAction action = CodingErrorAction.REPORT;
System.out.println(StandardCharsets.UTF_8.newDecoder()
    .onMalformedInput(action)
    .onUnmappableCharacter(action)
    .decode(ByteBuffer.wrap(bytes)).toString());

41 C3 28 42에 실행하면 REPORT에서는 MalformedInputException이 발생해 문자열을 출력하지 못한다. 읽을 수 있는 부분만 보여 주는 미리보기처럼 손실을 허용하는 경우에는 첫 줄의 REPORT를 REPLACE로 바꾸고 나머지 코드를 다시 실행한다. 같은 입력에서 이번에는 A와 (B 사이에 U+FFFD 하나가 있는 문자열이 출력됐다. REPLACE는 입력을 복구하는 기능이 아니라 오류 구간을 대체하면서 계속 읽는 선택이다.

CharsetDecoder 문서 (새 탭에서 열림)는 새 디코더의 두 오류 정책 기본값을 REPORT, 기본 대체 문자열을 U+FFFD로 설명한다. decode(ByteBuffer)는 REPORT 상태에서 잘못된 입력을 만나면 예외를 던진다. 위 코드에서 두 설정을 함께 적은 것은 정책을 명시하기 위해서다. 이번 UTF-8 예제에서 재현한 오류는 onMalformedInput의 대상이며, 별도의 매핑 불가 오류를 재현한 것은 아니다.

손실 여부를 나중에 알아야 한다면 대체하기 전 바이트를 보존하고 엄격한 디코딩 결과도 따로 기록한다. 추가 비교에서 41 FF 28 42와 41 C3 28 42는 서로 다른 잘못된 입력인데도 둘 다 A\uFFFD(B가 됐다. 이 문자열을 UTF-8로 다시 인코딩하면 41 EF BF BD 28 42가 되어 원래 바이트와 달랐다. 대체된 문자열만 남기면 어느 입력이었는지 되돌릴 수 없다.

반대로 41 EF BF BD 28 42는 처음부터 U+FFFD를 포함한 유효한 UTF-8이어서 REPORT도 성공했다. 따라서 결과 문자열에서 U+FFFD를 찾는 것만으로 이번 디코딩에서 손실이 발생했는지 판정할 수도 없다. 오류를 구별해야 하는 지점은 바이트를 문자열로 바꾸기 전이다.

이번 관찰은 OpenJDK 21.0.1에서 작은 합성 파일 다섯 개를 읽은 결과다. 실제 업로드 경로, 다른 문자셋, 다른 JDK 구현, 대용량 스트리밍은 실행하지 않았다. UTF-8 검사에 실패했다는 사실만으로 원본이 어느 다른 문자셋인지 알 수는 없으므로, 예외를 피하려고 다른 문자셋을 임의로 골라 저장하지 않는다. 발신 측의 인코딩 약속과 원본 바이트를 먼저 확인한다. JShell은 /exit로 종료한다.

← 전체 글 보기