자바의 String과 인코딩(UTF-8과 EUC-KR)

3,650 views
Skip to first unread message

구르마

unread,
Mar 26, 2016, 9:35:43 AM3/26/16
to Korean Clojure User Group
String 클래스의 인코딩 처리에 대해 좀 혼동이 되는 부분이 있었는데요. 구글링을 해봐도 속 시원한 설명은 못 찾겠더군요. 자바의 문자 인코딩 관련해서 디벼보면서 알게된 것을 공유해 보겠습니다. 뭐 아시는 분들은 다 아시 내용이겠지만, 제가 자바 자체에 그리 많은 경험이 없으신 분들에게는 도움이 되었으면 좋겠습니다.


1. 우선 먼저 유니코드에 대해서...

유니코드는 지구상의 모든 문자(과거에 사용했던 문자들까지, 예를 들어 옛 한글 문자까지, 그리고 미래에 발견할 언어까지 포함하여)를 표현하기 위한 코드지요. 뭐 다들 아시는 내용이겠지만... 다음 링크가 상당히 잘 설명되어 있는 것 같습니다. (번역 용어도 상당히 맘에 듭니다. ^^; 이글에서는 좋은 번역 용어를 그래도 따르겠습니다. 나머지는 그냥 음차를 하겠습니다. 내용을 공유해 주신 김진숙님께 감사~)


자세한 내용을 원하시는 분은 위 링크를 읽어보시면 좋구요...뭐 간단히 정리하면...

유니코드는 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개) 보충언어판을 코드를 나타냅니다. 사실 일반적으로는 우리가 대행문자코드를 다룰 일은 거의 없을 듯 합니다. 다만 그런 것이 있다는 정도만 파악하고 나중에 실제로 취급할 일이 있으면 더 자세히 알아보면 좋겠죠.

여기까지가 유니코드 이야기고요... 


2. 다음으로는 UTF-8.

우선 먼저 왜 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


3. 그리고... EUC-KR

세상은 또 그렇게 심플하게 돌아가지 않습니다. 세상이 그리 간단치 않은 이유는 시간때문입니다. 항상 시간은 복잡성의 근원중 하나죠. 이것이 인간의 눈이 오징어 눈보다 설계적 관점에서 열등한 이유이기도 하구요. 역사적으로 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으로 된 유니코드입니다. 이건 변하지 않습니다.

이상입니다.
혹시나 저처럼 모르셨던 분들에게 도움이 되면 좋겠네요~

박상규

unread,
Mar 26, 2016, 9:47:48 AM3/26/16
to cloju...@googlegroups.com
허걱... 보내고 보니 메일로 보면 너비가 좁아져서 깨지는 군요. 
구글 그룹스로 들어가시면 깨지지 않고 보실 수 있습니다.

2016년 3월 26일 오후 10:35, 구르마 <psk...@gmail.com>님이 작성:

--
이 메일은 Google 그룹스 'Korean Clojure User Group' 그룹에 가입한 분들에게 전송되는 메시지입니다.
이 그룹에서 탈퇴하고 더 이상 이메일을 받지 않으려면 clojure-kr+...@googlegroups.com에 이메일을 보내세요.
이 그룹에 게시하려면 cloju...@googlegroups.com에 이메일을 보내세요.
웹에서 이 토론을 보려면 https://groups.google.com/d/msgid/clojure-kr/a27bca67-9770-4d04-b4ed-cc2abcac08bd%40googlegroups.com을(를) 방문하세요.
더 많은 옵션을 보려면 https://groups.google.com/d/optout을(를) 방문하세요.

Ilkyun Park

unread,
Mar 26, 2016, 9:44:37 PM3/26/16
to Korean Clojure User Group
자바에서의 String과 인코딩에 대해 구체적으로 정리해주셔서 감사합니다. 저도 한달 전에 같은 문제로 시행착오를 겪어본 적이 있어서 그런지 내용이 더 와 닿네요.
한마디로 외부에서 어떤 인코딩을 사용하던 상관 없이 String 내부에서는 unicode로 관리한다, 로 요약할 수 있을 것 같습니다. :)

구르마

unread,
Mar 28, 2016, 9:21:44 PM3/28/16
to Korean Clojure User Group
사실, 자바 String 인코딩/디코딩시의 문제는 정보 손실 입니다. 즉 웹에서 많이 사용하는 문자 인코딩인 UTF-8과 EUC-KR간 인코딩/디코딩시 원래의 글자가 손실되어서 ?나 �로 나타나게 되는데요. 이때 원래의 정보가 손실되는 현상이 발생합니다. 흔히 글짜 깨짐 현상과 관련된 것이죠.

이것은 아래 코드처럼 utf-8로 인코딩된 바이트 배열을 euc-kr로 디코딩 할 경우, 혹은 그 반대의 경우에 발생하는 현상입니다.
(String. (.getBytes "가" "utf-8") "euc-kr")
;=> "媛�"

(String. (.getBytes "가" "euc-kr") "utf-8")
;=> "��"
�는 특수 유니코드 문자로서 대치 문자(U+FFFD) 인데, 인코딩/디코딩시 적절한 문자를 찾지 못하면 바로 이 문자로 대치됩니다. 이렇게 되면 원래의 정보를 다시 획득할 방법이 없게 됩니다.

