프론트엔드 개발자를 위한 문자열 인코딩

1. 문자열 인코딩의 역사

A. ASCII 코드

1960년대, 다양한 통신 시스템들이 각각 고유한 문자 인코딩 방식을 사용했기 때문에 서로 다른 통신 시스템 사이에 문자를 교환하는 것이 무척 까다로웠다. 이러한 문제를 해결하고자 통신 시스템에서 사용하는 문자 인코딩 방식을 통일하기 위해 미국 표준 협회(ANSI)는 아스키(ASCII; American Standard Code for Information Interchange)라는 표준 인코딩 방식을 개발하였다. 

ASCII 코드
1. 7비트로 제어문자, 영문자 및 숫자, 일부 특수문자를 표현(총 128개 문자)
2. 당시 많은 시스템, 장비들이 7비트를 기본으로 처리해 ASCII 코드에서도 7비트를 사용해 메모리를 절약하고, 1비트는 데이터의 오류를 확인하기 위한 Pairty Bit로 사용하였다.

ASCII 코드의 등장으로 표준화된 인코딩 방식이 도입되었지만 비영어권 국가의 문자는 표현하지 못했기 때문에 남겨둔 1비트까지 문자를 표현할 수 있는 확장된 ASCII 코드(Extended ASCII)가 개발되었다. 하지만 한글, 일본어와 같은 문자들은 256개의 문자로도 모든 글자를 표현할 수 없기 때문에 EUC-KR, CP949, EUC-JP, Shift_JIS 등과 같은 개별 2바이트 언어에 특화된 여러 인코딩 방식이 개발되었다.

 

 

B. 유니코드(Unicode)

중국어, 한글, 아랍어, 히브리어, 일본어 등 각국에서는 자국의 언어를 다루기 위해 독자적인 인코딩 방식을 개발했지만 서로 호환되지 않는 문제가 발생했다. 2000년대 초반의 인터넷을 경험해 본 사람이라면 웹페이지의 문자가 깨져 보이는 경험을 해본 적이 있었을 것이다. 이런 문제는 유럽어의 문자 집합에도 있었다. 유로화를 나타내는 '€' 기호에는 ISO 8859-15(Latin 9)의 코드 값 중 0xA4이 할당되었으나 이 코드에 해당하는 ISO 8859-1(Latin 1)의 문자는 '¤'이다. 이렇게 인코딩 방식에 따라 글자가 제대로 표현되지 못하거나 깨지는 문제를 해결하기 위해 모든 문자를 하나의 통합된 코드 체계로 표현할 방법이 필요해졌고 1990년에 유니코드가 등장하게 되었다. 유니코드는 전세계의 모든 문자를 2바이트 공간(65,535자)에 표현하고자 탄생했고, 2.0에서는 더 많은 문자를 표현하기 위해 그 공간이 4바이트로 늘어났다.

유니코드: 전세계의 모든 문자를 다루기 위해 도입된 정보 교환 표준 부호 
유니코드 평면: 유니코드에 표현된 문자들을 논리적으로 나눈 구획으로, 0번 다국어 기본 평면을 포함해 17개가 있다
유니코드 인코딩: 유니코드를 이진 데이터로 변환하기 위한 규칙으로 UTF-8, UTF-16, UTF-32가 있다

유니코드는 아스키와 마찬가지로 문자를 특정 숫자 코드에 매핑하는 방식으로 구성되어 있는데, 특정 문자에 부여되어 있는16진수 숫자값을 코드 포인트라고 하고, 유니코드 평면에서 41번에 해당하는 A는 U+0041 또는 \u0041과 같이 표현한다. [유니코드 표]

 

 

 

2. 유니코드 인코딩

유니코드로 다양한 문자들을 표현할 수 있게 되었지만 4바이트를 사용하기 때문에 그대로 문자를 인코딩하게 되면 기존 ASCII 방식에 비해 4배나 많은 메모리를 차지하게 되는 문제가 있다. 이러한 문제를 보완하기 위해 가변 길이 문자 인코딩(UTF-8, UTF-16)이 도입되었다.

 

 

A. UTF-8 (8-bit Unicode Transformation Format)

기존 ASCII와의 호환성을 유지하면서도 전 세계 모든 문자를 표현할 수 있는 인코딩 방식으로, 영어와 같은 ASCII 문자는 1바이트로, 한글, 일본어, 중국어 등 비ASCII 문자는 2~4바이트로 표현된다. ASCII 문자에 대해서는 1바이트만 사용하므로, 영어를 많이 사용하는 텍스트에서는 메모리 사용이 매우 효율적인 장점이 있다. 현재 웹, 파일 시스템에서 가장 널리 사용되는 인코딩 방식이다.

