유니코드와 UTF, 자바스크립트 charCodeAt의 함정

김민재·2025년 10월 28일

CS

목록 보기
11/12

1. 유니코드 (Unicode)

컴퓨터는 'A'나 '한' 같은 문자를 직접 이해하지 못합니다. 오직 숫자 0과 1만 알 뿐이죠.

과거에는 나라마다 "A=65로 하자" (ASCII), "ㄱ=12644로 하자" (EUC-KR)처럼 각자의 규칙을 사용했습니다. 이 때문에 미국에서 만든 문서를 한국에서 열면 인코딩이 달라 글자가 깨지는 문제가 발생했습니다.

유니코드(Unicode)는 이 문제를 해결하기 위해 등장한 전 세계 문자 집합 표준입니다.

"이 세상 모든 문자에 중복되지 않는 고유한 '번호(Index)'를 하나씩 지정해주자!"

이 고유한 번호를 코드 포인트(Code Point)라고 부릅니다.

'A' = 65
'한' = 54620
'😊' = 128522

블로그나 기술 문서에서 U+0041 같은 표기를 본 적이 있을 겁니다. 여기서 U+는 "이것은 유니코드 코드 포인트다"라는 뜻이며, 뒤의 숫자는 이 코드 포인트를 16진수로 표현한 것입니다. (16진수 0041 = 10진수 65)


2. 유니코드 저장 방법: UTF (Unicode Transformation Format)

유니코드는 '번호가 적힌 목록(전화번호부)'일 뿐, 이 번호를 컴퓨터 메모리나 파일에 "어떻게 저장할 것인가?"에 대한 규칙은 아닙니다.

이 "저장 방식(인코딩)"을 정의하는 것이 바로 UTF(Unicode Transformation Format)입니다.

가장 대표적인 두 가지 방식이 UTF-8과 UTF-16입니다.

📌 UTF-8

문자를 표현하는 최소 단위를 1바이트(8비트)로 합니다.

문자에 따라 1바이트에서 4바이트까지 가변적인 크기를 가집니다.

'A' (U+0041)처럼 표준 ASCII(0~127번) 문자는 1바이트로 표현합니다.
'한' (U+D55C) 같은 한글은 3바이트로 표현합니다.
'😊' (U+1F60A) 같은 이모지는 4바이트로 표현합니다.

📌 UTF-16

문자를 표현하는 최소 단위를 2바이트(16비트)로 합니다.

대부분의 문자를 2바이트로 표현하지만, 일부 문자는 4바이트로 표현합니다.



왜 굳이 둘로 나눴을까요?
두 방식은 효율성에서 장단점이 갈립니다.

🔎 UTF-8 vs UTF-16

UTF-8

HTML, CSS, JS 코드 대부분은 <p>, "function", "color"처럼 영문자(ASCII)로 이루어져 있습니다.

UTF-8은 영문자를 1바이트로 저장하므로, 영문 위주의 문서에서 파일 용량이 매우 효율적입니다.

ASCII와 완벽하게 하위 호환됩니다. 둘은 저장 방식(바이트)이 100% 동일하기 때문에, ASCII 파일은 그 자체로 유효한 UTF-8 파일입니다.

이것이 오늘날 웹의 압도적인 표준이 된 이유입니다.

UTF-16

'A'도 2바이트, '한'도 2바이트로 처리합니다. (이모지 등은 4바이트)

한글, 한자 등 아시아권 문자를 다룰 때는 1~3바이트로 크기가 변하는 UTF-8보다 2바이트 고정처럼 보이는 UTF-16이 내부적으로 다루기 편할 수 있습니다.

Java, C#, 그리고 자바스크립트(JS)가 내부적으로 문자열을 다룰 때 이 UTF-16 방식을 사용합니다.


3. 자바스크립트 charCodeAt()과 론 서로게이트

"자바스크립트는 내부적으로 UTF-16을 쓴다."는 사실이 바로 우리가 겪는 문제의 핵심입니다.

