Charset과 인코딩 오류 처리
Charset 객체를 얻고 비교하는 방법과, 잘못된 인코딩으로 읽어 깨진 글자와 대체 문자로 바뀌어 사라진 글자의 차이를 익히고, CharsetEncoder·CharsetDecoder로 변환 오류를 예외로 받는 방법을 배웁니다.
앞 글에서 같은 글자도 인코딩마다 다른 바이트가 되고, 인코딩을 잘못 고르면 글자가 깨지거나 ?로 바뀐다는 것을 보았습니다. Java에서 이 인코딩 규칙을 나타내는 것이 java.nio.charset.Charset 클래스입니다. getBytes(charset)와 new String(bytes, charset)에 넘기던 바로 그 객체로, 문자열과 바이트열을 서로 바꾸는 규칙 하나를 담고 있습니다.
인코딩 문제는 크게 두 가지로 나뉩니다. 하나는 바이트를 엉뚱한 인코딩으로 읽어 글자가 깨지는 경우이고, 다른 하나는 대상 인코딩이 표현하지 못하는 글자가 대체 문자로 바뀌어 사라지는 경우입니다. 겉으로는 둘 다 '글자가 깨졌다'로 보이지만, 앞의 경우는 원문을 되찾을 수 있을 때가 있고 뒤의 경우는 되찾을 수 없습니다. 이 글에서는 Charset을 얻고 비교하는 방법부터 익힌 뒤 두 경우를 구별하고, 변환 오류를 조용히 넘기지 않고 예외로 받는 방법을 배웁니다.
Charset 객체 얻기: StandardCharsets와 Charset.forName
Charset을 얻는 가장 쉬운 방법은 StandardCharsets의 상수를 쓰는 것입니다. StandardCharsets.UTF_8, US_ASCII, ISO_8859_1, UTF_16BE, UTF_16LE, UTF_16 여섯 개는 모든 Java 구현이 반드시 지원해야 하는 표준 문자 집합이라서, 어느 JVM에서 실행해도 지원하지 않는다는 오류가 날 일이 없습니다. 이름을 문자열로 적지 않으니 오타가 날 일도 없습니다. JDK 22부터는 UTF-32, UTF-32BE, UTF-32LE도 반드시 지원해야 하는 표준 문자 집합에 들어가면서 UTF_32, UTF_32BE, UTF_32LE 상수가 추가되었습니다.
MS949처럼 StandardCharsets에 없는 문자 집합은 Charset.forName에 이름을 넘겨 얻습니다. 설정 파일이나 사용자 입력에서 인코딩 이름을 받을 때도 이 메서드를 씁니다. 다음 프로그램은 MS949의 Charset을 얻어 정식 이름과 별칭을 출력하고, 여러 형태의 이름을 forName에 넘겨 봅니다.
import java.nio.charset.Charset;
import java.nio.charset.IllegalCharsetNameException;
import java.nio.charset.StandardCharsets;
import java.nio.charset.UnsupportedCharsetException;
import java.util.List;
public final class CharsetCatalog {
public static void main(String[] args) {
Charset utf8 = StandardCharsets.UTF_8;
Charset korean = Charset.forName("MS949");
System.out.println("utf8=" + utf8.name());
System.out.println("korean=" + korean.name());
System.out.println("aliases=" + korean.aliases().stream().sorted().toList());
System.out.println("same=" + korean.equals(Charset.forName("windows-949")));
for (String configured : List.of("utf8", "not-a-charset", "UTF 8")) {
try {
System.out.println(configured + " -> " + Charset.forName(configured).name());
} catch (UnsupportedCharsetException e) {
System.out.println(configured + " -> 지원하지 않는 이름");
} catch (IllegalCharsetNameException e) {
System.out.println(configured + " -> 형식이 틀린 이름");
}
}
}
}앞의 네 줄은 두 Charset의 이름과 별칭, 그리고 다른 이름으로 찾은 객체와의 비교 결과입니다. 뒤의 세 줄은 설정 값처럼 받은 세 이름을 forName에 넘겨 본 결과로, 찾지 못한 이름은 어떤 예외가 났는지에 따라 메시지가 달라집니다.
utf8=UTF-8
korean=x-windows-949
aliases=[ms949, ms_949, windows-949, windows949]
same=true
utf8 -> UTF-8
not-a-charset -> 지원하지 않는 이름
UTF 8 -> 형식이 틀린 이름forName("MS949")로 얻은 객체의 name()은 x-windows-949입니다. 하나의 문자 집합에는 정식 이름(canonical name) 하나와 여러 별칭(alias)이 있는데, forName은 어느 이름으로 찾든 같은 문자 집합을 돌려주고 name()은 언제나 정식 이름을 돌려줍니다. 그래서 windows-949로 찾은 객체와 비교한 same도 true입니다. 이름은 대소문자를 구별하지 않으므로 utf8도 별칭 UTF8과 같은 이름으로 취급되어 UTF-8로 찾아집니다. 설정에 적힌 인코딩 이름을 비교해야 한다면 "MS949".equals(name)처럼 문자열끼리 비교하지 말고, Charset 객체로 바꾼 뒤 비교해야 이런 표기 차이에 흔들리지 않습니다.
이름이 잘못되면 forName은 두 가지 예외 가운데 하나를 던집니다. not-a-charset처럼 이름의 형식은 맞지만 이 JVM이 지원하지 않으면 UnsupportedCharsetException이고, UTF 8처럼 이름에 쓸 수 없는 문자(여기서는 공백)가 들어 있으면 IllegalCharsetNameException입니다. 문자 집합 이름에는 영문자와 숫자, -, +, :, _, .만 쓸 수 있고 첫 글자는 영문자나 숫자여야 합니다. 두 예외는 모두 IllegalArgumentException의 하위 클래스인 비검사 예외라서 컴파일러가 처리를 강요하지 않습니다. 그러니 외부에서 받은 이름이라면 직접 잡아서 '설정이 잘못되었다'는 오류로 바꿔 알려야 합니다.
지원 여부만 알고 싶다면 Charset.isSupported(name)를 쓸 수 있습니다. 다만 이 메서드는 지원하지 않는 이름에는 false를 돌려주지만, 형식이 틀린 이름에는 false 대신 IllegalCharsetNameException을 던집니다. JDK 18부터는 Charset.forName(name, fallback)도 있어서, 이름이 틀렸거나 지원되지 않을 때 예외 대신 두 번째 인자로 준 Charset을 돌려받을 수 있습니다. 편리하지만 설정 실수를 조용히 덮을 수 있으므로, 대체 값을 썼다는 사실을 로그로 남기는 식으로 실수가 드러나게 해 두는 편이 좋습니다.
이렇게 이름을 Charset으로 바꾸는 일은 프로그램을 시작할 때 한 번 해 두는 것이 좋습니다. 설정을 읽는 시점에 바로 검증하면 잘못된 이름이 첫 파일을 처리할 때가 아니라 시작하자마자 드러나고, 이후 코드는 문자열 대신 검증된 Charset 객체만 주고받으면 됩니다.
지금 JVM에서 쓸 수 있는 Charset 목록
Charset.availableCharsets()는 현재 JVM이 지원하는 모든 문자 집합을 정식 이름과 Charset 객체의 SortedMap으로 돌려줍니다. 다음 프로그램은 UTF-8의 별칭을 출력한 뒤, 이름이 UTF-로 시작하거나 949가 들어간 항목만 골라 출력합니다.
import java.nio.charset.Charset;
import java.util.SortedMap;
public final class AvailableCharsetReport {
public static void main(String[] args) {
SortedMap<String, Charset> available = Charset.availableCharsets();
Charset utf8 = available.get("UTF-8");
if (utf8 == null) throw new IllegalStateException("UTF-8 must be available");
System.out.println("UTF-8 aliases=" + utf8.aliases().stream().sorted().toList());
available.forEach((name, charset) -> {
String upper = name.toUpperCase();
if (upper.startsWith("UTF-") || upper.contains("949")) {
System.out.println(name + " -> " + charset.displayName());
}
});
}
}이 글의 예제를 실행한 JDK 21에서는 다음과 같이 출력되었습니다. 첫 줄 뒤의 목록은 정식 이름의 알파벳순입니다. availableCharsets()가 돌려주는 맵이 정렬된 맵이라서 forEach도 그 순서로 돕니다.
UTF-8 aliases=[UTF8, unicode-1-1-utf-8]
UTF-16 -> UTF-16
UTF-16BE -> UTF-16BE
UTF-16LE -> UTF-16LE
UTF-32 -> UTF-32
UTF-32BE -> UTF-32BE
UTF-32LE -> UTF-32LE
UTF-8 -> UTF-8
x-IBM949 -> x-IBM949
x-IBM949C -> x-IBM949C
x-windows-949 -> x-windows-949이 JDK에는 문자 집합이 모두 173개 있었고, 그 가운데 조건에 맞는 것만 출력되었습니다. x-windows-949 말고도 IBM이 정의한 949 계열 문자 집합이 따로 있다는 것을 볼 수 있습니다. 목록과 별칭은 JDK 배포판과 버전에 따라 달라질 수 있으므로, 이 출력이나 개수를 어느 환경에서나 같은 값으로 가정해서는 안 됩니다. 돌려받은 맵은 수정할 수 없고 키를 찾을 때 대소문자를 구별하지 않으므로, available.get("utf-8")로도 UTF-8을 찾을 수 있습니다.
이 목록은 관리 화면이나 진단 명령에서 '이 서버에서 실제로 쓸 수 있는 인코딩'을 보여 줄 때 쓸모가 있습니다. 하지만 목록에 있다고 해서 외부 입력에 아무 인코딩이나 허용할 이유는 없습니다. 외부 입력에는 UTF-8이나 꼭 필요한 MS949처럼 허용할 인코딩을 따로 정해 두고, 이름 하나의 지원 여부를 확인할 때는 전체 목록을 만들 필요 없이 forName이나 isSupported를 씁니다.
잘못 읽은 글자와 대체되어 사라진 글자
먼저 잘못된 인코딩으로 읽는 경우를 보겠습니다. 다음 프로그램은 café 빵을 UTF-8로 인코딩한 바이트를 ISO-8859-1로 읽고(wrong), 그 문자열을 다시 UTF-8로 인코딩합니다(changed). 앞 글에서 보았듯 ISO-8859-1은 바이트 하나를 글자 하나로 읽으므로, 어떤 바이트를 넣어도 오류 없이 문자열이 만들어집니다.
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
public final class MismatchedRoundTrip {
public static void main(String[] args) {
String source = "café 빵";
byte[] utf8 = source.getBytes(StandardCharsets.UTF_8);
String wrong = new String(utf8, StandardCharsets.ISO_8859_1);
byte[] changed = wrong.getBytes(StandardCharsets.UTF_8);
System.out.println("source=" + source);
System.out.println("wrong=" + wrong);
System.out.println("before=" + HexFormat.of().formatHex(utf8));
System.out.println("after=" + HexFormat.of().formatHex(changed));
}
}wrong 줄에서 글자가 어떻게 바뀌었는지와, before와 after 두 바이트열의 길이가 같은지를 보세요. 이 프로그램도 예외 없이 끝납니다.
source=café 빵
wrong=café 빵
before=636166c3a920ebb9b5
after=636166c383c2a920c3abc2b9c2b5before는 원문의 UTF-8 바이트 9개입니다. 63 61 66이 caf, c3 a9가 é, 20이 공백, eb b9 b5가 빵입니다. ISO-8859-1은 이 바이트를 하나씩 글자로 바꾸므로 c3은 Ã, a9는 ©, eb는 ë가 되어, wrong처럼 낯선 라틴 문자가 섞인 문자열이 됩니다. UTF-8 바이트를 한 바이트짜리 문자 집합으로 잘못 읽으면 이처럼 Ã나 ë 같은 글자가 잔뜩 나타나는 것이 특징입니다.
after는 이 wrong을 다시 UTF-8로 적은 결과입니다. Ã가 c3 83, ©가 c2 a9처럼 글자마다 두 바이트가 되어 14바이트로 늘었습니다. 이미 UTF-8인 데이터를 한 번 더 UTF-8로 인코딩한 셈이어서, 이런 상태를 이중 인코딩(double encoding)이라고도 부릅니다. 잘못 읽은 문자열을 그대로 저장하면 바로 이런 바이트가 파일에 남습니다.
그런데 이렇게 깨진 문자열은 되돌릴 수 있습니다. ISO-8859-1은 256가지 바이트 값 각각을 서로 다른 글자 하나에 대응시키므로, 잘못 읽는 과정에서 바이트 정보가 하나도 사라지지 않았기 때문입니다. 반면 표현할 수 없는 글자가 대체값으로 바뀐 경우는 사정이 다릅니다. 다음 프로그램이 두 경우를 비교합니다.
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
public final class MojibakeRepair {
public static void main(String[] args) {
String source = "café 빵";
String wrong = new String(source.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1);
String repaired = new String(wrong.getBytes(StandardCharsets.ISO_8859_1), StandardCharsets.UTF_8);
System.out.println("repaired=" + repaired + " same=" + source.equals(repaired));
byte[] seoul = "서울".getBytes(StandardCharsets.US_ASCII);
byte[] tokyo = "도쿄".getBytes(StandardCharsets.US_ASCII);
System.out.println("서울 -> " + Arrays.toString(seoul));
System.out.println("도쿄 -> " + Arrays.toString(tokyo));
System.out.println("same bytes=" + Arrays.equals(seoul, tokyo));
}
}첫 줄은 깨진 문자열을 되돌려 본 결과이고, 나머지 세 줄은 서로 다른 두 지명을 US-ASCII로 인코딩해 비교한 결과입니다. 두 지명의 바이트가 서로 구별되는지 보세요.
repaired=café 빵 same=true
서울 -> [63, 63]
도쿄 -> [63, 63]
same bytes=true첫 줄은 wrong을 ISO-8859-1로 다시 인코딩해 원래 UTF-8 바이트를 되찾고, 그것을 UTF-8로 읽어 원문을 복원한 결과입니다. 잘못 읽은 경로를 정확히 알고 그 사이에 데이터가 더 바뀌지 않았다면, 이렇게 경로를 거꾸로 따라가 되돌릴 수 있습니다. 다만 바이트만 보고 어떤 경로로 잘못 읽었는지 확정할 수는 없으므로, 되돌리기 전에 그 경로부터 확인해야 합니다.
나머지 세 줄은 서울과 도쿄를 US-ASCII로 인코딩한 결과입니다. 두 글자 모두 US-ASCII에 없어서 똑같이 [63, 63]이 되었고, 이 바이트만 보고는 원래 서울이었는지 도쿄였는지 알 방법이 없습니다. 서로 다른 입력이 같은 대체값으로 합쳐지면 둘을 구별하던 정보 자체가 사라지기 때문입니다. 아래 도표는 두 경우를 단계별로 비교합니다.
잘못된 해석과 대체 손실의 복원 조건
UTF8 인코딩→ISO 오해석→재인코딩의 바이트·문자 변환과 역변환 조건입니다. 손실 대체와 구분해 값이 바뀌는 각 경계를 추적하며 복원 조건을 확인할 수 있습니다.
잘못된 해석과 대체 손실의 복원 조건
문자 복원 · 경로를 아는 경우
잘못된 해석과 대체 손실의 복원 조건
일대일로 잘못 해석한 값은 역변환할 수 있지만, 대체값으로 합쳐진 정보는 유일하게 복원할 수 없습니다.
잘못된 해석에도 구별이 남는 경로
UTF-8 바이트를 ISO-8859-1 문자로 일대일 해석하고 UTF-8로 재인코딩한 changed도 알려진 역경로와 추가 변경 없음의 조건에서 복원할 수 있다. main은 복원을 실행하지 않는다.
대체 치환은 서로 다른 입력을 합친다
서로 다른 표현 불가 입력이 같은 대체값으로 합쳐지면 결과만으로 원문을 유일하게 복원할 수 없다.
상태별 상세 해설
원문처럼 UTF-8 바이트를 ISO-8859-1로 해석
- 남은 정보
- 각 바이트가 일대일로 문자에 대응
- 복원 조건
- 다른 변경이 없다면 ISO-8859-1로 다시 인코딩해 원래 바이트 회수
표현 불가 문자 등을 대체 문자·바이트로 치환
- 남은 정보
- 서로 다른 입력이 같은 대체값이 될 수 있음
- 복원 조건
- 변환 결과만으로 원문을 유일하게 복원할 수 없음
원문의 changed도 알려진 역변환(UTF-8 디코딩 → ISO-8859-1 인코딩 → UTF-8 디코딩)으로 복원 가능합니다. 변환 경로를 알아야 하며, 바이트만 보고 그 경로를 확정할 수는 없습니다.
이 예의 조건
- 잘못된 일대일 해석과 대체 치환은 독립 정보 변환 조건이며 공통 변환·남은 정보·복원 단계에서 비교합니다.
- 원 main은 source·wrong·before·after를 출력합니다. 역변환은 실행된 main 결과가 아니라 알려진 경로와 추가 변경 없음에 근거한 분석입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 잘못된 해석과 대체 손실의 복원 조건 잘못된 해석과 대체 손실의 복원 조건 잘못된 해석과 대체 손실의 복원 조건 문자 복원 · 경로를 아는 경우 잘못된 해석과 대체 손실의 복원 조건 일대일로 잘못 해석한 값은 역변환할 수 있지만, 대체값으로 합쳐진 정보는 유일하게 복원할 수 없습니다. 원문처럼 UTF-8 바이트를 ISO-8859-1로 해석 남은 정보 각 바이트가 일대일로 문자에 대응 복원 조건 다른 변경이 없다면 ISO-8859-1로 다시 인코딩해 원래 바이트 회수 표현 불가 문자 등을 대체 문자·바이트로 치환 남은 정보 서로 다른 입력이 같은 대체값이 될 수 있음 복원 조건 변환 결과만으로 원문을 유일하게 복원할 수 없음 원문의 changed도 알려진 역변환(UTF-8 디코딩 → ISO-8859-1 인코딩 → UTF-8 디코딩)으로 복원 가능합니다. 변환 경로를 알아야 하며, 바이트만 보고 그 경로를 확정할 수는 없습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 두 변환의 입력과 처리 같은 복원 질문에서 잘못된 문자 해석과 대체값 치환을 구분합니다. |
|
|
| 각 결과에 남은 정보 일대일 대응은 바이트 구별을 남기지만 대체 치환은 서로 다른 입력을 합칠 수 있습니다. |
|
|
| 각 복원의 조건 알려진 경로와 추가 변경 없음에서 일대일 역변환을 분석하고 대체 손실의 한계를 구분합니다. |
|
|
잘못된 해석과 대체 손실의 복원 조건
UTF8 인코딩→ISO 오해석→재인코딩의 바이트·문자 변환과 역변환 조건입니다. 손실 대체와 구분해 값이 바뀌는 각 경계를 추적하며 복원 조건을 확인할 수 있습니다.
잘못된 해석과 대체 손실의 복원 조건
문자 복원 · 경로를 아는 경우
잘못된 해석과 대체 손실의 복원 조건
일대일로 잘못 해석한 값은 역변환할 수 있지만, 대체값으로 합쳐진 정보는 유일하게 복원할 수 없습니다.
잘못된 해석에도 구별이 남는 경로
UTF-8 바이트를 ISO-8859-1 문자로 일대일 해석하고 UTF-8로 재인코딩한 changed도 알려진 역경로와 추가 변경 없음의 조건에서 복원할 수 있다. main은 복원을 실행하지 않는다.
대체 치환은 서로 다른 입력을 합친다
서로 다른 표현 불가 입력이 같은 대체값으로 합쳐지면 결과만으로 원문을 유일하게 복원할 수 없다.
상태별 상세 해설
원문처럼 UTF-8 바이트를 ISO-8859-1로 해석
- 남은 정보
- 각 바이트가 일대일로 문자에 대응
- 복원 조건
- 다른 변경이 없다면 ISO-8859-1로 다시 인코딩해 원래 바이트 회수
표현 불가 문자 등을 대체 문자·바이트로 치환
- 남은 정보
- 서로 다른 입력이 같은 대체값이 될 수 있음
- 복원 조건
- 변환 결과만으로 원문을 유일하게 복원할 수 없음
원문의 changed도 알려진 역변환(UTF-8 디코딩 → ISO-8859-1 인코딩 → UTF-8 디코딩)으로 복원 가능합니다. 변환 경로를 알아야 하며, 바이트만 보고 그 경로를 확정할 수는 없습니다.
이 예의 조건
- 잘못된 일대일 해석과 대체 치환은 독립 정보 변환 조건이며 공통 변환·남은 정보·복원 단계에서 비교합니다.
- 원 main은 source·wrong·before·after를 출력합니다. 역변환은 실행된 main 결과가 아니라 알려진 경로와 추가 변경 없음에 근거한 분석입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 잘못된 해석과 대체 손실의 복원 조건 잘못된 해석과 대체 손실의 복원 조건 잘못된 해석과 대체 손실의 복원 조건 문자 복원 · 경로를 아는 경우 잘못된 해석과 대체 손실의 복원 조건 일대일로 잘못 해석한 값은 역변환할 수 있지만, 대체값으로 합쳐진 정보는 유일하게 복원할 수 없습니다. 원문처럼 UTF-8 바이트를 ISO-8859-1로 해석 남은 정보 각 바이트가 일대일로 문자에 대응 복원 조건 다른 변경이 없다면 ISO-8859-1로 다시 인코딩해 원래 바이트 회수 표현 불가 문자 등을 대체 문자·바이트로 치환 남은 정보 서로 다른 입력이 같은 대체값이 될 수 있음 복원 조건 변환 결과만으로 원문을 유일하게 복원할 수 없음 원문의 changed도 알려진 역변환(UTF-8 디코딩 → ISO-8859-1 인코딩 → UTF-8 디코딩)으로 복원 가능합니다. 변환 경로를 알아야 하며, 바이트만 보고 그 경로를 확정할 수는 없습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 두 변환의 입력과 처리 같은 복원 질문에서 잘못된 문자 해석과 대체값 치환을 구분합니다. |
|
|
| 각 결과에 남은 정보 일대일 대응은 바이트 구별을 남기지만 대체 치환은 서로 다른 입력을 합칠 수 있습니다. |
|
|
| 각 복원의 조건 알려진 경로와 추가 변경 없음에서 일대일 역변환을 분석하고 대체 손실의 한계를 구분합니다. |
|
|
이 차이를 알면 문제를 만났을 때 할 일도 달라집니다. Ã나 ë가 섞인 글자라면 어떤 경로로 잘못 읽었는지 확인해 되돌릴 수 있는지 따져 볼 수 있지만, ?나 �로 바뀐 글자는 원본 바이트를 다시 구하는 수밖에 없습니다. 그래서 대체가 일어나는 순간을 놓치지 않는 것이 중요합니다. 다음 절에서는 대체 대신 예외가 나도록 바꾸는 방법을 봅니다.
변환 오류를 예외로 받기: CodingErrorAction.REPORT
String.getBytes(Charset)와 new String(byte[], Charset)은 편리하지만 변환 오류를 알려 주지 않습니다. 두 메서드는 규칙에 맞지 않는 입력과 표현할 수 없는 글자를 만나면 언제나 그 문자 집합의 대체값으로 바꾸도록 정해져 있습니다. 대체값은 인코딩할 때는 대개 ?(63) 바이트이고, 디코딩할 때는 �(U+FFFD) 글자입니다. 레거시 시스템에 보낼 파일을 만들면서 이런 대체를 허용하면, 한글 일부가 ?로 저장되어도 작업은 성공한 것처럼 끝납니다.
변환 오류를 직접 다루려면 Charset에서 변환기를 꺼내 씁니다. charset.newEncoder()는 문자열을 바이트로 바꾸는 CharsetEncoder를, charset.newDecoder()는 바이트를 문자열로 바꾸는 CharsetDecoder를 만듭니다. 변환기에는 오류를 만났을 때의 동작(CodingErrorAction)을 정할 수 있는데, REPORT는 예외를 던지고 REPLACE는 대체값으로 바꾸며 IGNORE는 문제가 된 입력을 건너뜁니다. 동작은 오류 종류마다 따로 정합니다. 입력 자체가 규칙에 맞지 않는 malformed 오류와, 입력은 올바르지만 대상 문자 집합에 대응하는 글자가 없는 unmappable 오류입니다.
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.Charset;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
public final class StrictCharsetConversion {
static byte[] encodeStrict(String text, Charset charset)
throws CharacterCodingException {
ByteBuffer encoded = charset.newEncoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.encode(CharBuffer.wrap(text));
byte[] bytes = new byte[encoded.remaining()];
encoded.get(bytes);
return bytes;
}
static String decodeStrict(byte[] bytes, Charset charset)
throws CharacterCodingException {
return charset.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes))
.toString();
}
public static void main(String[] args) throws Exception {
byte[] bytes = encodeStrict("서울", StandardCharsets.UTF_8);
System.out.println(decodeStrict(bytes, StandardCharsets.UTF_8));
}
}main은 서울을 UTF-8로 엄격하게 인코딩한 뒤 같은 방식으로 디코딩해 출력합니다. 오류가 없는 입력이므로 예외 없이 한 줄이 나옵니다.
서울encodeStrict와 decodeStrict는 두 종류의 오류 모두에 REPORT를 지정한 변환기로 변환합니다. 사실 newEncoder()와 newDecoder()로 막 만든 변환기는 기본 동작이 이미 REPORT라서 설정을 생략해도 결과는 같습니다. 그래도 이렇게 적어 두면 오류를 예외로 받겠다는 의도가 코드에 드러납니다. encode는 결과를 ByteBuffer로, decode는 CharBuffer로 돌려주므로 각각 바이트 배열과 문자열로 꺼냅니다. 서울은 UTF-8로 문제없이 왕복하므로 main의 출력은 원문 그대로입니다.
이제 같은 메서드에 문제가 있는 입력을 넣어 보겠습니다. 다음 프로그램은 앞의 StrictCharsetConversion과 같은 폴더에 두고 함께 컴파일합니다.
import java.nio.charset.CharacterCodingException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
public final class StrictFailureDemo {
public static void main(String[] args) {
tryEncode("서울", StandardCharsets.US_ASCII);
tryEncode("\uD800", StandardCharsets.UTF_8); // 짝 없는 상위 서로게이트
tryDecode(new byte[] {(byte) 0xC3, 0x28}, StandardCharsets.UTF_8);
}
static void tryEncode(String text, Charset charset) {
try {
byte[] bytes = StrictCharsetConversion.encodeStrict(text, charset);
System.out.println("encode ok: " + HexFormat.of().formatHex(bytes));
} catch (CharacterCodingException e) {
System.out.println("encode to " + charset + " -> " + e);
}
}
static void tryDecode(byte[] bytes, Charset charset) {
try {
System.out.println("decode ok: " + StrictCharsetConversion.decodeStrict(bytes, charset));
} catch (CharacterCodingException e) {
System.out.println("decode from " + charset + " -> " + e);
}
}
}javac StrictCharsetConversion.java StrictFailureDemo.java
java StrictFailureDemoencode to US-ASCII -> java.nio.charset.UnmappableCharacterException: Input length = 1
encode to UTF-8 -> java.nio.charset.MalformedInputException: Input length = 1
decode from UTF-8 -> java.nio.charset.MalformedInputException: Input length = 1첫 줄은 올바른 문자열 서울을 US-ASCII로 바꾸려다 난 오류입니다. 입력에는 문제가 없지만 US-ASCII에 대응하는 글자가 없으므로 unmappable 오류, 즉 UnmappableCharacterException입니다. 둘째 줄의 "\uD800"은 짝이 없는 상위 서로게이트라서 그 자체로 올바른 UTF-16 문자열이 아닙니다. 그래서 대상 문자 집합과 상관없이 malformed 오류가 납니다.
셋째 줄의 C3 28은 2바이트 글자를 시작하는 C3 뒤에 이어지는 바이트(10으로 시작) 대신 ((28)가 온 잘못된 UTF-8이므로, 디코딩에서 malformed 오류가 납니다. 같은 바이트를 new String으로 읽었다면 예외 없이 �(가 되었을 것입니다. 메시지의 Input length = 1은 문제가 된 입력의 길이로, 인코딩에서는 char 수이고 디코딩에서는 바이트 수입니다.
두 예외는 모두 CharacterCodingException의 하위 클래스이고, CharacterCodingException은 IOException을 상속하므로 검사 예외입니다. 그래서 encodeStrict와 decodeStrict처럼 throws로 선언하거나 잡아서 처리해야 합니다. 오류 종류를 구별할 필요가 없으면 CharacterCodingException 하나로 잡고, 구별해야 하면 하위 예외를 따로 잡습니다.
엄격 변환의 입력별 정상·오류 경계
엄격 변환 · REPORT 오류 종류
엄격 변환의 입력별 정상·오류 경계
표현할 수 없는 문자는 Unmappable, 잘못된 입력 단위는 Malformed로 구분합니다.
“서울”을 UTF-8로 인코딩하고 디코딩
- REPORT 결과
- 정상 왕복
- 원문에서의 범위
- StrictCharsetConversion의 main 경로
유효한 “서울”을 US-ASCII로 인코딩
- REPORT 결과
- UnmappableCharacterException
- 원문에서의 범위
- 연습 문제는 상위 예외를 잡아 결과로 반환
짝 없는 상위 서로게이트를 UTF-8로 인코딩
- REPORT 결과
- MalformedInputException
- 원문에서의 범위
- API가 정의하는 추가 입력 예시
바이트 C3 28을 UTF-8로 디코딩
- REPORT 결과
- MalformedInputException
- 원문에서의 범위
- API가 정의하는 추가 입력 예시
원문 코드와 오류 계약을 비교한 표이며 별도 실행 관측이 아닙니다. 두 예외는 모두 CharacterCodingException의 하위 타입입니다.
같은 '바이트를 글자로 읽는' 일이라도 Java API에 따라 조용히 대체하기도 하고 예외를 던지기도 합니다. 다음 표는 이 글의 JDK 21에서 잘못된 UTF-8 바이트와 짝 없는 서로게이트로 직접 확인한 결과입니다.
| API | 변환 오류를 만났을 때 |
|---|---|
String.getBytes(Charset), new String(byte[], Charset) | 대체값으로 바꿈 |
new InputStreamReader(in, Charset) | 대체 문자 �로 바꿈 |
new InputStreamReader(in, CharsetDecoder) | 넘긴 디코더에 정한 동작을 따름 |
Files.readString, Files.readAllLines | MalformedInputException을 던짐 |
newEncoder(), newDecoder()로 만든 변환기 | 기본 동작이 REPORT라서 예외를 던짐 |
예외로 오류를 받은 다음에는 무엇을 할지 정해야 합니다. 사용자가 올린 파일이라면 몇 번째 바이트에서 문제가 생겼는지 알려 주고 다시 올리게 할 수 있고, 여러 레코드를 처리하는 일괄 작업이라면 문제가 된 레코드만 따로 모아 두고 나머지를 진행할 수도 있습니다. 한 글자라도 틀리면 안 되는 파일이라면 전체를 거부합니다. 어느 쪽이든 오류 내용을 로그에 남길 때는 개인 정보가 섞여 있을 수 있으므로, 원문 전체 대신 위치와 짧은 16진수 구간만 남기는 것이 안전합니다.
다른 인코딩으로 옮겨 저장하기
MS949로 저장된 옛 파일을 UTF-8로 바꾸는 것처럼 인코딩을 옮길 때의 올바른 순서는, 원래 인코딩으로 디코딩해 문자열을 만들고 그 문자열을 새 인코딩으로 인코딩하는 것입니다. 바이트끼리 바로 바꾸는 방법은 없습니다. 다음 프로그램은 앞의 엄격한 변환 메서드로 이 순서를 따르며, 이것도 StrictCharsetConversion과 함께 컴파일합니다.
import java.nio.charset.CharacterCodingException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
public final class LegacyTranscode {
public static void main(String[] args) throws CharacterCodingException {
Charset ms949 = Charset.forName("MS949");
HexFormat hex = HexFormat.ofDelimiter(" ").withUpperCase();
byte[] legacy = {(byte) 0xBC, (byte) 0xAD, (byte) 0xBF, (byte) 0xEF};
String text = StrictCharsetConversion.decodeStrict(legacy, ms949);
byte[] utf8 = StrictCharsetConversion.encodeStrict(text, StandardCharsets.UTF_8);
System.out.println(text + ": " + hex.formatHex(legacy) + " -> " + hex.formatHex(utf8));
try {
StrictCharsetConversion.encodeStrict("서울😀", ms949);
} catch (CharacterCodingException e) {
System.out.println("to MS949 failed: " + e);
}
}
}첫 줄은 MS949 바이트를 UTF-8 바이트로 옮긴 결과이고, 둘째 줄은 반대로 😀가 섞인 문자열을 MS949로 엄격하게 인코딩하다 난 예외입니다. 첫 줄의 화살표 왼쪽은 앞 글에서 본 서울의 MS949 바이트와 같습니다.
서울: BC AD BF EF -> EC 84 9C EC 9A B8
to MS949 failed: java.nio.charset.UnmappableCharacterException: Input length = 2첫 줄에서 MS949 바이트 4개가 서울로 디코딩된 뒤 UTF-8 바이트 6개로 다시 인코딩되었습니다. MS949의 글자는 모두 유니코드에 있으므로, 원래 바이트가 올바른 MS949이기만 하면 이 방향의 변환은 실패하지 않습니다. 반대 방향은 다릅니다. 둘째 줄처럼 😀가 섞인 문자열은 MS949로 표현할 수 없어서 unmappable 오류가 납니다. 이번에는 Input length = 2인데, 😀가 char 두 개로 된 서로게이트 쌍이기 때문입니다. getBytes(ms949)를 썼다면 😀 자리는 조용히 ?가 되었을 것입니다.
간혹 바이트를 ISO-8859-1 문자열에 잠시 담아 두었다가 다시 꺼내는 식으로 데이터를 옮기는 코드가 보입니다. ISO-8859-1이 바이트를 하나도 잃지 않으니 동작은 하지만, 그 문자열에 담긴 것은 글자가 아니라 바이트여서 다른 코드가 그것을 글자로 오해하기 쉽습니다. 바이트는 byte[]로, 글자는 올바르게 디코딩한 String으로 다루는 것이 원칙입니다.
큰 파일을 변환할 때는 파일 전체를 한 번에 메모리에 올리지 말고, InputStreamReader와 OutputStreamWriter를 이어 조금씩 읽고 쓰면서 변환합니다. 이때도 양쪽에 오류 동작을 REPORT로 정한 디코더와 인코더를 넘겨야 대체가 일어나지 않습니다. 바이트를 일정한 크기로 직접 잘라 조각마다 new String으로 디코딩하면, 여러 바이트로 된 글자가 두 조각 사이에서 잘려 깨질 수 있다는 점도 주의해야 합니다. 문자 스트림은 문자 스트림 Reader·Writer와 Charset에서 자세히 다룹니다.
BOM과 UTF-16의 세 가지 변형
Java에는 UTF-16 계열 문자 집합이 셋 있습니다. UTF-16BE와 UTF-16LE는 바이트 순서가 이름에 정해져 있고, 순서가 적혀 있지 않은 UTF-16은 BOM으로 순서를 알립니다. 다음 프로그램은 세 문자 집합으로 A를 인코딩하고, 바이트열 세 가지를 세 문자 집합으로 각각 디코딩한 결과를 코드 포인트로 출력합니다. 마지막 두 줄은 UTF-8 BOM이 붙은 바이트열을 읽고 BOM을 지우는 과정입니다.
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
import java.util.List;
import java.util.stream.Collectors;
public final class ByteOrderMarks {
static final HexFormat HEX = HexFormat.ofDelimiter(" ").withUpperCase();
public static void main(String[] args) {
List<Charset> utf16 = List.of(StandardCharsets.UTF_16,
StandardCharsets.UTF_16BE, StandardCharsets.UTF_16LE);
for (Charset charset : utf16) {
System.out.printf("encode A as %-8s -> %s%n", charset, HEX.formatHex("A".getBytes(charset)));
}
byte[][] inputs = {
{(byte) 0xFE, (byte) 0xFF, 0x00, 0x41},
{(byte) 0xFF, (byte) 0xFE, 0x41, 0x00},
{0x00, 0x41}};
for (byte[] input : inputs) {
for (Charset charset : utf16) {
System.out.printf("decode %-11s as %-8s -> %s%n",
HEX.formatHex(input), charset, codes(new String(input, charset)));
}
}
byte[] utf8WithBom = {(byte) 0xEF, (byte) 0xBB, (byte) 0xBF, 0x41};
String text = new String(utf8WithBom, StandardCharsets.UTF_8);
System.out.println("decode EF BB BF 41 as UTF-8 -> " + codes(text));
if (text.startsWith("\uFEFF")) {
text = text.substring(1);
}
System.out.println("after removing BOM -> " + codes(text));
}
static String codes(String text) {
return text.codePoints()
.mapToObj(value -> String.format("U+%04X", value))
.collect(Collectors.joining(" "));
}
}처음 세 줄은 인코딩 결과이고, 그다음 아홉 줄은 바이트열 세 가지를 세 문자 집합으로 하나씩 디코딩한 결과입니다. 디코딩한 줄에서 U+0041 하나만 나오지 않은 줄을 찾아보세요.
encode A as UTF-16 -> FE FF 00 41
encode A as UTF-16BE -> 00 41
encode A as UTF-16LE -> 41 00
decode FE FF 00 41 as UTF-16 -> U+0041
decode FE FF 00 41 as UTF-16BE -> U+FEFF U+0041
decode FE FF 00 41 as UTF-16LE -> U+FFFE U+4100
decode FF FE 41 00 as UTF-16 -> U+0041
decode FF FE 41 00 as UTF-16BE -> U+FFFE U+4100
decode FF FE 41 00 as UTF-16LE -> U+FEFF U+0041
decode 00 41 as UTF-16 -> U+0041
decode 00 41 as UTF-16BE -> U+0041
decode 00 41 as UTF-16LE -> U+4100
decode EF BB BF 41 as UTF-8 -> U+FEFF U+0041
after removing BOM -> U+0041처음 세 줄을 보면 인코딩할 때 UTF-16만 맨 앞에 BOM FE FF를 붙이고 그 뒤를 big-endian으로 적습니다. UTF-16BE와 UTF-16LE는 BOM 없이 정해진 순서로만 적습니다. 디코딩에서는 차이가 더 뚜렷합니다. UTF-16은 맨 앞의 FE FF나 FF FE를 보고 바이트 순서를 정한 뒤 BOM을 버리고, BOM이 없으면 big-endian으로 읽습니다.
반면 UTF-16BE와 UTF-16LE는 맨 앞의 BOM을 순서 표시로 보지 않고 그냥 글자로 읽습니다. 그래서 순서가 맞으면 보이지 않는 글자 U+FEFF가 문자열 앞에 남고, 순서가 반대면 U+FFFE와 U+4100 같은 엉뚱한 글자가 됩니다. BOM이 없는 00 41을 UTF-16LE로 읽은 경우도 A가 아니라 한자 U+4100이 됩니다. 그러니 'UTF-16 파일'이라는 말만으로는 충분하지 않습니다. 파일 형식이나 통신 규약이 BOM을 붙이는지, 붙이지 않는다면 어느 바이트 순서인지까지 확인하고 그에 맞는 문자 집합을 골라야 합니다.
UTF-8에서는 바이트 순서를 따질 일이 없지만, 앞 글에서 말했듯 EF BB BF를 맨 앞에 붙이는 프로그램이 있습니다. Java의 UTF-8 디코더는 이 바이트를 지우지 않고 U+FEFF로 남기므로, 출력의 끝에서 둘째 줄처럼 A 앞에 U+FEFF가 붙습니다. 이 상태에서는 "A".equals(text)가 false이고, CSV 파일이라면 첫 열의 이름이 맞지 않는 식의 문제가 생깁니다. 형식이 UTF-8 BOM을 허용한다면 예제처럼 디코딩한 문자열이 U+FEFF로 시작할 때 첫 글자를 지우고, 허용하지 않는다면 그대로 두거나 형식 오류로 처리합니다.
바이트만 보고 인코딩을 알아낼 수 없는 이유
받은 바이트가 어떤 인코딩인지 바이트만 보고 알아낼 수 있다면 편하겠지만, 항상 정확하게 알아내는 방법은 없습니다. 같은 바이트열이 여러 인코딩에서 모두 올바른 경우가 많기 때문입니다. 다음 프로그램은 두 바이트 B0 A1을 세 가지 인코딩으로 읽어 봅니다.
import java.nio.charset.Charset;
import java.util.List;
public final class AmbiguousBytes {
public static void main(String[] args) {
byte[] bytes = {(byte) 0xB0, (byte) 0xA1};
for (String name : List.of("MS949", "ISO-8859-1", "UTF-8")) {
System.out.println(name + " -> " + new String(bytes, Charset.forName(name)));
}
}
}같은 두 바이트를 세 인코딩으로 읽었습니다. 줄마다 다른 결과가 나온다면 그 차이는 모두 읽는 쪽의 인코딩에서 생긴 것입니다.
MS949 -> 가
ISO-8859-1 -> °¡
UTF-8 -> ��B0 A1은 MS949로 읽으면 가이고, ISO-8859-1로 읽으면 °¡라는 두 글자입니다. 둘 다 오류 없이 읽히므로 어느 쪽이 맞는지 바이트만으로는 정할 수 없습니다. UTF-8로 읽을 때만 B0과 A1이 둘 다 이어지는 바이트라서 규칙 위반이 드러납니다. 이처럼 엄격한 디코더는 'UTF-8이 아니다'처럼 어떤 인코딩이 아닌지는 알려 줄 수 있지만, 무엇인지까지 정해 주지는 못합니다. ASCII 범위의 바이트만 있는 파일이라면 UTF-8, ISO-8859-1, MS949 가운데 어느 것으로 읽어도 결과가 같아서 구별할 단서조차 없습니다. 인코딩을 추측해 주는 라이브러리도 있지만 그 결과는 어디까지나 추정입니다.
그래서 인코딩은 데이터를 주고받는 쪽끼리의 약속으로 정해 두어야 합니다. 새 형식이라면 UTF-8로 고정하고 문서에 적어 둡니다. 여러 인코딩을 받아야 한다면 업로드 API에 인코딩을 지정하는 항목을 두거나 HTTP의 Content-Type: text/plain; charset=UTF-8처럼 함께 오는 정보를 쓰고, 정보가 없을 때 어떻게 할지도 미리 정해 둡니다. BOM과 헤더가 서로 다른 인코딩을 가리키는 것처럼 정보끼리 충돌하면, 한쪽을 조용히 고르지 말고 오류로 알리는 편이 안전합니다.
정리
StandardCharsets의 상수는 모든 Java 구현이 지원하므로 우선 씁니다. 그 밖의 문자 집합은Charset.forName으로 얻고, 설정에서 받은 이름은 시작할 때 한 번Charset으로 바꿔 검증합니다.- 하나의 문자 집합에는 정식 이름과 여러 별칭이 있습니다. 이름 문자열끼리 비교하지 말고
Charset객체로 비교합니다. - 잘못된 인코딩으로 읽어 깨진 글자는 경로를 알면 되돌릴 수 있을 때가 있지만, 대체값으로 바뀐 글자는 되찾을 수 없습니다.
getBytes와new String은 언제나 대체합니다. 오류를 알아야 한다면newEncoder()·newDecoder()로 만든 변환기에CodingErrorAction.REPORT를 지정하고, malformed 오류와 unmappable 오류를 구별해 처리합니다.- 인코딩을 옮길 때는 원래 인코딩으로 디코딩한 뒤 새 인코딩으로 인코딩합니다.
UTF-16,UTF-16BE,UTF-16LE는 BOM을 다루는 방식이 다르고, Java의 UTF-8 디코더는 BOM을 지우지 않습니다. 바이트만 보고는 인코딩을 확정할 수 없으므로 형식과 메타데이터로 정합니다.
연습 문제
1. 손실 없이 인코딩할 수 있는지 검사하기
문자열과 문자 집합 이름을 받아, 그 문자 집합으로 손실 없이 인코딩할 수 있는지 알려 주는 메서드를 작성하세요. 지원하지 않는 이름, 표현할 수 없는 글자, 정상 변환을 서로 다른 결과로 나타내고, 정상일 때만 인코딩한 바이트 수를 알려 줍니다.
정답과 해설
import java.nio.CharBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.Charset;
import java.nio.charset.CodingErrorAction;
public final class CharsetCompatibilitySolution {
record Check(boolean supported, boolean encodable, int bytes, String reason) {}
static Check check(String text, String charsetName) {
if (!Charset.isSupported(charsetName)) {
return new Check(false, false, 0, "unsupported charset");
}
var encoder = Charset.forName(charsetName).newEncoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
try {
int bytes = encoder.encode(CharBuffer.wrap(text)).remaining();
return new Check(true, true, bytes, "ok");
} catch (CharacterCodingException e) {
return new Check(true, false, 0, "unmappable input");
}
}
public static void main(String[] args) {
System.out.println(check("café", "ISO-8859-1"));
System.out.println(check("서울", "US-ASCII"));
System.out.println(check("hello", "unknown-encoding"));
}
}실행 결과:
Check[supported=true, encodable=true, bytes=4, reason=ok]
Check[supported=true, encodable=false, bytes=0, reason=unmappable input]
Check[supported=false, encodable=false, bytes=0, reason=unsupported charset]check는 먼저 Charset.isSupported로 이름을 확인하고, 지원되면 오류 동작을 REPORT로 정한 인코더로 실제 인코딩을 해 봅니다. 예외가 나지 않으면 결과 ByteBuffer에 남은 바이트 수가 인코딩한 바이트 수입니다. café는 ISO-8859-1에서 글자당 1바이트라 4바이트이고, 서울은 US-ASCII로 표현할 수 없어 예외가 나며, unknown-encoding은 이 JVM이 지원하지 않는 이름입니다.
다만 이 해답에는 빈틈이 두 가지 있습니다. UTF 8처럼 형식이 틀린 이름을 넘기면 isSupported가 IllegalCharsetNameException을 던지는데 이를 잡지 않습니다. 또 짝 없는 서로게이트 때문에 생긴 MalformedInputException도 unmappable input이라는 같은 문구로 합쳐 버립니다. 설정 오류와 데이터 오류를 정확히 알려 주려면 이 두 경우를 구별해야 합니다.
아래는 두 경우를 구별하도록 고친 구현입니다. text와 charsetName이 null이 아니어야 한다는 조건은 Objects.requireNonNull로 확인하고, 형식이 틀린 이름, 지원하지 않는 이름, 잘못된 UTF-16 입력, 표현할 수 없는 글자를 서로 다른 상태로 돌려줍니다. 성공했을 때만 OptionalInt에 바이트 수가 들어 있습니다. Charset.forName의 공식 문서도 형식이 틀린 이름과 지원하지 않는 이름을 서로 다른 예외로 구별합니다.
import java.nio.CharBuffer;
import java.nio.charset.*;
import java.util.Objects;
import java.util.OptionalInt;
public final class DetailedCharsetCheckSolution {
enum Status { OK, ILLEGAL_NAME, UNSUPPORTED, MALFORMED, UNMAPPABLE, CODING_ERROR }
record Check(Status status, OptionalInt bytes) {}
static Check failure(Status status) { return new Check(status, OptionalInt.empty()); }
static Check check(String text, String charsetName) {
Objects.requireNonNull(text);
Objects.requireNonNull(charsetName);
final Charset charset;
try { charset = Charset.forName(charsetName); }
catch (IllegalCharsetNameException e) { return failure(Status.ILLEGAL_NAME); }
catch (UnsupportedCharsetException e) { return failure(Status.UNSUPPORTED); }
try {
int count = charset.newEncoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.encode(CharBuffer.wrap(text)).remaining();
return new Check(Status.OK, OptionalInt.of(count));
} catch (MalformedInputException e) {
return failure(Status.MALFORMED);
} catch (UnmappableCharacterException e) {
return failure(Status.UNMAPPABLE);
} catch (CharacterCodingException e) {
return failure(Status.CODING_ERROR);
}
}
public static void main(String[] args) {
System.out.println(check("café", "ISO-8859-1"));
System.out.println(check("서울", "US-ASCII"));
System.out.println(check("hello", "unknown-encoding"));
System.out.println(check("hello", "UTF 8"));
System.out.println(check("\uD800", "UTF-8")); // 짝 없는 상위 서로게이트
}
}실행 결과:
Check[status=OK, bytes=OptionalInt[4]]
Check[status=UNMAPPABLE, bytes=OptionalInt.empty]
Check[status=UNSUPPORTED, bytes=OptionalInt.empty]
Check[status=ILLEGAL_NAME, bytes=OptionalInt.empty]
Check[status=MALFORMED, bytes=OptionalInt.empty]결과는 차례로 OK(4바이트), UNMAPPABLE, UNSUPPORTED, ILLEGAL_NAME, MALFORMED입니다. 마지막 두 입력은 첫 해답이 구별하지 못하던 경우를 확인하려고 추가했습니다. catch의 순서도 중요한데, 하위 예외인 MalformedInputException과 UnmappableCharacterException을 상위 예외 CharacterCodingException보다 먼저 잡아야 합니다. 실제 서비스라면 예외 메시지를 그대로 보여 주기보다 이렇게 정해 둔 상태 값을 오류 코드로 쓰고, 민감할 수 있는 원문은 로그에 남기지 않습니다.
2. 되찾을 수 있는 경우와 없는 경우
다음 세 경우 가운데 원래 문자열을 되찾을 수 있는 것은 무엇이고, 어떻게 되찾을 수 있을까요?
- UTF-8로 저장된
café를 ISO-8859-1로 읽어café가 되었다. 똠을getBytes(Charset.forName("EUC-KR"))로 인코딩해 저장했다.- MS949로 저장된 파일을
new String(bytes, StandardCharsets.UTF_8)로 읽어�가 섞인 문자열을 얻었고, 원본 파일은 지웠다.
정답과 해설
1번만 되찾을 수 있습니다. ISO-8859-1은 바이트와 글자를 하나씩 대응시키므로 café를 다시 ISO-8859-1로 인코딩하면 원래 UTF-8 바이트 63 61 66 C3 A9가 나오고, 이것을 UTF-8로 디코딩하면 café가 됩니다.
2번은 EUC-KR에 똠이 없어서 getBytes가 3F(?)로 바꿔 저장했습니다. 표현할 수 없는 다른 글자도 모두 같은 3F가 되므로, 저장된 바이트만으로는 원래 글자를 알 수 없습니다. 3번도 규칙에 맞지 않는 바이트가 모두 같은 U+FFFD로 바뀌었으므로 문자열에서 원래 바이트를 되살릴 수 없고, 원본 파일도 없으니 되찾을 방법이 없습니다. 2번과 3번 모두 엄격한 변환기를 썼다면 저장하거나 읽는 순간 예외로 알아챌 수 있었습니다.
다음 글
바이트 스트림: InputStream과 OutputStream에서는 파일이나 메모리의 바이트를 읽고 쓰는 스트림을 다룹니다. read가 돌려주는 값의 의미와 일부만 읽히는 경우를 고려한 복사 반복문, 그리고 스트림을 누가 닫아야 하는지를 배웁니다.