좀 더 설명해 보면...

아래 코드와 같이 제대로 디코딩하면 정보의 손실이 없이 원래의 바이트 배열을 획득할 수 있습니다.
(def utf-8-bytes (byte-array [0xea 0xb0 0x80])) ; 한글 '가'를 utf-8로 인코딩한 바이트 배열.
(def s-from-utf-8-bytes (String. utf-8-bytes "utf-8")) ; 바이트 배열이 utf-8로 인코딩되어 있으니, utf-8로 디코딩하라고 해서 유니코드를 만든다.
s-from-utf-8-bytes
;=> "가"   <==== 제대로 글자가 찍힌다.


(map #(format "%x" %) (.getBytes s-from-utf-8-bytes "utf-8"))
;=> ("ea" "b0" "80") <==== 위의 utf-8-bytes와 바이트 배열이 같다.

하지만 다음과 같이 utf-8로 인코딩된 것을 euc-kr롤 디코딩하라고 하면 정보가 손실되어 원래의 바이트를 획득할 수 없게 됩니다.
(def utf-8-bytes (byte-array [0xea 0xb0 0x80])) ; 한글 '가'를 utf-8로 인코딩한 바이트 배열
(def s-from-utf-8-bytes (String. utf-8-bytes "euc-kr")) ; 바이트 배열이 utf-8로 인코딩되어 있는데, euc-kr로 디코딩하라고 해서 유니코드를 만든다.
s-from-utf-8-bytes
;=> "媛�" <== 글자 깨짐

(map #(format "%x" %) (.getBytes s-from-utf-8-bytes "utf-8"))  ; utf-8로 인코딩하면...
;=> ("e5" "aa" "9b" "ef" "bf" "bd") <== utf-8-bytes와 다르다. 정보가 손실되어 원래의 바이트 배열을 획득하는데 실패

(map #(format "%x" %) (.getBytes s-from-utf-8-bytes "euc-kr")) ; euc-kr로 인코딩하면...
;=> ("ea" "b0" "3f")    
<==  utf-8-bytes와 다르다. 정보가 손실되어 원래의 바이트 배열을 획득하는데 실패

(String. (.getBytes s-from-utf-8-bytes "euc-kr") "euc-kr") ; euc-kr로 인코딩하고 다시 디코딩하지만
;=> "媛?" <== 원래의 '가'로 돌아오지 못한다
자바의 String은 utf-8로 인코딩된 [0xea 0xb0 0x80]를 euc-kr로 디코딩하면서 매칭되는 문자를 찾지 못해 대치 문자()로 저장한 것입니다. 이 과정에서 s-from-utf-8-bytes은 더 이상 '가'라는 정보를 담고 있지 않게 되고, 이후로는 어떠한 방법으로도 손실된 정보를 복구할 수가 없습니다.

이러한 현상은 보통 서버단에서 유저 request를 받아서 url-decoding하면서 발생하게 됩니다. 만일 브라우저에서 EUC-KR로 인코딩했는데 request 헤더로 그 인코딩 방식을 전달하지 않으면, 서버에서는 기본 인코딩(예를 들어 utf8)로 디코딩하게 되면서, 글자 깨짐 현상이 발생하게 되는 것입니다.

사실 이러한 현상이 나타나는 이유는 utf-8이나 euc-kr이나 다중 바이트 인코딩 방식으로,  그 코드들이 유니코드와 바이너리 차원에서 일대일 매칭이 되지 않기 때문입니다. 반면에 톰캣에서 사용하는 ISO-8859-1의 경우에는 8bit 인코딩 방식이라 유니코드와 일대일로 매칭되기 때문에 '정보 손실' 이 없습니다.

ISO-8859-1의 경우, 정보의 손실이 없이 원래의 바이트를 획득할 수 있습니다.
(def utf-8-bytes (byte-array [0xea 0xb0 0x80])) ; 한글 '가'를 utf-8로 인코딩한 바이트 배열.
(def s-from-utf-8-bytes (String. utf-8-bytes "ISO-8859-1")) ; 바이트 배열이 utf-8로 인코딩되어 있으니, ISO-8859-1로 디코딩하라고 해서 유니코드를 만든다.
s-from-utf-8-bytes
;=> 
"ê°€" <=== 글짜는 역시 깨진다.

(map #(format "%x" %) (.getBytes s-from-utf-8-bytes "
ISO-8859-1")) ; ISO-8859-1로 인코딩하는데...
;=> ("ea" "b0" "80")   <==== 위의 utf-8-bytes와 바이트 배열이 같다.

 ; 사실, 코드 포인트를 보면 입력된 바이트 배열 그대로 보존되어 있음을 알 수 있다.
(map #(format "%x" (.codePointAt s-from-utf-8-bytes %)) [0 1 2])
;=> 
("ea" "b0" "80")    <==== 위의 utf-8-bytes와 바이트 배열이 같다.







2016년 3월 27일 일요일 오전 10시 44분 37초 UTC+9, Ilkyun Park 님의 말:
Reply all
Reply to author
Forward
0 new messages