
컴퓨터는 0과 1만 이해할 수 있다. 그렇다면 우리가 사용하는 문자는 어떻게 컴퓨터에 저장될까? 이 질문에 대한 답을 찾아가는 과정이 바로 문자 인코딩의 역사다.
미국에서 ASCII(American Standard Code for Information Interchange)가 만들어졌다. 7비트(128개 문자)로 영어 알파벳, 숫자, 기본 기호만 표현했다.
'A' = 65
'a' = 97
'0' = 48
' ' = 32
영어권에서는 문제가 없었다. 하지만 세계에는 영어만 있는 것이 아니었다.
각 나라가 자신들의 언어를 표현하기 위해 독자적인 인코딩을 만들었다:
<!-- 2000년대 초반 한국 웹사이트의 흔한 풍경 -->
<meta http-equiv="Content-Type" content="text/html; charset=euc-kr">
인코딩이 맞지 않으면 "¾È³çÇϼ¼¿ä"같은 글자가 나타났다.
"전 세계 모든 문자에 고유한 번호를 주자!"
유니코드는 문자 집합(Character Set)이다. 각 문자에 코드 포인트(Code Point)라는 고유 번호를 부여한다:
U+0041 = A (라틴 대문자 A)
U+AC00 = 가 (한글 가)
U+4E00 = 一 (한자)
U+1F600 = 😀 (이모지)
유니코드는 현재 15만 개 이상의 문자를 정의하고 있다. 고대 문자부터 이모지까지 모두 포함한다.
유니코드는 문자에 번호만 부여했을 뿐, 어떻게 저장할지는 정하지 않았다. 이 번호를 컴퓨터에 어떻게 저장할 것인가?
유니코드 코드 포인트를 실제 바이트로 변환하는 방법이 필요했다.
모든 문자를 4바이트로 저장한다.
'A' = 00 00 00 41 (4바이트)
대부분의 문자를 2바이트로 저장하고, 일부는 4바이트를 사용한다.
'A' = 00 41 (2바이트)
'가' = AC 00 (2바이트)
'😀' = D8 3D DE 00 (4바이트)
가변 길이 인코딩이다. 1~4바이트를 사용한다.
'A' = 41 (1바이트)
'가' = EA B0 80 (3바이트)
'😀' = F0 9F 98 80 (4바이트)
현재 웹의 98% 이상이 UTF-8을 사용한다. UTF-8이 표준이 된 이유는 무엇일까?
기존의 영어 텍스트 파일을 그대로 사용할 수 있다. ASCII는 UTF-8의 부분집합이다.
영어권 중심의 초기 인터�트에서 가장 효율적이었다. 영어 텍스트는 UTF-8과 ASCII가 동일하다.
바이트 스트림의 중간부터 읽어도 문자 경계를 찾을 수 있다. 이는 UTF-8의 독특한 바이트 패턴 덕분이다:
0xxxxxxx = 1바이트 문자 (ASCII)
110xxxxx 10xxxxxx = 2바이트 문자
1110xxxx 10xxxxxx 10xxxxxx = 3바이트 문자
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx = 4바이트 문자
첫 바이트를 보면 해당 문자가 몇 바이트인지 알 수 있다.
UTF-16과 UTF-32는 바이트 순서(Endianness)를 결정해야 하지만, UTF-8은 바이트 단위로 처리되므로 이 문제가 없다.
웹 개발을 하다 보면 UTF-8과 관련된 다양한 상황을 마주치게 된다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>웹페이지</title>
</head>
<meta charset="UTF-8">이 없으면 브라우저가 다른 인코딩으로 해석할 수 있다. 특히 오래된 브라우저나 특정 환경에서는 기본 인코딩이 다를 수 있다.
반드시 <head> 태그의 최상단에 위치해야 한다. 브라우저가 문서를 파싱하다가 charset을 만나면 처음부터 다시 파싱하기 때문이다.
JavaScript는 내부적으로 UTF-16을 사용한다. 이로 인해 예상치 못한 동작이 발생할 수 있다.
// 이모지는 UTF-16에서 4바이트 (2개의 코드 유닛)를 사용한다
const emoji = "😀";
console.log(emoji.length); // 2 (예상: 1)
// 올바르게 세는 방법
console.log([...emoji].length); // 1
console.log(Array.from(emoji).length); // 1
const text = "안녕😀하세요";
// 잘못된 방법: 이모지가 잘릴 수 있다
console.log(text.substring(0, 4)); // "안녕�" (깨진 문자)
// 올바른 방법
const chars = [...text];
console.log(chars.slice(0, 4).join("")); // "안녕😀하"
// 점(.)은 UTF-16 코드 유닛 하나만 매칭한다
console.log(/^.$/.test("😀")); // false
// u 플래그를 사용하면 유니코드 문자로 처리한다
console.log(/^.$/u.test("😀")); // true
const text = "😀";
// charCodeAt: UTF-16 코드 유닛을 반환
console.log(text.charCodeAt(0)); // 55357 (상위 서로게이트)
console.log(text.charCodeAt(1)); // 56832 (하위 서로게이트)
// codePointAt: 유니코드 코드 포인트를 반환
console.log(text.codePointAt(0)); // 128512 (U+1F600)
// fetch API
fetch("/api/data", {
method: "POST",
headers: {
"Content-Type": "application/json; charset=utf-8"
},
body: JSON.stringify({ name: "홍길동" })
});
서버가 올바른 Content-Type 헤더를 보내지 않으면 한글이 깨질 수 있다.
const query = "검색어";
// 잘못된 방법
const url = `https://example.com/search?q=${query}`;
// https://example.com/search?q=검색어
// 올바른 방법
const encodedUrl = `https://example.com/search?q=${encodeURIComponent(query)}`;
// https://example.com/search?q=%EA%B2%80%EC%83%89%EC%96%B4
encodeURIComponent는 문자를 UTF-8 바이트로 변환한 후 퍼센트 인코딩한다.
// 파일 다운로드 시 한글 파일명
const filename = "보고서.pdf";
const encodedFilename = encodeURIComponent(filename);
// Content-Disposition 헤더
const header = `attachment; filename="${encodedFilename}"; filename*=UTF-8''${encodedFilename}`;
// File API를 사용할 때
const file = document.querySelector('input[type="file"]').files[0];
// UTF-8로 읽기
const text = await file.text(); // 기본적으로 UTF-8
// 또는 명시적으로
const reader = new FileReader();
reader.readAsText(file, "UTF-8");
reader.onload = (e) => {
console.log(e.target.result);
};
// localStorage는 UTF-16 문자열로 저장한다
localStorage.setItem("name", "홍길동"); // 문제없다
// 하지만 바이너리 데이터를 저장하려면 인코딩이 필요하다
const binary = new Uint8Array([72, 101, 108, 108, 111]);
const base64 = btoa(String.fromCharCode(...binary));
localStorage.setItem("data", base64);
쿠키는 ASCII만 지원하므로 한글을 저장하려면 인코딩해야 한다:
// 쿠키에 한글 저장
document.cookie = `name=${encodeURIComponent("홍길동")}`;
// 쿠키에서 한글 읽기
const value = decodeURIComponent(
document.cookie.split("name=")[1]?.split(";")[0]
);
<!-- 폼 전송 시 인코딩 지정 -->
<form accept-charset="UTF-8" method="post" action="/submit">
<input type="text" name="comment" />
<button type="submit">전송</button>
</form>
accept-charset을 지정하지 않으면 페이지의 인코딩을 따른다.
프론트엔드에서 보낸 데이터가 데이터베이스에 올바르게 저장되려면:
// API 응답 확인
fetch("/api/user")
.then(res => {
// Content-Type 확인
const contentType = res.headers.get("Content-Type");
console.log(contentType); // application/json; charset=utf-8
return res.json();
})
.then(data => {
console.log(data.name); // 한글이 올바르게 표시되는가?
});
서버와 데이터베이스도 모두 UTF-8로 설정되어야 한다.
<!-- lang 속성으로 언어 명시 -->
<html lang="ko">
<body>
<p>한글 텍스트</p>
<!-- 영어 단어가 섞인 경우 -->
<p>이것은 <span lang="en">UTF-8</span> 설명이다.</p>
</body>
</html>
스크린 리더가 올바른 발음으로 읽으려면 lang 속성이 필요하다. UTF-8 자체는 문자 데이터이고, 스크린 리더는 lang 속성을 참고하여 어떤 언어로 읽을지 결정한다.
// 긴 문자열 처리 시
const longText = "가".repeat(1000000); // 한글 100만 자
// [...text] 는 느리다 (전체 배열 생성)
console.time("spread");
const chars1 = [...longText];
console.timeEnd("spread"); // ~50ms
// for...of 가 더 빠르다
console.time("for-of");
const chars2 = [];
for (const char of longText) {
chars2.push(char);
}
console.timeEnd("for-of"); // ~30ms
대용량 텍스트를 처리할 때는 성능을 고려해야 한다.
프로젝트에서 UTF-8 관련 문제를 예방하려면:
<meta charset="UTF-8"> 선언charset=utf-8 포함utf8mb4)encodeURIComponent 사용한글이 깨졌을 때:
// 1. 문자열을 바이트로 확인
const text = "깨진텍스트";
const bytes = new TextEncoder().encode(text);
console.log(bytes);
// 2. 바이트를 문자열로 변환
const decoder = new TextDecoder("utf-8");
const restored = decoder.decode(bytes);
console.log(restored);
// 3. 잘못된 인코딩으로 디코딩되었을 가능성
const wrongDecoder = new TextDecoder("euc-kr");
// 브라우저에서 euc-kr은 지원하지 않을 수 있다
UTF-8은 단순한 기술 스펙이 아니라, 전 세계 사람들이 자유롭게 소통할 수 있게 만든 표준이다.
프론트엔드 개발자로서 UTF-8을 이해하면:
관습적으로 느껴지는 <meta charset="UTF-8"> 이 한 줄에 수십 년의 역사가 있었다는 것을 이해하자.