UTF-8: 문자열 깨짐 문제 해결하기

문자 인코딩의 표준, UTF-8을 이해하고 문자열 처리 문제를 해결하기

Table Of Contents

왜 UTF-8을 알아야 할까요?

"문자열이 깨졌어요!"

개발하다 보면 한 번쯤 파일을 읽었는데 한글이 깨져 있거나, 데이터베이스에 저장한 이모지가 ?로 바뀌어 있거나, API 응답에서 특수문자가 이상하게 보이는 문제를 만나볼 수 있어요.

이런 문제의 대부분은 인코딩 때문이에요. 대부분의 시스템에서는 UTF-8 인코딩을 채택하고 있어요. 하지만 UTF-8 인코딩을 당연하게 생각하고 넘어간 순간, 예상치 못한 곳에서 문제가 터질 수 있어요.

이 글에서는 UTF-8이 무엇인지, 왜 문자열이 깨지는지, 그리고 어떻게 예방할 수 있는지 알아볼게요.

이 글을 읽고 나면

문자 인코딩의 역사와 UTF-8의 탄생

ASCII: 문자는 1Byte

초기 컴퓨터 시스템에서는 문자는 영문 알파벳과 숫자, 그리고 기본 제어 문자(줄바꿈, 탭 등)만을 포함했어요.

ASCII(아스키, American Standard Code for Information Interchange)는 1Byte, 즉 7Bit(0~127)로 128개 문자를 표현해요. 예를 들어 'A'는 65(0x41), 'a'는 97(0x61), 줄바꿈(LF)은 10(0x0A)에 대응해요.

컴퓨터에 65라는 값이 저장되면, 그 자체는 단지 숫자일 뿐이에요. 하지만 우리가 그 값을 문자로 해석하기로 약속하면, 시스템은 ASCII 테이블을 참조해 65(0x41)이 문자 'A'에 해당한다는 것을 알아내요. 그리고 화면 등으로 출력할 때에는 폰트에서 'A'의 모양을 찾아 그려 주기 때문에, 우리는 숫자 65가 알파벳 A로 보이는 것처럼 인식할 수 있어요.

