JavaScript 문자열 인코딩

#javascript#encoding#unicode
• • •

이모지가 다르게 잘린다?

문자열 자르기는 실무에서 흔한 요구사항이다. 어떤 문자열을 substring()으로 자르면서 이모지가 영문 또는 한글과 다르게 카운트된다는 사실을 알게 된 적이 있다.

'abcdefghij'.substring(0, 5);            // "abcde"
'일이삼사오육칠팔구십'.substring(0, 5);      // "일이삼사오"
'🌙🌙🌙🌙🌙🌙🌙🌙🌙🌙'.substring(0, 5);   // "🌙🌙\uD83C"

영어와 한글은 정확히 다섯 글자가 잘리는데, 이모지는 두 글자 반에서 끊긴다. 이걸 이해하려면 유니코드와 인코딩 방식부터 짚고 가야 한다.

유니코드란 무엇인가

전 세계 모든 문자를 하나의 체계로 표현하기 위해 만든 국제 표준이 유니코드(Unicode) 다. 유니코드는 각 문자에 고유한 번호를 부여하는데, 이 번호를 코드 포인트(Code Point) 라고 한다. U+ 접두사와 16진수로 표기한다.

문자코드 포인트
AU+0041
U+AC00
🌙U+1F319

유니코드가 정의하는 코드 포인트 범위는 U+0000부터 U+10FFFF까지, 약 110만 개다. 이 중 실제로 문자가 배정된 건 약 15만 개 정도라고 한다.

유니코드는 이 번호 체계만 정의한다. 실제로 메모리나 파일에 어떻게 저장할지는 인코딩 방식이 결정한다. 대표적인 인코딩이 UTF-8과 UTF-16이다.

UTF-8 vs UTF-16

UTF-8

UTF-8은 코드 포인트를 1~4바이트로 인코딩한다. 코드 포인트 값이 작을수록 적은 바이트를 쓴다.

코드 포인트 범위바이트 수예시
U+0000 ~ U+007F1바이트A (U+0041)
U+0080 ~ U+07FF2바이트é (U+00E9)
U+0800 ~ U+FFFF3바이트가 (U+AC00)
U+10000 ~ U+10FFFF4바이트🌙 (U+1F319)

ASCII 문자는 1바이트로 처리되기 때문에 ASCII와 완전히 호환된다. 웹 표준 인코딩으로 사용되는 이유다.

const bytes = new TextEncoder().encode('🌙');
console.log(bytes);        // Uint8Array(4) [240, 159, 140, 153]
console.log([...bytes].map(b => '0x' + b.toString(16).padStart(2, '0')));
// ["0xf0", "0x9f", "0x8c", "0x99"]  ← 4바이트
console.log(bytes.length); // 4

UTF-16

UTF-16은 코드 유닛(Code Unit) 이라는 개념을 사용한다. 코드 유닛 하나는 16비트(2바이트) 다. 코드 포인트에 따라 코드 유닛 1개 또는 2개를 사용한다.

코드 포인트 범위코드 유닛 수영역 이름
U+0000 ~ U+FFFF1개 (2바이트)BMP (Basic Multilingual Plane)
U+10000 ~ U+10FFFF2개 (4바이트)보조 문자 (Supplementary Characters)

영어(A, U+0041)와 한글(, U+AC00)은 모두 BMP 범위 안에 있어서 코드 유닛 1개로 표현된다. 이모지 대부분은 U+10000 이상이라 코드 유닛 2개가 필요하다. 이 2개의 코드 유닛 쌍을 서로게이트 페어(Surrogate Pair) 라고 한다.

이모지가 2개로 카운트되는 이유

U+10000 이상의 문자를 UTF-16으로 표현할 때, 단순히 16비트로는 번호를 담을 수 없다. 그래서 UTF-16은 2개의 16비트 코드 유닛을 조합하는 방법을 택했다.

🌙 (U+1F319)가 어떻게 변환되는지 추적해보자.

UTF-16은 서로게이트 페어를 위해 유니코드에서 두 영역을 예약해두었다.

High Surrogate: U+D800 ~ U+DBFF  (1024개 슬롯)
Low Surrogate:  U+DC00 ~ U+DFFF  (1024개 슬롯)

각각 1024개, 즉 10비트(2^10)의 공간이다. 변환은 보조 문자의 코드 포인트를 상위/하위 10비트로 쪼개어 각 영역의 시작점에 오프셋으로 더하는 방식이다.

1. U+1F319에서 0x10000을 뺀다
   0x1F319 - 0x10000 = 0xF319

