Charset과 변환 손실
Charset과 StandardCharsets의 역할을 익히고 Encoder·Decoder 오류 정책으로 문자 손실을 막습니다.
자바의 Charset은 문자와 바이트 사이의 변환 규칙을 객체로 표현합니다.
현재 JVM의 문자 집합 공급자가 제공하는 변환 규칙을 이름으로 찾거나, UTF-8처럼 항상 제공되는 표준 상수를 사용할 수 있습니다.
입력 형식이 외부 설정으로 정해진다면 이름을 검증해 Charset으로 한 번 변환하고, 코드 곳곳에 문자열을 흩뿌리지 않습니다.
인코딩과 디코딩은 방향이 반대일 뿐 한 쌍의 규칙입니다.
문자열을 A 규칙으로 인코딩한 바이트를 B 규칙으로 디코딩하면 우연히 ASCII 부분만 맞고 나머지는 깨질 수 있습니다.
특히 샘플이 영문뿐이면 잘못된 구성을 놓치기 쉬우므로 한글, 서유럽 문자, 보조 문자를 포함한 대표 표본으로 확인합니다.
잘못된 해석과 대체 손실의 구별
new String(bytes, charset)은 잘못된 입력을 대체 문자로 바꿀 수 있습니다. 다만 잘못된 문자 집합을 선택했다고 언제나 대체 손실이 생기는 것은 아닙니다.
아래 반례는 UTF-8 바이트를 ISO-8859-1로 읽은 뒤 다시 UTF-8로 출력하면서 원문과 다른 결과를 만듭니다.
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));
}
}잘못된 해석과 대체 손실의 복원 조건
| 변환 상황 | 남은 정보 | 복원 조건 |
|---|---|---|
| 원문처럼 UTF-8 바이트를 ISO-8859-1로 해석 | 각 바이트가 일대일로 문자에 대응 | 다른 변경이 없다면 ISO-8859-1로 다시 인코딩해 원래 바이트 회수 |
| 표현 불가 문자 등을 대체 문자·바이트로 치환 | 서로 다른 입력이 같은 대체값이 될 수 있음 | 변환 결과만으로 원문을 유일하게 복원할 수 없음 |
- 원문처럼 UTF-8 바이트를 ISO-8859-1로 해석
- 남은 정보: 각 바이트가 일대일로 문자에 대응복원 조건: 다른 변경이 없다면 ISO-8859-1로 다시 인코딩해 원래 바이트 회수
- 표현 불가 문자 등을 대체 문자·바이트로 치환
- 남은 정보: 서로 다른 입력이 같은 대체값이 될 수 있음복원 조건: 변환 결과만으로 원문을 유일하게 복원할 수 없음
원문의 changed도 알려진 역변환(UTF-8 디코딩 → ISO-8859-1 인코딩 → UTF-8 디코딩)으로 복원 가능합니다. 변환 경로를 알아야 하며, 바이트만 보고 그 경로를 확정할 수는 없습니다.
표준 상수와 별칭 사용
StandardCharsets.UTF_8, US_ASCII, ISO_8859_1, UTF_16BE, UTF_16LE, UTF_16은 모든 자바 구현에서 제공됩니다.
상수를 사용하면 이름 오타와 지원 여부 예외를 제거할 수 있습니다.
MS949처럼 표준 상수에 없는 값은 Charset.forName으로 한 번 조회하고 구성 오류를 시작 단계에서 보고합니다.
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.List;
public final class CharsetCatalog {
public static void main(String[] args) {
Charset utf8 = StandardCharsets.UTF_8;
Charset korean = Charset.forName("windows-949");
System.out.println("utf8=" + utf8.name());
System.out.println("korean=" + korean.name());
System.out.println("aliases=" + korean.aliases().stream().sorted().toList());
for (String configured : List.of("UTF-8", "MS949", "not-a-charset")) {
boolean supported = Charset.isSupported(configured);
System.out.printf("name=%s supported=%s%n", configured, supported);
}
}
}별칭이 여러 개여도 name()은 정규 이름을 반환합니다.
저장된 설정을 비교할 때 문자열 대소문자나 별칭을 직접 비교하지 말고 Charset 객체로 정규화합니다.
isSupported는 문법상 유효하지만 지원하지 않는 이름에 false를 반환합니다. 이름 문법 자체가 틀리면 이 메서드도 IllegalCharsetNameException을 던지므로, 사용자 설정은 예외까지 구성 오류로 번역해야 합니다.
JVM 제공 문자 집합
Charset.availableCharsets()는 현재 JVM의 문자 집합 공급자가 제공하는 정규 이름과 Charset 객체를 이름순 SortedMap으로 반환합니다.
관리 화면이나 진단 명령에서 실제 사용 가능한 목록을 보여 줄 때 유용합니다.
특정 이름 하나의 지원 여부만 확인한다면 전체 목록을 순회하지 말고 Charset.isSupported나 Charset.forName을 사용합니다.
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 배포판과 공급자에 따라 달라질 수 있습니다. 반환 맵의 정렬은 정규 이름의 대소문자를 구별하지 않는 순서이며, 목록 개수를 고정된 제품 조건으로 가정하지 않습니다.
사용 가능한 문자 집합이 많다는 사실도 모두 허용해야 한다는 뜻은 아닙니다.
외부 입력에는 UTF-8처럼 승인한 목록을 따로 두고, availableCharsets는 환경 진단과 선택 UI의 근거로만 사용합니다.
반환된 Map은 수정 대상이 아니며, 공급자 탐색을 요청마다 반복하기보다 시작 시 필요한 이름을 검증해 보관합니다.
REPORT 기반 변환 손실 방지
String.getBytes는 대상 문자 집합이 표현하지 못하는 문자를 대체 바이트로 바꿀 수 있습니다.
레거시 파일을 생성할 때 이런 대체를 허용하면 한글 일부가 물음표로 저장되어도 작업이 성공한 것처럼 보입니다.
CharsetEncoder와 CharsetDecoder에 CodingErrorAction.REPORT를 설정하면 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));
}
}엄격 변환의 입력별 정상·오류 경계
| 입력과 변환 | REPORT 결과 | 원문에서의 범위 |
|---|---|---|
| “서울”을 UTF-8로 인코딩하고 디코딩 | 정상 왕복 | StrictCharsetConversion의 main 경로 |
| 유효한 “서울”을 US-ASCII로 인코딩 | UnmappableCharacterException | 연습 문제는 상위 예외를 잡아 결과로 반환 |
| 짝 없는 상위 서로게이트를 UTF-8로 인코딩 | MalformedInputException | API가 정의하는 추가 입력 예시 |
| 바이트 C3 28을 UTF-8로 디코딩 | MalformedInputException | API가 정의하는 추가 입력 예시 |
- “서울”을 UTF-8로 인코딩하고 디코딩
- REPORT 결과: 정상 왕복원문에서의 범위: StrictCharsetConversion의 main 경로
- 유효한 “서울”을 US-ASCII로 인코딩
- REPORT 결과: UnmappableCharacterException원문에서의 범위: 연습 문제는 상위 예외를 잡아 결과로 반환
- 짝 없는 상위 서로게이트를 UTF-8로 인코딩
- REPORT 결과: MalformedInputException원문에서의 범위: API가 정의하는 추가 입력 예시
- 바이트 C3 28을 UTF-8로 디코딩
- REPORT 결과: MalformedInputException원문에서의 범위: API가 정의하는 추가 입력 예시
원문 코드와 오류 계약을 비교한 표이며 별도 실행 관측이 아닙니다. 두 예외는 모두 CharacterCodingException의 하위 타입입니다.
사용자에게 수정 기회를 줄지, 해당 레코드를 격리할지, 전체 파일을 거부할지를 오류 종류에 맞춰 정합니다.
바이트 변환 파이프라인
한 인코딩의 파일을 다른 인코딩으로 바꾸는 올바른 순서는 원본 Charset으로 디코딩한 뒤 대상 Charset으로 인코딩하는 것입니다.
바이트 값을 숫자로 복사하거나 ISO-8859-1 문자열을 임시 운반체로 사용하면 의미 구분이 흐려집니다.
대용량 입력은 InputStreamReader와 OutputStreamWriter를 연결해 점진적으로 변환하고 양쪽 디코더·인코더의 오류 정책을 명시합니다.
UTF_16 디코더는 시작 BOM으로 바이트 순서를 정하고, BOM이 없으면 big-endian으로 읽습니다. 인코더는 big-endian 바이트 순서와 BOM을 사용합니다.
UTF_16BE와 UTF_16LE 인코더는 BOM을 추가하지 않습니다. 이 두 디코더는 시작의 BOM도 순서 선택용 서명이 아니라 문자로 해석합니다.
프로토콜이 정확히 어떤 변형을 요구하는지 확인해야 합니다.
“UTF-16”이라는 느슨한 이름만으로 파일 호환성을 단정하지 않습니다.
문자 집합 탐지 한계의 API 표현
임의 바이트열만 보고 문자 집합을 항상 정확히 알아내는 방법은 없습니다.
ASCII 범위의 파일은 UTF-8, 여러 서유럽 인코딩, 한글 계열에서 동시에 유효할 수 있습니다.
탐지 라이브러리는 확률을 줄 뿐 사양을 대신하지 않습니다.
업로드 API에 Charset 필드를 두거나 파일 형식을 UTF-8로 고정하고, 값이 없을 때 적용할 정책을 문서화합니다.
입력에 BOM이나 HTTP Content-Type 문자 집합이 있다면 근거의 우선순위를 정합니다.
헤더와 실제 바이트가 충돌하면 조용히 한쪽을 선택하지 말고 오류와 표본을 남깁니다.
비밀 정보가 포함될 수 있으므로 원문 전체 대신 위치, 바이트 오프셋, 짧은 16진수 구간만 기록합니다.
운영 판단표
| 경계 | 권장 동작 | 관측 항목 |
|---|---|---|
| 애플리케이션 설정 | 시작 시 Charset 정규화 | 정규 이름 |
| 새 텍스트 파일 | UTF-8 명시 | 헤더와 메타데이터 |
| 레거시 입력 | strict 디코더 사용 | 오류 바이트 위치 |
| 레거시 출력 | 표현 불가 문자 거부 | 레코드 식별자 |
| 불명확한 업로드 | 사용자 선택 요구 | 추정값과 신뢰도 |
연습 문제
문자열과 대상 Charset 이름을 받아 손실 없이 인코딩 가능한지 반환하는 함수를 작성하세요.
지원하지 않는 이름, 표현 불가 문자, 정상 변환을 서로 다른 결과로 나타내고 정상일 때만 바이트 수를 제공합니다.
정답과 해설
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(java.nio.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"));
}
}제시된 세 입력에서 결과는 정상 4바이트, 지원되지만 표현 불가, 지원하지 않는 이름으로 나뉩니다.
다만 이 해답은 잘못된 이름 문법 예외를 잡지 않으며, 잘못된 서로게이트로 생긴 MalformedInputException도 같은 unmappable input 문구로 합칩니다. 구성 오류와 데이터 오류를 더 세밀하게 공개하려면 이 두 경계를 구별해야 합니다.
실제 서비스에서는 예외 메시지 대신 안정적인 오류 코드를 두고 민감한 원문을 로그에 남기지 않습니다.
무손실 문자 집합 변환의 승인 조건
문자 변환의 성공은 예외가 없었다는 사실이 아니라 원문 의미가 보존되었다는 뜻입니다.
Charset을 접점에서 정규화하고 strict 변환을 적용하며, 형식 사양과 다른 입력을 별도 오류로 공개하면 깨진 데이터가 다음 저장 단계로 확산되는 일을 막을 수 있습니다.