UTF-8에서는 위와 같이 코드 포인트에 따라 각 바이트의 앞부분에 표식 코드를 설정한다. 예를 들어, ""라는 한글은 U+AC00이므로 이를 2진수로 표현하면 1010 1100 0000 0000이고, 표식 코드 사이에 순서대로 끼워 넣으면 11101010 10110000 10000000로 인코딩된다. 

 

 

B. UTF-16 (16-bit Unicode Transformation Format)

UTF-16은 고정 길이 인코딩 방식과 가변 길이 인코딩 방식의 특성을 혼합한 방식으로, 기본적으로 2바이트로 문자를 표현하며, 필요에 따라 4바이트를 사용한다. 기존 UCS-2와의 호환성 유지하면서 확장된 유니코드 U+10000 ~ U+10FFFF 범위의 문자를 추가하기 위해 도입된 방식이다. ASCII 문자는 2바이트로 UTF-8 방식보다 차지하는 메모리는 증가하지만 한글, 일본어 등 비 ASCII 문자도 2바이트로 표현되므로 UTF-8보다는 효율적이라고 할 수 있다. JavaScript, Java, Python 등의 프로그래밍 언어에서는 내부적으로 문자열을 UTF-16으로 인코딩해 처리한다.

 

1. 서로게이트 쌍(Surrogate Pair)

기본 다국어 평면(U+0000 ~ U+FFFF)에 속하는 문자들은 1비트~16비트를 차지하기 때문에 2바이트 내에서 표현이 가능하지만 U+10000부터는 3바이트 이상이 필요하게 된다. UTF-16은 문자를 16비트(2바이트) 단위로 인코딩하도록 설계되었기 때문에 3바이트 문자는 표현할 수 없다. 대신 이러한 문자들은 특별한 연산을 거쳐 상위/하위 서로게이트 쌍인 4바이트로 표현하게 된다.

1. 코드 포인트에서 0x10000을 빼고, 그 값에서 상위 10개 비트와 하위 10개 비트를 추출한다.
2. 0xD800와 상위 10개 비트를 더한 값이 상위 서로게이트이다.
3. 0xDC00와 하위 10개 비트를 더한 값이 하위 서로게이트이다.

상위 서로게이트: 0xD800 ~ 0xDBFF (1,024개)
하위 서로게이트: 0xDC00 ~ 0xDFFF (1,024개)

이것을 JavaScript 비트 연산으로 표현하면 아래와 같다.

high_surrogate = 0xD800 + ((code_point - 0x10000) >> 10)
low_surrogate = 0xDC00 + ((code_point - 0x10000) & 0x3FF)

0xD800와 0xDC00는 각각 상위, 하위 서로게이트 코드의 시작점이다. `>> 10`에서 비트를 오른쪽으로 10칸 만큼 밀어 상위 10개 비트를 추출하고, `& 0x3FF`는 0x3FF이 00000011 11111111로 표현되는데 비트 연산자 `&`를 통해 하위 10개 비트를 추출한다.

 

Q. 왜 3바이트 문자를 표현할 때 서로게이트라는 개념을 도입했을까?

문자열 이진데이터를 디코딩할 때 UTF-8, UTF-16 등 인코딩 방식에 따라 비트를 나눠서 어떤 문자인지 해석을 해야 한다. 3바이트 문자를 3바이트 그대로 인코딩하게 되면 디코딩할 때 어떤 부분이 3바이트 문자의 시작과 끝인지 알 수 없기 때문에 문자를 제대로 디코딩할 수 없게 된다. 4바이트 문자도 2바이트 문자 두 개로 쪼개져 처음과는 다른 문자로 디코딩될 수 있는 문제가 발생한다. UTF-8에서 2바이트~4바이트로 인코딩되는 문자와 1바이트 문자가 구별이 될 수 있도록 표식 코드를 넣어주는 것과 같은 이유이다.

"😀😀"를 아무런 처리 없이 인코딩한다면 어떻게 될까?
😀는 U+1F600이기 때문에 이진데이터로 표현하면 1111101100000000011111011000000000가 되고 이것이 그대로 인코딩되었다고 해보자. UTF-16에서는 16비트씩 묶어서 데이터를 디코딩하므로 이 비트들은 1111101100000000와 0111110110000000 두 개의 문자로 해석된다. 이 값을 코드 포인트로 표현하면 U+FB00, U+7D80이고, "ff綀"라는 잘못된 결과가 나온다.

 

Q. 서로게이트 쌍으로 사용되는 문자도 2바이트인데 U+10000 미만의 문자와 겹치는 일은 없을까?

서로게이트 문자는 0xD800~0xDFFF 범위로 U+10000 이상의 문자를 표현하는 데만 사용되고, 그 단독으로는 유효한 문자로 해석되지 않는다. 따라서 다른 일반 문자로 잘못 디코딩되는 경우는 존재하지 않는다.

 