ASCII 테이블 (ASCII 테이블, 출처: https://www.asciitable.com/)

ASCII 시대의 시스템들은 "문자 하나는 1Byte로 표현할 수 있다"는 전제를 당연하게 받아들였어요. 초기 컴퓨터의 파일 시스템, 네트워크 프로토콜, 문자열 처리 라이브러리 등의 프로그램은 모두 이런 가정 아래에 설계되었어요.

// 1. 문자열 길이를 계산하기 int length = strlen(str); // 문자 수 = Byte 수 // 2. n번째 문자에 접근하기 char c = str[n]; // 문자 위치 = 배열 인덱스 // 3. 문자 수만큼 메모리를 할당하기 char* buffer = malloc(10); // 10글자 = 10Byte

하지만 "1문자 = 1Byte"를 내세우는 ASCII 인코딩은 영어권 밖에서는 통용되기 어려웠어요. 한글, 한자, 아랍어처럼 수천, 혹은 수만 개의 문자가 필요한 언어는 7비트로 표현할 수 없기 때문이에요. 각 언어권에서는 EUC-KR, Shift-JIS, GB2312같은 자체 인코딩을 만들었지만, 이 방식들은 서로 호환되지 않기 때문에 더 큰 혼란을 불러오기도 했어요.

유니코드: 모든 문자에 코드 부여하기

유니코드(Unicode)는 전 세계 모든 문자를 하나의 번호 체계로 통합하려는 프로젝트예요. 각 문자에 고유한 정수 값을 부여하고, 이 값을 **코드 포인트(code point)**라고 불러요.

예를 들어서, 'A'라는 영문자에는 U+0041를, 한글 '가'에는 U+AC00를, '🌊'라는 이모지에는 U+1F30A 코드를 부여해요.


유니코드는 U+0000부터 U+10FFFF까지의 범위를 사용해요. 따라서 이론적으로 약 110만 개(1,114,112개)의 문자를 표현할 수 있어요. 현재로서는 110만 자리 중에서, 약 15만개의 문자가 할당되어 있어요.

유니코드는 서로 다른 언어권의 문자들을 하나의 번호 체계로 통합했다는 의의가 있어요.

그런데 이미 세상에는 ASCII를 전제로 만들어진 시스템이 너무 많았어요. 파일 시스템, 네트워크 프로토콜, 터미널 출력, 파서들은 "문자는 1Byte로 나타낸다"는 가정 아래에 설계되어 있었어요. 즉, 유니코드를 도입하는 일은 단순히 새로운 체계를 만드는 게 아니라, 기존 시스템을 망가뜨리지 않으면서도 전 세계 문자를 확장 가능하게 담아내는 일을 목표로 해야 했어요.

또한 컴퓨터는 결국 데이터를 Byte 단위로 저장하고 전송하는데, U+AC00같은 코드 포인트는 1 Byte보다 자리를 많이 차지해요. 실제로 디스크에 저장하거나 네트워크로 전송하려면, 유니코드 코드 포인트를 컴퓨터가 다룰 수 있는 형태로 변환해야 해요. 유니코드의 코드 포인트를 실제 저장 가능한 Byte로 바꾸는 규칙이 바로 **문자 인코딩(encoding)**이고, 유니코드에는 여러 인코딩 방식이 존재해요.

이런 제약 속에서 기존 ASCII 기반 시스템과의 호환성을 유지하면서도, 전 세계 문자를 표현할 수 있도록 설계된 인코딩이 바로 UTF-8이에요.

UTF-8: 호환성과 확장성을 고려하기

유니코드를 Byte로 인코딩하는 방법은 여러 가지예요. UTF-8 외에도 UTF-32, UTF-16같은 방식이 있어요.

그렇다면 UTF-8은 어떻게 ASCII를 그대로 유지하면서, 동시에 더 많은 문자를 표현할 수 있었을까요?

UTF-8의 핵심 원칙

  1. ASCII는 범위는 그대로 두고, 나머지 영역에서 확장하기

Plain ASCII string is also a valid UTF-8 string.

— RFC 3629

"ASCII 영역(U+0000 ~ U+007F)은 건드리지 않는다"는 원칙은 UTF-8의 가장 중요한 원칙이에요. ASCII 문자는 UTF-8에서도 완전히 동일한 1Byte 값으로 표현돼요. 이 원칙 덕분에 기존 ASCII 기반 시스템을 망가뜨리지 않고 유니코드를 도입할 수 있었어요.

문자코드 포인트ASCIIUTF-8
AU+00410x410x41
/U+002F0x2F0x2F
\nU+000A0x0A0x0A

  1. ASCII Byte 값은 다른 문자의 일부에서 사용하지 않기

"ASCII Byte 값은 다른 문자의 일부로 나타나지 않는다"는 원칙 덕분에 기존 시스템의 파싱 로직이 깨지지 않아요.

UTF-8로 인코딩된 2Byte 이상의 문자에서 ASCII Byte 값(0x00~0x7F)이 포함되면, 기존 시스템이 그 Byte를 ASCII 문자로 잘못 해석할 수 있어요.

예를 들어 파일 경로를 구분할 때 사용하는 / 문자는 ASCII에서 0x2F로 정의되어 있어요. 만약 한글을 UTF-8로 인코딩할 때 0x2F Byte가 포함된다면, 경로 파싱 로직이 한글 파일명 중간에 /가 있다고 잘못 인식해 경로를 잘못 나눌 수 있어요.

따라서 UTF-8은 ASCII Byte 값이 다른 문자의 인코딩에 절대 사용되지 않도록 설계되었어요.

  1. 첫 Byte에 전체 길이를 알리기

"첫 Byte의 비트 패턴만으로 문자의 전체 Byte 수를 알 수 있다"는 원칙 덕분에 UTF-8을 효율적으로 파싱할 수 있어요. 각 문자의 첫 Byte는 이 문자가 몇 Byte로 표현될지 알려줘요.

Byte 수비트 패턴
1Byte0xxxxxxx
2Byte110xxxxx 10xxxxxx
3Byte1110xxxx 10xxxxxx 10xxxxxx
4Byte11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

이 원칙을 응용하면 현재 읽고 있는 Byte가 문자의 시작인지 혹은 중간인지를 알아낼 수 있어요. 덕분에 UTF-8은 문자 데이터에 부분적으로 손상이 있어도 빠르게 감지할 수 있어요.

현재 Byte 시작이

UTF-8의 인코딩 규칙

이제 실제로 코드 포인트를 어떻게 Byte로 변환되어서 저장하는지 알아볼게요.

UTF-8로 인코딩할 때는 코드 포인트의 비트를 패턴의 빈(x) 부분에 채워넣어요. 비트는 마지막 Byte부터 시작해서 역순으로 채워넣어요.

Byte 수비트 패턴코드 포인트 범위
1Byte0xxxxxxxU+0000 ~ U+007F
2Byte110xxxxx 10xxxxxxU+0080 ~ U+07FF
3Byte1110xxxx 10xxxxxx 10xxxxxxU+0800 ~ U+FFFF
4Byte11110xxx 10xxxxxx 10xxxxxx 10xxxxxxU+10000 ~ U+10FFFF

예시를 보면서 코드 포인트를 어떻게 채우는지 알아볼게요.

🌊(U+1F30A)를 UTF-8로 인코딩하는 과정을 다시 한 번 살펴봐요.

  1. 🌊의 코드 포인트 U+1F30A를 2진수로 변환하면 0001 1111 0011 0000 1010이에요.
  2. U+1F30A는 4Byte로 표현해야 해요. 4Byte 패턴 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx에서 x 부분에 비트를 채울게요.
  3. 마지막 Byte부터 역순으로 채워넣어요:
    • 마지막 Byte(10xxxxxx): 0001 1111 0011 0000 1010에서 마지막 6비트 001010을 채워넣어요. → 10 + 001010 = 10001010
    • 세 번째 Byte(10xxxxxx): 남은 비트에서 다음 6비트 011000을 채워넣어요. → 10 + 011000 = 10011000
    • 두 번째 Byte(10xxxxxx): 남은 비트에서 다음 6비트 011111을 채워넣어요. → 10 + 011111 = 10011111
    • 첫 Byte(11110xxx): 남은 비트에서 마지막 3비트 000을 채워넣어요. → 11110 + 000 = 11110000
  4. 결과적으로 U+1F30A라는 코드 포인트는 UTF-8에서는 11110000 10011111 10011000 10001010로 표현돼요. 16진수로 나타내면 0xF0 0x9F 0x98 0x8A이에요.

UTF-8의 장단점

UTF-8은 모든 상황에서 가장 효율적인 인코딩은 아니에요. 기존 ASCII 기반 시스템과 호환되면서도 전 세계 문자를 담을 수 있도록 설계되었기 때문에 이런 장점과 단점이 있어요.

UTF-8을 사용하면 얻을 수 있는 것:

UTF-8을 사용할 때 포기해야 하는 것:

이런 트레이드오프 덕분에 UTF-8은 웹의 표준이 됐어요. HTML, JSON, XML을 비롯한 대부분의 웹 표준은 UTF-8을 기본 인코딩으로 사용해요.

"한 글자"란 무엇일까

UTF-8은 가변 길이 인코딩이에요. 같은 "한 글자"라도 영문 'A'는 1Byte, 한글 '가'는 3Byte, 이모지 '🌊'는 4Byte로 표현돼요.

이런 차이 때문에 "10글자 제한"처럼 보이는 간단한 기능도 Byte 수로 센다면 영문은 10글자, 한글은 3글자만 들어가요. 따라서 실제로는 어떤 단위로 글자를 세야 할지 결정해야 해요.

글자를 나누는 단위

문자열을 다루는 방법은 목적에 따라 달라요. 저장, 전송, 처리, 사용자 경험이라는 서로 다른 목적을 위해서, 문자열을 네 가지 단위로 나누어 설명할 수 있어요. 이제 각 단위의 특징을 살펴볼게요.

  1. Byte: 저장과 전송에 사용하는 물리적 단위예요.
    • 같은 문자라도 인코딩 방식에 따라 Byte 수가 달라져요.
    • ex) 'A'는 UTF-8에서 1Byte, '가'는 3Byte, '🌊'는 4Byte예요.
  2. 코드 포인트(Code Point): 유니코드가 각 문자에 부여한 고유 번호예요.
    • 인코딩 방식과 무관하게 문자 하나당 하나의 코드 포인트를 가져요.
    • ex) 'A'는 U+0041, '가'는 U+AC00, '🌊'는 U+1F30A예요.
  3. 코드 유닛(Code Unit): 프로그래밍 언어가 내부적으로 문자열을 저장하는 단위예요.
    • JavaScript는 UTF-16 코드 유닛을 사용해요.
    • UTF-16은 대부분의 문자를 2Byte(1개 코드 유닛)로 표현하지만, U+10000 이상의 문자는 4Byte(2개 코드 유닛)로 표현해요.
    • 이렇게 2개의 코드 유닛으로 표현되는 방식을 서로게이트 페어(Surrogate Pair)라고 해요.
    • ex) "🌊".length는 2가 나와요.
  4. 그래핌 클러스터(Grapheme Cluster): 사용자가 실제로 보는 "한 글자"예요.
    • 여러 코드 포인트가 조합되어 하나의 문자로 보일 수 있어요.
    • ex) '👨‍👩‍👧'는 남자(U+1F468) + ZWJ(U+200D) + 여자(U+1F469) + ZWJ(U+200D) + 여자아이(U+1F467)의 조합이에요.

