[UNICODE] 9분 읽기

[UNICODE] UTF-8 및 유니코드를 위한 Base64

btoa('héllo')는 예외를 던집니다. btoa('日本語')도 던집니다. 이는 버그가 아니라 1990년대 API가 2020년대 텍스트와 충돌하는 현상입니다. 유니코드를 올바르게 Base64로 인코딩하는 방법을 바이트 단위로 살펴봅니다.

2026년 4월 | encoding

// 함정

브라우저의 btoa()는 순해 보입니다. 문자열을 넘기면 Base64가 돌아옵니다. 그러던 어느 날 사용자가 자신의 이름 Renée를 제출하고, 여러분의 코드는 InvalidCharacterError: The string to be encoded contains characters outside of the Latin1 range로 크래시합니다.

이것은 변덕이 아니며, JavaScript 자체만의 잘못도 아닙니다. btoa()는 "문자열"과 "바이트"가 U+00FF 이하에만 머무른다면 같은 것으로 취급되던 시대에 명세되었습니다. 유니코드가 세상을 삼켰지만, btoa()의 계약은 Latin-1에 얼어붙은 채 남았습니다. 서유럽 문자 너머의 무언가를 Base64로 인코딩하려면, 먼저 UTF-8을 거쳐야 합니다.

// 30초 해결책

// Browser — encode any Unicode string to Base64
function encodeUtf8Base64(str) {
  const bytes = new TextEncoder().encode(str);           // UTF-8 bytes
  let bin = '';
  for (const b of bytes) bin += String.fromCharCode(b);  // Latin-1 wrapper
  return btoa(bin);                                       // now btoa can handle it
}

// Browser — decode Base64 back to a Unicode string
function decodeUtf8Base64(b64) {
  const bin = atob(b64);
  const bytes = Uint8Array.from(bin, c => c.charCodeAt(0));
  return new TextDecoder().decode(bytes);                // UTF-8 → string
}

// Usage
encodeUtf8Base64('Renée');         // 'UmVuw6ll'
encodeUtf8Base64('日本語');         // '5pel5pys6Kqe'
encodeUtf8Base64('👋 hello');       // '8J+RiyBoZWxsbw=='

decodeUtf8Base64('5pel5pys6Kqe');   // '日本語'

// 왜 이것이 작동하는가 — UTF-8로 잠깐 우회

JavaScript 문자열은 UTF-16 코드 단위의 시퀀스입니다. 은 U+65E5로, 하나의 16비트 코드 단위입니다. 이모지 👋(U+1F44B)은 기본 다국어 평면(BMP)을 벗어나며 두 개의 16비트 코드 단위로 이루어진 서러게이트 페어로 표현됩니다.

btoa()는 코드 단위가 아니라 바이트를 원합니다. 그것도 아무 바이트가 아니라 0–255 범위의 Latin-1 바이트를 원합니다. UTF-16 코드 단위는 일상적으로 255를 초과하므로(예를 들어 = 0x65E5 = 26085), btoa()는 예외를 던집니다.

UTF-8은 전체 유니코드 문자 집합을 8비트 클린(0–255) 방식으로 인코딩합니다. 모든 유니코드 문자는 1–4바이트가 되며, 각 바이트는 0–255 범위 안에 있음이 보장됩니다. 문자열을 먼저 UTF-8 바이트로 변환한 다음, 그 바이트를 Latin-1 문자열로 보이게끔 btoa()를 속이면(문자당 바이트 하나), 모든 것이 맞아 떨어집니다. btoa()는 자신이 다룰 수 있는 바이트를 보고, 수신 측은 동일한 바이트를 다시 UTF-8로 디코딩합니다.

// TextEncoder는 내부적으로 무엇을 하는가

TextEncoder는 JavaScript 문자열을 UTF-8 바이트로 변환하는 표준 기반 방법입니다. 2017년경부터 모든 브라우저에 탑재되었으며, Node.js 11+에서는 전역으로 사용 가능합니다.

어떤 바이트 시퀀스를 생성하는지 살짝 들여다 봅시다:

new TextEncoder().encode('A');
// Uint8Array [ 0x41 ]                         (1 byte)

new TextEncoder().encode('é');
// Uint8Array [ 0xC3, 0xA9 ]                   (2 bytes)

new TextEncoder().encode('日');
// Uint8Array [ 0xE6, 0x97, 0xA5 ]             (3 bytes)

new TextEncoder().encode('👋');
// Uint8Array [ 0xF0, 0x9F, 0x91, 0x8B ]       (4 bytes)

new TextEncoder().encode('Renée');
// Uint8Array [ 0x52, 0x65, 0x6E, 0xC3, 0xA9, 0x65 ]   (6 bytes for 5 chars)

// NODE.JS: Buffer는 기본적으로 UTF-8을 인식한다

