컴퓨터는 '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)
유니코드는 '번호가 적힌 목록(전화번호부)'일 뿐, 이 번호를 컴퓨터 메모리나 파일에 "어떻게 저장할 것인가?"에 대한 규칙은 아닙니다.
이 "저장 방식(인코딩)"을 정의하는 것이 바로 UTF(Unicode Transformation Format)입니다.
가장 대표적인 두 가지 방식이 UTF-8과 UTF-16입니다.
문자를 표현하는 최소 단위를 1바이트(8비트)로 합니다.
문자에 따라 1바이트에서 4바이트까지 가변적인 크기를 가집니다.
'A' (U+0041)처럼 표준 ASCII(0~127번) 문자는 1바이트로 표현합니다.
'한' (U+D55C) 같은 한글은 3바이트로 표현합니다.
'😊' (U+1F60A) 같은 이모지는 4바이트로 표현합니다.
문자를 표현하는 최소 단위를 2바이트(16비트)로 합니다.
대부분의 문자를 2바이트로 표현하지만, 일부 문자는 4바이트로 표현합니다.
왜 굳이 둘로 나눴을까요?
두 방식은 효율성에서 장단점이 갈립니다.
HTML, CSS, JS 코드 대부분은 <p>, "function", "color"처럼 영문자(ASCII)로 이루어져 있습니다.
UTF-8은 영문자를 1바이트로 저장하므로, 영문 위주의 문서에서 파일 용량이 매우 효율적입니다.
ASCII와 완벽하게 하위 호환됩니다. 둘은 저장 방식(바이트)이 100% 동일하기 때문에, ASCII 파일은 그 자체로 유효한 UTF-8 파일입니다.
이것이 오늘날 웹의 압도적인 표준이 된 이유입니다.
'A'도 2바이트, '한'도 2바이트로 처리합니다. (이모지 등은 4바이트)
한글, 한자 등 아시아권 문자를 다룰 때는 1~3바이트로 크기가 변하는 UTF-8보다 2바이트 고정처럼 보이는 UTF-16이 내부적으로 다루기 편할 수 있습니다.
Java, C#, 그리고 자바스크립트(JS)가 내부적으로 문자열을 다룰 때 이 UTF-16 방식을 사용합니다.
"자바스크립트는 내부적으로 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(index): 주어진 인덱스에서 시작하는 "완성된 문자"의 코드 포인트를 반환합니다.
"😊".codePointAt(0) -> 128522