2. BOM(Byte Order Mark)

UTF-16에서 모든 문자는 2바이트 또는 4바이트로 인코딩된다. 즉, 기본 데이터 단위가 2바이트이다. 예를 들어, "A"라는 문자는 00 41 로 표현되는데(UTF-16에서 "AB"는 41 42 가 아니라 00 41 00 42 라는 점에 유의해야 한다.) 컴퓨터 메모리는 1바이트가 기본 단위이기 때문에 2바이트 데이터 00 41 을 메모리에 어떤 순서로 저장해야 하는지 문제가 생긴다. 빅 엔디언(Big Endian) 방식에서는 00 41 로 저장할 수 있지만, 리틀 엔디언(Little Endian)에서는 41 00 으로 저장된다. 이렇게 엔디언의 차이로 인해 문자열를 잘못 해석하지 않기 위해 UTF-16, UTF-32에서는 어떤 방식의 엔디언으로 문자열이 인코딩되어 있는지 알려줄 필요가 있는데 이것을 BOM이라고 한다. UTF-16의 경우 문서의 맨 앞에 아래와 BOM을 삽입한다.

UTF-16 BE(빅 엔디언): FE FF
UTF-16 LE(리틀 엔디언): FF FE

 

Q. UTF-8은 가변 1~4바이트로 2바이트 이상이 가능한데도 왜 바이트 순서에 의존하지 않을까? #

UTF-8로 인코딩된 "😀"는 F0 9F 98 80인데, 빅 엔디언에선 F0 9F 98 80 로, 리틀 엔디언에서는 9F F0 80 98 로 저장하는 걸까? 엔디언은 전체 데이터가 아니라 단위 데이터가 2바이트 이상일 때 고려되는 사항이다. "ABC"와 같이 길이가 2 이상인 문자열은 `char[]` 타입으로 단위 데이터가 아니기 때문에 문자열 전체에 대해서 엔디언이 고려되는 것이 아니다. 물론 엔디언과는 별개로 어떤 CPU 아키텍처에서는 바이트를 그렇게 뒤집어서 저장할 수도 있을 것이다. 하지만 이것은 해당 아키텍처의 메모리에 직접 접근하는 로우 레벨 단의 프로그램에서 처리해야 할 문제이지 UTF 스펙에서 고려되어야 할 사항은 아니다.

예를 들어, 메모리에서 UTF-16로 인코딩된 문자열 데이터 30 AE 를 디코딩한다고 해보자. UTF-16은 기본 단위가 2바이트이기 때문에 30AE를 하나의 문자로 인식해 디코딩하려고 할 것이다. 이때 이 문자는 일본어 ""(U+30AE)일까, 한글 ""(U+AE30)일까? 로우 레벨 영역에서 보면 데이터는 엔디언이 고려된 그대로 저장되고 불러와지는데 이로 인해 엔디언 문제가 발생하는 것이다.

UTF-8로 인코딩된 문자라면 어떨까? UTF-8 데이터인 EA B8 B0 를 디코딩한다고 해보자. UTF-8이 가변 길이 인코딩임에도 불구하고 한 바이트씩 순차적으로 디코딩하면 되기 때문에 문제가 없다. 특정 바이트를 읽었을 때 1110XXXX이라면 뒤 부분에 하나의 문자를 이루는 2개의 바이트가 더 올 것임을 파악할 수 있고, 10XXXXXX이라면 문자의 일부분임을 알 수 있기 때문이다. EA11101010이므로 1010를, B810111000이므로 111000를, B010110000이므로 110000를 가져오면 1010111000110000이 되고 이것은 한글 ""(U+AE30)이다. 이 과정에서 다른 문자로 디코딩될 여지가 없기 때문에 UTF-8에서는 바이트 순서가 고려될 필요가 없는 것이다.

 

Q. 왜 엔디언을 문자열 인코딩에서 고려해야 할까?

앞선 질문에서 동일한 데이터도 CPU의 엔디언 방식에 따라 다른 문자로 디코딩될 수 있기 때문에 UTF-16에서 엔디언 정보도 같이 저장할 필요가 있다고 했는데, 그럼 컴파일러가 문자열을 디코딩할 때 CPU의 엔디언 방식에 대한 정보도 같이 참조하는 방법은 어떨까? 하지만, 이 방법도 데이터를 다른 방식의 엔디언을 사용하는 컴퓨터에서 불러오게 된다면 문제가 된다. 따라서 UTF-16 방식에서 엔디언 정보도 같이 저장하는 것은 필연적이다. 

 

더보기

엔디언(Endianness)

