이 포스트는 NAVER D2에 게시된 한글 인코딩의 이해 2편: 유니코드와 Java를 이용한 한글 처리를 읽고 정리한 것이다.
유니코드 범위 목록(Mapping of Unit characters)을 살펴보면, 한글 표현을 위한 코드 영역 개수는 다른 언어 글자를 위한 코드 영역 개수보다 대체로 많다는 것을 알 수 있다.
유니코드에서 한글을 표현하기 위한 코드 영역은 다음과 같다.
이름 | 처음 | 끝 | 개수 |
---|---|---|---|
한글 자모 (Hangul Jamo) | 1100 | 11FF | 256 |
호환용 한글 자모 (Hangul Compatibility Jamo) | 3130 | 318F | 96 |
한글 자모 확장 A (Hangul Jamo Extended A) | A960 | A97F | 32 |
한글 소리 마디 (Hangul Syllables) | AC00 | D7AF | 11184 |
한글 자모 확장 B (Hangul Jamo Extended B) | D7B0 | D7FF | 80 |
한글은 한글 자모, 한글 자모 확장을 사용해 조합형으로 표현할 수도 있고 한글 소리 마디를 사용해 확장형으로도 표현할 수 있다.
이러한 패턴은 한글 뿐 아니라 다른 언어에서도 발견된다. 가령 스페인어의 "ñ"을 표현할 때 완성형인 U+00F1을 사용할 수도 있고, U+006E (라틴 소문자 "n") 과 U+0303( 결합 틸데 "◌̃")을 조합하여 표현할 수도 있다.
유니코드 정규화(Unicode equivalence)는 이렇게 연속적인 코드를 사용하여 표현한 어떤 글자를 처리하는 방법을 다루는 명세로, 문자열을 비교하거나 정렬할 때 사용된다.
유니코드 정규화 방법은 네 가지가 있는데 한글 표현에 사용되는 방법은 NFD와 NFC이다.
정규화 방법 | 예 |
---|---|
NFD (정준 분해) | À (U+00C0) → A (U+0041) + ̀ (U+0300) 위 (U+C704) → ᄋ (U+110B) + ᅱ (U+1171) |
NFC (정준 분해한 뒤 다시 정준 결합) | A (U+0041) + ̀ (U+0300) → À (U+00C0) ᄋ (U+110B) + ᅱ (U+1171) → 위 (U+C704) |
NFKD (호환 분해) | fi (U+FB01) → f (U+0066) + i (U+0069) |
NFKC (호환 분해한 뒤 다시 정준 결합) | 樂 (U+F914), 樂 (U+F95C), 樂 (U+F9BF) → 樂 (U+6A02) |
Java에서 문자열(String
)은 UTF-16 BE(Big Endian)로 인코딩되어 저장된다.
문자열 전송/수신을 위해서 직렬화가 필요할 때에는 변형된 UTF-8(Modified UTF-8)을 사용한다. Java의 DataInput
, DataOutput
인터페이스 구현체에서는 문자열을 기록하거나 읽어들일 때 이 변형된 UTF-8을 사용한다.
코드 범위 | 인코딩 규칙 |
---|---|
U+0000 | 11000000 10000000 (0xC080) |
U+0001 ~ U+FFFF | UTF-8 인코딩과 동일 |
U+010000 ~ U+1FFFFF | UTF-16 인코딩한 값을, UTF-8 인코딩함 (CESU-8) |
U+0000(NULL)은 기본 UTF-8 규칙에 따르면 그대로 인코딩되어 0x00
이 되어야 하지만 예외적으로 0xC080
로 인코딩한다. C 언어와 같이 널 문자를 문자열의 끝으로 해석하는 언어를 고려한 것이다.
Java에서 char
타입은 UTF-16 문자를 저장하고 2 바이트를 차지한다. 그런데 U+010000 이상의 글자는 2 바이트 이상의 공간이 필요하기 때문에 다른 처리 방법이 필요하므로 이 때는 UTF-8의 변형인 CESU-8 방식을 사용한다.
즉 Java의 변형된 UTF-8은 CESU-8에 NULL 문자 처리(U+0000)을 추가한 것이다.
Java에서 문자열은 항상 UTF-16 BE 인코딩으로 저장되고, 문자열을 입/출력할 때에만 사용자가 지정한 인코딩 값 또는 운영체제의 기본 인코딩 값으로 문자열을 인코딩한다.
file.encoding
시스템 프로퍼티에서 인코딩을 변경할 수 있다. 단 JVM 기본 인코딩은 JVM 로딩 시에만 초기화되므로 코드 중간에서 file.encoding
프로퍼티를 바꾸는 것은 아무 의미가 없다.
만약 file.encoding
이 지정되어 있지 않다면 OS 환경 변수(예: LANG
) 값을 따른다.
Java에서는 유니코드의 코드 포인트 값을 String.codePointAt(int);
메서드를 이용하여 확인할 수 있다. 다음은 '한글
'(U+D55C U+AE00)에 대한 코드 포인트 값을 출력한 예이다.
String string = "한글";
for (int i = 0; i < string.length(); i++) {
System.out.print(String.format("U+%04X ", string.codePointAt(i)));
}
System.out.println();
Java에서 인코딩된 값을 알아보려면, getBytes()
메서드를 이용하여 확인할 수 있다. 다음은 '한글'에 대한 인코딩 값을 출력한 예이다.
String string = "한글";
byte[] bytes = string.getBytes();
for (byte b : bytes) {
System.out.print(String.format("0x%02X ", b));
}
System.out.println();
참고로 Java에서 글자를 깨뜨리지 않으려면 문자 집합의 이름을 지정해야 한다.
예를 들어 문자열 객체의 getBytes()
메서드를 이용하여 바이트 배열을 얻고자 할 때는 getBytes()
대신 getBytes(String charsetName)
메서드를 사용하고, 반대로 바이트 배열에서 문자열 객체를 얻고자 할 때는 new String(byte[] b)
대신 new String(byte[] bs, String charsetName)
메서드를 사용한다.
웹에서 한글이 왜 깨지는가? 브라우저 인코딩 값과 서버 인코딩 값이 다르기 때문이다.
한글 처리, 특히 웹에서의 한글 처리는 무척 까다롭다. 그 이유는 사용자의 환경이 매우 다르다는 데 있다. 웹 프로그래밍을 하려면, 운영체제의 기본 인코딩, Java 소스 코드의 인코딩, JSP 파일의 인코딩, HTTP 요청의 인코딩, HTTP 응답의 인코딩, 데이터베이스의 인코딩, 파일의 인코딩 - 이렇게 많은 인코딩과 마주하게 된다.
'한글
'이라는 문자열을 EUC-KR
, UTF-8
, ISO8859-1
로 인코딩한 뒤 다시 디코딩하여 어떻게 표시되는지 살펴보자.
String hangul = "한글";
String[] encodings = new String[] {"EUC-KR", "UTF-8", "ISO8859-1"};
for (String encoding1 : encodings) {
String encoded = URLEncoder.encode(hangul, encoding1);
System.out.println(encoded);
System.out.print("\t");
for (String encoding2 : encodings) {
String decoded = URLDecoder.decode(encoded, encoding2);
System.out.print(decoded + "\t\t");
}
System.out.println("\n");
}
코드를 실행한 결과이다.
첫 번째 루프에서는 EUC-KR
로 인코딩했기 때문에 같은 EUC-KR
로 디코딩했을 때만 글자가 깨지지 않는다. 두 번째 루프에서는 UTF-8
로 인코딩했기 때문에 똑같이 UTF-8
로 디코딩했을 때만 글자가 정상적으로 보인다.
그런데 세 번째 루프에서는 ISO8859-1
로 인코딩했는데, 똑같은 방식으로 디코딩했지만 글자가 깨져버렸다. 그 이유는 ISO8859-1
가 한글이 아닌 라틴 알파벳을 인코딩하는 데 사용되기 때문이다.
이 예시에서 보듯 웹에서 여러 인코딩을 지원하려면 인코딩된 URL 문자열 뿐 아니라 사용한 인코딩 정보도 파라미터로 전달 해야 한다. 예를 들어, "/search.nhn?query=%C7%D1%B1%DB&ie=EUC-KR"
과 같이 URL이 설정되어 있다면, ie
파라미터 값을 이용하여 query
의 파라미터 값을 URL 디코딩하면 된다.
그리고 가능하다면 Javascript의 encodeURI
메서드 (또는 encodeURIComponent
메서드)를 사용하는 것이 좋다.
Javascript는 escape
, encodeURI
, encodeURIComponent
메서드를 이용하여 URL을 인코딩할 수 있다.
이 중 escape
메서드는 A~Z
, a~z
, 0~9
, @*-_+./
문자가 아니면 유니코드 형식으로 인코딩하는데, Tomcat은 인코딩된 슬래시(/
)를 경로 구분자로 취급하여 URL 디코딩 시에 문제가 발생하게 된다. 예를 들어, /proxy/http%3A%2F%2Fwiden.com%2Fcareers
라는 URI는 인코딩된 슬래시를 경로 구분자로 인식하여 /proxy/http%3A//widen.com/careers
로 변환되어, 이는 일치하는 엔드포인트 핸들러를 찾지 못하여 요청이 거부된다.
일반적으로 문자열을 URL 인코딩하기 위해서 encodeURI
메서드를 많이 사용하며, encodeURI
는 :;=?&
문자는 인코딩하지 않는다. Java의 URLEncoder.encode
메서드와 Javascript의 encodeURI
메서드는 공백(whitespace)을 '%20
'으로 인코딩하느냐, '+
'로 인코딩하느냐만 다르다.
마지막으로 encodeURIComponent
메서드는 encodeURI
메서드와 유사하지만, :;/=?&
도 인코딩한다.
EUC-KR 인코딩은 완성형 방식으로 각 글자는 2 바이트 크기의 코드로 변환된다.
한글 뿐 아니라 한국어에서 통용되는 한자 및 특수기호 등이 포함되어 있다. 총 2,350자이기 때문에 '똠'과 같이 EUC-KR 문자 집합에 포함되어있지 않은 글자들이 다소 존재한다. 문자 집합에 없는 글자는 브라우저가 자체적으로 처리하지만 깨져 보일 수 있다.
LANG
환경 변수에 따라 다르지만, ko
, ko_KR
, ko_KR.eucKR
은 모두 EUC-KR 인코딩이며, ko_KR.UTF-8
만 UTF-8 인코딩이다.0xEF 0xBB 0xBF
이며, 나머지 인코딩에 대한 BOM 값은 위키백과(http://en.wikipedia.org/wiki/Byte_order_mark)를 참고하면 좋다.