문자열 인코딩(Character Encoding)

2021. 10. 13. 17:46TIL💡/Others

문자열 인코딩이란 2진법을 사용하는 컴퓨터가 인간의 언어를 일정한 규칙에 따라 2진수로 변환하는 방식이다. 위의 이미지처럼 컴퓨터는 Hello라는 문장을 그대로 읽거나 처리할 수 없기 때문에 사람들이 만든 2진수와 문자를 일대일로 대응하는 규칙을 통해 2진수로 문자를 처리한다. 규칙은 크게 아스키 코드, EUC-KR, UTF-9, UTF-16, UTF-32 등이다.

 

그런데 어떠한 규칙을 적용하는지에 따라 글자가 깨지거나 보이지 않는 문제가 발생한다. 왜 이러한 문제가 발생할까?

 

컴퓨터가 처음 등장했을 때 모든 프로그램은 영어와 일부 특수문자만 지원했다. 하지만 여러 국가에서 컴퓨터를 사용하기 위해 국가별로 사용하는 언어를 표현하고자 독자적인 규칙을 만들기 시작했다. → 즉 여러 언어를 구현하기 위해 다양한 규칙 혼재

그리고 모든 언어를 같은 규칙으로 표현할 수 있는 유니코드(Unicode) 방식이 등장하였다!! 

 

그러나 모든 개발환경이 유니코드를 동일하게 처리하지 않아 개발자는 서로 호환되지 않는 유니코드 문자열 인코딩 방식(UTF-8, UTF-16, UTF-32) 중 하나를 택해야 한다. 심지어 한국에는 유니코드가 등장하기 전에 만든 독자적인 인코딩 방식(EUC-KR)을 쓰는 오래된 시스템도 아직 남아 있다.

 

문자열 인코딩을 알고있다면 인코딩 관련 문제가 발생했을 때 운영체제, 개발 환경 등이 달라서인지, 연동하려는 다른 서비스나 라이브러리에 맞지 않아서인지 파악할 수 있다.

 

문자 집합(Charset)이란?

문자 집합이란 사용할 수 있는 문자들의 집합을 말한다. 유니코드, ISO-8859, ASCII 등이 이에 해당한다. 반면 문자열 인코딩은 문자를 코드로 표현하는 방식을 일컫는다. 예를 들어, 유니코드라는 문자 집합을 표현하는 문자열 인코딩은 UTF-8, UTF-16, UTF-32 등이 있다. 그러나 흔히 문자 집합과 문자열 인코딩을 혼용해서 사용한다. 

 

아스키 코드(ASCII)란?

처음으로 표준을 정립한 문자열 인코딩 방식으로 아직까지 많이 사용된다. 사용할 수 있는 문자의 종류에는 대문자, 소문자, 아라비아 숫자, 공백 및 특수 문자들이 있으며 문자를 표현할 때는 0부터 127까지, 총 128개의 숫자를 사용한다.

 

과거에는 7비트 2진수만 사용했지만, 현대 운영체제는 성능 향상과 편의를 위해 8비트(1바이트)를 사용하여 아스키 코드를 표현한다. 

아스키 코드를 문자로 표현하면 아래와 같다.

  • 'Hello' 문자열 길이: 5
  • 'Hello' 전체 문자를 표현하는 데 사용한 바이트 수: 5바이트
  • 'Hello' 16진수 값: 0x48 0x65 0x6c 0x6f
  • 'Hello' 10진수 값: 72 101 108 108 111

대신 아스키 코드는 영어를 제외한 다른 언어를 표현할 수 없다. 그래서 각 나라에서 컴퓨터를 사용하기 시작했을 때는 아스키 코드 문자 대신 독자적인 문자 집합과 인코딩 방식을 만들어 사용했고, 한국도 예외는 아니었다.

 

EUC-KR(CP949)

EUC-KR은 한국 산업 표준으로 지정된 한국어 문자 집합으로 문자 하나를 표현하기 위해 2바이트를 사용한다. 단, 아스키 코드 문자를 표현할 때는 1바이트를 사용하기 때문에 아스키 코드와 호환된다.

EUC-KR은 모든 글자가 완성된 형태로만 존재하는 '완성형' 코드이다. 따라서 한글처럼 초성, 중성, 종성을 조합해 문자를 만들 수 없기 때문에 EUC-KR로 표현할 수 없는 한글이 일부 존재한다. 물론 이는 일반적으로 잘 사용되지 않는 글자다.

유니코드 2.0 버전에서 초성, 중성, 종성에 해당하는 코드로 나눠 표현하는 조합형 글자를 만들면 EUC-KR로 표현할 수 없는 글자들을 만들 수 있다.

 

CP949는 EUC-KR을 확장한 문자 집합으로 EUC-KR과 같은 문자열 인코딩이나 더 많은 문자를 표현할 수 있다. 오늘날에는 EUC-KR로 표기하더라도 실제로는 CP949문자 집합을 사용하는 경우가 많다.

 