2. 이진수로 변환 — 상하위 10비트로 쪼개기 위해 20비트로 맞춘다
   0xF319 = [00 0011 1100] [11 0001 1001] ← LSB(Least Significant Bit) 기준으로 묶기
              상위 10비트     하위 10비트

3. 상위 10비트 → High Surrogate 영역(0xD800)의 시작점에 오프셋으로 더한다
   00 0011 1100 = 0x3C
   0xD800 + 0x3C = 0xD83C  ✓ (D800~DBFF 범위 안)

4. 하위 10비트 → Low Surrogate 영역(0xDC00)의 시작점에 오프셋으로 더한다
   11 0001 1001 = 0x319
   0xDC00 + 0x319 = 0xDF19  ✓ (DC00~DFFF 범위 안)
코드 포인트에서 0x10000을 빼는 이유는 보조 문자 영역의 오프셋을 0부터 시작하게 만들기 위해서다

결과: 🌙 = \uD83C\uDF19

디코딩은 반대로, \uD83C에서 0xD800을, \uDF19에서 0xDC00을 빼고 다시 합치면 원래 코드 포인트 U+1F319가 복원된다.

'🌙'.charCodeAt(0).toString(16); // "d83c"  ← High Surrogate
'🌙'.charCodeAt(1).toString(16); // "df19"  ← Low Surrogate
'🌙'.length;                     // 2  ← 코드 유닛 2개

JavaScript의 lengthsubstring()은 이 코드 유닛 수를 기준으로 동작한다. 그래서 🌙 10개짜리 문자열을 substring(0, 5)로 자르면, 코드 유닛 5개 = 🌙 2개 + High Surrogate 반 토막이 나온다.

'🌙🌙🌙🌙🌙🌙🌙🌙🌙🌙'.substring(0, 5); // "🌙🌙\uD83C"

Surrogate Pair가 왜 필요한가

UTF-16의 코드 유닛은 16비트이다. 16비트로 표현할 수 있는 값은 0x0000~0xFFFF, 즉 65,536개인데, 유니코드가 확장되면서 코드 포인트가 U+10FFFF까지 늘어났고, 총 1,114,112개의 문자를 표현해야 했다. 16비트 하나로는 담을 수 없으니, 16비트 두 개를 조합해서 그 이상의 범위를 표현하는 방법을 채택했다.

다만, 단순히 두 개를 조합해서는 디코더 입장에서 "방금 읽은 문자가 단독 문자인지, 아니면 앞 2바이트와 조합해야 하는지" 알 수 있는 방법이 없다. 디코더는 어떤 코드 유닛이 단독 문자인지 아닌지를 구별할 수 있어야 한다.

이러한 이유로 서로게이트 쌍이 필요하다. 유니코드는 U+D800~U+DFFF 범위를 문자 배정 없이 영구 예약했다. 이 범위는 서로게이트 페어의 영역이다. 디코더는 이 범위가 나오면 "서로게이트 페어의 절반"이라고 확신할 수 있기 때문에 코드 유닛 2개를 조합할 수 있다.

U+0000 ~ U+D7FF  일반 문자
U+D800 ~ U+DBFF  High Surrogate (예약)
U+DC00 ~ U+DFFF  Low Surrogate  (예약)
U+E000 ~ U+FFFF  일반 문자

덕분에 디코더는 단순한 규칙으로 동작할 수 있다.

코드 유닛 값이 D800~DBFF 사이  →  High Surrogate, 다음 유닛과 쌍을 이룬다
코드 유닛 값이 DC00~DFFF 사이  →  Low Surrogate, 앞 유닛과 쌍을 이룬다
그 외                          →  단독 문자

범위가 각각 0x400(1024)개인 것도 의도적이다. High 1024 × Low 1024 = 1,048,576개의 조합이 나오고, 여기에 BMP 문자 65,536개를 더하면 유니코드 전체 범위인 1,114,112개를 정확히 커버한다.

JavaScript가 UTF-16을 선택한 이유

JavaScript가 탄생한 1995년, 유니코드는 아직 보조 문자 영역(U+10000 이상)이 없었다. 당시 유니코드 1.0은 모든 문자를 16비트 안에 담으려 했고, UTF-16의 전신인 UCS-2가 주류였다. Netscape는 UCS-2를 기반으로 JavaScript 문자열을 설계했다.

