본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
23장 : 문자 인코딩과 스트림

Charset과 변환 손실

Charset 이름과 별칭을 조회하고 StandardCharsets로 규칙을 고정하며 CharsetEncoder와 Decoder의 오류 정책으로 조용한 문자 손실을 차단합니다.

자바의 Charset은 문자와 바이트 사이의 변환 규칙을 객체로 표현합니다. 운영체제에 설치된 문자 집합을 문자열 이름으로 찾을 수도 있고, UTF-8처럼 항상 제공되는 표준 상수를 사용할 수도 있습니다. 입력 형식이 외부 설정으로 정해진다면 이름을 검증해 Charset으로 한 번 변환하고, 코드 곳곳에 문자열을 흩뿌리지 않습니다.

인코딩과 디코딩은 방향이 반대일 뿐 한 쌍의 규칙입니다. 문자열을 A 규칙으로 인코딩한 바이트를 B 규칙으로 디코딩하면 우연히 ASCII 부분만 맞고 나머지는 깨질 수 있습니다. 특히 샘플이 영문뿐이면 잘못된 구성을 놓치기 쉬우므로 한글, 서유럽 문자, 보조 문자를 포함한 대표 표본으로 확인합니다.

문자 집합 불일치와 무음 손실

기본 String 생성자는 잘못된 입력을 대체 문자로 바꿀 수 있습니다. 예외가 발생하지 않았다는 사실을 성공으로 판단하면 이미 손실된 텍스트가 저장소에 들어갑니다. 아래 반례는 UTF-8 바이트를 ISO-8859-1로 읽은 뒤 다시 UTF-8로 출력하면서 원문과 다른 결과를 만듭니다.

bad/MismatchedRoundTrip.java
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));
    }
}

ISO-8859-1은 모든 바이트를 어떤 문자로 매핑하므로 이 단계에서는 오류가 나지 않습니다. 그러나 그 문자열은 원래 의미가 아니라 UTF-8 바이트를 라틴 문자로 오해한 결과입니다. 이후 UTF-8로 다시 인코딩하면 바이트열 자체가 달라져 원래 정보로 자동 복구할 수 없습니다.

표준 상수와 별칭 사용

StandardCharsets.UTF_8, US_ASCII, ISO_8859_1, UTF_16BE, UTF_16LE, UTF_16은 모든 자바 구현에서 제공됩니다. 상수를 사용하면 이름 오타와 지원 여부 예외를 제거할 수 있습니다. MS949처럼 표준 상수에 없는 값은 Charset.forName으로 한 번 조회하고 구성 오류를 시작 단계에서 보고합니다.

src/CharsetCatalog.java
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 객체로 정규화합니다. 사용자 입력을 그대로 forName에 넘기기 전에는 isSupported 또는 예외 처리를 통해 잘못된 이름을 구성 오류로 번역합니다.

JVM 제공 문자 집합

Charset.availableCharsets()는 현재 JVM의 문자 집합 공급자가 제공하는 정규 이름과 Charset 객체를 이름순 SortedMap으로 반환합니다. 관리 화면이나 진단 명령에서 실제 사용 가능한 목록을 보여 줄 때 유용합니다. 특정 이름 하나의 지원 여부만 확인한다면 전체 목록을 순회하지 말고 Charset.isSupportedCharset.forName을 사용합니다.

src/AvailableCharsetReport.java
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는 대상 문자 집합이 표현하지 못하는 문자를 대체 바이트로 바꿀 수 있습니다. 레거시 파일을 생성할 때 이런 대체를 허용하면 한글 일부가 물음표로 저장되어도 작업이 성공한 것처럼 보입니다. CharsetEncoderCharsetDecoderCodingErrorAction.REPORT를 설정하면 malformed 입력과 unmappable 문자를 예외로 구분할 수 있습니다.

src/StrictCharsetConversion.java
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));
    }
}