대부분의 컴퓨터 아키텍처에서 메모리의 최소 단위는 1바이트(8비트)이기 때문에 멀티바이트 데이터를 어떤 방식으로 메모리에 저장할 것인지에 대한 관점 차이가 생기게 되었다. 그 방식에는 빅 엔디언(Big Endian)과 리틀 엔디언(Little Endian)이 있다.

 

빅 엔디언(Big Endian): MSB를 낮은 메모리 주소에 저장하는 방식. IBM 메인프레임, 네트워크 프로토콜 등에서 사용된다.

리틀 엔디언(Little Endian): MSB를 높은 메모리 주소에 저장하는 방식. x86, x64, ARM 등 대부부의 현대 CPU에서 사용된다.

 

* 최상위 바이트(Most Significant Byte): 가장 큰 자릿수에 해당하는 바이트. 예를 들어, 0x123456에서 MSB는 12에 해당하는 바이트이다.

https://basiclike.tistory.com/259

예를 들어, 메모리에 0x12345678를 저장한다면 각각의 방식에서 데이터는 아래와 같이 저장된다.

(좌) 빅 엔디언, (우) 리틀 엔디언

빅 엔디언은 사람이 숫자를 읽는 방식과 동일한 순서로 데이터가 저장된다는 장점이 있고, 리틀 엔디언은 CPU 연산 시에 성능 상의 이점이 있다. 예를 들어, 0xA001과 0x1을 더하는 연산을 한다고 해보자. 리틀 엔디언 방식에서 두 데이터는 메모리에 01 A0, 01로 저장되는데 이미 자리수가 맞춰져 있으므로 그대로 연산이 가능하다. 반면에, 빅 엔디언 방식에서는 A0 01, 01로 저장되므로 자리수가 가장 큰 데이터에 자리수를 맞춰주는 연산이 추가적으로 필요하다. 설명1     

 

 

네트워크 프로토콜과 엔디언

TCP/IP 네트워크 프로토콜 및 인터넷에서 사용하는 네트워크 바이트 순서(Network Byte Order)는 빅 엔디언(Big Endian)을 사용한다. 현대 CPU는 대부분 리틀 엔디언이지만 네트워크 프로토콜이 개발될 당시(1970년대~1980년대) 컴퓨터 아키텍처의 주류는 빅 엔디언이었기 때문에 빅 엔디언이 채택되었다고 한다. 또한, 네트워크를 통해 서로 다른 아키텍처(CPU, OS 등)를 가진 장치들이 데이터를 주고받아야 하는데 두 시스템의 엔디언이 다르다면 데이터 해석에 오류가 발생할 수 있다. 따라서 특정 CPU 아키텍처에 종속되지 않고, 여러 장치 간에 원활하게 데이터를 교환하기 위해 네트워크 프로토콜에서는 빅 엔디언을 사용하게 되었다. 

 

 

질문과 답변

Q. 프로그램에서 데이터를 가져올 때 데이터는 엔디언이 반영된 바이트 순서인가?

#include <stdio.h>
#include <wchar.h>

void printHexBytes(void *ptr, size_t size) {
    unsigned char *bytes = (unsigned char *)ptr;
    for (size_t i = 0; i < size; i++) {
        printf("%02X ", bytes[i]);
    }
    printf("\n");
}

int main() {
    wchar_t ch = L'가';
    printHexBytes(&ch, sizeof(ch)); // 00 AC 00 00
    return 0;
}

리틀 엔디언 CPU에서 "가"라는 글자는 메모리에 00 AC 로 저장되고, 프로그램에서 해당 데이터를 읽어올 때도 00 AC 로 불러와진다. 반대로 빅 엔디언 CPU에서는 메모리에 AC 00, 이 데이터를 읽어올 때도 AC 00으로 불러와진다. 즉, 리틀 엔디언 CPU라고 해서 데이터를 프로그램에 전달할 때 엔디언을 고려해서 원래(?) 바이트 순서대로 바꿔주지는 않는다. 거울 속 세계가 현실 세계와 반대 모습으로 보이지만 거울 속 세계에서는 그 반대 모습이 당연하고 자연스러운 것처럼 말이다.

 

Q. JavaScript 엔진에서 엔디언을 고려해 문자열을 인코딩하는 로직이 존재하는 걸까?

JavaScript는 문자열을 다룰 때 내부적으로 UTF-16 인코딩을 사용한다. 메모리에서 데이터를 가져올 때 프로그램에서는 엔디언 방식 그대로인 데이터를 읽게 되는데 그렇다면 JavaScript 엔진에 엔디언을 고려해 문자열을 디코딩하는 로직이 포함되어 있지 않을까?