Node의 Buffer 클래스는 UTF-8 변환을 내부적으로 처리합니다. TextEncoder가 전혀 필요 없습니다. 명시적으로 다른 인코딩을 지정하지 않는 한 Buffer.from()에 문자열을 넘기면 UTF-8로 인코딩됩니다. Node에서 서버 사이드 Base64가 한 줄로 끝나는 이유가 바로 이것입니다:

// Encode
Buffer.from('日本語').toString('base64');
// '5pel5pys6Kqe'

// Decode
Buffer.from('5pel5pys6Kqe', 'base64').toString();
// '日本語'

// Explicit UTF-8 (the default, but clearer)
Buffer.from('Renée', 'utf8').toString('base64');
// 'UmVuw6ll'

// 모든 언어에서의 유니코드 BASE64

// Python
import base64
base64.b64encode('日本語'.encode('utf-8'))
# b'5pel5pys6Kqe'
base64.b64decode('5pel5pys6Kqe').decode('utf-8')
# '日本語'

// PHP
base64_encode('日本語');          // '5pel5pys6Kqe'
base64_decode('5pel5pys6Kqe');     // '日本語'
// (PHP strings are byte strings, so UTF-8 is handled implicitly
// if your source file is saved as UTF-8.)

// Java
import java.util.Base64;
import java.nio.charset.StandardCharsets;
Base64.getEncoder().encodeToString("日本語".getBytes(StandardCharsets.UTF_8));
// '5pel5pys6Kqe'
new String(Base64.getDecoder().decode("5pel5pys6Kqe"), StandardCharsets.UTF_8);
// '日本語'

// Go
import "encoding/base64"
base64.StdEncoding.EncodeToString([]byte("日本語"))
// '5pel5pys6Kqe'
b, _ := base64.StdEncoding.DecodeString("5pel5pys6Kqe")
string(b)
// '日本語'

// Ruby (source file must be UTF-8)
require 'base64'
Base64.strict_encode64('日本語')     # '5pel5pys6Kqe'
Base64.decode64('5pel5pys6Kqe')      # '日本語'

// Rust
use base64::{Engine, engine::general_purpose::STANDARD};
STANDARD.encode("日本語");            // "5pel5pys6Kqe"
STANDARD.decode("5pel5pys6Kqe")
  .map(|b| String::from_utf8(b).unwrap())?;  // "日本語"

// Shell (bash/zsh with GNU coreutils)
echo -n '日本語' | base64                   # '5pel5pys6Kqe'
echo -n '5pel5pys6Kqe' | base64 -d          # '日本語'

// 흔한 유니코드 실패

  • > 디코딩 시 모지바케(깨진 문자)atob()로 곧바로 디코딩해서 수신 측이 깨진 문자를 봅니다. 해결: TextDecoder로 감싸세요.
  • > 서러게이트 페어 오류 — 문자 수로 문자열을 자르다가 👋 서러게이트 페어 중간에 도달합니다. 해결: 문자 단위가 아니라 전체 문자열에 TextEncoder를 적용하세요.
  • > BOM 문제 — 원본 텍스트가 UTF-8 BOM(EF BB BF)으로 시작합니다. 다른 모든 것과 함께 인코딩되어, 수신 측이 떠돌이 \uFEFF를 보게 됩니다. 인코딩 전에 BOM을 제거하세요.
  • > 잘못된 charset 메타데이터 — UTF-8 바이트의 Base64를 charset=iso-8859-1이라고 표시된 필드에 넣습니다. 디코더는 라벨을 사용해 바이트를 해석합니다. 메타데이터를 실제 인코딩과 항상 일치시키세요.
  • > URL 안전 Base64 혼동 — 저장용으로 base64url을 썼지만 디코딩 전에 표준 알파벳으로 복원하는 것을 잊었습니다. 해결: replace(/-/g, '+').replace(/_/g, '/')로 정규화하고 4의 배수로 패딩하세요.
  • > 로케일에 의존적인 인코딩 — Python 2에서는 '日本語'.encode()가 ASCII로 기본 설정되어 크래시했습니다. 모든 모던 런타임에서 항상 utf-8을 명시적으로 지정하세요.

// 왕복 테스트 — 결정적인 증명

Base64 파이프라인이 유니코드를 올바르게 처리하는지 확인하는 가장 좋은 단 하나의 방법은 왕복 테스트입니다. 알려진 정답 문자열을 인코딩하고, 디코딩한 뒤, 원본과 바이트 단위로 비교합니다.

// Vitest / Jest / any test runner
const samples = [
  'hello',
  'héllo',
  '日本語',
  '한국어',
  '👋 Renée — 日本語 — مرحبا',
  '\n\t\0 edge cases',
  '𐀀 ancient greek (U+10000)',
];