이후 유니코드가 확장되면서 보조 문자 영역이 생겼고, UTF-16이 서로게이트 페어로 이를 처리하게 됐다. 하지만 JavaScript의 내부 표현은 그대로 유지됐다. 수십억 개의 웹페이지와 하위 호환성 때문이다.

Java, C#도 같은 이유로 UTF-16 기반 문자열을 사용한다.

substring 사용이 틀렸던 이유

substring(), slice(), length, 인덱스 접근(str[i]) — 이 모든 연산은 코드 유닛 단위로 동작한다.

const emoji = '🌙';

console.log(emoji.length);       // 2  (코드 유닛 수)
console.log(emoji[0]);          // 깨진 문자 
console.log(emoji[1]);          // 깨진 문자
console.log(emoji.substring(0, 1)); // 깨진 문자
console.log(emoji.slice(0, 1)); // 깨진 문자

서로게이트 페어를 분리하면 유효하지 않은 문자가 된다. 렌더링 결과는 브라우저마다 다르고, 해당 문자를 다시 처리하는 코드에서 예상치 못한 버그가 생긴다.

올바른 해결법

1. 스프레드 연산자 / Array.from()

const str = '🌙🌙🌙🌙🌙🌙🌙🌙🌙🌙';

[...str].slice(0, 5).join('');          // "🌙🌙🌙🌙🌙"
Array.from(str).slice(0, 5).join('');   // "🌙🌙🌙🌙🌙"

스프레드 연산자는 이터레이터 프로토콜을 사용하며, 문자열 이터레이터는 코드 유닛이 아닌 코드 포인트 단위로 순회한다. 서로게이트 페어를 하나의 문자로 묶어 처리한다.

다만 결합 이모지(👨‍👩‍👧‍👦 같이 여러 코드 포인트가 ZWJ로 연결된 경우)는 여전히 여러 개로 쪼개질 수 있다.

ZWJ(Zero Width Joiner)

유니코드 문자 중 하나(U+200D)로, 눈에 보이지 않는 붙임 문자다. 여러 개의 독립된 이모지나 문자 사이에 ZWJ를 끼워 넣으면, 지원하는 시스템에서 이를 하나의 이모지로 렌더링한다. ZWJ 자체는 아무 너비도 없고 렌더링되지 않는다. 렌더링 엔진이 ZWJ 앞뒤 문자를 감지해서 조합된 글리프(glyph)로 대체하는 방식으로 지원하지 않는 시스템에서는 그냥 각각의 이모지가 따로 표시된다.

실제 코드 포인트 나열은 이렇다.

[...'👨‍👩‍👧‍👦'].map(c => c.codePointAt(0).toString(16));
// ["1f468", "200d", "1f469", "200d", "1f467", "200d", "1f466"]
//    👨      ZWJ     👩       ZWJ     👧       ZWJ     👦

2. Intl.Segmenter

const segmenter = new Intl.Segmenter();
const segments = [...segmenter.segment('👨‍👩‍👧‍👦🌙🌙🌙')];

segments.length;        // 4 (사람 가족 1 + 달 3)
segments[0].segment;    // "👨‍👩‍👧‍👦"

Intl.Segmenter그래핌 클러스터(Grapheme Cluster) 단위로 문자열을 분리한다. 그래핌 클러스터는 사용자가 실제로 하나의 글자로 인식하는 단위다. 결합 이모지, 악센트 문자 등을 정확하게 처리한다.

function sliceByGrapheme(str, start, end) {
  const segmenter = new Intl.Segmenter();
  const segments = [...segmenter.segment(str)];
  return segments.slice(start, end).map(s => s.segment).join('');
}

sliceByGrapheme('👨‍👩‍👧‍👦🌙🌙🌙🌙', 0, 3); // "👨‍👩‍👧‍👦🌙🌙"

방법 비교

방법코드 포인트 처리결합 이모지 처리지원 환경
substring()XX모든 환경
[...str].slice()OXES6+
Intl.SegmenterOO모던 브라우저, Node 16+

정리

substring()이 이모지를 이상하게 자르는 건 버그가 아니라, UTF-16의 코드 유닛 단위로 동작하는 의도된 결과다. JavaScript 문자열의 기본 단위는 코드 포인트도, 눈에 보이는 글자도 아닌 16비트 코드 유닛이다.

실무에서 사용자 입력을 다루거나 문자열 길이를 제한할 때는 이 차이가 중요하다. 이모지, 다국어 문자, 결합 문자가 섞인 문자열을 다룬다면 Intl.Segmenter를 기본으로 두는 것이 안전하다.

published almost 2 years ago · last updated 6 days ago