// src/inspector/string-16.cc
namespace v8_inspector {
  String16 String16::fromUTF16LE(const UChar* stringStart, size_t length) {
  #ifdef V8_TARGET_BIG_ENDIAN
    // Need to flip the byte order on big endian machines.
    String16Builder builder;
    builder.reserveCapacity(length);

    for (size_t i = 0; i < length; i++) {
      const UChar utf16be_char = stringStart[i] << 8 | (stringStart[i] >> 8 & 0x00FF);
      builder.append(utf16be_char);
    }
    return builder.toString();
  #else
    // No need to do anything on little endian machines.
    return String16(stringStart, length);
  #endif
  }
}

위 코드는 V8에서 UTF-16 LE 데이터에서 String16 클래스 인스턴스를 생성하는 코드이다. # V8에서 String16 클래스는 리틀 엔디언 방식으로 데이터를 저장하는데 현재 시스템의 아키텍처가 빅 엔디언이면 UTF-16 LE 데이터의 바이트 순서를 뒤바꾸는 로직이 존재한다. JavaScript에서 문자열을 다룰 때는 엔디언을 생각할 필요가 없지만 JavaScript 엔진 내부적으로 엔디언에 따라 데이터를 뒤집어주는 작업을 하고 있는 것이다.

 

Q. 엔디언은 메모리 저장 방식에 대한 것인데 네트워크 프로토콜이 빅 엔디언을 사용한다는 건 무슨 의미일까?

네트워크를 통해 데이터를 주고받을 때 네트워크 바이트 순서는 빅 엔디언 방식으로 전송해야 한다는 의미이다.

#include <stdio.h>
// htonl() 함수 사용을 위한 헤더
#include <arpa/inet.h> 

int main() {
    unsigned int value = 0x12345678; // 리틀 엔디언 CPU에서 메모리에 78 56 34 12로 저장됨
    unsigned int network_value = htonl(value); // 네트워크 바이트 순서로 변환

    printf("Original value: 0x%x\n", value);
    printf("Network order value: 0x%x\n", network_value);

    return 0;
}

소켓 프로그래밍에서 `htonl()`과 같은 함수를 이용해 클라이언트의 메모리에 저장된 데이터를 빅 엔디언으로 변환하는 과정이 필요하다. 이렇게 네트워크로 데이터를 전송할 때 데이터는 빅 엔디언 방식을 따라야 한다는 것이다.

 

 

C. UTF-32 (32-bit Unicode Transformation Format)

UTF-32는 고정 길이 인코딩 방식으로, 모든 문자를 4바이트(32비트)로 인코딩한다. ASCII 문자도 4바이트로 인코딩되므로 메모리 사용이 비효율적일 수 있으나 인코딩 및 디코딩이 매우 간단하고 빠르다는 장점이 있다.

 

 

 

3. JavaScript에서 유니코드 다루기

The String type is the set of all ordered sequences of zero or more 16-bit unsigned integer values (“elements”) up to a maximum length of 253 - 1 elements. The String type is generally used to represent textual data in a running ECMAScript program, in which case each element in the String is treated as a UTF-16 code unit value. Each element is regarded as occupying a position within the sequence. #

JavaScript에서 문자열의 길이와 문자열 메서드에 의한 가공은 코드 단위(Code Unit)를 기준으로 동작한다. JavaScript는 문자열을 UTF-16으로 다루기 때문에 2바이트(16비트)가 하나의 코드 단위이다. 화면에 보이는 하나의 문자는 2바이트가 아닐 수 있기 때문에 JavaScript에서 문자열을 다룰 때의 동작이 우리의 기대와는 다를 수 있어 주의해야 한다. 

 

A. 이모티콘과 length 속성

The length of a String is the number of elements (i.e., 16-bit values) within it.

UTF-16에서 U+10000 이상의 문자는 4바이트의 서로게이트 쌍으로 표현되므로 JavaScript에서 해당 문자열의 `length` 속성값은 2이다. 한편, 아래와 같은 키캡 이모지는 0-9, #, *와 U+FE0F(Variation Selector-16), U+20E3(Combining Enclosing Keycap) 문자의 조합으로 구성되어 있기 때문에 `length` 속성값은 3이다.

 

 

B. charCodeAt, codePointAt

이모티콘 "😀"는 U+D83D, U+DE00의 서로게이트 쌍으로 표현되기 때문에 `charCodeAt`은 인덱스 0, 1에 대해 각각 상위/하위 서로게이트의 코드 포인트를 반환한다. 

const emoji = "😀"
const paragraph = "😀 my dog is cuter than your dog!";

emoji.charAt(1) // �가 반환되지만 U+DE00이다
emoji.charCodeAt(0) // 55357(U+D83D)
emoji.charCodeAt(1) // 56832(U+DE00)
paragraph.indexOf("dog") // 😀의 length가 2이므로 6이다

