JavaScript/Node.js에서의 Base64 인코딩: 완전한 가이드
텍스트가 되어야 할 데이터가 있습니다. JSON 필드 안에 타야 할 파일, CSS 파일에 살고 싶어 하는 이미지, 환경 변수에 머물 시크릿, 쿼리 문자열을 타고 다닐 토큰. JavaScript와 Node.js에서의 답은 거의 언제나 같습니다: Base64. 이 글은 포장 매뉴얼입니다. 손에 쥔 첫 바이트부터, 인코딩된 문자열이 기계를 떠나가는 순간까지.
이 사이트의 홈 페이지가 포맷을 온전히 설명해 줍니다. 알파벳, 산수, 패딩. 그래서 여기서는 한 문장으로: 바이트 3개가 인쇄 가능한 문자 4개가 되는데, 그래서 당신의 출력은 입력보다 약 33퍼센트 더 클 것입니다. 그 숫자를 주머니에 넣어 두세요. 이 글의 각 절이 존재하는 이유이며, 당신의 저장비 청구서가 그 단위로 계산되는 것이니까요.
위로가 되는 부분: 설치할 것이 아무것도 없습니다. 모든 모던 브라우저는 btoa()와 더 새로운 Uint8Array.toBase64()를 싣고 있고, 중요한 모든 Node.js 버전은 Buffer 클래스를 가지고 있으며, 'base64' 모드와, 15.7.0 버전부터는 1급 'base64url' 모드를 가지고 있습니다. 예술은 손에 쥔 것이 어떤 모양의 입력인지, 목적지가 어떤 알파벳을 요구하는지, 그리고 옛 포맷들이 여전히 강제하는 줄바꿈 규칙이 무엇인지 아는 데 있습니다.
인코딩 전에 손에 쥔 것을 알라
모든 인코딩 질문은 같은 질문으로 시작합니다: 당신은 정확히 무엇을 쥔 겁니까? JavaScript 문자열은 UTF-16 텍스트이고, Buffer는 바이트 배열이며, 알맞은 호출은 어떤 것을 갖고 있느냐에 달려 있습니다:
| 당신이 쥔 것 | 이것을 호출하라 | 참고 |
|---|---|---|
| ASCII 전용 문자열 (256 미만 문자) | btoa(string) |
브라우저와 Node.js 16 이상에서 가장 빠른 경로, 하지만 바이트 하나에 안 맞는 첫 번째 문자에서 멈춘다 |
| 아무 Unicode 문자열이든 | TextEncoder로 바이트, 그다음 base64 호출 |
UTF-8 다리; 발음 기호가 붙은 문자와 이모지에 대해 유일한 안전한 경로 |
| Buffer 또는 Uint8Array | buffer.toString('base64') 또는 bytes.toBase64() |
Node.js 일꾼, 그리고 모던 브라우저와 Node.js 25+의 ES2026 메서드 |
표의 행당 하나씩, 예시 세 개:
// ASCII 전용 텍스트: 레거시 단축로 (브라우저와 Node.js 16 이상)
console.log(btoa('hello world')); // "aGVsbG8gd29ybGQ="
// Node.js에서 아무 텍스트: Buffer는 기본적으로 UTF-8을 읽는다
const { Buffer } = require('node:buffer');
console.log(Buffer.from('héllo ⛳', 'utf8').toString('base64')); // "aMOpbGxvIOKbsw=="
// 이미 갖고 있는 바이트
console.log(Buffer.from([1, 2, 3, 4]).toString('base64')); // "AQIDBA=="
console.log(new Uint8Array([1, 2, 3, 4]).toBase64()); // "AQIDBA==" (ES2026 런타임)
두 번째 예시를 주목하세요: 같은 텍스트가 어떤 캐릭터셋으로 인코딩하느냐에 따라 다른 Base64 문자열을 만듭니다. 그것은 버그가 아니라, 이 게임 전체입니다. Base64 계층은 바이트를 인코딩하고, 문자열이 바이트가 되는 것은 캐릭터셋을 고른 이후이므로, "이 텍스트를 인코딩하라"는 언제나 몰래 "이 텍스트의 UTF-8 바이트를 인코딩하라"를 의미합니다 (명시한다면 Latin-1 바이트).
Unicode 벽, 그리고 그 위의 다리
btoa()는 이 방에서 가장 오래된 API이며, 그 계약은 1990년대의 것입니다: 입력 문자열의 각 문자는 바이트 하나에 들어가야 하며, 코드 포인트는 0에서 255 사이여야 합니다. 그 이상의 것, 이모지든, 발음 기호가 붙은 키릴 문자든, 한자든, 예외를 던집니다:
try {
btoa('héllo ⛳');
} catch (error) {
console.log(error.name); // "InvalidCharacterError"
console.log(error.message); // Node에서는 "Invalid character", 브라우저에서는 Latin1 범위 표현
}
해법은 문자 단위로 생각하는 것을 멈추고, 바이트 단위로 생각하기 시작하는 것입니다. TextEncoder (모든 브라우저와 Node.js의 전역)가 문자열을 UTF-8 바이트 시퀀스로 바꾸고, 당신은 그 바이트를 Latin-1 문자열로 들어 올립니다. 그러면 btoa()는 약속한 대로 처리할 수 있는 것을 정확히 받게 됩니다:
function encodeUnicode (text) {
const bytes = new TextEncoder().encode(text);
let binary = '';
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
console.log(encodeUnicode('héllo ⛳')); // "aMOpbGxvIOKbsw=="
console.log(encodeUnicode('héllo ⛳') === Buffer.from('héllo ⛳', 'utf8').toString('base64')); // true, 같은 바이트
코드베이스에서 오래된 관용 표현도 만나게 될 텐데, 그 안쪽에서는 같은 방식으로 동작합니다: btoa(unescape(encodeURIComponent(text))). encodeURIComponent 호출이 퍼센트 인코딩된 UTF-8 바이트를 만들고, unescape가 퍼센트 이스케이프를 다시 원시 문자로 바꿉니다. escape와 unescape 둘 다 레거시 함수이므로, 새 코드는 TextEncoder 다리를 선호해야 하지만, 옛 형태를 상속받았을 때 이제는 어깨를 으쓱하기 대신 그것이 정확히 무슨 짓을 하는지 알 수 있습니다.
Node.js에서는 이 벽은 대부분 문제가 되지 않습니다. Buffer.from(text)가 UTF-8을 가정하고, 같은 호출 안에서 바이트 변환을 대신해 주거든요. 다리가 가장 중요한 곳은 btoa()가 레거시 옵션이고, UTF-8 단계를 명시적으로 당신이 만들어야 하는 브라우저입니다.
바이트 들어가고, 문자 나오고: Buffer, 패딩, 맛의 차이
바이트가 손에 있다면, Node.js의 인코딩 쪽은 하나의 메서드입니다: toString('base64'). 그룹 산수도, 패딩도, 전부 처리하며, 항상 RFC 4648 의미의 정규 출력을 만듭니다. 즉, 마지막 그룹의 미사용 패딩 비트는 0이라는 뜻입니다:
const { Buffer } = require('node:buffer');
const fox = Buffer.from('The quick brown fox jumps over the lazy dog');
console.log(fox.length); // 43바이트
console.log(fox.toString('base64')); // "VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZw=="
console.log(fox.toString('base64').length); // 60자, 33퍼센트 세금이 작동하는 모습
끝의 패딩은 장식이 아니라 진짜 일을 하고 있습니다. 남은 바이트 1개의 마지막 그룹은 Base64 문자 2개에 = 두 개가 되며, 남은 바이트 2개의 그룹은 문자 3개에 = 하나입니다. 당신의 출력이 그 패딩을 지닐 수 있는지는 목적지에 달려 있고, 그것이 일상에서 쓸 두 Base64 맛의 차이입니다:
const one = new Uint8Array([72]);
console.log(one.toBase64()); // "SA==" (ES2026, 패딩 포함)
console.log(one.toBase64({ omitPadding: true })); // "SA"
console.log(Buffer.from([72]).toString('base64url')); // "SA", Node는 base64url 모드에서 패딩을 뺀다
크기를 정할 때 그 비율을 기억하세요: 바이트 3개가 들어가면 문자 4개가 나오므로, 데이터 1MB는 텍스트 약 1.33MB가 되고, 이메일이나 PEM을 위해 텍스트를 줄바꿈으로 감싸면 줄바꿈이 그 위에 몇 퍼센트를 더 얹습니다.
바이트를 와이어로 건너 보내기
JavaScript에서 가장 흔한 와이어 문제는 JSON에 바이트가 없다는 것입니다. 문자열은 있지만, 어떤 JSON 파서든, 어떤 HTTP 프록시든, 어떤 로깅 시스템이든을 안전하게 건너갈 수 있는 문자열은 Base64 그것뿐입니다. 패턴은 연결의 양쪽 끝에서 같습니다: 경계에서 인코딩하고, 경계에서 디코딩하고, 그 사이에는 바이트를 쥐고 있는 것:
const fs = require('node:fs');
const photo = fs.readFileSync('./photo.jpg', 'base64');
const payload = JSON.stringify({
name: 'photo.jpg',
contentType: 'image/jpeg',
data: photo
});
console.log(payload.startsWith('{"name":"photo.jpg"')); // true, 이제 파일이 평범한 JSON 안에 탄다
이 패턴과 싸워야 할 때를 알아야 합니다. 전송 경로의 바이너리를 이미 지원한다면, 그것을 쓰세요: multipart/form-data 업로드는 크기 세금 없이 원시 파일을 보내고, WebSocket 프레임은 원시 바이트를 싣으며, Postgres bytea 컬럼은 네이티브로 저장합니다. 원시 바이트가 이미 허용되던 장소의 Base64는 순전한 오버헤드, 아무것도 남기지 않는 33퍼센트의 세금입니다. Base64가 제 값을 하는 곳은 채널이 텍스트 전용일 때입니다: JSON API, 이메일 본문, 환경 변수, URL 쿼리 문자열, 그리고 텍스트만 통과시키는 수많은 다리 (모바일 SDK, 데스크톱 앱, 채팅 시스템).
데이터 URL: 텍스트 안에서 사는 그림
데이터 URL, data:image/png;base64,... 문자열은 MIME 레이블을 쓴 Base64이며, 그것이 한 이미지 전체를 단일 HTML 속성에 넣을 수 있는 이유입니다. 이 스킴을 정의한 1998년의 RFC조차 그것이 "짧은 값에만 유용하다"고 말합니다. 초기 HTML은 속성 값에 1024자 한도가 있었으니까요. 모던 브라우저는 그 한도를 비웃으며 기꺼이 메가바이트 크기 데이터 URL을 렌더링하는데, 그것은 초능력인 동시에 함정입니다.
브라우저에서는 캔버스 API가 픽셀 들어가고 데이터 URL 나가는 전체 일을 대신해 줍니다:
const canvas = document.createElement('canvas');
canvas.width = 1;
canvas.height = 1;
const context = canvas.getContext('2d');
context.fillStyle = '#ff0000';
context.fillRect(0, 0, 1, 1);
const dataUrl = canvas.toDataURL('image/png'); // "data:image/png;base64,iVBOR..."
console.log(dataUrl.slice(0, 24)); // "data:image/png;base64,iV"
그리고 Node.js를 포함해 어떤 런타임에서든, 하나를 만드는 것은 메타데이터를 알맞은 자리에 두고 문자열을 이을 뿐입니다: data: 접두사, 미디어 타입, 임의의 ;base64 마커, 쉼표, 페이로드. ;base64 마커가 없으면 페이로드는 퍼센트 인코딩된 텍스트가 기대되는데, 마커가 존재하는 이유는 바로 그것입니다:
const { Buffer } = require('node:buffer');
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB', 'base64');
const dataUrl = 'data:image/png;base64,' + png.toString('base64');
console.log(dataUrl.startsWith('data:image/png;base64,')); // true
정직한 트레이드오프: 데이터 URL은 문서의 일부이므로, 자기 독립된 리소스로 캐싱될 수 없고, 그 안에 사는 HTML이나 CSS의 크기에 계산되며, DOM이 그것을 파싱해서 쥐고 있어야 합니다. 20킬로바이트 아이콘이라면 그게 거래입니다. 4메가바이트 히어로 이미지라면, 캐싱과 압축이 모두 동작하는 HTTP로 파일을 보내고, 데이터 URL은 작은 것들에 남겨 두세요.
문자열로 여행하는 파일
파일에서 Base64로 가는 일은, 두 런타임이 모두 한 호출로 압축해 주는 두 단계 춤입니다. Node.js에서는 파일 시스템이 읽기 인코딩으로 'base64'를 받아 주며, 쓰기 쪽도 그것을 받습니다:
const fs = require('node:fs');
const { Buffer } = require('node:buffer');
const base64 = fs.readFileSync('./report.pdf', 'base64');
console.log(base64.length); // 파일, 약 33퍼센트 더 무거워진
fs.writeFileSync('./report.pdf.b64', base64, 'utf8');
const copy = Buffer.from(base64, 'base64');
fs.writeFileSync('./report.copy.pdf', copy);
브라우저에서는 FileReader가 같은 일을 하며, 한 줄의 반전이 있습니다: 데이터 읽기 모드가 당신에게 데이터 URL을 건네 주므로, 접두사를 잘라 내면 맨 Base64 페이로드가 나옵니다:
const fileInput = document.querySelector('input[type="file"]');
fileInput.addEventListener('change', () => {
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result; // "data:application/pdf;base64,..."
const payload = {
name: fileInput.files[0].name,
data: dataUrl.slice(dataUrl.indexOf(',') + 1)
};
console.log(payload.data.length); // 파일, JSON 요청을 위한 준비 완료
};
reader.readAsDataURL(fileInput.files[0]);
});
브라우저에서 데이터 URL 접두사 없는 맨 Base64가 필요하다면, file.arrayBuffer() 뒤에 Uint8Array.toBase64() (있는 런타임에서)는 접두사를 아예 건너뛰고, 업로드 파이프라인에 더 깨끗한 경로입니다.
base64url: URL을 살아남는 알파벳
클래식한 Base64는 URL이 알레르기를 일으키는 두 문자를 싣고 있습니다. +는 쿼리 문자열이 폼 디코딩될 때마다 공백이 되고, /는 경로 구분자이며, = 패딩은 대배정처럼 보입니다. RFC 4648 5절의 URL 및 파일명 안전 변형인 base64url은 두 특수를 -와 _로 바꾸고, 길이가 컨텍스트에서 알 수 있으면 패딩을 빠뜨립니다. JWT와 OAuth 토큰, 딥 링크의 알파벳이며, 당신의 머릿속 도구함에 전용 자리가 있습니다.
Node의 Buffer는 15.7.0 버전부터 이 방언을 말해 왔고, 인코딩 쪽은 인자 하나입니다:
const { Buffer } = require('node:buffer');
const classic = 'k+XS/B4=';
console.log(Buffer.from(classic, 'base64').toString('base64url')); // "k-XS_B4"
두 가지 주목할 점. +가 -가 되고, /가 _가 되었으며, 패딩은 사라졌습니다. base64url 모드가 설계상 그것을 빠뜨리니까요. 그리고 IETF는 이것이 같은 인코딩의 변장이 아니라 다른 인코딩이라고 명시합니다. 그래서 명세가 "base64url"이라 하면, 찾아서 치환한 클래식한 Base64가 아니라 base64url을 만들어야 합니다. ES2026 메서드는 같은 선택을 명시적 옵션으로 만들고, 그 omitPadding 플래그가 컨텍스트가 요구하면 패딩을 되돌려 줍니다:
console.log(new Uint8Array([0xab, 0xff]).toBase64({ alphabet: 'base64url', omitPadding: true })); // "q_8"
console.log(new Uint8Array([0xab, 0xff]).toBase64({ alphabet: 'base64url' })); // "q_8=", 바이트 두 개는 패딩 문자 하나가 필요하다
둘 다 없는 런타임에서는, 변환은 2문자 스왑과 패딩 다듬기이며, JavaScript 세계에서 가장 많이 복사-붙여넣기되는 조각 코드 중 하나입니다:
const toUrlSafe = (value) => value.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
console.log(toUrlSafe('k+XS/B4=')); // "k-XS_B4"
URL, 쿼리 문자열, 파일명, 토큰 표준에 살 것에는 base64url을 쓰세요. MIME 본문, 데이터 URL, 그리고 퍼센트 디코더를 영원히 만나지 않을 것에는 클래식한 Base64를 쓰세요. 이 둘을 섞어 놓는 것이 이 포맷 전체에서 가장 흔한 상호 운용 버그입니다.
자격 증명을 봉인하기: Basic 인증, JWT, PKCE
웹의 인증 구석 세 곳이 Base64 위에 지어져 있고, 셋 다 한 번 손으로 만드는 데 드는 비용이 적으니, 이것은 좋은 일입니다. 라이브러리 아래에서 무슨 일이 일어나는지 아는 것이, 라이브러리가 당신을 놀라게 할 때 당신을 차분하게 지켜 주니까요.
첫째, HTTP Basic 인증 (RFC 7617): 클라이언트가 스킴 단어 Basic과 user-id:password의 Base64를 보냅니다. 한 줄이며, 한 가지 진지한 경고가 첨부되어 있습니다:
const { Buffer } = require('node:buffer');
console.log('Basic ' + Buffer.from('octo:cat').toString('base64')); // "Basic b2N0bzpjYXQ="
여기서 Base64는 난독화이지, 보안이 아닙니다. 헤더를 읽을 수 있는 사람은 비밀번호를 읽을 수 있으므로, 이 스킴은 HTTPS 위에서만 용인되며, 그래도 레거시 패턴입니다: 토큰을 선호하세요. 둘째, JWT: 첫 두 개의 점 구분 부분은 평문 JSON의 base64url이며, 세 번째는 서명입니다. HMAC-SHA256 토큰을 손으로 만드는 것은 내장 crypto 모듈의 몇 줄입니다:
const crypto = require('node:crypto');
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({ sub: 'octocat', exp: 1893456000 })).toString('base64url');
const signature = crypto.createHmac('sha256', 'topsecret').update(header + '.' + payload).digest('base64url');
const token = header + '.' + payload + '.' + signature;
console.log(token.split('.').length); // 3개 부분, 전편 패딩 없는 base64url
토큰을 성사시키기도, 무너뜨리기도 하는 디테일에 주목하세요: 어디에도 패딩이 없으며 (RFC 7515가 그것을 빠뜨립니다), 서명은 파싱된 객체가 아니라 문자열 그대로인 header + '.' + payload 위에서 계산되고, 전체는 키만큼만 비밀입니다. 프로덕션에서는 라이브러리를 씁니다. jose (의존성 제로, 브라우저와 Node.js) 또는 jsonwebtoken (Node.js) - 하지만 그것들은 이 안쪽에서 정확히 이 호출들을 실행하고 있습니다. 셋째, PKCE (RFC 7636): SPA와 모바일 앱 같은 공개 클라이언트가 안전하게 로그인할 수 있게 해 주는 확장입니다. 클라이언트가 고엔트로피 code_verifier를 생성하고, BASE64URL(SHA256(verifier))를 챌린지로 게시하며, 토큰 교환에서 검증자 소유를 증명합니다. 랜덤성이 중요하므로, 검증자는 crypto 모듈에서 오고, Math.random()에서는 절대 오지 않습니다:
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
console.log(verifier.length, challenge.length); // 43 43, 둘 다 허용 범위 43-128 안에
옛 우편은 래핑이 필요하다: MIME과 PEM 아머
세상에서 가장 오래된 Base64 포맷 두 개가 여전히 줄 길이를 강제하며, 둘 다 대략 30년 먹은 포맷입니다. RFC 2045의 이메일 표준인 MIME은 Base64를 줄당 76자에서 감싸고, 줄은 CRLF로 끝나야 하며, 이것은 8비트 클린 SMTP 시대 - 아주 긴 줄이 진짜 메일 서버를 깬 시대 - 의 유산입니다. 인증서와 키의 PEM 규칙을 적어 놓은 RFC 7468은 더 엄격합니다: 생성기는 정확히 줄당 64자에서 감싸야 하고, 마지막 줄은 짧으며, 내용 이름을 붙인 -----BEGIN과 -----END 아머 줄로 감싸입니다.
래핑 자체는 한 줄이고, 아머는 템플릿입니다:
const wrap = (base64, width) => base64.match(new RegExp('.{1,' + width + '}', 'g')).join('\r\n');
const certBase64 = Buffer.from('x'.repeat(150), 'utf8').toString('base64'); // 200자
console.log(wrap(certBase64, 76).split('\r\n').map((line) => line.length).join(', ')); // "76, 76, 48"
console.log(wrap(certBase64, 64).split('\r\n').map((line) => line.length).join(', ')); // "64, 64, 64, 8"
const armor = (label, body) => '-----BEGIN ' + label + '-----\r\n' + wrap(body, 64) + '\r\n-----END ' + label + '-----\r\n';
console.log(armor('CERTIFICATE', 'QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo='));
// -----BEGIN CERTIFICATE-----
// QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo=
// -----END CERTIFICATE-----
실용 노트 두 가지. MIME이나 PEM을 만든다면 래핑하세요. 엄격한 소비자 (메일 게이트웨이, OpenSSL 시대 도구, Java 키 스토어)는 4000자 한 줄짜리 Base64 덩어리를 거부합니다. 소비하는 쪽에서는 보통 그럴 필요가 없습니다. Node의 디코더가 공백을 건너뛰므로, Buffer.from이 줄바꿈을 당신 대신 처리합니다 - 하지만 아머 줄 자체는 Base64가 아니므로, 디코딩 전에 -----BEGIN/-----END 줄을 벗겨야 합니다 (본문만 매칭해도 됩니다): Buffer.from(pem.replace(/-----[A-Z ]+-----/g, ''), 'base64'). 그 비대칭은 선물이지만, 아무것도 건너뛰지 않는 곳 - DER 파서 같은 곳으로 Base64가 간다면, 아머 벗김 단계를 건너뛸 수 있다는 뜻은 아닙니다.
인코딩된 데이터가 사는 곳: 환경 변수, 설정, 데이터베이스
Base64는 저장 포맷이기도 하며, 그것은 편리한 동시에 위험할 만큼 쉽게 보안으로 오해됩니다. 환경 변수는 클래식한 집입니다: 여러 시크릿 매니저와 CI 시스템이 Base64로 인코딩된 값을 건네 주며, 디코딩은 한 줄입니다:
const { Buffer } = require('node:buffer');
const stored = process.env.API_KEY_B64; // "c3VwZXItc2VjcmV0"
console.log(Buffer.from(stored, 'base64').toString('utf8')); // "super-secret"
중요한 문장을 소리 내어 말하세요: 인코딩은 암호화가 아닙니다. 환경 변수, .env 파일, Kubernetes 시크릿에 있는 Base64 "시크릿" (k8s는 API와 etcd에 시크릿을 Base64로 저장하고, 문서도 그것을 계속 반복합니다)은 프로세스 환경을, 파일을, 클러스터를 읽을 수 있는 누구에게나 읽힐 수 있습니다. 거기에 Base64를 쓰는 이유는 전송 (셸, YAML, JSON)이 텍스트 전용이어서이지, 그것이 무언가를 숨긴다고 믿어서가 아닙니다.
데이터베이스에서, Base64는 JSON 문서 저장소에서 바이너리를 위한 표준 다리입니다. jsonb 컬럼이나 MongoDB 문서에는 자기만의 바이트 타입이 없으니까요:
const document = {
name: 'logo',
mime: 'image/png',
data: Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64')
};
console.log(JSON.stringify(document)); // {"name":"logo","mime":"image/png","data":"iVBORw=="}
예시가 하듯, 미디어 타입을 페이로드 옆에 저장해 두세요. 1년 뒤, 누군가 그 바이트가 무엇인지 물을 때, 당신은 스스로에게 고마워할 겁니다. 데이터베이스에 네이티브 바이너리 타입이 있다면 (Postgres bytea가 대표적인 예입니다), 그것을 선호하세요: 바이트는 아무것도 추가 비용이 들지 않고, 33퍼센트 세금은 영원히 건너뛰게 됩니다.
그룹을 깨뜨리지 않고 스트림 인코딩하기
Base64는 3바이트 그룹으로 동작하므로, 아무 청크나 받는 인코더는 나머지를 싣고 다녀야 합니다: 아직 그룹을 못 이룬 1~2바이트는 다음 청크를 기다린 뒤에야 인코딩될 수 있습니다. 청크마다 계산해서 완전한 그룹만 내보내면, 출력은 스트림 전체를 한 번에 인코딩한 것과 바이트 단위로 동일해집니다:
const { Transform } = require('node:stream');
const { Buffer } = require('node:buffer');
function base64Encoder () {
let pending = Buffer.alloc(0);
return new Transform({
transform (chunk, _encoding, done) {
pending = Buffer.concat([pending, chunk]);
const whole = Math.floor(pending.length / 3) * 3;
this.push(pending.subarray(0, whole).toString('base64'));
pending = pending.subarray(whole);
done();
},
flush (done) {
if (pending.length > 0) {
this.push(pending.toString('base64'));
}
done();
}
});
}
let output = '';
const encoder = base64Encoder();
encoder.on('data', (part) => { output += part; });
encoder.on('end', () => {
console.log(output); // "aGVsbG8gd29ybGQsIHRoaXMgaXMgYSBzdHJlYW0h"; 큰 toString('base64') 한 번과 동일
});
encoder.end(Buffer.from('hello world, this is a stream!'));
flush 콜백은 모두가 잊는 디테일입니다: 마지막 1~2바이트, 평범한 청크에서 짝을 찾지 못한 그것들이, 패딩을 받고 마지막에 밀려 나옵니다. 같은 carry 로직은 디코딩 쪽에서 당신이 거울처럼 따라 할 것이고, 거기서는 ES2026 API가 그것을 공짜로 줍니다: "stop-before-partial"를 붙인 setFromBase64()는 정확히 그룹 경계에서 멈추고, 몇 개의 문자를 소비했는지 알려 줍니다.
큰 파일과 메모리 청구서
Base64는 공간을 관대하게 쓰기 때문에, 큰 파일은 전략이 필요합니다. 1GB 파일은 Base64 텍스트 약 1.33GB가 되고, JavaScript 문자열은 UTF-16, 문자당 힙 바이트 2개를 저장하므로, 그 텍스트만으로도 Buffer가 도착하기 전에 메모리 약 2.7GB를 원합니다. 천장은 Node에서 명시적입니다: buffer.constants.MAX_STRING_LENGTH는 536870888자, 텍스트로 512MiB가 채 못 되고, 디코딩하면 바이트 약 400MB가 됩니다. 그 너머에서는 단일 문자열은 선택지가 아니며, 유일한 게임은 스트리밍입니다:
const fs = require('node:fs');
const { Buffer } = require('node:buffer');
let carried = Buffer.alloc(0);
const source = fs.createReadStream('./video.mp4', { highWaterMark: 64 * 1024 });
source.on('data', (chunk) => {
const joined = Buffer.concat([carried, chunk]);
const whole = Math.floor(joined.length / 3) * 3;
process.stdout.write(joined.subarray(0, whole).toString('base64'));
carried = joined.subarray(whole);
});
source.on('end', () => {
if (carried.length > 0) {
process.stdout.write(carried.toString('base64'));
}
process.stdout.write('\n');
});
패턴은 이전 절의 스트림 인코더를 편평하게 한 것입니다: 64KiB 청크로 읽고, 1~2바이트 나머지를 싣고 다니며, 완전한 그룹을 내보내고, 꼬리를 flush합니다. 파일 무게와 상관없이 메모리 자국은 청크 하나에 나머지 하나 정도에 머무릅니다. 그리고 받는 쪽이 바이너리를 받아들일 수 있다면, 왜 그 세금을 내는지에 대해 스스로에게 물어보세요.
터미널용 한 줄 명령
Node는 커맨드 라인 Base64 인코더도 겸합니다. 설정 값을 포장하거나, API를 디버깅하거나, 작은 파일을 채팅 메시지로 기계 사이를 이동시킬 때 유용하죠:
# 파일을 stdout으로 클래식한 Base64로 인코딩
node -e 'const fs=require("node:fs");process.stdout.write(fs.readFileSync(process.argv[1],"base64"))' notes.txt
# URL-safe 변형, 패딩은 빠졌다
node -e 'const fs=require("node:fs");process.stdout.write(fs.readFileSync(process.argv[1],"base64url"))' notes.txt
# stdin에서 읽기, 파이프가 있는 이유다
echo -n "hello world" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>process.stdout.write(Buffer.from(d,"utf8").toString("base64")))'
셋 다 자기 혼자 끝의 줄바꿈을 추가하지 않으며, 그래서 복사-붙여넣기와 셸 스크립트의 $(...) 치환에 출력이 깔끔합니다. 줄이 감긴 깔끔한 출력이 필요하면, 결과를 당신이 고르는 에디터로 파이프하거나, 한 줄 명령 끝에 \n을 추가하세요.
개발자의 시간을 갉아먹는 함정
여기 있는 것마다 진짜 코드베이스에서 진짜 오후를 먹었습니다:
- Unicode 벽:
btoa('héllo ⛳')는 골프 플래그가 바이트 하나에 안 들어가므로InvalidCharacterError를 던집니다. 해법은 UTF-8 다리: 먼저TextEncoder로 바이트, 그다음 인코딩. Node.js에서는 UTF-8을 가정한Buffer.from(text)로 문제를 아예 건너뜁니다. - 레거시 관용 표현:
btoa(unescape(encodeURIComponent(x)))로 가득한 옛 코드는 동작합니다. 하지만escape와unescape는 비추천 레거시 함수입니다. 그 코드를 리팩터링할 때TextEncoder다리로 바꾸면 동작은 동일하게 유지됩니다. - 역방향의 빠진 인코딩 인자: 디코딩 쪽 함정이 'base64' 모드 없는
Buffer.from(str)라면, 인코딩 쪽의 쌍둥이는Buffer.from(someString)이 Base64로 뭔가 특별한 일을 한다고 가정하는 것입니다. 그렇지 않습니다. 명시적 인코딩이 없으면 문자열의 UTF-8 바이트로 Buffer를 만들고, 당신의 "인코딩된" 출력은 문자열 글자 바이트의 Base64인데, 그것은 거의 언제나 원한 것이 아닙니다. 두 방향 모두에서 명시적이세요. - 패딩 불일치: JWT와 대부분의 토큰 표준은 패딩 없는 base64url을 원하고, MIME은 패딩 있는 클래식한 Base64를 원하며, 둘은 쉽게 교차합니다. JWT 부분 안의 패딩
=는 strict 검증기를 깨뜨리고, 길이가 모르는 곳의 패딩 누락은 느슨한 디코더를 깨뜨립니다. 당신의 습관이 아니라 표준과 맞추세요. - 비정규 꼬리: RFC 4648은 마지막 그룹의 미사용 패딩 비트가 0이도록 요구합니다. 내장 인코더는 전부 정규 출력을 만듭니다. 하지만 손으로 비트를 밀어 붙인 인코더는 그 비트에 쓰레기를 남길 수 있고, strict 디코더는 겉으로 이유도 없이 당신의 페이로드를 거부합니다. 자기 인코더를 쓰면, 자기 데이터뿐 아니라 RFC 4648 테스트 벡터와 시험하세요.
- 잊힌 래핑: MIME은 76자 줄을, PEM은 64자 줄을 원하고, 엄격한 소비자 (메일 게이트웨이, Java 키 도구)는 한 줄 덩어리를 거부합니다. 반대는 더 드물지만 실재합니다: 몇몇 파서는 줄 지향적이고, PEM 파일 끝에 빠진 CRLF는 Base64 자체의 어떤 버그보다 많은 빌드를 깼습니다.
- 보안의 착시: 환경 변수,
.env파일, Kubernetes 시크릿의 Base64는 암호화가 아닙니다. 어떤 언어든, 파일이나 클러스터를 읽을 수 있는 아무가, 코드로 한 줄에 디코딩합니다. 그것을 전송용 차림으로 대하고, 진짜 통제 수단 (권한, TLS, 키 로테이션)이 보호하게 두세요. - JSON 부풀림: JSON 안의 Base64는 33퍼센트에 이스케이프를 더 쓰고, 5MB 업로드는 JSON 파서가 메모리에 복사해야 하는 6.7MB 문자열이 됩니다. HTTP로 파일 크기의 것에 대해선,
multipart/form-data나 원시 바이너리 본문이 더 나은 전송이며, Base64는 채널이 텍스트 전용일 때 위한 것입니다. - 힙 청구서: 인코딩된 문자열은 JavaScript 힙에서 UTF-16, 문자당 바이트 2개이며, 디코딩되거나 소스가 된 Buffer는 데이터의 두 번째 사본입니다. 100MB 파일은 잠시 동안, 문자열 약 270MB에 Buffer 100MB를 뜻합니다. 큰 것은 스트림으로, 인코딩 형태가 참조로 남아 있는 시간은 코드가 허용하는 한 짧게.
- Node의 레거시 전역: Node의 자기 문서가
btoa()와atob()를 Stability 3, Legacy로 표시하고, 대신Buffer를 쓰라 말합니다. 브라우저에서btoa()는 ASCII 텍스트에 완벽히 좋은 도구이지만, Node.js에서는 Buffer를 잡고, 전역은 그것들이 필요한 폴리필 모양의 코드에게 맡기세요.
JavaScript가 바이트를 포장하는 법을 배운 과정
브라우저 쪽은 길고, 조용한 이야기입니다. btoa()는 2011년 초의 HTML5 초안에서 명세화되었고, 2000년대 중반 이후 모든 주요 브라우저에 자리 잡고 있으며, 동작은 바뀌지 않았습니다. 문자 하나당 바이트 하나의 계약과, 언제나 패딩 있는 출력이죠. 그 계약은 타입드 어레이보다 앞선 것입니다 - 2009년 이전에는 바이너리 문자열이 바이트를 싣는 유일한 방법이었고 - 그래서 btoa()는 아직도 "바이너리 문자열"로 생각합니다. 이야기의 근대 반쪽은 아주 최근입니다: 타입드 어레이에 네이티브 Base64 (hex와 함께)를 추가한 TC39 제안이 ES2026의 일부로 표준화되어, 2024년 Firefox 133과 Safari 18.2, 2025년 9월 2일 Chrome 140에 도착했고, 그 후 Baseline Newly available로 선언되었습니다. Bun은 2024년 8월, 버전 1.1.22에서 같은 메서드를 싣고 나왔습니다.
Node.js는 다른 시계로 바이트를 포장했습니다. Buffer 클래스는 2010년 여름, 버전 0.1.103에서 전역이 되었으니 Node 1.0보다 거의 다섯 해가량 앞서며, toString('base64')는 십여 년 동안 인코더의 선택지였고, 그 시대의 알파벳 이상함 (디코딩에서는 이미 URL-safe 문자를 받아 들여, 명세가 절대 요구한 적 없는 이중 언어 습관)을 갖고 있었습니다. 2021년 1월의 15.7.0 버전이 'base64url' 모드를 1급 인코딩 이름으로 추가했고, 같은 해의 Node 16이 브라우저의 btoa()/atob() 전역을 추가했으며 (즉시 Legacy로 표시), 2024년의 Node 22가 더 큰 V8과 base64 성능 작업을 싣고 나왔습니다. 그리고 2025년 10월 15일에 출시된 Node 25는 V8을 14.1로 올려 ES2026 메서드, omitPadding 옵션을 가진 toBase64()와 반대 방향을 위한 setFromBase64()를 런타임에 들여왔습니다. 따라가지 못하는 런타임에는, core-js가 폴리필을 싣고 (features/typed-array/to-base64 / from-base64), 작은 base64-js 패키지 (함수 3개, 의존성 제로)는 수년간 전달 의존성으로 생태계를 조용히 싣고 왔습니다.
그들이 섬기는 포맷은 그것들보다 더 오래되었습니다. 이 알파벳은 1987년 Privacy-Enhanced Mail을 위해 처음 표준화되었고 (RFC 989), 1993년 개정 (RFC 1421)이 그것을 유지했으며, MIME은 같은 해 몇 달 뒤에 76자 래핑과 함께 그것을 받아들였습니다. RFC 3548이 2003년 Base-N 가문을 하나로 묶어 URL-safe 변형을 추가했고, RFC 4648이 2006년에 그것을 다시 발행했습니다. 십 년 뒤에, RFC 7515와 7519가 패딩 없는 base64url을 모든 JWT의 뼈대가 되게 했고, RFC 7636은 그것을 OAuth의 PKCE 플로우에 넣었습니다. 이 글의 인코더들은, 서른 살이 된 포맷의 마지막 마일이며, 그 포맷은 아직도 탑승객을 모으고 있습니다.
파티에서 꺼내기 좋은 지식
btoa('GIF89a')는"R0lGODlh"를 반환합니다. GIF의 매직 헤더 전체가 여덟 글자에. 바이너리 파일이 Base64로 할 수 있는 가장 작은 "안녕"이며, 그게 바로 Wikipedia의 Web API 첫 예시인 이유입니다.toBase64()에는btoa()가 가질 수 없었던omitPadding옵션이 있습니다. Web API 계약이 무조건 패딩하기 때문이죠. 같은 알파벳 20년, 그런데 새 API만 옛 API에게는 절대 허락되지 않은 일을 하나 할 수 있습니다.- 알파벳은 하나, 공식 줄 길이는 둘: MIME은 76에서, PEM은 64에서 감쌉니다. 같은 64개 문자, 같은 패딩, 줄이 얼마나 넓을 수 있는지에 대한 두 개의 30년 된 의견.
- 33퍼센트라는 숫자는 정확합니다: 바이트 3개당 문자 4개는 4/3 비율이며, RFC 시대 이메일은 줄바꿈으로 대략 3.5퍼센트를 더 얹었습니다. 당신의 "작은" 설정 문자열은 아무것도 없이 37퍼센트 더 뚱뚱합니다.
- 작은
base64-js패키지는 npm에서 매주 1억 회가 넘는 다운로드를 끌고 오는데, 거의 전부가 다른 패키지의 의존성 나무 안에 숨어 있습니다. Base64는 JavaScript 생태계에서 가장 많이 밀수되는 코드입니다. - 작은 Buffer는 하나씩 할당되지 않습니다: Node가 공유되는 65536바이트 풀 (
Buffer.poolSize)에서 깎아 내는데, 그래서 Buffer 생성이 빠르고, 그래서 "unsafe" 할당 변형이 이전 세입자의 데이터가 상관없는 경우를 위해 존재합니다. - 1998년에 데이터 URL을 정의한 RFC는 그것이 "짧은 값에만 유용하다"고 경고하며, 1024자 HTML 속성 한도를 거론했습니다. 모던 브라우저는 같은 속성에 메가바이트 크기 이미지를 데이터 URL로 심어 주는데, 이것은 진보이기도 하고 오만이기도 합니다. 당신의 히어로 이미지에 따라요.
- Unix 비밀번호 해시는 패딩 없는 자기만의 Base64풍 알파벳을 쓰며, 헷갈리게도 순서가 스킴마다 다릅니다: 클래식한
crypt(3)해시는./0-9A-Za-z를 쓰고, JavaScript 프로젝트가 사용자 비밀번호로 저장하는$2b$bcrypt 문자열은 같은 64개 문자를./A-Za-z0-9로 대신 섞어 놓습니다. 보안 컨텍스트에서 "Base64"가 하나의 포맷이 아니라 가문임을 일깨워 주는 좋은 예입니다. - Node의 디코더는
'base64'와'base64url'모드 둘 다에서-,_,+,/를 받아들입니다. 네 문자, 하나의 표. 인코더는 물론 당신이 요청한 방언만 말합니다.
왕복의 절반
JavaScript와 Node.js에서 Base64를 인코딩하는 일은 세 가지 결정으로 압축됩니다: 어떤 바이트를 쥐고 있는지 (문자열은 캐릭터셋이 필요하고, Buffer는 필요하지 않다), 목적지가 어떤 알파벳을 요구하는지 (MIME과 데이터 URL에는 클래식, 토큰과 URL에는 base64url, 패딩은 컨텍스트에 따라 임의), 그리고 포맷이 여전히 어떤 줄 규칙을 강제하는지 (이메일은 76, PEM은 64, JSON은 없음). 그것들에 답하면 내장 기능들이 나머지를 합니다: Node에서는 Buffer.toString(), 브라우저에서는 btoa()와 UTF-8 다리, 그리고 드디어 그것을 갖게 된 모던 런타임에서는 Uint8Array.toBase64().
그리고 여기에서 봉인하는 모든 패키지는, 언젠가 누군가 열게 됩니다. 디코딩 쪽은 자기만의 함정 묶음을 가지고 있습니다: 쓰레기를 소리 없이 삼키는 관대한 디코더, atob()가 건네는 바이너리 문자열의 차림, 벽의 읽는 쪽에서 벌어지는 캐릭터셋 결정, 그리고 방금 배운 carry 패턴을 거울처럼 따르는 스트리밍 로직. 그 이야기, 모든 단계에 코드 예시와 함께, 자매 사이트의 관련 Base64 디코딩 글에서 깊이 다룹니다. 다음은 그것을 읽으세요. 알파벳 반대편의 함정들은 더 조용하며, 조용함이란 정확히 그것들이 이기는 방법입니다.
마지막 업데이트: 2026-09-08