UTF-16은 최소 단위가 2바이트(16비트)입니다. 즉, 0부터 65,535(U+FFFF)까지의 숫자 하나를 담을 수 있습니다.

'A' (65)는 이 범위 안에 있습니다.
'한' (54620)도 이 범위 안에 있습니다.
'😊' (128522)는 이 범위를 초과합니다.

UTF-16은 65,535를 초과하는 문자를 표현하기 위해, 서로게이트 페어(Surrogate Pair)라는 기법을 씁니다.

128522라는 숫자 하나를 16비트짜리 두 조각(앞쪽 조각 + 뒤쪽 조각)으로 쪼개서 저장합니다.

'😊' (128,522) -> 55,357 (앞 조각) + 56,842 (뒤 조각)

여기서 한 가지 의문이 생깁니다. '😊' 같은 문자는 코드포인트로 보아 17비트로 충분히 표현이 가능한데, 왜 굳이 용량을 늘려가면서까지 16비트짜리 두 개로 쪼개는 복잡한 서로게이트 페어 방식을 사용할까요?

그 이유는 바로 과거 16비트 시스템과의 호환성 때문입니다.

유니코드가 처음 설계되던 시기(UCS-2, UTF-16의 초기 모델), 개발자들은 16비트(65,536자)만으로 전 세계 모든 문자를 담을 수 있다고 낙관했습니다.

자바스크립트를 비롯해 Java, Windows NT 등 많은 시스템이 1문자 = 16비트(2바이트)라는 가정 하에 설계되었습니다.

하지만 이모지, 고대 한자 등이 대거 추가되며 유니코드는 65,536자를 훌쩍 넘기게 되었습니다.

이미 1문자=16비트라는 규칙으로 만들어진 거대한 시스템 생태계를 무너뜨리지 않고 이 새로운 문자들을 표현할 방법이 필요했습니다.

그 천재적인 해결책이 바로 서로게이트 페어입니다. 기존 16비트 공간(U+D800~U+DFFF)의 일부를 "대리 문자 영역"으로 비워두고, 이 영역의 16비트짜리 두 개를 조합하여 65,536번이 넘는 문자를 대신(Surrogate) 표현하기로 약속한 것입니다.

결국 '😊'가 4바이트가 필요한 것은, 이 문자가 4바이트 크기라서가 아니라, 16비트 시스템과의 호환성을 위한 '서로게이트 페어'라는 꼼꼼한 약속을 지키기 위해서입니다.

이제 charCodeAt() 메서드를 봅시다.

charCodeAt(index): 주어진 인덱스(index)의 UTF-16 코드 단위(16비트 조각)를 반환합니다.

"A".charCodeAt(0) -> 65 
"한".charCodeAt(0) -> 54620 
"😊".charCodeAt(0) -> 55357 (앞쪽 조각 반환)
"😊".charCodeAt(1) -> 56842 (뒤쪽 조각 반환)

charCodeAt()은 "완성된 문자"의 번호를 알려주는 것이 아니라, 그 위치에 있는 "16비트짜리 퍼즐 조각"의 번호를 알려줍니다.

여기서 55357처럼 "짝을 잃고 홀로(Lone) 발견된 대리(Surrogate) 조각"을 "론 서로게이트(Lone Surrogate)"라고 부릅니다. 각 서로게이트는 UTF-16의 내부적인 규칙에 의해 구해지고, 이 값은 그 자체로는 아무 의미가 없습니다.

해결책 codePointAt()

'😊' 같은 문자도 안전하게 다루려면, 완전한 유니코드 코드 포인트를 반환하는 codePointAt()을 써야 합니다.

codePointAt(index): 주어진 인덱스에서 시작하는 "완성된 문자"의 코드 포인트를 반환합니다.

"😊".codePointAt(0) -> 128522
profile
넓이보다 깊이있게

0개의 댓글