직접 문자열을 Byte, 코드 포인트, 코드 유닛, 그래핌 클러스터로 분해해서 확인해보세요.

모든 상황에 항상 맞는 답은 없어요. 우리가 다루고자 하는 제품의 목표에 따라 적절한 방법을 선택하면 돼요.

실전에서 마주치는 문자열 문제

이제부터는 자주 마주치는 문제들을 예제로 살펴볼게요.

문자열 문제는 대부분 시스템 경계에서 발생하고,
원인은 대부분 단위(Byte / Code Point / Code Unit / Grapheme)를 섞어 쓰는 데 있어요.


1) 문자를 잘랐는데, 깨졌어요

UTF-8은 가변 길이 인코딩이에요. 즉 "문자 경계"는 항상 Byte 경계와 일치하지 않아요.

특히 이런 상황에서 자주 발생하기 쉬워요.

// ❌ Byte 단위로 잘라요 const text = "안녕하세요"; // '안'(3) '녕'(3) '하'(3) '세'(3) '요'(3) = 15 Byte (UTF-8) const bytes = Buffer.from(text, "utf-8"); const sliced = bytes.slice(0, 10); // 문자 경계를 무시하고 10Byte만 잘라려요 console.log(sliced.toString("utf-8")); // "안녕하�"

이건 3Byte 문자의 중간 Byte에서 잘렸기 때문이에요.

