[JS] 10분 읽기

[JS] JavaScript의 Base64 — atob, btoa, Node Buffer

JavaScript에서 Base64를 다루는 실전 가이드. btoa/atob를 언제 사용해야 하는지, 유니코드에서 왜 깨지는지, Node.js의 Buffer가 어떻게 동작하는지, 그리고 런타임을 가로지르는 base64url 헬퍼를 소개합니다.

2026년 4월 | javascript

// 전체 지형도

JavaScript와 Base64의 관계는 역사에 의해 조각나 있습니다. 브라우저는 1990년대 Netscape로부터 btoa() / atob()를 물려받았습니다. 이름은 문자 그대로 binary-to-ASCIIASCII-to-binary를 뜻합니다. 이 함수들은 Latin-1 문자만 처리할 수 있기 때문에 이모지나 한국어·중국어 텍스트를 btoa()에 복사해 붙여 넣으면 오류가 발생합니다.

Node.js는 Buffer 클래스를 통해 자체적인 경로를 추가했습니다. Buffer는 여러분이 필요로 할 만한 거의 모든 인코딩(utf8, base64, base64url, hex, latin1, ascii)을 이해하고, 이진 데이터를 기본적으로 다루며, 서버 사이드 코드에서 당연한 선택입니다.

모던 런타임(Node 18+, Deno, Bun, 최신 브라우저)은 TextEncoder / TextDecoder도 제공합니다. 유니코드 문자열을 Base64로 인코딩하기 전에 바이트로 변환하는 표준 기반의 방법입니다. 어느 도구를 꺼낼지 아는 것이 전투의 절반입니다.

// 브라우저: btoa / atob — 올바른 사용법

btoa(str)는 모든 문자가 1바이트(Latin-1 범위 U+0000 ~ U+00FF)에 들어가야 하는 문자열을 받아서 Base64 문자열을 반환합니다. atob(b64)는 그 역입니다. 이들은 동기적이고 빠르며, 아주 오래전부터 모든 브라우저에 내장되어 있습니다.

올바른 사용법은 다음과 같습니다. 이미 바이트에 가까운 데이터를 Base64로 만드는 것. 즉 FileReader에서 읽어 온 원시 바이트, 이진 XMLHttpRequest(responseType: 'arraybuffer')에서 받은 바이트, 혹은 Uint8Array처럼 각 바이트의 숫자 값이 0–255 범위에 들어가는 데이터입니다.

// Encode a Latin-1 string
const latin1 = 'Hello, world!';
const encoded = btoa(latin1);   // 'SGVsbG8sIHdvcmxkIQ=='

// Decode it back
const decoded = atob(encoded);  // 'Hello, world!'

// Encode a binary Uint8Array (the common real-world case)
const bytes = new Uint8Array([0xFF, 0xD8, 0xFF, 0xE0]);  // JPEG magic
const binStr = String.fromCharCode(...bytes);            // 'ÿØÿà' (Latin-1)
const b64 = btoa(binStr);                                // '/9j/4A=='

// btoa()가 유니코드에서 왜 예외를 던지는가

브라우저 콘솔에서 btoa('héllo')를 시도해 보세요. InvalidCharacterError: The string to be encoded contains characters outside of the Latin1 range라는 오류가 납니다. é(U+00E9)는 괜찮지만, (U+65E5)이 Latin-1 밖이기 때문에 btoa('日本語')는 실패합니다. 이 함수는 UTF-8 개념이 없으며, 각 JavaScript String 코드 단위를 하나의 바이트로 읽습니다.

해결책은 유니코드 문자열을 먼저 UTF-8 바이트로 변환한 다음 그 바이트를 Base64로 만드는 것입니다. 모든 모던 브라우저는 정확히 이 목적을 위해 TextEncoder를 제공합니다.

// ❌ broken
// btoa('日本語')  → throws InvalidCharacterError

// ✅ correct: encode to UTF-8 first
function b64encodeUtf8(str) {
  const bytes = new TextEncoder().encode(str);
  let bin = '';
  for (const byte of bytes) bin += String.fromCharCode(byte);
  return btoa(bin);
}

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

// ✅ round-trip decode
function b64decodeUtf8(b64) {
  const bin = atob(b64);
  const bytes = Uint8Array.from(bin, c => c.charCodeAt(0));
  return new TextDecoder().decode(bytes);
}

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

// NODE.JS: Buffer 접근 방식

Node에서는 Buffer를 손에 잡으세요. 기본적으로 UTF-8을 인식하며, 상상할 수 있는 모든 인코딩을 지원하고, 이진 데이터를 깔끔하게 다룹니다. Node 16+는 'base64url'도 인코딩 이름으로 이해하기 때문에 URL 안전 출력을 위해 별도의 헬퍼가 필요하지 않습니다.

// String → Base64
const encoded = Buffer.from('héllo 世界').toString('base64');
// 'aMOpbGxvIOS4lueVjA=='

// Base64 → string (UTF-8 by default)
const decoded = Buffer.from(encoded, 'base64').toString();
// 'héllo 世界'

