[JS] JavaScript의 Base64 — atob, btoa, Node Buffer
JavaScript에서 Base64를 다루는 실전 가이드. btoa/atob를 언제 사용해야 하는지, 유니코드에서 왜 깨지는지, Node.js의 Buffer가 어떻게 동작하는지, 그리고 런타임을 가로지르는 base64url 헬퍼를 소개합니다.
// 전체 지형도
JavaScript와 Base64의 관계는 역사에 의해 조각나 있습니다. 브라우저는 1990년대 Netscape로부터 btoa() / atob()를 물려받았습니다. 이름은 문자 그대로 binary-to-ASCII와 ASCII-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또는 브라우저의TransformStreamAPI를 청크 기반 Base64 인코더와 함께 사용하세요.
// 함께 읽기
• Base64란 무엇이며 어떻게 작동하는가? — 6비트 그룹화 기법을 처음부터 설명합니다.
• UTF-8 및 유니코드를 위한 Base64 — TextEncoder의 춤사위를 깊이 있게 다룹니다.
• URL 안전 vs 표준 Base64 — 언제 +/ 대신 -_가 필요한지.
• 라이브 Base64 인코더 사용해 보기 — 텍스트를 붙여 넣고 인코딩을 확인한 다음 결과를 복사하세요.
btoa()를 떠올릴 일이 있다면 이 페이지를 북마크해 두세요. 열 번 중 아홉 번은 지금 구글에서 찾으려던 답이 여기에 있습니다.