`charCodeAt`를 포함한 다른 메서드는 인덱스에 해당하는 코드 단위에 대해 동작하지만, `codePointAt`은 전달된 인덱스에 따라 반환하는 값이 다를 수 있다. 예를 들어, `string.charCodeAt(1)`은 `string`의 1번째 코드 단위의 UTF-16 코드 포인트를 반환하지만, `string.codePointAt(1)`은 `string`의 1번째 코드 단위의 UTF-16 코드 포인트가 아닐 수 있어 유의해야 한다.

emoji.codePointAt(0) // 128512(U+1F600)
emoji.codePointAt(1) // 56832(U+DE00)
CodePointAt(string, position) #
1. Let size be the length of string.
2. Assert: position ≥ 0 and position < size.
3. Let first be the code unit at index position within string.
4. Let cp be the code point whose numeric value is the numeric value of first.
5. If first is neither a leading surrogate nor a trailing surrogate, then
  a. Return the Record { [[CodePoint]]: cp, [[CodeUnitCount]]: 1, [[IsUnpairedSurrogate]]: false }.
6. If first is a trailing surrogate or position + 1 = size, then
  a. Return the Record { [[CodePoint]]: cp, [[CodeUnitCount]]: 1, [[IsUnpairedSurrogate]]: true }.
7. Let second be the code unit at index position + 1 within string.
8. If second is not a trailing surrogate, then
  a. Return the Record { [[CodePoint]]: cp, [[CodeUnitCount]]: 1, [[IsUnpairedSurrogate]]: true }.
9. Set cp to UTF16SurrogatePairToCodePoint(first, second).
10. Return the Record { [[CodePoint]]: cp, [[CodeUnitCount]]: 2, [[IsUnpairedSurrogate]]: false }.

해당 위치의 코드 단위를 cu라고 했을 때,

  1. cu가 서로게이트 문자가 아니라면 해당 위치의 코드 포인트를 반환한다
  2. cu가 하위 서로게이트 문자라면 해당 하위 서로게이트 코드 포인트를 반환한다 
  3. cu상위 서로게이트 문자라면 서로게이트 쌍의 코드 포인트를 반환한다 

즉, `codePointAt`은 해당 코드 단위의 코드 포인트를 반환할 수도, 해당 코드 단위가 구성하는 서로게이트 쌍의 코드 포인트를 반환할 수도 있다. 일반적인 문자를 다룰 때에는 크게 신경써야 할 부분은 아니지만 한자, 이모티콘 등 4바이트 문자를 다룰 때는 이러한 사항을 염두에 둬야 한다. JavaScript에서 문자를 다룰 때는 항상 UTF-16 2바이트 단위로써 생각하는 것이 좋다.

 

 

C. 코드 포인트를 얻는 올바른 방법

// ❗서로게이트 쌍과 하위 서로게이트가 같이 출력된다
const printCodePoint = (m) => {
  const res = []

  for (let i = 0; i < 5; i++) {
    const codePoint = m.codePointAt(i)
    if (codePoint) {
      res.push(codePoint.toString(16).toUpperCase())
    }
  }
  return res
}

printCodePoint("😀") // ['1F600', 'DE00']

앞서 살펴본 대로 서로게이트 쌍으로서 존재하는 문자(ex. 이모티콘)를 다룰 때 문자열 인덱스로 `codePointAt` 메서드를 사용하면 올바른 UTF-16 코드 포인트를 얻지 못할 수 있다. 이 함수를 사용해 "😀"의 코드 포인트를 얻으려고 하면 상위 서로게이트, 하위 서로게이트가 아니라 서로게이트 쌍, 하위 서로게이트가 출력된다. `codePointAt`의 동작에는 문제가 없지만 우리가 문자열의 코드 포인트를 얻고자 할 때, 문자 하나의 코드 포인트나 코드 단위에 해당하는 코드 포인트를 원하지 하나의 문자에 대해 전체 코드 포인트와 하위 서로게이트의 코드 포인트를 얻고 싶지는 않을 것이다. 반복문으로 문자 하나에 대응되는 코드 포인트를 얻고자 한다면 인덱스 대신 `for ... of`문을 사용해야 한다.

반복에 문자열 인덱스를 사용하면 동일한 코드 포인트를 두 번(선행 서로게이트에 한 번, 후행 서로게이트에 한 번) 방문하게 되고 두 번째 codePointAt()은 후행 서로게이트만 반환하므로 인덱스로 반복은 피하는 것이 좋습니다. #
const printCodePoints = (m) => {
  const res = []

  // for ... of 문을 사용해 코드 포인트를 얻어야 한다
  for (const codePoint of m) {
    res.push(codePoint.codePointAt(0).toString(16).toUpperCase())
  }
  return res
}

