JavaScript/브라우저에서의 Base64 인코딩: 완전한 가이드
여행가야 할 무언가가 있고, 길은 평범한 ASCII가 겨우 지날 만큼만 넓다. JSON 응답 안에 있어야 할 이미지일 수도, URL을 함께 타고 다녀야 할 설정 객체일 수도, 세 개의 세그먼트가 점과 알파벳인 토큰일 수도, JSON 본문 안의 Base64 문자열로 도착해야 한다며 버티는 API의 파일일 수도. Base64는 정확히 이 상황을 위한 통행료 부스다. 이 사이트의 홈 페이지가 이미 포맷을 한 걸음씩 보여 주었으니 - 바이트 3개마다 인쇄 가능한 문자 4개가 대신 서고, = 패딩이 그룹을 마무리 - 읽는 동안 머릿속에 담아 둘 숫자 하나만: 인코딩은 커지는 방향이다. 건네는 바이트 3개마다 문자 4개로 돌아오고, 약 33퍼센트의 크기 세금은 대역폭, 저장소, 메모리에서 징수된다. 채널이 인쇄 가능한 텍스트를 요구할 때 Base64를 쓰고, 그 세금이 당신에게 정확히 얼마의 대가를 치르게 하는지 알고 있어야 한다.
희소식은, 브라우저가 이 일은 늘 패키지 하나 없이 해낼 수 있었다는 것이다. btoa()는 2000년대 초반부터 싣고 있었고, TextEncoder는 십 년 전부터 당신의 진짜 Unicode 텍스트를 정직한 바이트로 바꿔 주었으며, Baseline 2025 물결 속에서 플랫폼은 마침내 Uint8Array.toBase64()를 추가했다. 바이트 배열을, URL-safe 알파벳 옵션과 함께 직접 인코딩하는 함수다. 이 글은 결정지도다: 어떤 일에 어떤 도구, 날카로운 끝이 어디에 숨어 있는지 (그것들은 모두 같은 경계로 거슬러 올라간다), 그리고 실제로 Base64를 만들라고 요구받을 장소들을 위한 구체적인 레시피.
인코더 고르기
더 이상 단 하나의 "그" 인코더는 없으며, 잘못된 것을 집는 것이 바로 클래식한 버그들이 태어나는 방법이다. 아래 표가 곧 전체 결정 나무다:
| 상황 | 무엇을 잡나 |
|---|---|
| 평범한 ASCII 텍스트, 일회용 값 | btoa(text) |
| 발음 기호, 이모지, CJK가 있는 진짜 텍스트 | new TextEncoder().encode(text), 그다음 btoa 또는 toBase64 |
이미 Uint8Array에 있는 바이트 |
2025년 이후 브라우저에서는 bytes.toBase64(), 그 외에서는 청크로 나누는 btoa 다리 |
| URL, JWT, 파일명 | toBase64({ alphabet: 'base64url', omitPadding: true }) |
| 오래된 브라우저, 혹은 공유 코드베이스 | js-base64, 혹은 클래식한 TextEncoder + btoa 레시피 |
표 아래에 깔린 패턴: btoa()는 1바이트 문자만 읽으므로, ASCII가 아닌 것은 먼저 바이트 배열이 되어야 하며, 바로 그 바이트 배열을 축으로 근대 API들이 지어졌다. "텍스트는 바이트가 되고, 바이트는 Base64가 된다"를 머릿속에 두면, 이 글의 모든 레시피는 이름만 다른 같은 두 단계다.
btoa와 Latin1 경계
btoa(stringToEncode) - 바이너리 문자열에서 ASCII 문자열로 - 는 원조 인코더로, 중요한 모든 브라우저(Chrome 4, Firefox 1, Safari 3, IE 10 이상, 모든 워커 스코프, Node 16 버전부터)에서 사용할 수 있다. 계약은 단 하나의 조항을 가지며, 바로 그 조항에서 모든 것이 어긋난다: 입력의 모든 문자는 0에서 255 사이의 코드 포인트를 가져야 한다. 이 함수는 UTF-8 바이트가 아니라 코드 포인트를 읽으므로, "é" (코드 포인트 233)는 통과하지만 "你" (코드 포인트 20320)는 문자 하나도 인코딩되지 않은 채 InvalidCharacterError라는 이름의 DOMException을 던진다. 경계는 "ASCII"도 아니고 "Unicode"도 아니며, 정확히 256이며, 맨 아래쪽의 제어 문자를 포함한다 - NUL 바이트를 인코딩하는 것은 합법적이고 의미 있는 일이며, 그것이 이 함수가 존재하는 이유 중 하나다.
전체 동작, 한 줄씩:
| 입력 | 결과 |
|---|---|
"Hello, World!" |
"SGVsbG8sIFdvcmxkIQ==" - 교과서의 경우 |
"" (빈 문자열) |
"" - 들어가는 것 없고, 나가는 것도 없다 |
"\u0000" (NUL) |
"AA==" - 제어 문자는 1급 시민 |
"a\u00e9z" (é, 코드 포인트 233) |
"Yel6" - Latin1 전체 범위가 통과 |
"\u0100" (코드 포인트 256) |
InvalidCharacterError 발생 - 경계보다 한 걸음 더 |
"h\u4f60" (你, 코드 포인트 20320) |
InvalidCharacterError 발생 - 이모지도 전부 마찬가지, 모두 255보다 훨씬 위이므로 |
실용적인 노트 두 개. 오류 메시지는 엔진마다 다르다 - Firefox는 "String contains an invalid character"라고 하고, Chrome은 문자열이 "contains characters outside of the Latin1 range"라고 한다 - 그러므로 방어적인 코드에서는 예외 이름으로 잡아야 한다. 그리고 예외는 첫 번째 문제 문자에서 던져지며, 끝에 던져지지 않는다: btoa는 문자열의 절반을 인코딩하고 사과하지 않는다. 의도적으로 Latin1 동작을 원할 때 (0-255 코드 포인트로 의도적으로 지은 바이트 문자열을 인코딩할 때), 이 함수는 당신이 요청한 것을 정확히 하고 있으며, 위 표가 그것의 기질 전부다.
바이트 다리
그러면 질문은 이렇게 된다: 진짜 데이터 - 텍스트의 UTF-8 바이트, 파일의 내용, 캔버스의 출력 - 은 어떻게 btoa()의 입력으로 들어가는가? 답은 "바이트 다리"다: 각 문자가 바이트 값 하나를 지닌 JavaScript 문자열. 디코더가 만들어 내는 것과 같은 트릭으로, btoa가 네이티브로 이해하는 것이다. 순진한 버전은 루프다:
function bytesToBase64 (bytes) {
let binary = '';
for (let i = 0; i < bytes.length; i += 1) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
맞다. 그러나 루프 안의 문자열 연결은 큰 파일에서는 느리고, 인기 있는 바로 가다 - 한 번의 호출로 배열 전체를 인자로 먹이는 String.fromCharCode.apply(null, bytes) - 는 단단한 절벽이 있다. 함수 호출은 인자 수에 한계가 있으며, 첫 메가바이트에 훨씬 앞서 도달한다:
const big = new Uint8Array(1000000);
btoa(String.fromCharCode.apply(null, big));
// RangeError in Firefox: "too many arguments provided for a function call"
// RangeError in Chrome: "Maximum call stack size exceeded"
다른 어떤 단일 변경보다 더 많은 파일 업로드 기능을 구해 낸 해결책은, 문자 몇 천 개씩 청크로 다리를 건너 그 결과를 이어 붙이는 것이다:
function bytesToBase64Chunked (bytes) {
const CHUNK = 0x8000;
const parts = [];
for (let i = 0; i < bytes.length; i += CHUNK) {
parts.push(String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK)));
}
return btoa(parts.join(''));
}
각 청크는 안전하게 적용할 만큼 작고, subarray는 복사 없이 뷰를 주며, join이 만들어 내는 것은 루프가 만들어 냈을 바이너리 문자열과 정확히 같은 것이다. 이제 동전의 텍스트 편. 어떤 진짜 텍스트든, 플랫폼의 UTF-8 인코더인 TextEncoder (Firefox 18, Chrome 38, Safari 10.1, 그리고 그 이후 모든 곳에 존재)가 다리 일이 시작되기 전에 당신의 문자열을 정직한 바이트로 바꾼다:
const bytes = new TextEncoder().encode('hello 你好');
const base64 = bytesToBase64Chunked(bytes);
console.log(base64); // "aGVsbG8g5L2g5aW9"
그 출력이 "hello 你好"가 와이어 위에서의 진짜 모습이다: ASCII 바이트 6개, 중국어 2자를 위한 UTF-8 바이트 6개, 전부 같은 인쇄 가능한 분장을 한 채. 텍스트가 UTF-8이 아니라면 - 웹에서는 보통 UTF-8이긴 하지만 - 다른 캐릭터셋이 먼저 필요하며, 그것은 그 캐릭터셋을 말하는 어딘가, 보통은 서버에서 인코딩해야 한다는 뜻이다. TextEncoder는 의도적으로 추측을 거부하며, 그것이 옳다.
2025년 단축로: Uint8Array.toBase64
이미 Uint8Array를 가지고 있다면, 다리는 우회로다. 새로운 ECMAScript (ES2026) 기능이 배열을 직접 인코딩하기 때문이다: bytes.toBase64(options). Chrome 140, Edge 140, Firefox 133, Safari 18.2, Node 25, Deno 2.5에 도착했다 - 디코딩 하는 형제와 같은 Baseline 2025 물결 - 그리고 플랫폼에서 가장 다재다능한 인코더로 만드는 옵션 두 개를 받는다. 첫 번째는 alphabet: "base64" (기본값) 또는 "base64url". 두 번째는 omitPadding: true로 두면 끝의 = 문자가 빠지며, 이것이 대부분의 URL 친화적 사용자가 원하는 형태다. 옵션으로 그 밖의 것을 넘기면 TypeError를 던지는데, 이는 API가 당신의 오타에 예의를 갖추는 방식이다:
const bytes = new Uint8Array([251, 255]);
console.log(bytes.toBase64()); // "+/8="
console.log(bytes.toBase64({ omitPadding: true })); // "+/8"
console.log(bytes.toBase64({ alphabet: 'base64url' })); // "-_8="
그 바이트 두 개는 알파벳에 최대한 무례하도록 고른 것이다: 표준 모드에서는 +와 /를 만들므로, 마지막 줄은 base64url로 전환하면 정확히 무엇이 바뀌는지를 보여 준다. 성능은 조용한 보너스다: 최신 Firefox에서, 10 메가바이트 인코딩은 toBase64로 약 5밀리초가 걸리는 반면, 위의 문자열 다리 경로는 약 15배 오래 걸린다. 그 과정에 거대한 중간 문자열을 만들기 때문이다. 오래된 브라우저에서는 몇 메가바이트 미만에는 여전히 다리가 완벽하게 쓸 만하다 - 그리고 지난 섹션의 이유 때문에, 위에 있는 청크 버전이 당신이 원하는 것이다.
URL-safe 출력
Base64에는 +, /, =가 피해를 주는 곳을 위한 전용 변형이 있다. 그 자체로 섹션 하나를 받을 만하다. 깨진 코드의 대부분이 URL에 부딪힌 표준 Base64이기 때문이다. 쿼리 문자열에서는 +가 공백이고, 경로에서는 /가 구분자이며, =는 어떤 위치에서는 퍼센트 인코딩을 원한다. RFC 4648 5절의 URL 및 파일명 안전 알파벳 - base64url - 은 그 두 문자를 -와 _로 바꾸고, 데이터 길이는 보통 수신 쪽에서 알려져 있으므로 패딩을 통째로 뺌도 허용한다. 출력은 퍼센트 부호 하나 없이 URLSearchParams, 경로 세그먼트, 프래그먼트, 파일명을 지나간다.
2025년 API에서는 이것이 옵션 객체 하나다:
const params = new URLSearchParams();
params.set('payload', bytes.toBase64({ alphabet: 'base64url', omitPadding: true }));
console.log(params.toString()); // "payload=-_8" - 퍼센트 인코딩이 전혀 필요 없다
오래된 브라우저에서는 btoa로 인코딩한 뒤 변환한다. replace 두 개와 trim 하나로 전부 끝난다:
function toUrlBase64 (base64) {
return base64
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
console.log(toUrlBase64(btoa('hi?/x'))); // "aGk_L3g"
채널을 깨끗하게 지켜 주는 규칙 세 개. 채널마다 알파벳을 하나 고르고 그것에 붙으라 - +와 -를 섞은 값은 어느 집에도 속하지 않으며, 어떤 디코더도 당신이 어느 쪽을 의도한 건지 추측하지 않는다. 패딩은 계약이지 제안이 아니다: 빼면 수신 쪽이 패딩 없는 값을 준비해 두고 있어야 하고, 유지하면 수신 쪽이 그것에 걸트기 않아야 한다 (브라우저는 관대하지만, 어떤 JSON 스키마는 그렇지 않다). 그리고 교환은 가역적이고 손실이 없음을 기억하라 - -와 _는 +와 /가 차지하는 62번째, 63번째 알파벳 위치와 같은 곳에 매핑되므로, 더 친근한 쌍을 고른다고 잃는 것은 아무것도 없다.
이미지를 여행하게 하기: 데이터 URL
브라우저에서 Base64의 가장 오래되고 가장 눈에 띄는 용도는 데이터 URL이다: data:, 선택적인 미디어 타입, 선택적인 ;base64 플래그, 쉼표, 그리고 페이로드. 텍스트 페이로드는 퍼센트 인코딩이고, 바이너리 페이로드 - 이미지, 폰트, 오디오 - 는 Base64이며, 브라우저는 HTTP 요청 제로로 그것들을 렌더링한다. 사용자가 방금 고른 이미지 파일에는 FileReader가 인코딩을 대신 해 주고, 완성된 URL을 손에 들려 준다:
const reader = new FileReader();
reader.onload = () => {
console.log(reader.result); // "data:image/png;base64,iVBORw0KGgo..."
imageElement.src = reader.result;
};
reader.readAsDataURL(file);
결과는 완성된 src, localStorage에 저장할 수 있는 값, JSON 본문에 보낼 수 있는 값이다. 이미지가 캔버스 위에 있다면 - 스크린샷, 처리한 사진, 생성된 차트 - canvas.toDataURL()는 가장 초기 브라우저 릴리스부터 이 일을 해 왔으며, 포맷을 고를 수 있고, 손실 압축 포맷에는 품질도 고를 수 있다:
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.drawImage(photo, 0, 0);
const pngUrl = canvas.toDataURL('image/png');
const jpegUrl = canvas.toDataURL('image/jpeg', 0.8);
둘러봐야 할 함정이 세 개 있다. 첫째, 오염된 캔버스 규칙: CORS 허가 없이 크로스 오리진 이미지를 캔버스에 그렸다면, 픽셀을 다시 읽으려는 모든 시도 - toDataURL를 포함 - 은 SecurityError를 던진다. 해결책은 이미지를 crossOrigin = 'anonymous'로 로드하고, 서버가 올바른 헤더를 보내도록 하는 것이다. 둘째, 품질 인자는 PNG에서는 무시되고, JPEG (그리고 WebP)에서만 의미가 있다 - "왜 내 PNG가 더 큰 거냐"의 흔한 원인. 셋째, 그리고 가장 큰 것: 페이로드는 파일보다 약 33퍼센트 크며, 문자열로서 페이지 위에 얹혀 있다. 브라우저에서 절대 나오지 않는 이미지는 무료 대안이 있다 - Blob을 인코딩도 없이 감싸는 오브젝트 URL이다:
const objectUrl = URL.createObjectURL(blob);
imageElement.src = objectUrl;
URL.revokeObjectURL(objectUrl); // 사용이 끝나면
이에서 나오는 노동 분담: 페이지에 머물 것은 오브젝트 URL, 복사되거나 저장되거나 텍스트로 보내져야 할 것은 데이터 URL. 둘 다 1급이다; 단지 서로 다른 문제를 풀고 있을 뿐이다.
JWT 만들기와 서명하기
브라우저에서 토큰을 생성한다면 - 자체 호스팅 인증 플로우, 데모, 서버리스 프런트엔드용 - 컴팩트 JWS 포맷은 base64url 세그먼트 세 개다: 헤더, 페이로드, 서명, 패딩은 어디에도 없다. 서명은 Web Crypto API가 담당하며, 인코딩은 정확히 두 섹션 전의 URL-safe 출력이다:
const encoder = new TextEncoder();
const segment = (bytes) =>
bytes.toBase64({ alphabet: 'base64url', omitPadding: true });
const header = segment(encoder.encode(JSON.stringify({ alg: 'HS256', typ: 'JWT' })));
const payload = segment(encoder.encode(JSON.stringify({ sub: '1234567890', name: 'John Doe' })));
const key = await crypto.subtle.importKey(
'raw',
encoder.encode('shared-secret'),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = segment(
new Uint8Array(
await crypto.subtle.sign('HMAC', key, encoder.encode(header + '.' + payload))
)
);
const token = header + '.' + payload + '.' + signature;
배관보다 중요한 디테일이 두 개 있다. 서명은 정확히 header + '.' + payload를 덮는다 - 원시 세그먼트이며, JSON이 아니다 - 그래서 어느 쪽이든 수정하면 토큰이 무효가 되며, 그것이 바로 전체 취지다. 그리고 crypto.subtle.sign은 생짜 ArrayBuffer를 반환하므로, 세그먼트 인코더 전에 Uint8Array로 한 줄 감싸 준다. RSA 기반 토큰이면 RS256과 키 페어로 플로우가 동일하며, 공용 키를 JWK로 내보내면 (crypto.subtle.exportKey('jwk', key)), 숫자 멤버 - n, e, 그리고 비밀 키의 d, p, q - 는 자동으로 패딩 없는 base64url로 나온다. 보안 주의 사항은 어떤 토큰이든 같다: alg: "none" 헤더는 검증을 건너뛰어 달라는 요청이고, 시간 클레임 (exp, nbf)은 집행되어야 하며, 같은 청중에게 HMAC과 RSA를 다 받아 주는 서버는 클래식한 키 혼동의 문을 연다. 올바르게 인코딩하고, 올바르게 서명하고, 수신 쪽에서 검증하라.
인증 헤더
웹에서 가장 단순한 인증 방식은, Base64가 무엇이고 무엇이 아닌지에 관해서도 가장 교육적이다. HTTP Basic은 Authorization: Basic을 보내고, 그 뒤를 username:password의 Base64가 따른다 - 호출 한 번, 바이트 다리도 필요 없다. 사용자 이름과 비밀번호는 (기대컨대) 평문이므로:
const credentials = btoa('alice:secret123');
fetch('/api/me', {
headers: { Authorization: 'Basic ' + credentials }
});
// Authorization: Basic YWxpY2U6c2VjcmV0MTIz
그리고 한 줄에 들어 맞는 교훈이 바로 이것이다: Base64는 암호화가 아니다. 위의 헤더는 atob 호출 한 번이면 alice:secret123에 닿는다 - 공격자에게도, 로그를 읽는 누구에게도 - 그래서 Basic 인증은 HTTPS 위에서만 허용될 만하다. 거기서는 전송이 진정한 보호고, Base64는 그저 포맷일 뿐이다. 한 요청보다 긴 수명을 가진 것은 토큰 기반 방식을 선호하라: Bearer 토큰도 헤더 한 개지만, 그 시크릿은 애초에 헤더에 싣을 필요가 없는 무작위 값이며, 취소할 수도 있다. 둘 사이의 인코딩 선택은 자명하다 - 둘 다 btoa 또는 평문이므로 - 하지만 보안 선택은 자명이 아니라, 의도적으로 내려야 한다.
파일 넣고, 텍스트 내기
업로드는 33퍼센트 세금이 실제 돈으로 계산되는 장소다. 파일이 보통 페이지에서 가장 큰 것이기 때문이다. 길은 두 개이고, 기본적으로 택해야 할 것은 첫 번째다: multipart 폼 데이터. FormData는 표준 본문에 파일을 생짜 바이트로 싣고, 프레임 구성은 브라우저가 하며, 그림 어디에도 Base64가 없다 - 크기 세금도, 중간 문자열도, 바이트는 읽히는 대로 서버로 스트리밍된다:
const form = new FormData();
form.append('upload', file);
await fetch('/api/upload', { method: 'POST', body: form });
두 번째 길은, 파일이 문자열인 JSON 본문을 고집하는 API를 위한 것이다 - 어떤 서버리스 함수, 어떤 모바일 백엔드, 어떤 레거시 서비스. 거기서 인코딩은 파일당 한 줄이고, 비용은 세금이 말하는 그대로다: 5 메가바이트 파일이 6.7 메가바이트 문자열이 되고, 그것이 JSON으로 직렬화되고, 그것이 보내진다. 사진에는 괜찮고, 비디오에는 고통이다:
const bytes = new Uint8Array(await file.arrayBuffer());
const body = JSON.stringify({
name: file.name,
content: bytes.toBase64()
});
await fetch('/api/upload-json', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body
});
그 길의 큰 파일에는, 한 번의 호출로 거대한 문자열 하나를 지우지 마라 - 조각으로 지어라. 각 조각이 3바이트의 배수인 조각으로. 그 정렬이 바로 이 트릭을 합법적으로 만드는 것이다: 3바이트의 배수는 패딩 없이 깔끔한 4문자의 배수로 인코딩되므로, 독립적으로 인코딩된 조각들을 이어 붙이면 정확히 전체 파일의 인코딩이 되고, 패딩을 가진 것은 언제나 마지막 조각뿐이다:
async function encodeLargeFile (file) {
const bytes = new Uint8Array(await file.arrayBuffer());
const SLICE = 3 * 1000 * 1000;
const parts = [];
for (let i = 0; i < bytes.length; i += SLICE) {
parts.push(bytes.subarray(i, i + SLICE).toBase64());
}
return parts.join('');
}
같은 정렬 아이디어 때문에, Base64 문자열을 임의의 지점에서 잘라서 그 조각들이 각자 디코딩되기를 기대하는 일은 결코 하지 말아야 한다 - 3바이트 그룹이 원자이며, 그것을 중간에서 자르면 불완전한 조각이 남는다. 다운로드는 거울 이미지다: 생성된 파일에서 작은 것은 다운로드 링크를 통해 데이터 URL로 나갈 수 있지만, 분량이 있는 것은 Blob에 오브젝트 URL이 건강한 길이다. 브라우저는 애초에 페이로드 전체를 문자열로 싣을 필요가 없으니까.
상태 저장하기와 공유하기
Base64가 진짜 일을 하는 텍스트 전용 채널이 두 개 더 있다. 첫 번째는 스토리지다: localStorage와 sessionStorage는 문자열을 지니므로, 구조화되거나 바이너리인 데이터는 들어가기 전에 인코딩된다. 왕복은 인코딩 한 번, 디코딩 한 번이며, 스토리지 버그는 거의 언제나 둘 사이의 캐릭터셋 불일치이므로, 양쪽을 함께 보는 것이 값할 만하다:
const state = { theme: 'dark', draft: 'hello' };
const packed = new TextEncoder().encode(JSON.stringify(state));
localStorage.setItem('app-state', new Uint8Array(packed).toBase64());
const raw = atob(localStorage.getItem('app-state'));
const bytes = Uint8Array.from(raw, (c) => c.codePointAt(0));
const state = JSON.parse(new TextDecoder().decode(bytes));
다만 예산을 제대로 세워라: 오리진에는 localStorage로 대략 5 메가바이트가 주어지고, 저장한 문자열은 데이터보다 33퍼센트 더 통통하며, 페이지가 열려 있는 동안 그 문자열은 메모리에서 UTF-16으로 또 산다 - 길이가 다시 두 배다. 3 메가바이트 에셋은 저장 4 메가바이트, 메모리 8 메가바이트이며, "작은" 기능이 이렇게 해서 쿼터 오류가 된다. 두 번째 채널은 URL 자체다: 공유 링크, 딥 링크, OAuth 상태 모두 복사-붙여넣기를 견디는 곳에 구조화된 데이터를 원한다. 레시피는 컴팩트한 상태, JSON, 그리고 패딩 없는 base64url. 값이 퍼센트 인코딩을 전혀 필요로 하지 않게 되므로 - 그리고 URL 전체를 몇 천 자 미만으로 유지하라. 오래된 클라이언트와 프록시, 로깅 도구가 긴장하기 시작하는 지점이 거기다.
이메일과 MIME
Base64는 웹보다 오래됐고, 그 본거지는 이메일이다. Content-Transfer-Encoding: base64를 가진 MIME 첨부 파일은, 바이너리 파일이 텍스트 프로토콜 안에 탄 방법이다. 그리고 메시지 포맷의 옛 76자 줄 한계에서 이어진 관례는 알아 둘 가치가 있다: 인코딩된 본문을 1줄 76자로 랩핑하라. 브라우저는 SMTP를 보낼 수 없지만, 이메일 일은 두 개는 한다 - 백엔드 중계기가 보낼 MIME 본문을 짓고, 받은 메시지의 첨부 파일을 표시하는 것 - 둘 다 인코딩에 닿는다. 랩핑 자체는 두 줄짜리 함수이며, 작업 순서가 중요하다: 먼저 인코딩, 둘째 랩핑. btoa는 입력의 줄바꿈에 던지지 않기 때문이다 - 줄바꿈을 페이로드 바이트로 인코딩해서, 당신의 줄바꿈이 출력 안에 들어가 버린다:
function wrapForMime (base64, width) {
const w = width || 76;
return base64.match(new RegExp('.{1,' + w + '}', 'g')).join('\r\n');
}
받는 쪽이 공짜다: atob는 표준 동작의 일부로 ASCII 공백을 건너뛰므로, 랩핑된 MIME 본문은 줄바꿈까지 그대로, 도착한 그대로 디코딩된다 - 언랩핑 단계 없이. 웹메일 클라이언트나 첨부 파일 선택기를 만들고 있다면, 그 비대칭 하나 - 인코더는 깨끗한 줄을 만들어야 하고, 디코더는 무관심하다 - 가 한 문장인 MIME 전체 이야기다.
라이브러리를 잡아야 할 때
위의 네이티브 도구로는 라이브러리가 거의 필요 없으며, 정직한 안내는 이것이다: 플랫폼을 기본으로 하라, 그리고 진짜 요구가 그것을 가리킬 때만 패키지를 추가하라. 코드베이스에 실제로 나타나는 것은 세 개다:
js-base64 (npm install js-base64)는 범용이다: UTF-8 문자열을 1급 시민으로 대우하는 작은 순수 JavaScript 변환기 - CJK 문자열에 Base64.encode를 쓰면 UTF-8 춤을 대신 춰 준다 - 그리고, 인코딩만큼 디코딩에도 유용하게, decode에서 두 알파벳 다 받으며 isValid 검사도 싣고 있다. 2025년 API가 없는 브라우저를 타깃으로, 문자열과 바이트를 한 번의 import로 덮고 싶다면 이것이 정답이다:
import { Base64 } from 'js-base64';
const encoded = Base64.encode('小飼弾'); // "5bCP6aO85by+" - UTF-8은 알아서 처리
const decoded = Base64.decode('5bCP6aO85by-'); // 표준, URL-safe 가리지 않고 읽음
const valid = Base64.isValid(encoded); // true
base64-js는 바이트 중심이다: Uint8Array의 fromByteArray와 toByteArray, 의존성 없이, 옛 browserify 생태계의 일꾼이며, 당신의 코드가 타입 배열에 살고 인코딩이 바이트의 순수 함수이기를 원한다면 여전히 괜찮은 선택이다. 그리고 라이브러리를 원하는 이유가 "2025년 API는 좋지만 2025년 브라우저를 요구할 수는 없다"라면, 답은 Base64 패키지가 아니라 폴리필이다: core-js (그것을 끌어오는 Babel 프리셋과 함께)가 Uint8Array.fromBase64와 친구들을 구현하므로, 새 스타일 코드를 한 번 쓰고, 심(Shim)이 오래된 엔진에서 그 빈 곳을 채우게 하면 된다. 습관이 아니라 제약으로 고르라 - 오래된 브라우저, 문자열 편의성, 혹은 바이트 순수성 -.
개발자의 시간을 갉아먹는 함정
- 코드 포인트 255를 넘는 문자가 있는 문자열에
btoa를 호출하는 것. 던지긴 하지만 엉망으로 만들지는 않으며, 첫 번째 범인에서 멈춘다. 해결책은 언제나 같다: 먼저TextEncoder, 그다음 다리. - 큰 배열에서의
fromCharCode.apply절벽. 인자 100만 개는 양쪽 주요 엔진 모두에서RangeError다. 다리를 청크로 나누거나,toBase64로 갈아탄다. - 가장 아픈 곳, 스토리지에서의 크기 세금을 잊는 것.
localStorage의 파일은 파일보다 33퍼센트 크고, 쿼터는 오리진당이며, 앱이 저장하는 다른 모든 것과 공유된다. - 쿼리 문자열과 만난 표준 Base64.
+는 공백으로 도착하고,/는 경로를 깨뜨리며, 버그 보고서는 "API가 불안정하다"고 말한다. URL-safe 출력, 패딩 없음, 그리고 이 장르는 통째로 사라진다. - 서비스마다 일관되지 않은 패딩. 어떤 게이트웨이는
=를 유지하고, 다른 곳은 떼며, 또 다른 곳은 다시 붙인다. 수신 쪽은 두 형태 모두를 준비해 두어야 하며, 계약서는 어느 쪽이 정본인지를 말해야 한다. - Base64를 자물쇠로 대우하는 것. 직렬화 포맷이며, 평문에서 함수 호출 한 번 거리다. 보안 리뷰에서 "Base64로 인코딩되어 있다"는 것은 통제 수단이 아니라 발견 항목이다.
- 메모리 모델로서의 바이너리 문자열. 디코딩되거나 인코딩된 1메가바이트는 UTF-16으로 2메가바이트에 실리고,
Uint8Array는 1메가바이트로 지닌다. 큰 페이로드는 끝에서 끝까지 바이트를 타입 배열에 두라. - 이중 인코딩. 이미 Base64인 값이 다시 인코딩되고, 소비자는 한 번 디코딩하면 데이터 대신 알파벳 문자열을 얻는다. 의심스러우면 감싸기 전에 확인하라 - 이미 알파벳 문자로 되어 있고 패딩도 유효한 문자열은 스멜이다.
- 깨끗이 디코딩됐다는 이유로 JWT 페이로드를 믿는 것. 디코딩 가능성은 진정성이 아니다. 클레임 하나를 읽기 전에, 올바른 키와 올바른 알고리즘으로 서명을 검증하라.
성능: 100만 바이트의 가격
브라우저에서의 Base64는 비쌌던 곳이 이제는 싸며, 예산에는 이제 하나 대신 세 개의 항목이 있다. CPU: 최신 Firefox에서 Uint8Array.toBase64는 10 메가바이트를 약 5밀리초에 인코딩하는 반면, 청크로 나누는 btoa 다리는 약 15배 오래 걸린다 - btoa가 느리기 때문이 아니라, 다리가 가는 길에 거대한 중간 문자열을 만들기 때문이다. 인코딩 예산이 밀리초 단위라면 네이티브 메서드를 쓰고, 2 킬로바이트 설정 객체를 인코딩 중이라면 둘 다 인식 임계값 아래다. 대역폭: 이것이 영구 세금이다 - 인코딩하는 바이트 1개당 와이어 위에서는 1.33바이트가 들며, 전송이 추가하는 프레임까지 얹어진다. 인코딩을 "최적화"하기 전에 전송을 측정하라. 메모리: 인코딩된 문자열은 당신이 할 가장 큰 일시 할당이며, 5 메가바이트 파일이면 6.7 메가바이트 문자열, 즉 페이지가 그것을 들고 있는 동안 약 13.4 메가바이트의 UTF-16 메모리다. 실질적 함의는 산술에서 쏟아진다: 큰 인코딩은 어떤 단일 문자열도 거대해지지 않도록 조각내고, 문자열이 존재하는 즉시 중간 바이트를 놓아 주고, 바이트가 애초에 인쇄 가능할 필요가 없다면 오브젝트 URL과 multipart를 선호하며, 메인 스레드가 매끄럽게 스크롤을 이어 가야한다면 수 메가바이트짜리 일은 Web Worker로 옮기라. 이 포맷은 거의 40년 가까운 나이고, 플랫폼은 마침내 그것에 맞춰 왔다.
브라우저가 인코딩을 배우다
인코더에도 역사가 있고, 그것이 당신이 물려받을 유적들을 설명한다. btoa - "바이너리에서 ASCII로", 이름은 문자 그대로이며, atob는 그냥 같은 단어들을 뒤집은 것 - 는 2011년에 HTML 명세에 적혔는데, 이미 싣고 있던 브라우저들로부터 역공학한 것이다: Firefox는 2004년부터, Safari 3, Chrome 4. Internet Explorer는 특성상 두 함수를 모두 2012년 버전 10까지 건너뛰었고, 바로 그 한 가지 부재가 10년치 JavaScript에 손으로 만든 Base64 테이블과 Unicode를 위한 특정한 주문 하나 - btoa(unescape(encodeURIComponent(str))) - 가 가득한 이유다. 동작은 했다 - encodeURIComponent는 퍼센트 이스케이프된 UTF-8을 만들고, unescape가 그것을 바이트 문자열로 만들었으니 - 하지만 그것은 unescape(), 그 쌍 중 언어가 비권장 처리한 위에 지어졌으며, 순수한 관성으로 브라우저 코드에서 수년을 살았다. 원칙 있는 해결책은 Encoding 표준과 함께 왔다: TextEncoder와 TextDecoder, Firefox 18 (2013), Chrome 38 (2014), Safari 10.1 (2017), 그리고 IE의 어떤 버전에도 없음 - 또 하나의 IE 빈 구멍, 또 다른 10년의 우회 작업. Node.js는 이야기의 서버 쪽 반을 들려 준다: 처음부터 Base64를 가진 Buffer가 있었지만, atob와 btoa를 전역으로 가지기는 2021년 버전 16부터, 그 전에는 npm 심(Shim) 두 개가 그 짐을 짊어졌다. 그리고 2024년 말과 2025년을 관통해, 언어 자체가 Base64를 싣고 나왔다 - Uint8Array.toBase64와 친구들이 Firefox 133 (2024년 11월), Safari 18.2 (2024년 12월), Chrome 140 (2025년 9월), Node 25 (2025년 10월)에서 - 그리고 그 기능은 Baseline 2025로 표시되었다. 20년 동안 헬퍼로 근사해 왔던 같은 기능 세트가, 이제는 표준이다. 글 말미의 재미있는 비화들은 대부분 각 조각이 도착하는 데 얼마나 걸렸는지에 관한 것이다.
알고 있었나요?
- 함수 이름은 한 문구다:
btoa는 "바이너리에서 ASCII로",atob는 "ASCII에서 바이너리로". 방향이 이름 안에 있으니까, 이 쌍은 2000년대부터 스스로를 문서화해 왔다. - 컴퓨팅 역사에서 가장 많이 인코딩된 문자열은 아마 "hello"일 것이다:
btoa('hello')는aGVsbG8=, 지구상의 모든 튜토리얼, 테스트 스위트, 면접 화이트보드의 출력이다. - 모든 유효한 Base64 문자열의 길이는 패딩 포함 4의 배수다.
=문자는 지문이다: 하나는 마지막 그룹이 바이트 2개를 지녔다는 뜻이고, 둘이면 1개를 지녔다는 뜻이다. - MIME과 대부분의 커맨드 라인 도구의 76자 줄 랩핑은, 메시지 포맷의 줄 길이가 한계를 정하던 이메일 시대의 유산이다. 그 숫자는 모든 것이 빨라진 30년을 살아남았다.
- "Data URI"는 은퇴한 이름이다. WHATWG는 대대적인 URI-URL 조화 기간에 그것을 "data URL"로 이름을 바꾸었으니, 명세, 블로그 포스트, 패키지 이름이 같은 문단에서 서로 다른 표기를 쓰는 것이다.
btoa('')은''를 반환한다: 빈 입력은 빈 출력을 만들며, 패딩도, 특수 케이스도 없다 - 문자 0개인 유일한 Base64 문자열 (그 길이 0은 여전히 4의 배수다).- 캔버스는
toDataURL로 사진을 데이터 URL로 바꿀 수 있다 - IE 9, Firefox 2, Safari 4부터 존재해 온 능력으로, 우리가 "근대"라고 생각하는 웹 플랫폼의 대부분보다 먼저 왔다 - 그리고<img>태그와FileReader로 왕복해 올 수도 있다. - WebSocket 핸드셰이크는
SHA-1(key + 258EAFA5-E914-47DA-95CA-C5AB0DC85B11)을 Base64로 인코딩하며, 그 GUID는 RFC의 고정 상수로, 어떤 평범한 HTTP 서버도 우연히 핸드셰이크를 완성할 수 없도록 정확히 고른 것이다.
이제 어디로 갈까
그러니 브라우저에서의 인코딩이라는 전 공예가 한 페이지에 담긴다: 태어나기 위해 태어난 평범한 1바이트 케이스에는 btoa, 어떤 브라우저에서든 진짜 텍스트와 파일을 위한 TextEncoder에 청크로 건너지르는 다리, 근대적이고 직접적인 길을 위한 알파벳과 패딩 옵션을 가진 Uint8Array.toBase64, 그리고 URL에서 살게 될 모든 것을 위한 URL-safe 변형, 패딩이 있는지 없는지 불문하고. 나머지는 판단이다: 33퍼센트 세금을 쓸 전에 알고, 바이트가 큰 동안은 타입 배열에 두고, 먼저 인코딩하고 둘째 랩핑하며, 직렬화 포맷을 자물쇠라 부르는 일은 결코 하지 마라. 채널이 생짜 바이트를 싣을 수 있다면, 바이트를 받으라 - Base64는 인쇄 가능한 텍스트만 허용하는 길을 위한 것이며, 이제 통행료를 정확히 어떻게 내는지 알고 있다.
여정의 다른 한 편 - 이러한 문자열 중 하나를 받아서 바이트, 텍스트, 의미를 다시 꺼내는 일 - 은 JavaScript에서의 Base64 디코딩 동반 가이드에서 자세히 다룬다. 아래에 링크되어 있다.
마지막 업데이트: 2026-09-08