해결 방향 1: 코드 유닛 기준으로 자르기

// ⚠️ JS의 slice는 UTF-16 코드 유닛 기준이에요. // 한글은 안전하지만, 이모지나 결합 이모지는 여전히 위험할 수 있어요. const safe = text.slice(0, 3); console.log(safe); // "안녕하"

해결 방향 2: 그래핌 클러스터 기준(사용자 체감 글자 기준)으로 자르기

사용자가 보는 "한 글자" 기준으로 자르려면, 그래핌 클러스터 단위가 필요해요.

// ⚠️ Intl.Segmenter를 지원하는 환경에서만 가능해요 const segmenter = new Intl.Segmenter("ko", { granularity: "grapheme" }); const graphemes = Array.from( segmenter.segment("안녕👨‍👩‍👧하세요"), (s) => s.segment, ); console.log(graphemes); // ["안","녕","👨‍👩‍👧","하","세","요"] console.log(graphemes.slice(0, 3).join("")); // "안녕👨‍👩‍👧"

"10글자 제한" 같은 UX 요구사항은 보통 그래핌 클러스터 기준이 가장 안전해요.

2) JS에서 String.length로 글자수를 구하고 싶어요

JavaScript에서 문자열은 내부적으로 UTF-16 코드 유닛 배열이에요. 그래서 String.length는 코드 포인트 수도, 그래핌 수도 아니고, 그냥 코드 유닛 개수예요.

console.log("가".length); // 1 console.log("🌊".length); // 2 console.log("👨‍👩‍👧".length); // 5 (여러 코드 포인트 + ZWJ 조합)

코드 포인트 수가 필요하다면 이런 방법을 사용할 수도 있어요.

console.log([..."🌊"].length); // 1 console.log(Array.from("🌊").length); // 1 console.log([..."👨‍👩‍👧"].length); // 5 (코드 포인트 5개) console.log(Array.from("👨‍👩‍👧").length); // 5

사용자가 보는 "글자 수"를 정확히 측정하려면 그래핌 클러스터 단위로 세어야 해요.