// URL-safe (Node 16+): +/- omitted padding
const safe = Buffer.from('héllo 世界').toString('base64url');
// 'aMOpbGxvIOS4lueVjA'  ← no = padding, uses -_

// Binary file → Base64
const fs = require('node:fs');
const imgB64 = fs.readFileSync('logo.png').toString('base64');

// Base64 → binary file
fs.writeFileSync('out.png', Buffer.from(imgB64, 'base64'));

// BUFFER vs btoa/atob — 빠른 비교

  • > 유니코드: Buffer는 UTF-8을 기본적으로 처리합니다. btoa/atob는 Latin-1 바이트만 다룹니다.
  • > 이진 데이터: Buffer는 Node의 네이티브 이진 타입입니다. 브라우저는 Uint8Array + String.fromCharCode를 사용해야 합니다.
  • > URL 안전: Buffer는 'base64url'을 1급 인코딩으로 지원합니다(Node 16+). 브라우저에서는 사용자 정의 정규식 치환이 필요합니다.
  • > 성능: Buffer는 C++로 구현되어 있으며, 큰 입력에서 atob/btoa보다 약 3–5배 빠릅니다.
  • > 스트리밍: Buffer는 대용량 파일을 위해 트랜스폼 스트림을 통해 파이프할 수 있습니다. btoa/atob는 스트리밍이 불가능합니다.
  • > 가용성: Node 16+에서 btoa/atob는 전역으로 존재하지만, 선택할 수 있다면 Buffer를 손에 잡으세요 — 엣지 케이스를 처리해 줍니다.

// 브라우저에서 이진 파일 인코딩 및 디코딩

파일 선택기로 선택한 파일을 Base64 문자열로 바꾸는 브라우저의 전형적인 패턴은 FileReader.readAsDataURL()입니다. 이 메서드는 곧바로 사용할 수 있는 데이터 URI를 반환합니다. 원시 Base64 페이로드만 원한다면 data:...;base64, 접두사를 제거하면 됩니다.

매우 큰 파일의 경우 readAsArrayBuffer()로 읽은 뒤 청크 단위로 인코딩해, 약 65,000개 이상의 요소로 된 배열에 String.fromCharCode(...bytes)를 적용했을 때 발생하는 'maximum call stack size exceeded' 오류를 피하세요.

// ▸ Simple: file → data URI → strip prefix
async function fileToBase64(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => {
      const dataUri = reader.result;                      // 'data:image/png;base64,iVBOR...'
      resolve(dataUri.split(',')[1]);                     // raw Base64 only
    };
    reader.onerror = reject;
    reader.readAsDataURL(file);
  });
}

// ▸ Large files: chunked btoa() to avoid stack overflow
async function arrayBufferToBase64(buffer) {
  const bytes = new Uint8Array(buffer);
  const chunkSize = 0x8000;      // 32 KB chunks
  let binary = '';
  for (let i = 0; i < bytes.length; i += chunkSize) {
    binary += String.fromCharCode.apply(null,
      bytes.subarray(i, i + chunkSize));
  }
  return btoa(binary);
}

// Usage
const file = inputEl.files[0];
const buf = await file.arrayBuffer();
const b64 = await arrayBufferToBase64(buf);

// 브라우저에서 BASE64를 바이트로 디코딩하기

// ▸ Base64 → Uint8Array (binary bytes)
function base64ToBytes(b64) {
  const bin = atob(b64);
  const bytes = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
  return bytes;
}

// ▸ Base64 → Blob → downloadable
function base64ToBlob(b64, mime = 'application/octet-stream') {
  const bytes = base64ToBytes(b64);
  return new Blob([bytes], { type: mime });
}

// Trigger a download
const blob = base64ToBlob(b64, 'image/png');
const url = URL.createObjectURL(blob);
Object.assign(document.createElement('a'), { href: url, download: 'out.png' }).click();
URL.revokeObjectURL(url);

// JAVASCRIPT에서 URL 안전 BASE64(base64url)

표준 Base64는 +/를 사용하는데, 이 둘은 모두 URL에서 예약된 문자입니다. 또한 출력이 하나 또는 두 개의 = 문자로 끝나는데, 이는 %3D로 퍼센트 인코딩되어 공간을 낭비합니다. RFC 4648 §5는 URL 안전 변형(base64url)을 정의합니다. +-로, /_로 바꾸고 패딩을 제거합니다.

Node 16+에서는 그냥 Buffer.from(x).toString('base64url')를 사용하세요. 브라우저에서는 btoa() 위에 후처리 단계로 구현하세요.