만약 문자 하나에 대응되는 코드 포인트가 아니라 바이트 하나에 대응되는 코드 포인트를 얻고 싶다면 아래와 같이 `charCodeAt`을 사용할 수도 있다. 예를 들어, `printCodePoints("😀가")`는 ` ["1F600", "AC00"]`가, `printCodePointOfCodeUnits("😀가")`는 `["D83D", "DE00", "AC00"]`가 출력된다.

const printCodePointOfCodeUnits = (str) => {
  let i = 0
  const res = []

  while (true) {
    const code = str.charCodeAt(i)

    if (Number.isNaN(code)) break
    res.push(code.toString(16).toUpperCase())
    i++
  }
  return res
}

 

 

D. 이모티콘과 정규식

JavaScript에서 이모티콘을 필터링해야 할 필요가 있을 때 emoji-regex 라이브러리를 사용하는 방법도 있지만 Unicode character class escape를 사용할 수도 있다. 이때, 이 이스케이프를 사용하려면 정규식에 u 플래그를 설정해야 한다.

const filterEmoji = (s) => s.replace(/\p{Emoji}/gu, "")

filterEmoji("😀") // ""

하지만 단순히 이렇게 이모티콘을 제거하면 숫자와 #, *도 같이 제거되는 문제가 발생한다. 0-9(숫자), #, *는 키캡 이모지에서도 사용되기 때문에 Emoji 속성에 포함되어 있다. 따라서 이러한 문자 외에 이모티콘만 제거하기 위해서는 아래와 같은 정규식을 사용해야 한다.