function getGraphemeLength(str) { if (typeof Intl !== "undefined" && Intl.Segmenter) { const segmenter = new Intl.Segmenter("ko", { granularity: "grapheme" }); return Array.from(segmenter.segment(str)).length; } // fallback: Intl.Segmenter를 지원하지 않는 환경에서는 코드 포인트 수 반환 return Array.from(str).length; } console.log(getGraphemeLength("🌊")); // 1 console.log(getGraphemeLength("👨‍👩‍👧")); // 1

3) 정규식 .이 기대와 다르게 동작해요

정규식에서 .은 보통 한 글자를 가리키는 것처럼 보이지만, 실제로는 언어/엔진/플래그에 따라 다르게 동작해요. 특히 JS에서 유니코드 이모지는 기본 정규식에서 잘리는 경우가 많아요.

console.log("🌊".match(/./g)); // 환경에 따라서, ["\uD83C", "\uDF0A"] 처럼 쪼개질 수 있어요 console.log("🌊".match(/./gu)); // ["🌊"] u 플래그를 쓰면 코드 포인트 단위로 인식해요

하지만 u 플래그를 써도, 결합 이모지는 여전히 여러 코드 포인트로 분해돼요.

console.log("👨‍👩‍👧".match(/./gu)); // ["👨", "‍", "👩", "‍", "👧"] 처럼 분해돼요.

사용자 체감 글자 단위로 토큰화/검증하려면 정규식만으로는 한계가 있고, Intl.Segmenter 같은 도구가 필요해요.

4) 같아 보이는데, 다른 문자라고 해요

눈으로는 똑같아 보이는데, 실제 Byte/코드 포인트가 다른 문자열이 있어요. 대표적으로 유니코드 정규화(NFC/NFD) 문제가 있어요.

예를 들어, 어떤 글자는

이런 경우에 화면에는 동일하게 보이는데 문자열 비교/검색/해시가 실패해요.

// é는 하나의 문자(`U+00E9`)일 수도 있고, e(`U+0065`)+´(`U+0301`)일 수도 있어요. const a = "\u00E9"; const b = "\u0065\u0301"; console.log(a === b); // false console.log(a.normalize("NFC") === b.normalize("NFC")); // true

이런 문제가 있으면 입력을 normalize해서 비교해야 할 수 있어요.

5) UTF-8로 데이터를 전송했는데 깨져요

서버는 UTF-8로 보냈는데, 클라이언트가 다른 인코딩으로 읽으면 문자열이 깨지게 돼요. 즉, 시스템 경계에서 합의가 깨진 것이에요.

인코딩이 호환되지 않는 시스템 경계는 여기를 살펴보면 좋아요.

위 시스템들의 경계에서, 이렇게 3 지점을 살펴보세요.

  1. 문자열이 Byte로 바뀌는 지점(인코딩)
  2. Byte가 문자열로 바뀌는 지점(디코딩)
  3. 그 사이의 전송/저장 경로에서 인코딩이 유지되었는지

디버깅 단서

인코딩 문제 예방하기

명시적으로 UTF-8을 사용하겠다고 선언하면 많은 문제를 예방할 수 있어요.

언어별로 UTF-8 인코딩 지정하기

Node.js 파일 입출력

const fs = require("fs"); fs.writeFileSync("file.txt", text, { encoding: "utf-8" }); const content = fs.readFileSync("file.txt", { encoding: "utf-8" });

Python 파일 입출력

with open('file.txt', 'w', encoding='utf-8') as f: f.write(text) with open('file.txt', 'r', encoding='utf-8') as f: content = f.read()

Express.js HTTP 응답

app.get("/api/data", (req, res) => { res.setHeader("Content-Type", "application/json; charset=utf-8"); res.json({ message: "안녕하세요" }); });

MySQL 데이터베이스

CREATE TABLE messages ( id INT PRIMARY KEY, content TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci );

⚠️ MySQL의 utf8은 실제로는 최대 3Byte만 지원해요. 이모지를 포함하려면 반드시 utf8mb4를 사용해야 해요.

마치며

UTF-8은 대부분의 환경에서 기본값처럼 사용되고 있어요. 하지만 파일 입출력, 네트워크 전송, 데이터베이스 저장처럼 문자열이 시스템 경계를 넘나드는 지점에서는 인코딩에 대한 명시적인 합의가 없을 경우 문제가 드러나기 쉬워요. 눈에 보이는 글자와 저장되는 Byte가 다를 수 있다는 걸 인식하고, 시스템 경계에서 명시적으로 인코딩을 다루면 대부분의 문제를 예방할 수 있어요.

참고 자료