// ▸ Browser: standard Base64 → URL-safe Base64
function toBase64Url(b64) {
  return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

// ▸ URL-safe Base64 → standard Base64 (before feeding atob)
function fromBase64Url(b64u) {
  let b64 = b64u.replace(/-/g, '+').replace(/_/g, '/');
  while (b64.length % 4) b64 += '=';   // re-add padding
  return b64;
}

// Round-trip
const token = toBase64Url(btoa('hello+world/'));
// 'aGVsbG8rd29ybGQv'
atob(fromBase64Url(token));  // 'hello+world/'

// 흔한 함정과 포착하는 법

  • > btoa()의 InvalidCharacterError — 입력에 Latin-1 범위 밖의 문자가 있습니다. 해결: 먼저 TextEncoder로 UTF-8 인코딩하세요.
  • > atob()의 InvalidCharacterError — 입력이 유효한 Base64가 아닙니다. 보통 패딩 누락(base64url의 경우) 또는 공백이 섞여 있기 때문입니다. 공백을 제거하고 = 패딩을 다시 추가하세요.
  • > 디코딩 시 깨진 유니코드TextDecoder를 거치지 않고 atob()로 바로 디코딩했기 때문입니다. UTF-8 멀티바이트 시퀀스가 깨진 문자(모지바케)로 나타납니다.
  • > 'Maximum call stack size exceeded' — 약 65,000바이트를 초과하는 배열에 String.fromCharCode(...bytes)를 사용했기 때문입니다. 해결: 청크로 나누세요(위의 32 KB 루프 참고).
  • > 브라우저와 Node에서 결과가 다름 — 대개 한쪽이 URL 안전 변형이고 다른 쪽은 표준이라는 뜻입니다. 경계에서 하나의 변형으로 정규화하세요.
  • > 이중 인코딩된 페이로드 — 데이터가 두 번 Base64로 인코딩되었습니다. 확인하세요: atob(input)의 결과가 여전히 Base64처럼 보이나요? 그렇다면 한 번 더 디코딩하세요.
  • > JSON에 실수로 JSON 인코딩된 Base64 — Base64 문자열을 JSON.stringify로 감쌌는데, 수신 측이 JSON을 파싱하여 문자열을 그대로 받지만 서버 코드가 실수로 다시 인코딩했습니다. 전송되는 페이로드를 확인하세요.

// 런타임 간 헬퍼(브라우저 + NODE + DENO + BUN)

// A portable Base64/base64url helper. Uses Buffer when available,
// falls back to btoa/atob + TextEncoder in the browser / Deno.

const hasBuffer = typeof Buffer !== 'undefined';

export function encode(str, { urlSafe = false } = {}) {
  if (hasBuffer) {
    return Buffer.from(str, 'utf8').toString(urlSafe ? 'base64url' : 'base64');
  }
  const bytes = new TextEncoder().encode(str);
  let bin = '';
  for (const b of bytes) bin += String.fromCharCode(b);
  const b64 = btoa(bin);
  return urlSafe
    ? b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
    : b64;
}

export function decode(b64, { urlSafe = false } = {}) {
  if (hasBuffer) {
    return Buffer.from(b64, urlSafe ? 'base64url' : 'base64').toString('utf8');
  }
  if (urlSafe) {
    b64 = b64.replace(/-/g, '+').replace(/_/g, '/');
    while (b64.length % 4) b64 += '=';
  }
  const bin = atob(b64);
  const bytes = Uint8Array.from(bin, c => c.charCodeAt(0));
  return new TextDecoder().decode(bytes);
}

// Usage
encode('héllo 世界');                      // 'aMOpbGxvIOS4lueVjA=='
encode('héllo 世界', { urlSafe: true });    // 'aMOpbGxvIOS4lueVjA'
decode('aMOpbGxvIOS4lueVjA==');            // 'héllo 世界'

// 성능 관련 참고사항

  • > Buffer가 가장 빠릅니다 — Node에서는 C++로 실행되며 메가바이트 단위의 입력을 무리 없이 처리합니다.
  • > btoa/atob는 작은 페이로드(100 KB 미만)에서 경쟁력 있습니다 — 브라우저에서는 둘 다 동기적이며 큰 입력에서는 메인 스레드를 차단합니다.
  • > 브라우저에서 10 MB 이상 입력의 경우, 인코딩을 메인 스레드 외부에서 실행하도록 Worker를 사용하거나, 내부적으로 최적화된 Blob.text() + FileReader.readAsDataURL()을 사용하세요.
  • > 반복적인 인코딩/디코딩 사이클을 피하세요 — 각 왕복은 시간과 메모리 모두에서 O(n)입니다. 여러 번 사용한다면 Base64 형식을 캐시하세요.
  • > 스트리밍 — 정말 큰 페이로드(100 MB 이상)의 경우 Node의 stream.Transform 또는 브라우저의 TransformStream API를 청크 기반 Base64 인코더와 함께 사용하세요.

// 함께 읽기

Base64란 무엇이며 어떻게 작동하는가? — 6비트 그룹화 기법을 처음부터 설명합니다.
UTF-8 및 유니코드를 위한 Base64 — TextEncoder의 춤사위를 깊이 있게 다룹니다.
URL 안전 vs 표준 Base64 — 언제 +/ 대신 -_가 필요한지.
라이브 Base64 인코더 사용해 보기 — 텍스트를 붙여 넣고 인코딩을 확인한 다음 결과를 복사하세요.

btoa()를 떠올릴 일이 있다면 이 페이지를 북마크해 두세요. 열 번 중 아홉 번은 지금 구글에서 찾으려던 답이 여기에 있습니다.