const filterEmoji = (s) => s.replace(/[\d#*]\uFE0F\u20E3|(?![*#0-9]+)[\p{Emoji}\p{Emoji_Modifier}\p{Emoji_Component}\p{Emoji_Modifier_Base}\p{Emoji_Presentation}]/gu, '')

1. [\d#*]\uFE0F\u20E3: 키캡 이모지를 매칭한다

2. (?![*#0-9]+)[\p{Emoji} ... \p{Emoji_Presentation}]: 부정형 전방 탐색을 이용해 이모지 속성 문자 중에서 숫자, #, *가 아닌 것을 매칭한다.

Emoji 속성 문자
0023               Emoji     # E0.0 [1] (#️) hash sign
002A               Emoji     # E0.0 [1] (*️) asterisk
0030..0039    Emoji     # E0.0 [10] (0️..9️) digit zero..digit nine
...

 

 

 

4. 한글과 유니코드

A. 조합형과 완성형

한글을 컴퓨터에서 어떤 방식으로 표현할지에 대해 많은 논쟁이 오고갔다. N바이트 조합형, 3바이트 조합형, 7비트 완성형, 2바이트 조합형, 2바이트 완성형 등 한글의 창제 원리를 살려 초성, 중성, 종성으로 한글 자모에 코드 포인트를 부여하는 방식(조합형)과 "가", "함" 등의 완성된 음절에 코드 포인트를 부여하는 방식(완성형)으로 크게 나뉘었다. 하지만 삼성, 금성, 삼보 등 각 컴퓨터 제조사들은 자신들이 개발한 자체 한글 표기 포맷으로 표준화를 진행하려 해 호환성 문제가 대두하였다. 결국 1987년 정부에서 2바이트 완성형 KS X 1001을 표준으로 지정함으로써 KS 완성형이 빠르게 보급되었고, 1992년에는 조합형도 함께 표준으로 지정되면서 완성형과 조합형이 공존하게 되었다. 한편, KS 완성형은 KS C 5601로도 알려져 있으며 인코딩 포맷으로 EUC-KR이 사용된다. 모든 한글 글자는 11,172자인데 KS 완성형은 현대 한글에서 자주 사용되는 2,350자만 지원하는 문제가 있었다.

1991년, 유니코드 1.0이 제정되면서 한글 조합형과 완성형 2,350자가 수록되고, 유니코드 1.1에서 완성형 한글이 대폭 추가되어 6,656자를 지원하게 되었지만 역시 전체 한글 문자를 표기하지 못했기 때문에 크게 주목받지는 못했다.

1995년, 마이크로소프트에서 Windows 95를 발표하면서 한글  조합형을 사용하는 대신 기존 프로그램들과의 호환성을 위해 KS 완성형에  8,875자를 추가해 한글을 표기하는 방식을 선택했다. 그 방식이 바로 코드 페이지 949(CP949)이다. 하지만 CP949는 KS 완성형과의 완벽한 호환성을 유지하고자 기존의 2,350자에 8,875자를 단순히 끼워 넣었는데 한글 배열 순서가 완전히 무시되는 상황이 발생했다. 따라서 한글 정렬을 하거나 문자를 검색하는 데 심각한 문제가 생겼다. 이러한 문제에도 불구하고 완성형이 빠르게 표준으로 잡아가면서 조합형은 점점 시장에서 사장되어 갔다.

1996년, 유니코드 2.0에서 새 영역(U+AC00 ~ U+D7A3)에 완성형 한글 11,172자를 배당함으로써 모든 완성형 한글을 표기할 수 있게 되었다. 현재 한글 인코딩은 호환성이 뛰어난 UTF-8(유니코드)이 가장 많이 사용되고 있으며, 일부 레거시 시스템에 CP949와 EUC-KR(KS 완성형)이 사용되고 있다.

조합형
장점: 한글의 창제 원리를 고스란히 담고 있고, 조합이라는 방식 덕에 모든 한글을 표현할 수 있다
단점: 초성, 중성, 종성이 각각 1바이트 이상의 메모리를 차지하기 때문에 파일의 크기가 커진다

완성형
장점: 모든 음절이 2바이트이기 때문에 처리상의 부담이 없다
단점: 한글의 창제 원리를 무시하고 마치 한글을 한자처럼 취급한다

한편, Windows는 완성형으로 한글을 표현하고, Windows 7까지는 CP949, Windows 10부터는 UTF-8 인코딩을 기본으로 사용하고 있다. 반면에, MacOS는 조합형으로 한글을 표현하고, UTF-8 인코딩을 기본으로 사용한다. MacOS에서 사용하는 한글 유니코드는 U+1100 ~ U+115E(초성), U+1161 ~ U+11A7(중성), U+11A8 ~ U+11FF(종성)에 존재하고, Windows에서의 한글 유니코드는 U+3131~U+318E(자음과 모음), U+AC00~U+D7A3(음절)에 존재한다. 이렇게 두 운영체제에서 한글을 표현하는 방식이 달라 유니코드 코드 포인트도 다르기 때문에 한 운영체제에서 작성한 한글을 다른 운영체제에서 사용할 때 문제가 발생할 수 있다.

 

MacOS에서 작성된 "개선 사항"과 Windows에서 작성된 "개선 사항" 두 글자가 있다. 화면에 보이는 글자는 동일해도 MacOS에서는 조합형, Windows에서는 완성형을 사용하기 때문에 유니코드 코드포인트, 문자열의 길이가 다르다.

const mac = "개선 사항"
const windows = "개선 사항"

console.log(mac) // 11
console.log(windows) // 5

맨 앞 글자인 "개"의 유니코드 코드포인트도 전자는 U+1100, U+1162, 후자는 U+AC1C이다.  

const macRegex = /[ㄱ-ㅣ가-힣]/g
const windowsRegex = /[ㄱ-ㅣ가-힣]/g

또 다른 예시를 살펴보자. ` macRegex`는 MacOS에서 작성된 한글 정규식이고, `windowsRegex`는 Windows에서 작성된 한글 정규식이다. 전자의 정규식을 사용하면 Uncaught SyntaxError: Invalid regular expression: /[ㄱ-ㅣ가-힣]/g: Range out of order in character class와 같은 문법 오류가 발생한다. `macRegex`에서 "가"는 U+1100, U+1161이고, "힣"은 U+1112, U+1175, U+11C2로 표현된다. 정규식에서 `-`기호를 사용할 때는 문자 범위가 오름차순이어야 하는데 U+1161 → U+1112로 오름차순이 아니기 때문에 발생한 문제이다.

 

 

Q. 완성형과 조합형 vs. CP949, UTF-8, UTF-16 차이가 뭘까?

완성형과 조합형은 한글을 어떻게 표현하는가에 대한 방식이며, CP949, UTF-8, UTF-16 등은 문자를 저장하는 방식(인코딩)이다. 완성형과 조합형은 특정 인코딩과 반드시 1:1로 연결되는 것은 아니다. 예를 들어, CP949은 한글을 완성형으로만 표현할 수 있고, UTF-8, UTF-16 등 유니코드는 조합형 또는 완성형으로 모두 표현 가능하다. MacOS와 Windows 모두 유니코드 인코딩을 사용하고 있지만 MacOS는 조합형, Windows는 완성형을 사용한다.

 

 

 

 

 

 

 

 

[참고]

아스키 코드 간단 정의 & 짧은 역사

유니코드는 어떻게 탄생했을까? (6)

[CS] 유니코드(Unicode) & 인코딩(Encoding)

유니코드는 어떻게 구성되어 있을까?

How to remove emoji code using javascript?

no big endian and little endian in string?

한글 인코딩 이야기 (1) ASCII, 완성형, 조합형, EUCKR, CP949

한글 인코딩의 이해 : 한글 인코딩의 역사와 유니코드, 그리고 유니코드와 Java를 이용한 한글 처리

한글 인코딩의 역사와 미래