개발자 중에서도 사람마다 다양한 성향이 있다. 미적 감각이 뛰어난 예술가같은 개발자도 있고, 철학자같은 사람들도 있는 것 같다. 나는 고고학자같은 느낌이 있는 것 같다. 아무래도 전자전기컴퓨터공학부를 전공했다보니까 Low 레벨에 대한 내용도 꽤 많이 접해서 뭔가 궁금한게 생기면 Low 레벨 수준의 역사들을 발굴해내는 편이다.
예전에 딥러닝 관련해서 연구하는 과정에서 Kaggle에서 데이터를 수집하는 경우가 있었는데, 데이터가 깨지는 경우가 너무 많았다. 왜냐하면 일반적으로 영어로 쓰여있을 줄 알았던 .csv 데이터 속에서는 인도어나 독일어와 같은 다국어로 쓰여 있었고, .xlsx로 파일을 받는 경우에는 99% 확률로 깨져 있었던 것 같다.
그 외에 다른 경험으로는 데이터베이스에서 VARCHAR로 선언된 형식에 이모지(Emoji)를 함께 받고 있는데, 이래도 괜찮나?라고 하는 생각이 들었는데, 찾아보는 과정에서 UTF-8과 UTF-16에 대해서 찾아보게 되었다.
본론
유니코드(Unicode)란?
과거 컴퓨터 환경에서는 언어와 지역별로 각기 다른 문자 인코딩 방식을 사용했다. 이로 인해 시스템이나 프로그램 간에 텍스트 데이터를 교환할 때, 글자가 깨져 보이는 현상(소위 '모지바케(Mojibake)')이 빈번하게 발생했다. 이러한 혼란을 해결하고 전 세계의 모든 문자를 일관되게 표현하기 위해 등장한 것이 바로 유니코드(Unicode)이다.
유니코드는 전 세계의 거의 모든 문자(현대 및 고대 언어, 기호, 이모티콘 등)를 컴퓨터에서 일관되게 표현하고 다룰 수 있도록 설계된 국제 표준 문자 체계이다. 핵심 원리는 각 문자에 고유한 번호를 부여하는 것인데, 이 번호를 코드 포인트(Code Point)라고 부른다. 코드 포인트는 보통 U+ 뒤에 16진수 숫자를 붙여 표기하며, 현재 U+0000부터 U+10FFFF까지의 범위를 사용한다. 이를 통해 수십만 자에 이르는 방대한 문자 집합을 포괄한다.
예를 들어, 라틴 문자 'A'는 U+0041, 한글 '가'는 U+AC00, 이모지 😂는 U+1F602라는 코드 포인트를 갖는다. 이렇게 모든 문자에 고유한 문자를 매김으로써, 어떤 플랫폼이나 프로그램에서도 문자를 혼동 없이 식별할 수 있게 된다.
하지만 이 코드 포인트 자체는 추상적인 숫자일 뿐이다. 컴퓨터가 실제로 이 문자를 파일에 저장하거나 네트워크로 전송하려면, 이 코드 포인트를 실제 데이터인 바이트(byte) 시퀀스로 변환하는 과정이 필요하다. 이 변환 규칙, 즉 코드 포인트를 구체적인 바이트 값들의 나열로 바꾸는 방법을 문자 인코딩(Character Encoding)이라고 한다. 앞으로 자세히 살펴볼 UTF-8과 UTF-16이 바로 이 유니코드 문자들을 위한 대표적인 문자 인코딩 방식에 해당한다.
UTF-8이란?
오늘날 우리가 인터넷을 통해 다양한 언어의 텍스트를 문제없이 보고 있다면, 그 바탕에는 UTF-8이라는 문자 인코딩 방식이 있다고 해도 과언이 아니다. UTF-8은 유니코드 표준을 실제 바이트 데이터로 구현하는 가장 대표적이고 널리 사용되는 방식으로, 현재 웹과 대부분의 운영체제, 프로그래밍 언어 환경에서 사실상의 표준으로 자리 잡았다.
UTF-8의 가장 핵심적인 특징은 가변 길이 인코딩 방식을 채택했다는 점이다. 이는 하나의 유니코드 코드 포인트를 표현하기 위해 1바이트에서 최대 4바이트까지의 길이를 유동적으로 사용한다는 의미이다. 왜 이런 방식을 택했을까? 이는 엄청난 실용적 이점, 즉 ASCII와의 완벽한 하위 호환성을 확보하기 위함이었다.
유니코드 코드 포인트 중에서 U+0000부터 U+007F까지의 범위는 기존의 ASCII 문자 집합과 정확히 일치한다. UTF-8은 이 범위의 문자들을 인코딩할 때, 단 1바이트만을 사용하며 그 바이트 값 또한 기존 ASCII 인코딩과 완전히 동일하다. 예를 들어, 문자 'A'(U+0041)는 ASCII에서도 16진수 41로 표현되고, UTF-8에서도 똑같이 1바이트 41로 인코딩된다.
'A' (U+0041) → 01000001₂ → 41₁₆ (1 바이트)
이는 UTF-8이 등장하기 이전에 이미 방대하게 존재하던 ASCII 기반의 문서, 코드, 시스템들과의 호환성 문제를 원천적으로 해결하는 결정적인 설계였다. 영문 중심의 텍스트는 기존과 거의 동일한 저장 공간을 차지하면서도, 전세계의 모든 문자를 표현할 수 있는 확장성을 동시에 확보한 것이다.
멀티바이트 인코딩의 작동 원리
그렇다면 ASCII 범위를 벗어나는 문자들, 즉 U+0080 이상의 코드 포인트들은 어떻게 표현할까? 이때부터 UTF-8은 2바이트, 3바이트, 또는 4바이트를 사용한다. 여기서 중요한 것은 첫 번째 바이트의 시작 비트 패턴을 보면 해당 문자가 총 몇 바이트로 인코딩되는지 즉시 알 수 있다는 점이다. 규칙은 다음과 같다.
- 0xxxxxxx: 1바이트로 인코딩됨 (ASCII 문자와 동일). 최상위 비트가 0이다.
- 110xxxxx: 2바이트 시퀀스의 시작 바이트임을 나타낸다. (U+0080 ~ U+07FF 범위)
- 1110xxxx: 3바이트 시퀀스의 시작 바이트임을 나타낸다. (U+0800 ~ U+FFFF 범위)
- 11110xxx: 4바이트 시퀀스의 시작 바이트임을 나타낸다. (U+10000 ~ U+10FFFF 범위)
여기서 x로 표시된 비트들은 실제 유니코드 코드 포인트의 값을 비트들을 채워 넣는 데 사용된다.
더욱 영리한 설계는 두 번째 바이트부터 이어지는 모든 바이트는 항상 10xxxxxx 패턴으로 시작한다는 규칙이다. 이는 해당 바이트가 독립적인 문자의 시작이 아니라, 앞선 바이트에서 시작된 멀티바이트 시퀀스의 일부임을 명확히 표시하는 역할을 한다.
예를 들어 유로 기호 '€'(U+20AC)와 한글 '한'(U+D55C)을 인코딩해보자. 두 문자 모두 U+0800 ~ U+FFFF 범위에 속하므로 3바이트(1110xxxx 10xxxxxx 10xxxxxx)로 인코딩된다
- '€' (U+20AC = 0010 0000 1010 1100₂):
- 11100010 10000010 10101100 → E2 82 AC₁₆ (3 바이트)
- '한' (U+D55C = 1101 0101 0101 1100₂):
- 11101101 10010101 10011100 → ED 95 9C₁₆ (3 바이트)
이러한 UTF-8의 주요 장점에 대해서 살펴보면 다음과 같다.
UTF-8의 주요 장점들
이러한 작동 원리 덕분에 UTF-8은 여러 강력한 장점을 지닌다.
- ASCII 호환성: 앞서 강조했듯이, 기존 시스템과의 마찰을 최소화하며 유니코드를 도입할 수 있게 한 핵심 동력이다.
- 자기 동기화 (Self-synchronization): 데이터 스트림 일부가 손상되거나 중간부터 읽기 시작해도, 바이트 패턴(0xxxxxxx, 110xxxxx, 1110xxxx, 11110xxx는 문자의 시작, 10xxxxxx는 연속 바이트)을 통해 다음 문자의 시작점을 비교적 쉽게 찾아낼 수 있다. 이는 데이터 전송 및 처리 시 오류 복원력을 높여준다.
- 광범위한 사용과 호환성: HTML, XML, JSON, CSS 등 거의 모든 인터넷 표준과 최신 프로그래밍 언어(Python, Java, JavaScript, Rust 등), 그리고 유닉스 계열 운영체제(Linux, macOS)에서 기본 인코딩으로 채택되어, 호환성 문제 발생 소지가 매우 적다.
- 엔디안 문제 없음: UTF-8은 인코딩의 기본 단위가 1바이트이므로, UTF-16 등과 달리 시스템의 **바이트 순서(Endianness)**에 영향을 받지 않는다. 이는 서로 다른 시스템 간 데이터 교환 시 잠재적인 오류 요인을 제거해준다.
- 공간 효율성 (영문 위주 텍스트): 주로 ASCII 문자로 구성된 텍스트의 경우, UTF-16이나 다른 고정 길이 인코딩 방식보다 저장 공간을 훨씬 적게 차지한다.
물론 이 외에도 고려해야할 단점으로 특정 문자 집합에서는 공간 비효율성을 가진다. 한글 한자, 일본어 등 동아시아 언어 문자는 대부분 3바이트를 차지한다. 따라서, 문서 내용이 이러한 문자들로만 주로 구성된다면, 해당 문자들을 2바이트로 표현하는 UTF-16(BMP 영역 한정)보다 오히려 더 많은 저장 공간을 사용할 수 있다.
UTF-16
UTF-8이 웹과 유닉스 계열 환경에서 강세를 보이는 반면, UTF-16은 또 다른 주요 유니코드 인코딩 방식으로, 특히 Windows 운영체제 내부나 Java, Javascript 같은 프로그래밍 언어의 내부 문자열 표현 등 특정 환경에서 중요한 역할을 담당한다. UTF-16은 이름에서 알 수 있듯이, 기본적인 처리 단위를 16비트(2바이트)로 삼는다는 특징에서 출발한다.
BMP(기본 다국어 평면)
UTF-8 설명에서 동아시아 문자(한글, 한자 등)가 대부분 3바이트를 차지한다고 언급했다. 그렇다면 왜 UTF-16에서는 이 문자들을 2바이트로 표현할 수 있을까? 그 비밀은 유니코드 구조와 UTF-16의 설계 철학에 있다.
유니코드 코드 포인트 공간(U+0000 ~ U+10FFFF) 중에서 첫 65,536개(U+0000 ~ U+FFFF)를 기본 다국어 평면(Basic Multilingual Plane, BMP)이라고 부른다. 이 BMP에는 현대 세계에서 사용되는 대부분의 문자들, 즉 라틴 문자, 그리스 문자, 키릴 문자뿐만 아니라 한글 음질 전체, 자주 사용되는 한자, 일본 가나 등이 포함된다.
UTF-16의 핵심 설계는 바로 이 BMP에 속하는 모든 문자를 정확히 16비트, 즉 2바이트 코드 유닛(code unit) 하나로 표현하는 것이다. 유니코드 코드 포인트 값이 U+FFFF 이하인 문자는 그 숫자 값 자체가 거의 그대로 2바이트 UTF-16 코드 유닛 값이 된다.
예를 들어 보자.
- 'A' (U+0041): BMP에 속하며, 16비트 값 0041₁₆으로 표현된다. UTF-16으로는 그대로 2바이트 00 41₁₆ 이다.
- '한' (U+D55C): 역시 BMP에 속하며, 16비트 값 D55C₁₆으로 표현된다. UTF-16으로는 그대로 2바이트 D5 5C₁₆ 이다.
바로 이 점 때문에, 한글이나 한자 위주의 텍스트를 다룰 때 UTF-16이 UTF-8 (주로 3바이트 사용)보다 저장 공간 측면에서 더 효율적일 수 있다. UTF-8이 ASCII 호환성과 바이트 단위 처리에 중점을 둔 가변 길이 설계를 택한 반면, UTF-16은 BMP 내 문자를 2바이트로 직접 매핑하는 데 우선 순위를 둔 설계를 택했기 때문이다.
엔디안 문제
UTF-16은 처리 단위가 2바이트이기 때문에, 바이트 순서(Endianness) 문제에서 자유로울 수 없다. 2바이트 값 D5 5C('한')을 메모리에 저장할 때, 시스템 아키텍처가 빅 엔디안이냐 리틀 엔디안이냐에 따라서 저장 방식이 달라진다.
따라서, UTF-16 텍스트 파일이나 스트림이 시작되다가 중간에 끊어서 확인하면 2바이트씩 확인해야하기 때문에 어디가 시작이고 어디가 끝인지 알기 어렵다. 이를 방지하기 위해서 스트림 시작 부분에 바이트 순서 표식(Byte Order Mark, BOM)으로 유니코드 문자 U+FFFF를 추가하는 경우가 많다.
이 외에 intel 칩과 apple 칩의 엔디안 방식으로 인해서 생기는 여러 문제들이 많은데, 이 내용은 나중에 또 다루도록 한다.
마치며
유니코드 관련해서 이모지(Emoji), 모지바케(Mojibaki) 이러한 표현들은 일본어인 것으로 보인다. 아마 소프트웨어가 태동하던 시기에 일본에서 먼저 이러한 인코딩 문제를 해결해 나서지 않았을까 싶다.
참고
- [나무위키] UTF-8 : https://namu.wiki/w/UTF-8
- [위키백과] UTF-8 : https://ko.wikipedia.org/wiki/UTF-8
감사합니다.
'CS > Computer Architecture' 카테고리의 다른 글
컴파일러와 인터프리터의 차이 ( 고급편 ) (3) | 2025.06.07 |
---|---|
ARM vs AMD(x86) 아키텍처 (3) | 2025.06.07 |
빌드(build) 그리고 컴파일러와 인터프리터 (1) | 2023.10.15 |