EUC-KR vs. 아스키 코드

EUC-KR로 출력

  • 'Hello' 문자열 길이: 5
  • 'Hello' 전체 문자를 표현하는 데 사용한 바이트 수: 5바이트
  • 'Hello' 16진수 값: 0x48 0x65 0x6c 0x6f
  • 'Hello' 10진수 값: 72 101 108 108 111
  • '안녕하세요' 문자열 길이: 5
  • '안녕하세요' 전체 문자를 표현하는 데 사용한 바이트 수: 10바이트
  • '안녕하세요' 16진수 값: 0xbe 0xc8 0xb3 0xe7 0xc7 0xcf 0xbc 0xbc 0xbf 0xe4
  • '안녕하세요' 10진수 값: 190 200 179 231 199 207 188 188 191 228

영문자 'Hello'를 출력할 때 아스키 코드와 동일하게 5바이트를 사용했지만 한글 '안녕하세요'를 출력하기 위해서는 10바이트를 사용했다. EUC-KR로 아스키 코드 영역에 있는 글자를 표현할 때는 1바이트를 사용하지만, 한글 문자를 표현할 때는 2바이트를 사용하기 때문이다.

 

이처럼 문자열 인코딩에서는 실제 문자열 길이가 버퍼 길이와 다른 경우가 많다. 실제 문자열 길이는 사람 눈에 보이는 문자 길이에 해당하고, 버퍼 길이는 컴퓨터가 문자를 표현하는 데 사용하는 바이트 수를 의미한다. 여기서 버퍼는 메모리에 할당된 공간을 의미한다.(예를 들어 변수를 선언해 숫자나 문자열 값을 넣거나, 새로운 객체를 생성하는 행위 등 모두 버퍼가 필요하다.) 그래서 실제 문자열 길이와 컴퓨터가 할당하는 버퍼 길이는 항상 다를 수 있다는 점을 꼭 기억하자!

 

유니코드(UTF-8, UTF-16, UTF-32)

과거에는 EUC-KR처럼 국가별로 독자적인 문자 집합과 인코딩 방식을 사용했다. 따라서 전 세계 사용자를 대상으로 하는 프로그램이나 웹 페이지를 만들려면 언어별로 다른 인코딩 방식을 사용해야 했다. 언어별로 다른 메시지를 만들고 관리하는 것도 어려운데, 인코딩 방식까지 다르게 적용한다면 개발자 입장에서 너무 골치가 아파진다..🤯

 

이렇게 국가별로 독자적인 문자열 인코딩을 사용하는 문제를 해결하기 위해 국제 표준화 기구(ISO)에서 동일한 규칙으로 모든 언어를 표현할 수 있는 유니코드 문자집합을 만들었다. 

유니코드 문자 집합을 표현하는 문자열 인코딩은 총 세 가지로 UTF-8, UTF-16, UTF-32가 있다. 아스키 코드나 EUC-KR처럼 문자 집합에 해당하는 하나의 인코딩 규칙만 있는 게 아니다.

 

UTF-8

UTF-8은 8비트(1바이트)로 인코딩하는 것을 의미힌다. UTF-8은 아스키 코드와 완벽하게 호환되며, 표현하려는 문자에 따라 최소 1바이트에서 최대 6바이트까지 사용한다.

위 도표는 각 코드 포인트 범위 내에서 표현 가능한 수를 인코딩하는 방식이다. 인코딩 열에 표시된 1과 0은 고정된 비트 값이며, 사용하는 바이트 수에 따라 어떻게 고정될지가 달라진다. X문자는 유니코드를 저장하는 데 사용할 비트 영역이다. 

첫번째 줄에는 첫번째 비트 값이 0으로 고정되어야 하므로 0을 제외한 나머지 비트 7개로 문자를 표현한다. 0부터 127까지의 수로 문자를 표현하는 아스키 코드와 같은 규칙을 사용하므로 UTF-8은 아스키 코드와 완벽히 호환된다. 

 

두 번째 줄은 비트값이 110으로 시작한다. 이 경우에는 2바이트이므로 UTF-8 문자를 읽어야 한다. 첫 번째 바이트의 110XXXXX에서 110을 제외한 비트 5개, 두 번째 바이트에서 10XXXXXX에서 10을 제외한 비트 6개를 조합하면 총 11개의 비트로 UTF-8 문자를 만들 수 있다.

UTF-16

