UTF-8

contability·2025년 10월 1일

컴퓨터는 0과 1만 이해할 수 있다. 그렇다면 우리가 사용하는 문자는 어떻게 컴퓨터에 저장될까? 이 질문에 대한 답을 찾아가는 과정이 바로 문자 인코딩의 역사다.

1. 초기: ASCII의 시대 (1960년대)

미국에서 ASCII(American Standard Code for Information Interchange)가 만들어졌다. 7비트(128개 문자)로 영어 알파벳, 숫자, 기본 기호만 표현했다.

'A' = 65
'a' = 97
'0' = 48
' ' = 32

영어권에서는 문제가 없었다. 하지만 세계에는 영어만 있는 것이 아니었다.

2. 혼돈의 시대: 각국의 독립적인 인코딩

각 나라가 자신들의 언어를 표현하기 위해 독자적인 인코딩을 만들었다:

  • 한국: EUC-KR, CP949
  • 일본: Shift-JIS, EUC-JP
  • 중국: GB2312, Big5
  • 유럽: ISO-8859-1 (서유럽), ISO-8859-2 (동유럽)...

문제점

  1. 호환성 문제: 한국어로 작성한 파일을 일본 컴퓨터에서 열면 깨진다
  2. 다국어 표현 불가: 한 문서에 한글과 일본어를 함께 쓸 수 없다
  3. 인코딩 지옥: 웹사이트마다 다른 인코딩을 사용해서 매번 설정을 바꿔야 했다
<!-- 2000년대 초반 한국 웹사이트의 흔한 풍경 -->
<meta http-equiv="Content-Type" content="text/html; charset=euc-kr">

인코딩이 맞지 않으면 "¾È³çÇϼ¼¿ä"같은 글자가 나타났다.

3. 통일의 시도: 유니코드의 탄생 (1991년)

"전 세계 모든 문자에 고유한 번호를 주자!"

유니코드는 문자 집합(Character Set)이다. 각 문자에 코드 포인트(Code Point)라는 고유 번호를 부여한다:

U+0041 = A (라틴 대문자 A)
U+AC00 = 가 (한글 가)
U+4E00 = 一 (한자)
U+1F600 = 😀 (이모지)

유니코드는 현재 15만 개 이상의 문자를 정의하고 있다. 고대 문자부터 이모지까지 모두 포함한다.

하지만 새로운 문제

유니코드는 문자에 번호만 부여했을 뿐, 어떻게 저장할지는 정하지 않았다. 이 번호를 컴퓨터에 어떻게 저장할 것인가?

4. 인코딩 방식의 등장: UTF-32, UTF-16, UTF-8

유니코드 코드 포인트를 실제 바이트로 변환하는 방법이 필요했다.

UTF-32

모든 문자를 4바이트로 저장한다.

  • 장점: 구현이 간단하다. 모든 문자가 같은 크기다.
  • 단점: 영어 한 글자 'A'에 4바이트는 너무 낭비다.
'A' = 00 00 00 41 (4바이트)

UTF-16

대부분의 문자를 2바이트로 저장하고, 일부는 4바이트를 사용한다.

  • 장점: 균형적이다. 대부분의 현대 문자를 2바이트로 표현한다.
  • 단점: 여전히 영어에는 비효율적이다. 가변 길이라 복잡하다.
'A' = 00 41 (2바이트)
'가' = AC 00 (2바이트)
'😀' = D8 3D DE 00 (4바이트)

UTF-8

가변 길이 인코딩이다. 1~4바이트를 사용한다.

  • ASCII 문자 (영어, 숫자, 기호): 1바이트
  • 라틴 확장, 그리스, 아랍 문자: 2바이트
  • 한글, 한자, 일본어: 3바이트
  • 이모지, 고대 문자: 4바이트
'A' = 41 (1바이트)
'가' = EA B0 80 (3바이트)
'😀' = F0 9F 98 80 (4바이트)

5. UTF-8의 승리

현재 웹의 98% 이상이 UTF-8을 사용한다. UTF-8이 표준이 된 이유는 무엇일까?

1. ASCII와 100% 호환

기존의 영어 텍스트 파일을 그대로 사용할 수 있다. ASCII는 UTF-8의 부분집합이다.

2. 효율적인 저장 공간

영어권 중심의 초기 인터�트에서 가장 효율적이었다. 영어 텍스트는 UTF-8과 ASCII가 동일하다.

3. 자기 동기화 (Self-synchronization)

바이트 스트림의 중간부터 읽어도 문자 경계를 찾을 수 있다. 이는 UTF-8의 독특한 바이트 패턴 덕분이다:

