String 클래스의 인코딩 처리에 대해 좀 혼동이 되는 부분이 있었는데요. 구글링을 해봐도 속 시원한 설명은 못 찾겠더군요. 자바의 문자 인코딩 관련해서 디벼보면서 알게된 것을 공유해 보겠습니다. 뭐 아시는 분들은 다 아시 내용이겠지만, 제가 자바 자체에 그리 많은 경험이 없으신 분들에게는 도움이 되었으면 좋겠습니다.
유니코드는 지구상의 모든 문자(과거에 사용했던 문자들까지, 예를 들어 옛 한글 문자까지, 그리고 미래에 발견할 언어까지 포함하여)를 표현하기 위한 코드지요. 뭐 다들 아시는 내용이겠지만... 다음 링크가 상당히 잘 설명되어 있는 것 같습니다. (번역 용어도 상당히 맘에 듭니다. ^^; 이글에서는 좋은 번역 용어를 그래도 따르겠습니다. 나머지는 그냥 음차를 하겠습니다. 내용을 공유해 주신 김진숙님께 감사~)
자세한 내용을 원하시는 분은 위 링크를 읽어보시면 좋구요...뭐 간단히 정리하면...
유니코드는 2바이트로(0000 ~ FFFF) 모든 문자를 표현하는 방식입니다. 예를 들어 영문자 'A'는 U+0041이고, 한글 '가'는 U+AC00이죠. 2바이트를 16진수로 표시한 후 앞에 'U+'를 붙여서 유니코드임을 표시합니다. 자바에서는 '\uAC00'으로 표기하구요. 유니코드 공간상의 하나의 값을 코드 포인드(code point)라고 합니다. 즉 U+AC00은 한글 '가'에 대한 코드 포인트입니다. 코드 포인트는 글자(character)만이 아니라, 제어문자, 대행문자, 그래픽(이모티콘, 기호등), 비문자, 개인적 사용등을 나타낼 수 있습니다. 우리가 잘 아는 아스키 코드는 U+0000 ~ U+007F 까지로 표시합니다. 한글의 경우 U+AC00('가') ~ U+D7A3('힣') 범위입니다.(
한글 유니코드)
유니코드 3.0 부터는 더 많은 언어를 포함하기 위해 10FFFF 까지 확장되었습니다. 처음 0000 ~ FFFF까지의 유니코드를
기본다중언어판(Basic Multilingual Plane)이라하고, (1 ~ 10)FFFF까지 16개 언어판을
보충언어판(Supplementary Multilingual Plane)이라고 합니다. 결국 17개의 언어판이 있는 셈이죠. 2바이트로 보충언어판 영역을 표기하기 위해 대행문자(Surrogates)영역을 각 언어판에 만들었습니다. 상위대행문자(uD800 ~ uDBFF, 1024개)와 하위대행문자(uDC00 ~uDFFF, 1024개)의 조합(1024 x 1024 = 1048576개) 보충언어판을 코드를 나타냅니다. 사실 일반적으로는 우리가 대행문자코드를 다룰 일은 거의 없을 듯 합니다. 다만 그런 것이 있다는 정도만 파악하고 나중에 실제로 취급할 일이 있으면 더 자세히 알아보면 좋겠죠.
여기까지가 유니코드 이야기고요...
우선 먼저 왜 UTF-8이냐...
유니코드 자체를 그냥 모든 컴퓨터 관련 작업에서 사용하면 좋았겠지요...그렇다면 인코딩 문제는 신경쓰지 않아도 되니까요. 하지만 인간사가 그렇게 심플하게만 돌아가진 않죠. 기존에 ASCII 코드를 잘 쓰던 미국인들은 이 유니코드가 무척 맘에 들지 않았죠. 영문자 하나는 1바이트로 다 되는데, 왜 2개 씩 써야하는지...도무지 경제적인 이유에서도 참을 수 없는 낭비였죠.
그래서... 좀 더 효율적으로 유니코드를 표현할 방법을 찾은 것이 UTF-8입니다. 켄 톰슨과 롭 파이크가 만들었습니다(
http://www.cl.cam.ac.uk/~mgk25/ucs/utf-8-history.txt). 그리고 여러가지 이유로 유니코드는 그 자체로 사용되기보다는 다른 인코딩 방식을 통해서 표현되었죠. UTF-16, UTF-32 등. 아무튼 현재는 UTF-8이 가장 효율적이라고 하고, 웹 표준을 관리하는 W3C에서도 권장하고 있습니다. 물론 CJK의 경우 한 문자당 3 바이트로 표현된다는 점이 문제가 되긴 하지만...
UTF-8는 유니코드의 한 문자를 표시하기 위해 1 ~ 4개 바이트를 가변 길이 인코딩 방식인데, 다음과 같은 규칙입니다.
1. 아스키 코드처럼 한 바이트 표시할 수 있는 문자들은 최상위 비트가 항상 0이다.
2. 2 바이트 이상으로 표시되는 문자의 경우 첫 바이트의 최상위 비트는 그 문자를 표시하는데 필요한 바이트를 나타낸다. 그래서 2바이트는 110, 3바이트는 1110, 4바이트는 11110 입니다.
3. 첫 바이트가 아닌 나머지 바이트는 상위 2비트가 항상 10입니다.
유니코드 코드 포인트 범위 필요한 비트 수 인코딩 바이트 수 인코딩에서 비트 배치
U+0000 ~ U+007F 7 1 바이트 0xxx xxxx(그대로 인코딩)
U+0080 ~ U+07FF 11 2 바이트 110x xxxx 10xx xxxx
U+0800 ~ U+FFFF 16 3 바이트 1110 xxxx 10xx xxxx 10xx xxxx
U+10000 ~ U+1FFFFF 21 4 바이트 1111 0xxx 10xx xxxx 10xx xxxx 10xx xxxx
한글의 경우 처음 문자 '가'는 U+AC00, 마지막 문자 '힣'은 U+D7A3으로, 표현하는데 필요한 비트 수는 16 비트로, 다음과 같이 3 바이트 비트 배치로 인코딩됩니다.
한글 '가' = U+AC00, 3 바이트 비트 배치
1110 xxxx 10xx xxxx 10xx xxxx
A C 0 0
1010 1100 0000 0000
1010 11 0000 00 0000
1110 1010 1011 0000 1000 0000
E A B 0 8 0
한글 '가'의 UTF-8 인코딩은 0xEAB080 입니다.
한글 '힣' = U+D7A3, 3 바이트 비트 배치
1110 xxxx 10xx xxxx 10xx xxxx
D 7 A 3
1101 0111 1010 0011
1101 01 1110 10 0011
1110 1101 1001 1110 1010 0011
E D 9 E A 3
한글 '힣'의 UTF-8 인코딩은 0xED9EA3 입니다.
그래서 다음과 같습니다.
한글 유니코드 UTF-8
'가' U+AC00 0xEAB080
'힣' U+D7A3 0xED9EA3
세상은 또 그렇게 심플하게 돌아가지 않습니다. 세상이 그리 간단치 않은 이유는 시간때문입니다. 항상 시간은 복잡성의 근원중 하나죠. 이것이 인간의 눈이 오징어 눈보다 설계적 관점에서 열등한 이유이기도 하구요. 역사적으로 UTF-8이 나타나기 이전에 한국에서는 한글을 표현하기 위해 유니코드가 아닌 다른 인코딩 방식을 썼기 때문에 문제는 더 복잡해 집니다.
EUC-KR은 아스키는 최상위 비트가 0인 1바이트로, 한글은 최상위 비트를 1로 한 2바이트로 표현합니다. 자주 사용하는 한글 2350자만 표현하는데, EUC-KR을 확장한 CP949는8822자를 더 추가했습니다. EUC-KR의 한글 인코딩 방식은 KS X 1001인데... 정신건강상 알아서 좋을 건 없을 듯합니다.
각설하고...
EUC-KR로 한글 '가'는 0xB0A1이고, 마지막 글자 '힝'은 0xC8FE입니다.
그래서 다음과 같습니다.
한글 유니코드 UTF-8 EUC-KR
'가' U+AC00 0xEAB080 0xB0A1
'힝' U+D79D 0xED9E9D 0xC8FE
'힣' U+D7A3 0xED9EA3 ?
'힣'은 EUC-KR로는 표현할 수 없어 ?로 표시됩니다.
4. 자바의 String과 인코딩
자바의 String은 내부적으로 유니코드를 UTF-16으로 저장합니다. 2 바이트 기본 자료형 char를 랩핑한 Character 클래스에서
자바의 유니코드를 표현하는 방식에 대해 설명하고 있습니다. 사실 UTF-16이 유니코드를 가장 자연스럽게 표현하는 인코딩 방식이죠.
다음은 자바의 String에서 "가"를 표현하는 방식입니다.
(def s-from-hangul-str (String. "가"))
(def s-from-unicode-str (String. "\uac00"))
(def s-from-euc-kr-bytes (String. (byte-array [0xb0 0xa1]) "euc-kr"))
(def s-from-utf-8-bytes (String. (byte-array [0xea 0xb0 0x80]) "utf-8"))위 코드의 마지막 두 줄의 String 생성자는 첫 번째 인자는 바이트 배열이고, 두 번째 인자는 첫 번째 인자의 바이트 배열이 인코딩된 방식을 지정합니다. 그래서 세 번째 줄의 String 클래스는 주어진 바이트 배열은 UTF-8로 인코딩되어 있으니, UTF-8로 디코딩한 다음 UTF-16의 유니코드로 String 개체 내부에 저장합니다. 마지막 줄의 경우는 EUC-KR로 디코딩합니다.
위 스트링들은 다 같습니다.
(= s-from-hangul-str
s-from-unicode-str
s-from-euc-kr-bytes
s-from-utf-8-bytes)
;=> true
codePointAt 함수는 String 객체에서 지정된 인덱스의 유니코드(코드 포인트)를 반환합니다.
(= 0xAC00
(.codePointAt s-from-hangul-str 0)
(.codePointAt s-from-unicode-str 0)
(.codePointAt s-from-euc-kr-bytes 0)
(.codePointAt s-from-utf-8-bytes 0))
;=> true
getBytes 메소드는 String 객체의 유니코드 값을 지정한 인코딩 방식으로 인코딩한 바이트 배열을 반환합니다.
다음은 utf-8로 인코딩된 바이트 배열을 반환하는 코드입니다.
(map #(format "%x" %) (.getBytes s-from-hangul-str "utf-8"))
;=> ("ea" "b0" "80")
(map #(format "%x" %) (.getBytes s-from-unicode-str "utf-8"))
;=> ("ea" "b0" "80")
(map #(format "%x" %) (.getBytes s-from-euc-kr-bytes "utf-8"))
;=> ("ea" "b0" "80")
(map #(format "%x" %) (.getBytes s-from-utf-8-bytes "utf-8"))
;=> ("ea" "b0" "80")
다음은 euc-kr로 인코딩된 바이트 배열을 반환하는 코드입니다.
(map #(format "%x" %) (.getBytes s-from-hangul-str "euc-kr"))
;=> ("b0" "a1")
(map #(format "%x" %) (.getBytes s-from-unicode-str "euc-kr"))
;=> ("b0" "a1")
(map #(format "%x" %) (.getBytes s-from-euc-kr-bytes "euc-kr"))
;=> ("b0" "a1")
(map #(format "%x" %) (.getBytes s-from-utf-8-bytes "euc-kr"))
;=> ("b0" "a1")
결국, String 클래스의 디코딩/인코딩의 과정은 다음과 같습니다.
디코딩 인코딩
(String. "가") -----> |----------|
(String. "\uac00") -----> | String s | -----> (.getBytes s "utf-8") -----> [0xea 0xb0 0x80](String. (byte-array [0xb0 0xa1]) "euc-kr") -----> | (U+AC00) | -----> (.getBytes s "euc-kr") -----> [0xb0 0xa1]
(String. (byte-array [0xea 0xb0 0x80]) "utf-8") -----> |----------|
디코딩시에는 주어진 바이트 배열이 어떤 방식으로 인코딩되어 있는지를 지정해 주어야 해당 방식으로 정확하게 인코딩할 수 있게 됩니다. 반대로 인코딩시에는 인코딩 방식만 지정해 주면 해당 방식으로 인코딩된 바이트 배열을 반환합니다. 즉 인코딩과 디코딩시에 지정된 인코딩 방식은 주어지는 혹은 반환되는 바이트 배열의 인코딩을 말하는 것이지, String 객체 내부의 인코딩 방식을 나타내는 것은 아닙니다. String 객체 내부는 항상 UTF-16으로 된 유니코드입니다. 이건 변하지 않습니다.
이상입니다.
혹시나 저처럼 모르셨던 분들에게 도움이 되면 좋겠네요~