for (const s of samples) {
  const encoded = encodeUtf8Base64(s);
  const decoded = decodeUtf8Base64(encoded);
  expect(decoded).toBe(s);
  expect([...new TextEncoder().encode(s)])
    .toEqual([...new TextEncoder().encode(decoded)]);
}

// 이진 데이터의 BASE64 vs 텍스트의 BASE64

미묘하지만 중요한 구분입니다. PNG 파일을 Base64로 만들 때, 입력은 이미 바이트입니다. 유니코드 질문을 할 필요가 없습니다. 그냥 그 바이트를 인코더에 그대로 넘기면 됩니다.

문자열을 Base64로 만들 때, 입력은 바이트가 아니라 유니코드 코드 포인트의 시퀀스입니다. 인코더가 무언가를 볼 수 있기 전에, 여러분은 바이트 인코딩(2026년에는 사실상 항상 UTF-8)을 확정해야 합니다. 여러분이 만든 Base64 문자열은 수신 측이 동일한 바이트 인코딩을 역으로 적용할 때만 유효합니다.

이것이 "왜 내 Base64가 잘못되었지?" 티켓 대부분의 근원입니다. 두 시스템이 같은 논리적 문자열을 서로 다른 기반 바이트 인코딩(한쪽은 UTF-8, 다른 쪽은 UTF-16 또는 Latin-1)을 사용해 Base64 인코딩하고, 수신 측의 디코더는 기꺼이 잘못된 바이트를 재현해 냅니다.

// 항상 UTF-8을 사용해야 할까?

네, 다른 것을 지정하는 레거시 시스템 안에서 작업하는 경우를 제외하고는요. UTF-8은:

• 웹에서 지배적인 인코딩(2026년 기준 웹사이트의 97% 이상)
• 모든 모던 프로그래밍 언어 표준 라이브러리의 기본값
• 처음 128 코드 포인트에 대해 ASCII 호환이므로 영어는 절대 깨지지 않음
• 모든 유니코드 문자에 대해 잘 정의되고 왕복 가능
• 새 프로토콜에 대한 IETF 권장 charset(RFC 2277)

오래된 대안들(UTF-16, UTF-32, Latin-1, GBK, Shift-JIS, EUC-KR)은 여전히 레거시 데이터베이스, Windows API, 일부 통신 프로토콜에 등장합니다. 데이터가 그런 시스템에서 왔다면, 경계에서 UTF-8로 변환한 뒤 UTF-8을 Base64로 만들고, 페이로드 포맷에 인코딩 결정을 문서화하세요.

// TextEncoder 없이 쓰는 폴백(레거시 브라우저)

TextEncoder가 없는 매우 오래된 브라우저(IE11 및 그 이전)를 지원해야 한다면, 고전적인 기법은 encodeURIComponent를 UTF-8 인코더로 사용한 뒤 unescape로 바이트 문자열로 되돌리는 것입니다. 이 패턴은 2008년부터 2020년까지 Base64와 유니코드에 관한 모든 StackOverflow 답변에 등장합니다.

// Legacy browser fallback (pre-2017)
function encodeUtf8Base64Legacy(str) {
  return btoa(unescape(encodeURIComponent(str)));
}
function decodeUtf8Base64Legacy(b64) {
  return decodeURIComponent(escape(atob(b64)));
}

encodeUtf8Base64Legacy('日本語');    // '5pel5pys6Kqe'
decodeUtf8Base64Legacy('5pel5pys6Kqe'); // '日本語'

// Note: unescape() and escape() are deprecated.
// Only use this pattern if TextEncoder is genuinely unavailable.
// It also mishandles some edge-case surrogates.

// 기억해 둘 것

  • > Base64는 문자가 아니라 바이트를 인코딩합니다. 먼저 바이트 인코딩을 고르세요 — 거의 항상 UTF-8입니다.
  • > 브라우저에서: TextEncoderString.fromCharCodebtoa한 방향.
  • > 브라우저에서: atobUint8Array.fromTextDecoder반대 방향.
  • > Node.js에서: Buffer.from(str).toString('base64')Buffer.from(b64, 'base64').toString() — 둘 다 기본적으로 UTF-8입니다.
  • > Python / Go / Java에서: .encode() / .getBytes()에 항상 명시적인 'utf-8'을 전달하세요.
  • > 실제 유니코드(이모지, CJK, 아랍어, 결합 문자)로 항상 왕복 테스트한 뒤에 출시하세요.

// 관련 글

Base64란 무엇이며 어떻게 작동하는가? — 여기의 모든 것을 뒷받침하는 6비트 그룹화.
JavaScript에서의 Base64(브라우저 + Node) — 전체 런타임 간 헬퍼.
URL 안전 vs 표준 Base64 — 함께 내려야 할 수 있는 직교적 선택.
라이브 Base64 인코더 사용해 보기 — 같은 유니코드 안전 파이프라인을 여러분의 브라우저에서.