0xxxxxxx          = 1바이트 문자 (ASCII)
110xxxxx 10xxxxxx = 2바이트 문자
1110xxxx 10xxxxxx 10xxxxxx = 3바이트 문자
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx = 4바이트 문자

첫 바이트를 보면 해당 문자가 몇 바이트인지 알 수 있다.

4. 엔디안 문제 없음

UTF-16과 UTF-32는 바이트 순서(Endianness)를 결정해야 하지만, UTF-8은 바이트 단위로 처리되므로 이 문제가 없다.

프론트엔드에서의 UTF-8

웹 개발을 하다 보면 UTF-8과 관련된 다양한 상황을 마주치게 된다.

1. HTML의 charset 선언

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <title>웹페이지</title>
</head>

<meta charset="UTF-8">이 없으면 브라우저가 다른 인코딩으로 해석할 수 있다. 특히 오래된 브라우저나 특정 환경에서는 기본 인코딩이 다를 수 있다.

반드시 <head> 태그의 최상단에 위치해야 한다. 브라우저가 문서를 파싱하다가 charset을 만나면 처음부터 다시 파싱하기 때문이다.

2. JavaScript의 문자열 처리

JavaScript는 내부적으로 UTF-16을 사용한다. 이로 인해 예상치 못한 동작이 발생할 수 있다.

문제 1: 이모지의 길이

// 이모지는 UTF-16에서 4바이트 (2개의 코드 유닛)를 사용한다
const emoji = "😀";
console.log(emoji.length); // 2 (예상: 1)

// 올바르게 세는 방법
console.log([...emoji].length); // 1
console.log(Array.from(emoji).length); // 1

문제 2: 문자열 자르기

const text = "안녕😀하세요";

// 잘못된 방법: 이모지가 잘릴 수 있다
console.log(text.substring(0, 4)); // "안녕�" (깨진 문자)

// 올바른 방법
const chars = [...text];
console.log(chars.slice(0, 4).join("")); // "안녕😀하"

문제 3: 정규표현식

// 점(.)은 UTF-16 코드 유닛 하나만 매칭한다
console.log(/^.$/.test("😀")); // false

// u 플래그를 사용하면 유니코드 문자로 처리한다
console.log(/^.$/u.test("😀")); // true

문제 4: charCodeAt vs codePointAt

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)

3. HTTP 요청과 응답

Content-Type 헤더

// fetch API
fetch("/api/data", {
  method: "POST",
  headers: {
    "Content-Type": "application/json; charset=utf-8"
  },
  body: JSON.stringify({ name: "홍길동" })
});

서버가 올바른 Content-Type 헤더를 보내지 않으면 한글이 깨질 수 있다.

URL 인코딩

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 바이트로 변환한 후 퍼센트 인코딩한다.

4. 파일 업로드와 다운로드

파일명 처리

// 파일 다운로드 시 한글 파일명
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);
};

5. 로컬 스토리지와 쿠키

// 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]
);

6. 텍스트 입력과 폼

<!-- 폼 전송 시 인코딩 지정 -->
<form accept-charset="UTF-8" method="post" action="/submit">
  <input type="text" name="comment" />
  <button type="submit">전송</button>
</form>

accept-charset을 지정하지 않으면 페이지의 인코딩을 따른다.

7. 데이터베이스 저장

프론트엔드에서 보낸 데이터가 데이터베이스에 올바르게 저장되려면:

// 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로 설정되어야 한다.

8. 접근성: 스크린 리더

<!-- lang 속성으로 언어 명시 -->
<html lang="ko">
  <body>
    <p>한글 텍스트</p>
    <!-- 영어 단어가 섞인 경우 -->
    <p>이것은 <span lang="en">UTF-8</span> 설명이다.</p>
  </body>
</html>

스크린 리더가 올바른 발음으로 읽으려면 lang 속성이 필요하다. UTF-8 자체는 문자 데이터이고, 스크린 리더는 lang 속성을 참고하여 어떤 언어로 읽을지 결정한다.

9. 성능 고려사항

// 긴 문자열 처리 시
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 관련 문제를 예방하려면:

  • HTML에 <meta charset="UTF-8"> 선언
  • 서버 응답 헤더에 charset=utf-8 포함
  • 데이터베이스 인코딩 UTF-8 설정 (예: utf8mb4)
  • 파일 저장 시 UTF-8 인코딩 사용
  • URL에 한글이 들어갈 때 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을 이해하면:

  • 다국어 지원이 쉬워진다
  • 문자 관련 버그를 예방할 수 있다
  • 국제화(i18n) 작업이 수월해진다
  • 사용자 경험을 개선할 수 있다

관습적으로 느껴지는 <meta charset="UTF-8"> 이 한 줄에 수십 년의 역사가 있었다는 것을 이해하자.

0개의 댓글