UTF-16은 16비트(2바이트)로 인코딩하는 것을 의미한다. UTF-16은 2바이트 또는 4바이트만 사용하기 때문에 아스키 코드와 호환되지 않는다. 유니코드에는 문자의 종류에 따라 기본 다국어 평면(BMP), 보충 다국어 평면(SMP), 상형 문자 보충 평면(SIP), 특수 목적 보충 평면(SSP) 등 평면 4개가 잇고, 바이트 수는 표현하려는 문자가 어떤 평면에 속하는지에 따라 결정된다.

  • 'Hello' 문자열 길이: 5
  • 'Hello' 전체 문자를 표현하는 데 사용한 바이트 수: 12바이트
  • 'Hello' 16진수 값: 0xff 0xfe 0x48 0x0 0x65 0x0 0x6c 0x0 0x6f 0x0
  • 'Hello' 10진수 값: 255 254 72 101 0 108 0 108 0 111 0
  • '안녕하세요' 문자열 길이: 5
  • '안녕하세요' 전체 문자를 표현하는 데 사용한 바이트 수: 12바이트
  • '안녕하세요' 16진수 값: 0xff 0xfe 0x48 0xc5  0xc55 0xb1 0x58 0xd5 0x38 0xc1 0x94 0xc6
  • '안녕하세요' 10진수 값: 255 254 72 197 85 177 88 213 56 193 148 198

일반 글자를 표현할 때 2바이트를 사용하고, 아스키 코드와 호환되지 않으니 예상대로라면 10바이트를 사용해야 한다. 하지만 실제로는 12바이트를 사용했다. 바로 앞에 추가된 0xff 0xfe 때문이다.

 

바이트 순서 표시

UTF-16과 UTF-32는 바이트 순서 표시(byte order mask)(BOM)을 사용한다. BOM은 문자열 가장 맨 앞 2바이트에 0xFEFF(유니코드로 U+FEFF)라고 표기하여 사용한다는 것을 의미한다. 또한 0xFE, 0xFF 중 어떤 문자가 먼저 오는지에 따라 리틀 엔디언빅 엔디언으로 나뉜다. 그래서 두 방식에 따라 문자열 인코딩 시 바이트 데이터를 조합하는 순서가 바뀌게 된다.

BOM을 이요하여 바이트 표현 순서를 정하는 이유는, CPU 설계에 따라 바이트 값을 처리하는 순서가 다르기 때문이다. 같은 0xFEFF를 CPU가 읽을 때 리틀 엔디언 방식은 0xFF 다음 0xFE을 읽으며, 빅 엔디언 방식은 0xFE 다음 0xFF를 읽는다.

 

빅엔디언은 문자를 구성하는 바이트 두 개 [12],[34] 중 큰 단위인 [12]가 먼저 나온다. 반대로 리틀 엔디언은 [12],[34] 중 작은 단위인 [34]가 먼저 나온다. 

※ 여기서 단위의 크고 작음은 메모리의 주소값이 기준인 것 같다. 문자를 구성하는 바이트가 스택에 들어가게 되면 스택에 34부터 쌓이고, 그 위에 12가 쌓인다고 생각하면 이해하기 쉽다.

 

위의 Hello, 안녕하세요 결과에는 0xFF가 먼저 나왔으므로 리틀엔디언(UTF-16-LE)인코딩으로 볼 수 있다. 리틀 엔디언은 뒷자리부터 읽으므로 0x48, 0xC5를 0xC548로 읽는다. 오늘날 대부분의 개인 컴퓨터는 리틀엔디언 방식을 사용한다. 


마무리

UTF-8

  • 오늘날 가장 많이 사용하는 문자열 인코딩이며 최소 1바이트, 최대 6바이트를 사용한다. 그러나 대부분 4바이트 내로 처리한다.
  • 아스키 코드와 호환 가능하다.
  • 윈도우, 자바, 임베디드를 제외한 거의 모든 환경에서의 문자열 처리 표준으로 봐도 좋다.
  • JSON은 UTF-8 인코딩만 사용하며, 다른 문자열 인코딩은 표준에서 지원하지 않는다.

UTF-16

  • 자바와 윈도우는 유니코드를 사용하기 전부터 고정된 2바이트 길이의 문자 집합을 사용했다. 그래서 UTF-16은 멀티 바이트라고도 한다. 두 환경에서의 호환성 외에 UTF-16을 사용할 별다른 이유는 없다.
  • 2바이트 또는 4바이트 길이의 문자열을 사용하며, 아스키 코드와 호환되지 않는다.
  • UTF-16 기반 환경에서 UTF-8을 사용할 때는 사용 영역을 명확히 구분하는 게 좋다. 예를 들어 자바 기반 웹 서비스는 기본적으로 UTF-16을 사용하되, 외부(데이터베이스와 브라우저 간) 통신 시 UTF-8로 변환하여 사용하는 게 좋다.

UTF-32

  • 4바이트를 고정적으로 사용한다.
  • 반드시 UTF-32를 사용해야 하는 환경이 아니라면 사용하지 않는다.

EUC-KR

  • 한국에서 독자적으로 사용하는 문자열 인코딩으로 고정된 2바이트를 사용한다.
  • 가능하다면 UTF-8로 바꾸는 게 좋지만 현실적으로 어려운 경우가 많다. UTF-16과 마찬가지로 경계를 명확히 구분하여 사용하는 게 좋다.