malformed는 선택한 규칙 자체에 맞지 않는 바이트 또는 잘못된 서로게이트를 뜻하고, unmappable은 입력 문자는 유효하지만 대상 문자 집합에 대응값이 없다는 뜻입니다. 사용자에게 수정 기회를 줄지, 해당 레코드를 격리할지, 전체 파일을 거부할지를 두 오류 종류에 맞춰 정합니다.

바이트 변환 파이프라인

한 인코딩의 파일을 다른 인코딩으로 바꾸는 올바른 순서는 원본 Charset으로 디코딩한 뒤 대상 Charset으로 인코딩하는 것입니다. 바이트 값을 숫자로 복사하거나 ISO-8859-1 문자열을 임시 운반체로 사용하면 의미 구분이 흐려집니다. 대용량 입력은 InputStreamReaderOutputStreamWriter를 연결해 점진적으로 변환하고 양쪽 디코더·인코더의 오류 정책을 명시합니다.

UTF-16의 UTF_16은 입력 BOM을 보고 바이트 순서를 판단할 수 있고 출력 때 BOM을 포함할 수 있습니다. UTF_16BEUTF_16LE는 순서를 고정하며 일반적으로 자동 서명을 붙이지 않습니다. 프로토콜이 정확히 어떤 변형을 요구하는지 확인해야 합니다. “UTF-16”이라는 느슨한 이름만으로 파일 호환성을 단정하지 않습니다.

문자 집합 탐지 한계의 API 표현

임의 바이트열만 보고 문자 집합을 항상 정확히 알아내는 방법은 없습니다. ASCII 범위의 파일은 UTF-8, 여러 서유럽 인코딩, 한글 계열에서 동시에 유효할 수 있습니다. 탐지 라이브러리는 확률을 줄 뿐 사양을 대신하지 않습니다. 업로드 API에 Charset 필드를 두거나 파일 형식을 UTF-8로 고정하고, 값이 없을 때 적용할 정책을 문서화합니다.

입력에 BOM이나 HTTP Content-Type 문자 집합이 있다면 근거의 우선순위를 정합니다. 헤더와 실제 바이트가 충돌하면 조용히 한쪽을 선택하지 말고 오류와 표본을 남깁니다. 비밀 정보가 포함될 수 있으므로 원문 전체 대신 위치, 바이트 오프셋, 짧은 16진수 구간만 기록합니다.

운영 판단표

경계권장 동작관측 항목
애플리케이션 설정시작 시 Charset 정규화정규 이름
새 텍스트 파일UTF-8 명시헤더와 메타데이터
레거시 입력strict 디코더 사용오류 바이트 위치
레거시 출력표현 불가 문자 거부레코드 식별자
불명확한 업로드사용자 선택 요구추정값과 신뢰도

연습 문제

문자열과 대상 Charset 이름을 받아 손실 없이 인코딩 가능한지 반환하는 함수를 작성하세요. 지원하지 않는 이름, 표현 불가 문자, 정상 변환을 서로 다른 결과로 나타내고 정상일 때만 바이트 수를 제공합니다.

정답과 해설
exercise/CharsetCompatibilitySolution.java
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"));
    }
}

결과 타입이 지원 여부와 표현 가능 여부를 분리하므로 운영자는 구성 오류와 데이터 오류를 다르게 처리할 수 있습니다. 실제 서비스에서는 예외 메시지 대신 안정적인 오류 코드를 두고 민감한 원문을 로그에 남기지 않습니다.

무손실 문자 집합 변환의 승인 조건

문자 변환의 성공은 예외가 없었다는 사실이 아니라 원문 의미가 보존되었다는 뜻입니다. Charset을 접점에서 정규화하고 strict 변환을 적용하며, 형식 사양과 다른 입력을 별도 오류로 공개하면 깨진 데이터가 다음 저장 단계로 확산되는 일을 막을 수 있습니다.