JavaScript/Node.js에서의 Base64 디코딩: 완전한 가이드
애플리케이션으로 Base64 문자열 하나가 도착합니다. 들어오는 요청의 Authorization 헤더일 수도, JSON 페이로드 속의 필드일 수도, 데이터 URL에 숨어 있는 이미지일 수도, 설정 파일에 붙여 넣어진 인증서일 수도 있죠. 전부 같은 것입니다: ASCII 차림을 한 생짜 바이트. 이 글은 JavaScript와 Node.js에서 그 차림을 벗기는 일, 그리고 그 과정에서 단 하나의 바이트도 잃지 않는 방법에 관한 것입니다.
포맷 자체에 대해 한마디만: Base64는 입력 바이트 3개를 인쇄 가능한 문자 4개로 옮기는 텍스트 인코딩입니다. 이 사이트의 홈 페이지가 알파벳과 산수, 패딩을 온전히 설명해 주므로 여기서는 한 문장으로 끝납니다. 주머니에 넣어 둘 만한 귀결이 하나 있습니다: 인코딩된 데이터는 싣고 있는 바이트보다 약 33퍼센트 더 크다는 뜻이고, 그렇다면 디코딩은 줄어드는 작업입니다. 이 글 안의 어떤 것도 비밀을 더하거나 빼지는 않습니다. 봉인을 푸는 것이 아니라, 포장을 푸는 것입니다.
좋은 소식입니다: 설치할 것이 아무것도 없습니다. 브라우저는 이십 년째 atob()를 싣고 있고, Node.js에는 내장 base64 모드가 있는 Buffer 클래스가 있으며, 모던한 런타임에는 이제 ES2026 명세에서 온, 엄격하면서도 세팅할 수 있는 신참 Uint8Array.fromBase64()까지 실려 있습니다. 재주는 알맞은 도구를 고르는 일, 그리고 각 도구가 정확히 무엇을 용서하는지 아는 일입니다. 서버에서는 낯선 사람들로부터 오는 데이터를 디코딩하는데, 일이 꼬이기 시작하는 곳은 바로 그 용서심입니다.
디코더 고르기
디코딩 작업의 절대 다수를 세 개의 API가 책임집니다. 기질은 각기 다르며, 그 차이가 바로 전체 이야기입니다:
| 디코더 | 사용할 수 있는 곳 | 기질 |
|---|---|---|
Buffer.from(string, 'base64') |
Node.js (중요한 모든 버전) | 관대한 편: 알 수 없는 문자를 건너뛰고, 첫 번째 =에서 멈추며, 절대 예외를 던지지 않는다 |
atob(string) |
모든 브라우저, Node.js 16 이상 | 엄격한 편: 잘못된 입력이면 InvalidCharacterError를 던지고, ASCII 공백은 건너뛰며, 패딩 누락은 용서한다 |
Uint8Array.fromBase64(string) |
Chrome 140 이상, Firefox 133 이상, Safari 18.2 이상, Node.js 25 이상 | 설정 가능: 알파벳을 고르고, 마지막 청크가 얼마나 엄격해야 하는지도 정한다 |
세 API 모두 같은 클래식한 페이로드를 같은 방법으로 엽니다:
// Node.js의 일꾼
const { Buffer } = require('node:buffer');
console.log(Buffer.from('aGVsbG8gd29ybGQ=', 'base64').toString('utf8')); // "hello world"
// 레거시 쌍 (모든 브라우저, Node.js 16 이상)
console.log(atob('aGVsbG8gd29ybGQ=')); // "hello world", 바이너리 문자열로
// 모던한 ES2026 메서드 (Chrome 140 이상, Node.js 25 이상)
console.log(new TextDecoder().decode(Uint8Array.fromBase64('aGVsbG8gd29ybGQ='))); // "hello world"
atob()에 기대기 전에 경고 하나: 반환하는 것은 문자열이지만, 바이너리 문자열입니다. 각 문자가 0에서 255까지의 코드 포인트로 원시 바이트 1개를 실어 나르는 문자열이죠. 그냥 출력하는 것까지는 괜찮습니다. 하지만 JSON이나 데이터베이스, 쿠키에 보관하면 그 원시 바이트 값이 따라 함께 옮겨 다닙니다. 디코딩 직후 실제 바이트나 실제 텍스트로 바로 변환해 두세요.
관대한 디코더, 그리고 그것이 삼키는 것
Node의 Buffer는 용서하는 독자인데, 그것은 이면이 있는 칼입니다. 험한 길을 떠난 데이터에는 훌륭합니다: 줄바꿈이 있는 MIME 이메일, 손으로 복사한 문자열, 여기저기 공백이 든 로그 출력. 하지만 스스로 만들지 않은 데이터에는 위험합니다. 불평을 절대 하지 않으니까요. 실제로 벌어지는 일입니다:
| 입력 | Buffer.from(input, 'base64')가 하는 일 |
|---|---|
'!!!' |
빈 Buffer를 반환한다. 쓰레기는 전부 건너뛰고, 디코딩되는 것은 아무것도 없으며, 에러도 없다. |
'aGVsbG8== garbage' |
"hello"를 반환한다. 첫 번째 =에서 디코딩이 끝나고, 나머지는 무시된다. |
'aG!VsbG8' |
"hello"를 반환한다. 느낌표는 에러가 아니라 건너뛴다. |
'aGVs=bG8' |
"hel"을 반환한다. 문자열 중간에 온 =가 공연을 일찍 끝낸다. |
'aGVsbG8====' |
"hello"를 반환한다. 마지막에 붙은 여분의 패딩은 무시된다. |
'=aGVsbG8' |
빈 Buffer를 반환한다. 데이터보다 먼저 온 패딩은 아무 의미도 없다. |
신뢰할 수 없는 입력에 대한 해법은 검증기이며, Base64의 문법은 하나의 정규식으로 들어갈 만큼 작습니다:
const STRICT = /^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)$/;
function decodeStrict (base64) {
if (!STRICT.test(base64)) {
throw new TypeError('Not a valid base64 string');
}
return Buffer.from(base64, 'base64');
}
console.log(decodeStrict('aGVsbG8gd29ybGQ=').toString('utf8')); // "hello world"
try {
decodeStrict('aGVs!bG8');
} catch (error) {
console.log(error.message); // "Not a valid base64 string"
}
정규식이 확인하는 것은 모양입니다: 패딩이 정확한 4자 그룹. 확인할 수 없는 규칙이 하나 있는데, 그것은 RFC 4648의 정규 인코딩 규칙으로, 마지막 그룹의 미사용 패딩 비트는 0이어야 한다고 합니다. Uint8Array.fromBase64()의 strict 모드에서는 그것을 확인하므로, Node.js 25나 모던한 브라우저라면 정규식을 아예 건너뛰고 플랫폼이 감사를 하게 둘 수 있습니다:
console.log(new TextDecoder().decode(Uint8Array.fromBase64('aGVsbG8'))); // "hello", loose 모드라면 패딩 누락은 용서
try {
Uint8Array.fromBase64('QQB=', { lastChunkHandling: 'strict' });
} catch (error) {
console.log(error.name); // "SyntaxError", 패딩 비트가 0이 아니다
}
lastChunkHandling 옵션에는 알아둘 만한 세 가지 설정이 있습니다. "loose" (기본값)는 공백을 건너뛰고, 패딩 누락을 받아들이며, 남은 패딩 비트를 무시합니다. "strict"는 패딩이 끝난 완전한 마지막 그룹을 요구하며, 패딩 비트는 전부 0이어야 합니다. 그리고 "stop-before-partial"는 완전한 4자 그룹만 디코딩하고, 뒤처진 조각은 당신이 가져가도록 남겨 둡니다. 이 글의 뒤에서 보겠지만, 스트리밍 디코딩을 편하게 만들어 주는 부분이 바로 이것입니다.
바이트에서 텍스트로: 캐릭터셋의 결정
Base64를 디코딩하면 손에 바이트가 들어옵니다. 바이트가 텍스트가 되는 것은 캐릭터셋을 고를 때이며, 그 선택은 당신 것이 보통입니다. 송신자가 약속한 것에 근거해 정하죠. Node의 기본값은 대부분의 상황에서 원하는 그것입니다:
const { Buffer } = require('node:buffer');
const bytes = Buffer.from('w6k=', 'base64'); // C3 A9 바이트 두 개
console.log(bytes.toString('utf8')); // "é", 두 바이트가 한 문자로 합쳐진다
console.log(bytes.toString('latin1')); // "é", 같은 바이트를 한 문자씩 읽어 냈다
UTF-8에는 작은 꼬인 부분이 하나 있습니다. 바이트 시퀀스가 유효한 UTF-8이 아니면, Node는 예외를 던지지 않습니다. Unicode 치환 문자(U+FFFD, 물음표가 새겨진 다이아몬드)를 집어 넣고 그냥 계속 진행하는데, 그 뜻은 손상된 페이로드가 파이프라인을 그대로 건너 데이터베이스에 닿을 수 있다는 것입니다. 플랫폼의 진짜 텍스트 디코더인 TextDecoder (Node.js와 모든 브라우저의 전역)에는 fatal이라는 옵션이 있고, 손상을 잡을 수 있는 TypeError로 바꿔 줍니다:
const stray = new Uint8Array([0xe9]); // 고립된 바이트 하나, 유효한 UTF-8이 아니다
console.log(new TextDecoder().decode(stray)); // 치환 문자, 에러는 없다
try {
new TextDecoder('utf-8', { fatal: true }).decode(stray);
} catch (error) {
console.log(error.name); // "TypeError"
}
레거시 시스템은 죽지 않습니다. TextDecoder는 여전히 그것들을 읽는 법을 알고 있습니다. WHATWG 인코딩 표준의 전체 레이블 표를 받으므로, 1990년대 Windows 앱, 일본제 메인프레임, 오래된 FTP 미러에서 온 Base64 페이로도도 'windows-1250', 'shift_jis', 'euc-kr', 'gb18030' 같은 레이블로 디코딩할 수 있고, 전부 대소문자를 구분하지 않습니다. 경고가 필요한 레이블이 하나 있는데, 그 때문에 실제로 디버깅 시간을 쓴 사람이 많아서입니다: 명세는 'iso-8859-1', 'latin1', 심지어 'us-ascii'까지 Windows-1252 디코더로 별칭해 둡니다. 바이트 0x80은 진짜 Latin-1에서는 제어 문자인데, 여기서 나오는 것은 유로 기호입니다:
console.log(new TextDecoder('iso-8859-1').decode(new Uint8Array([0x80]))); // "€", 당신이 바란 Latin-1이 아니다
// 진짜 바이트 단위로 읽는 Latin-1이 필요하면 Buffer 쪽을 쓴다:
console.log(Buffer.from('gA==', 'base64').toString('latin1')); // 원시 0x80 제어 문자
정말 그 원시 매핑이 필요하다면, Buffer의 'latin1' 인코딩 (레거시 별칭인 'binary'는 Node 문서의 표현을 빌리면, 아주 오해의 여지가 많은 이름)이 Windows 우회로 없이 바이트 N을 코드 포인트 N으로 매핑해 줍니다. 근대적인 모든 것에는 UTF-8과 fatal: true가 안전한 짝입니다.
JWT 열기
JavaScript 서비스가 디코딩하는 Base64 페이로드 중 단연 가장 흔한 것은 JSON Web Token, 웹의 절반을 달리는 Authorization 헤더 안의 xxxxx.yyyyy.zzzzz 문자열입니다. RFC 7515에 따라 컴팩트 JWS는 점으로 구분된 세 부분이며, 그중 첫 두 부분은 패딩이 없는 base64url로 인코딩된 JSON 객체입니다. Node.js에서 읽는 데는 의식이 필요 없습니다. base64url 모드가 1급 인코딩이거든요:
const { Buffer } = require('node:buffer');
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJuYW1lIjoiQWRhIn0.JMjpmDdNzQZpTuUO1H33GJsj7nWhBu-qxkPD0GL2uaA';
const [head, body, signature] = token.split('.');
console.log(JSON.parse(Buffer.from(head, 'base64url').toString('utf8'))); // { alg: 'HS256', typ: 'JWT' }
console.log(JSON.parse(Buffer.from(body, 'base64url').toString('utf8'))); // { sub: '123', name: 'Ada' }
몇 번쯤 듣지만 다시 말하겠습니다: 디코딩은 검증이 아닙니다. 헤더와 페이로드는 포장만 한 것이지, 암호화한 것이 아닙니다. 토큰을 가진 누구든 둘 다 읽을 수 있습니다. 반드시 확인해야 하는 부분은 세 번째, 서명입니다. 클래식한 HMAC-SHA256 토큰이라면 전체 검증은 내장 crypto 모듈의 몇 줄에 불과하고, 미묘한 부분 하나 - 공격자가 바이트 단위 비교의 시간을 재 못하도록 timingSafeEqual로 비교하는 것 - 만 챙기면 됩니다:
const crypto = require('node:crypto');
const expected = crypto.createHmac('sha256', 'topsecret').update(head + '.' + body).digest();
const actual = Buffer.from(signature, 'base64url');
console.log(crypto.timingSafeEqual(expected, actual)); // true
console.log(crypto.timingSafeEqual(crypto.createHmac('sha256', 'wrong-secret').update(head + '.' + body).digest(), actual)); // false
진짜 서비스에서는 보통 이것을 손으로 만들지 않습니다. jose 패키지 (의존성 제로, Node.js와 브라우저, 엣지 런타임에서 동작)와 오랜 기간 쓰여 온 jsonwebtoken 패키지 (Node.js)가 그 춤을 감싸 주며, RSA와 ECDSA 알고리즘 가문을 처리하고 exp, aud, iss 클레임을 강제합니다. 어떤 라이브러리를 고르든, 그 아래 깔린 Base64 배관은 방금 본 그 두 호출과 같습니다.
HTTP: 헤더, 쿼리 문자열, 쿠키
와이어의 세 구석에는 Base64가 가득합니다. 가장 오래된 것은 RFC 7617이 정의한 HTTP Basic 인증입니다. 클라이언트가 Authorization: Basic과 함께 user-id:password의 Base64를 보내죠. 서버에서는 슬라이스 한 번과 디코딩 한 번이면 되고, 작은 프로토콜 디테일이 하나 있습니다: 사용자 이름과 비밀번호를 가르는 것은 첫 번째 콜론뿐이므로, 비밀번호에는 콜론이 여러 개 들어도 합법이지만 사용자 이름에는 안 됩니다:
const { Buffer } = require('node:buffer');
const header = 'Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==';
const credentials = Buffer.from(header.slice(6), 'base64').toString('utf8');
const [user, ...rest] = credentials.split(':');
console.log(user, rest.join(':')); // "Aladdin" "open sesame"
Basic 인증이 실제로 무엇인지 기억해 두세요: 난독화이지, 보안이 아닙니다. 자격 증명은 차림을 입고 와이어를 건너는데, 그래서 이 방식은 HTTPS 위에서만 용인될 수 있습니다. 두 번째 구석은 쿼리 문자열이며, Base64 땅에서 가장 독한 지뢰가 숨어 있습니다:
const params = new URLSearchParams('token=aGVs+bG8=');
console.log(params.get('token')); // "aGVs bG8=", 플러스가 공백이 되었다
당신의 Base64가 스스로 망가진 것이 아닙니다. URL 계층이 폼 인코딩 규칙 대신에 정중하게 해 둔 것입니다. 그 규칙은 +를 공백으로 대하죠. 그래서 쿼리 문자열에 사는 토큰은 URL-safe 알파벳을 쓰는 겁니다. 바로 아래 절에서 다룹니다. 세 번째 구석은 쿠키입니다. 쿠키는 ASCII 전용이므로, 안에 든 값이 ASCII가 아니면 거의 틀림없이 Base64이고, JSON 덩어리를 Base64로 만들어 쿠키에 넣는 옛 패턴은 놀라울 만큼 많은 프로덕션 시스템에서 살아 있습니다. 디코딩은 이미 아는 그것 그대로입니다. 다만 먼저 모양을 검증하세요. 쿠키는 사용자가, 혹은 브라우저 확장이, 쓰레기를 건네 올 수 있는 장소입니다.
파일, 이미지, 데이터 URL
Node의 파일 시스템은 Base64를 곧바로 말하므로, 파일 전체가 JSON 경계를 한 줄로 건넌습니다:
const fs = require('node:fs');
const base64 = fs.readFileSync('./photo.png', 'base64');
console.log(base64.length); // 파일, 약 33퍼센트 더 무거워진
const bytes = Buffer.from(base64, 'base64');
fs.writeFileSync('./photo.copy.png', bytes);
다른 파일 모양의 페이로드는 데이터 URL, 프론트 엔드가 인라인 이미지를 위해 사랑하는 data:image/png;base64,... 문자열입니다. 어떤 런타임에서나 레시피는 같습니다: 첫 번째 쉼표에서 자르고, 앞의 메타데이터를 파싱하고, 나머지를 디코딩합니다. 한 픽셀짜리 PNG가 살아나는 실제 예:
const dataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
const comma = dataUrl.indexOf(',');
const meta = dataUrl.slice(5, comma);
const bytes = Buffer.from(dataUrl.slice(comma + 1), 'base64');
console.log(meta); // "image/png;base64"
console.log(bytes.subarray(0, 8).toString('hex')); // "89504e470d0a1a0a", PNG 시그니처
시그니처를 확인하는 것은 값싼 습관입니다. PNG의 첫 8바이트는 언제나 89 50 4E 47 0D 0A 1A 0A이고, JPEG는 FF D8 FF로 시작합니다. 클라이언트에서 온 "Base64 이미지"가 약속한 매직 바이트로 시작하지 않으면, 아무 비싼 짓을 하기도 전에 지금 바로 알 수 있습니다.
URL-safe Base64: 토큰의 알파벳
클래식한 Base64는 +와 /를 두 개의 특수 문자로 씁니다 (RFC 4648, 4절). 둘 다 URL에서는 문제입니다: +는 폼 디코딩 과정에서 공백이 되고, /는 경로 구분자입니다. 5절의 URL 및 파일명 안전 변형 - 누구나 base64url이라 부르는 그것 - 은 그것들을 -와 _로 바꾸고, 길이가 컨텍스트에서 알 수 있으면 끝의 = 패딩을 아예 빼도 됩니다. 바로 그 조합이 JWT와 OAuth 토큰, 딥 링크가 필요로 하는 것이므로, base64url이 곧 세상에서 가장 많이 만날 알파벳입니다.
Node의 Buffer는 온통 잡음을 잠재웁니다. 'base64'와 'base64url' 디코딩 모드 둘 다 네 개의 특수 문자를 전부 받아 같은 값으로 매핑하므로, JWT 조각이든 OAuth 토큰이든 클래식한 Base64 덩어리든 어떤 문자 교체 의식도 없이 디코딩됩니다:
const { Buffer } = require('node:buffer');
console.log(Buffer.from('aGVs-bG8', 'base64').toString('hex')); // "68656cf9b1bc"
console.log(Buffer.from('aGVs+bG8', 'base64url').toString('hex')); // "68656cf9b1bc", 정확히 같은 바이트 여섯 개
ES2026 API는 의도적으로 까탈스럽고, 명시적인 다이얼로 같은 유연성을 줍니다. alphabet 옵션이 "base64" (기본값, +와 /)와 "base64url" (-와 _) 사이를 고르게 하며, 잘못 된 알파벳의 문자를 넣으면 조용한 크로스-알파벳 디코딩이 아니라 SyntaxError입니다:
console.log(Uint8Array.fromBase64('aGVs-bG8', { alphabet: 'base64url' }).length); // 6
try {
Uint8Array.fromBase64('aGVs-bG8'); // 기본 알파벳은 클래식한 것이다
} catch (error) {
console.log(error.name); // "SyntaxError", 대시는 클래식한 문자가 아니다
}
아직 새 메서드가 없는 브라우저에서는, 클래식한 알파벳만 아는 atob()에 건네기 전에 작은 스왑이 우회로입니다. 송신자가 패딩을 뺏다면 복원해야 합니다. 토큰 스타일의 페이로드에서는 이것이 보통입니다:
function decodeBase64Url (value) {
const classic = value.replace(/-/g, '+').replace(/_/g, '/');
const padded = classic + '='.repeat((4 - (classic.length % 4)) % 4);
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
console.log(new TextDecoder().decode(decodeBase64Url('aGVsbG8gd29ybGQ'))); // "hello world"
현장에 있는 Base64: 페이로드가 숨는 곳
Base64는 JavaScript 세계에서 바이트의 우편 서비스입니다. 어디에 나타나는지 한 바퀴 둘러보고, 각 정류장의 디코딩 레시피를 살펴봅니다:
- JSON API 필드 - 단연 가장 흔한 운반체: 아바타, 섬네일, 생성된 문서, 업로드가 평범한 JSON 안에서 Base64 문자열로 도착합니다. JSON에는 "이것은 바이트다"라는 말이 없으니까요. 그 필드로 다른 어떤 짓을 하기 전에 디코딩하세요.
- 환경 변수와 설정 파일: 여러 시크릿 매니저, CI 시스템, npm CLI 그 자체도 Base64 덩어리를 건네 줍니다 (오래된 npm 버전은
.npmrc에user:password의 Base64로 레지스트리 자격 증명을 저장했고, 최신 npm은_authToken에 원시 베어러 토큰을 씁니다). 시작할 때 한 번 디코딩하고, 평문은 필요할 만큼만 메모리에 두세요. - Kubernetes와 클러스터 도구들: k8s 시크릿은 악명 높게 API와
etcd에서 Base64로 인코딩되어 있고, 공식 문서도 그것이 인코딩이지 암호화가 아니라고 계속 반복합니다. 디코딩 코드는 그 결과를 안전의 증거가 아니라 시크릿으로 다뤄야 합니다. - 데이터베이스: JSON 컬럼 (Postgres
jsonb, MongoDB 문서, Redis)에 저장되는 바이너리라면 대부분 Base64 문자열입니다. 읽는 경로에서 Buffer나 Uint8Array로 디코딩하고, 데이터베이스는 텍스트 전용으로 두세요. - 이메일: 76자 줄바꿈이 있는 MIME Base64가 첨부 파일과 바이너리 헤더를 SMTP를 건너게 하는 방법입니다. SMTP는 원래 7비트 전용이었죠. Node의 디코더가 줄바꿈을 대신 건너뛰므로, 아무 정리 없이 한 번의 호출로 본문 전체가 디코딩됩니다.
- CI와 CD 파이프라인: 빌드 시스템과 시크릿 인젝터는 토큰을 Base64 환경 값으로 건네 줍니다. 파이프라인 스크립트에서 디코딩하고, 디코딩된 값을 로그에 출력하는 일은 절대 하지 마세요.
- 디렉터리와 SAML 데이터: LDIF 파일은 바이너리 속성 (예를 들어 인증서)을 Base64로 저장하고, SAML 응답은 HTTP 경계를 건너기 전에 종종 디플레이션 후 Base64 인코딩됩니다.
- 워커 스레드와 엣지 런타임: Base64 문자열은
worker_threads경계를 평범한 구조화 클로닝 가능한 문자열로 건넙니다. 그래서 무거운 디코딩은 워커에서 돌리고, 메인 스레드의 이벤트 루프는 자유롭게 둘 수 있습니다.
이중 두 정류장은 좀 더 가까이 보아 할 만합니다. 인터뷰와 프로덕션 양쪽에 모두 나오거든요:
const { Buffer } = require('node:buffer');
// 환경 변수: 시크릿이 Base64로 인코딩되어 온다
const token = Buffer.from(process.env.REGISTRY_TOKEN_B64, 'base64').toString('utf8');
// JSON API 필드: 다른 어떤 짓 전에 먼저 풀어라
const body = { attachment: 'iVBORw0KGgo...' };
const imageBytes = Buffer.from(body.attachment, 'base64');
console.log(imageBytes.subarray(0, 4).toString('hex')); // "89504e47", 다시 만난 PNG 시그니처
// MIME 이메일: 줄바꿈은 건너뛰고, 정리할 것 없다
const mimeBody = 'SGVsbG8sIHdyYXBw\nZWQgYmFzZTY0IQ==';
console.log(Buffer.from(mimeBody, 'base64').toString('utf8')); // "Hello, wrapped base64!"
이 여행에서 발견해야 할 안티패턴은 어디든 같습니다: 원시 바이트가 이미 허용되던 장소에 Base64를 넣는 것. WebSocket 프레임, 파일 스트림, Postgres bytea 컬럼 - 전부 바이트를 네이티브로 싣습니다. 그래서 거기서 Base64 왕복은 순전한 오버헤드, 대가를 보여 줄 것 없이 33퍼센트의 크기 세금만 내는 일입니다. 네이티브 바이너리 경로가 있으면, 그것을 타세요.
조각조각 디코딩하기: 스트림과 빅 데이터
Base64는 문자 4개 그룹마다 바이트 3개를 인코딩하므로, 청크의 흐름은 그룹을 반으로 갈라놓을 수 있습니다. 청크마다 디코딩해 놓고 기원하는 식의 무모한 접근은 랜덤한 경계에서 출력을 망가뜨립니다. ES2026 API는 정확히 이것을 위해 설계되었습니다: setFromBase64()는 미리 할당한 배열에 쓰고, 입력 문자 중 몇 개를 소비했는지 보고합니다. "stop-before-partial" 모드는 마지막 완전한 그룹에서 멈추게 하여, 조각은 다음 청크를 위해 남겨 둡니다. 이 패턴은 TextDecoder 스트림 API를 거울처럼 따라 갑니다:
const { Buffer } = require('node:buffer');
const chunks = ['aGVsbG8', 'gd29ybGQ='];
let leftover = '';
const parts = [];
for (const chunk of chunks) {
const pending = leftover + chunk;
const space = new Uint8Array(Math.ceil(pending.length * 3 / 4));
const { read, written } = space.setFromBase64(pending, { lastChunkHandling: 'stop-before-partial' });
parts.push(Buffer.from(space.buffer, space.byteOffset, written));
leftover = pending.slice(read);
}
parts.push(Buffer.from(Uint8Array.fromBase64(leftover)));
console.log(Buffer.concat(parts).toString('utf8')); // "hello world"
새 메서드가 없는 런타임에서는 (Node의 LTS 라인도 한동안 없었습니다), 부분 그룹을 추적하는 작은 사용자 지향 디코더로 같은 루프가 동작하거나, 단순하게 들어오는 청크를 그룹 경계에서 나눌 수 있을 때까지 버퍼링합니다. 중요한 아이디어는 carry입니다: 조각을 홀로 디코딩하지 마세요.
큰 페이로드는 두 가지 한계를 더 당신의 앞에 세웁니다. 첫째, 문자열 그 자체입니다: Node의 buffer.constants.MAX_STRING_LENGTH는 536870888자, 텍스트로 대략 512MiB이며, 디코딩하면 바이트 약 400MB가 됩니다. 그보다 큰 "Base64 파일"은 readFileSync 한 번이 아니라 스트리밍 접근이 필요합니다. 둘째, 메모리입니다: 인코딩된 문자열은 UTF-16으로 JavaScript 힙에, 문자당 바이트 2개를 차지하며, 디코딩된 Buffer는 데이터의 두 번째 사본입니다. 큰 페이로드는 잠시 동안 둘 다 손에 쥐고 있게 되므로, 코드가 허용하는 한 인코딩 형태를 가능한 짧게만 살아 있게 두세요. 파일 크기의 것에는 스트림을 선호하세요.
터미널에서
Node는 제대로 된 커맨드 라인 Base64 디코더도 겸합니다. 요청을 디버깅하거나 설정 값을 살필 때 유용하죠:
# 인수로 넘어온 클래식한 Base64 문자열 디코딩
node -e 'console.log(Buffer.from(process.argv[1], "base64").toString("utf8"))' "aGVsbG8gd29ybGQ="
# URL-safe 변형, 패딩은 임의
node -e 'console.log(Buffer.from(process.argv[1], "base64url").toString("utf8"))' "aGVsbG8gd29ybGQ"
# stdin에서 디코딩, 파이프가 있는 이유다
echo -n "aGVsbG8gd29ybGQ=" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>console.log(Buffer.from(d.trim(),"base64").toString("utf8")))'
세 가지 모두 hello world를 출력합니다. 기계에 coreutils의 클래식한 base64 명령도 함께 있다면, base64 -d로 같은 일을 합니다. 다만 Node 버전은 전통 도구가 모르는 base64url을 압니다.
JavaScript 억양을 가진 함정들
여기 있는 것마다 누군가의 JavaScript 또는 Node.js에서의 잃어버린 오후였습니다:
- 침묵하는 디코더:
Buffer.from('!!!', 'base64')는 에러가 아니라 빈 Buffer를 반환합니다. 반쯤 망가진 입력은 경고 한 줄 없이 반쯤 망가진 데이터로 디코딩됩니다. 신뢰할 수 없는 입력은 strict 정규식 (또는 strictfromBase64모드)으로 검증하고, 비어 있지 않은 문자열에서 나온 빈 Buffer는 빨간 깃발로 대하세요. - 빠진 인코딩 인자: 두 번째 인자 없는
Buffer.from('aGVsbG8=')은 아무것도 디코딩하지 않습니다. 그 글자들의 UTF-8 바이트로 Buffer를 만들 뿐이므로, 당신의 "디코딩된" 데이터는 문자 그대로 그 글자 자체가 바이트로 다시 묶인 것입니다.'base64'인자가 바로 트릭의 전부입니다. - 바이너리 문자열의 차림:
atob()의 출력은 당신이 그렇게 말하기 전까지는 텍스트가 아닙니다. JSON 응답이나 쿠키, 로그 한 줄에 집어 넣으면 "동작"하는데, 모든 null 바이트도 그대로 보존되므로, 로그 수집기와 직렬화기를 동등하게 놀라게 합니다.charCodeAt()로 Uint8Array로, 혹은 즉시 UTF-8 텍스트로 변환하세요. - 쿼리 문자열 속의 플러스: 폼 디코딩된 쿼리 값 안의
+는URLSearchParams가 당신에게 건넬 때 이미 공백입니다. URL에 사는 것에는 base64url을 선호하고, 클래식한 Base64 토큰을 이스케이프 없이 쿼리 문자열에 붙여 넣는 일은 하지 마세요. - 치환 문자: 유효하지 않은 UTF-8은 에러가 아니라, Buffer의 UTF-8 모드에서는 조용한 다이아몬드-물음표로 변합니다. 그래서 손상된 페이로드가 파이프라인을 지나 데이터베이스에 닿을 수 있습니다. 손상이 큰 실패가 되어야 하는 곳에는
TextDecoder에fatal: true를 켜 두세요. - Windows 우회로:
TextDecoder에게'iso-8859-1'이나'latin1'을 달라고 하면, Windows-1252 디코더가 나옵니다. 거기서 바이트 0x80은 유로 기호가 됩니다. 진짜 바이트 단위 Latin-1이 필요하다면,toString('latin1')으로 Buffer를 읽어 내세요. 그리고'binary'가 같은 Latin-1 매핑의 오해의 여지 있는 별칭일 뿐임을 기억하세요. - 크기의 천장: 64비트 시스템에서
buffer.constants.MAX_LENGTH는 9007199254740991바이트 (2의 53승에서 1을 뺀 것)이지만, Base64를 싣는 문자열은 536870888자의MAX_STRING_LENGTH를 넘을 수 없습니다. 그래서 단일 문자열이 담을 수 있는 디코딩된 데이터는 400MB가 약간 넘는 것이 한계이며, 그 너머는 스트림으로 가야 합니다. - 메모리 청구서: Base64 문자열은 문자당 힙 바이트 2개 (UTF-16)를 쓰며, 디코딩된 Buffer는 완전한 두 번째 사본입니다. 100MB 파일은 잠시 동안, 당신의 프로세스 안에서 문자열 약 133MB에 Buffer 100MB가 됩니다. 인코딩 형태가 참조로 살아 있는 창을 줄이세요.
- 엄격한 원격, 관대한 로컬의 불일치: 당신의 Node 디코더는 어딘가의 strict 디코더가 거부하는 것을 용서합니다 (Python 스크립트, Go 서비스, 모바일 앱). 시스템의 한쪽이 엄격하고 다른 쪽이 관대하면, 버그는 특정한 페이로드 길이에서만 등장하는데, 그것이 바로 가장 나쁜 종류의 버그입니다. 엄격도는 머릿속이 아니라 프로토콜 레벨에서 합의하세요.
JavaScript가 디코더를 키워 온 과정
브라우저 쪽은 길고, 지루하고, 믿음직한 역사를 가졌습니다. atob()과 btoa()는 2011년 초의 HTML5 초안에서 명세화되었고 (브라우저가 명세보다 먼저 갖고 있었습니다), 그 후 모든 주요 브라우저에 자리 잡고 있으며, 동작은 십여 년간 바뀌지 않았습니다. 이 둘은 언어 표준의 타입드 어레이 (ES2015)보다 앞선 것이므로, 바이트가 아니라 "바이너리 문자열"로 말합니다.
Node.js는 다른 시간표로 디코더를 키웠습니다. Buffer 클래스는 2010년 여름, 버전 0.1.103에서 전역이 되었으니 Node 1.0보다 무려 다섯 해가량 앞서며, 처음부터 'base64' 모드를 싣고 있었습니다. Node의 생애 대부분 동안 거기 있는 유일한 디코더가 바로 그것이었죠. 그리고 웹 표준의 물결이 도착합니다: 2021년의 Node 16이 atob()과 btoa()를 전역으로 추가하여, 브라우저용 코드가 폴리필 없이 서버에서도 돌게 해주었는데, 둘 다 첫날부터 Legacy로 표시되어 있었습니다. 2025년 10월 15일에 출시된 Node 25는 V8을 14.1로 올려 ES2026 메서드, Uint8Array.fromBase64(), setFromBase64()와 그 형제인 hex 메서드들을 런타임에 들여옵니다. 그 과정에서 낡은 new Buffer() 생성자는 Buffer.from(), alloc(), allocUnsafe() 대신에 비추천 처리되었습니다 (Node 10이 2018년에 경고를 시작했지요). 초기화되지 않은 할당이 그 자리에 이미 있던 어떤 메모리든 새어 흘릴 수 있었기 때문이기도 합니다.
브라우저에서는 같은 물결이 조금 일찍 도착했습니다: Firefox 133과 Safari 18.2가 2024년에 새 메서드를 싣고, Chrome 140 (2025년 9월 2일 안정화)이 그 세트를 완성하자, 그 기능은 브라우저 벤더들의 Baseline 프로그램에서 Baseline Newly available로 선언되었습니다. 올인원 JavaScript 런타임인 Bun은 2024년 8월, 버전 1.1.22에서 그것들을 받았습니다. 그리고 최근 런타임을 요구할 수 없는 곳에는, core-js와 es-shims 프로젝트의 es-arraybuffer-base64 패키지가 전부 위한 폴리필을 싣는데, 대부분의 프레임워크가 내부적으로도 이 길을 택합니다.
그들이 섬기는 포맷은 그보다도 오래된 계보가 있습니다. 이 알파벳은 1987년 Privacy-Enhanced Mail을 위해 처음 표준화되었고 (RFC 989), 1993년 개정 (RFC 1421)이 같은 알파벳을 유지했으며, MIME이 그 개정 약 3년 뒤에 1996년에 (RFC 2045) 76자 줄바꿈과 함께 그것을 받아들였습니다. 2003년의 RFC 3548은 base16, base32, base64를 하나의 문서로 묶었고, 2006년의 RFC 4648은 그것을 다시 발행하며, RFC 3548이 추가한 URL-safe 알파벳을 유지했습니다 - 십 년 뒤에 모든 JWT 안에 들어갈 바로 그 알파벳입니다. URL-safe 변형은 좋은 썰거리입니다: 토큰을 처음 만나기 전, 2001년 피어투피어 식별자에 대한 메일링 리스트 게시물에서 제안된 것이었으니까요.
다음 스탠드업에 쓸 재미있는 사실들
- WebSocket RFC의 예시 키
dGhlIHNhbXBsZSBub25jZQ==는 "the sample nonce"라는 단어들로 디코딩됩니다. 표준화위원회가 자기 예시 안에 눈웃음을 숨겨 두었고, Node의atob()는 그 농담을 한 번의 호출로 푸네요. Buffer.from('!!!', 'base64')는 길이가 0인 Buffer를 반환합니다. 안에 아무것도 없는 진짜 할당. 아무것도. Node가 어깨를 으쓱하는 데 가장 가까운 것입니다.- Node의 Base64 디코더는 명세가 절대 요구한 적 없는 방식으로 이중 언어를 구사합니다:
+,-,/,_는'base64'와'base64url'모드 둘 다에서 환영받으며, 각 쌍은 같은 값으로 매핑됩니다. atob()의 Node 문서에는 "대신 Buffer.from(data, 'base64')를 쓰세요"라는 문장이 들어 있습니다. 자기 전역 함수 중 하나를 쓰지 말라 말하는 런타임인데, 이주를 대신해 줄 공식 코덱모드 (npx codemod@latest @nodejs/buffer-atob-btoa)까지 동봉되어 있습니다.- 작은 Buffer들은 공유 슬래브에서 깎여 나옵니다:
Buffer.poolSize는 65536바이트이고, 모든 작은 할당은 그 풀의 청크를 재사용합니다. Buffer 생성이 빠른 이유이며, "unsafe" 할당이라는 표현의 의미를 알아 두어야 하는 이유이기도 합니다. - 작은
base64-js패키지, 함수 3개에 의존성 제로 - npm에서 매주 1억 회가 넘는 다운로드를 끌고 오는데, 거의 전부가 다른 패키지 안의 숨겨진 의존성으로 들어갑니다. Base64는 생태계에서 가장 많이 밀수되는 코드입니다. Uint8Array.fromBase64()에는"stop-before-partial"이라는 모드가 있습니다. 오직 4자 그룹을 갈라놓지 않고 스트림을 디코딩하기 위해 존재하는 것이지요. 자신이 거부하는 일의 이름으로 명명된 모드는 희귀한 API의 시입니다.- Unix 비밀번호 세계는 자기만의 Base64풍의 알파벳을 쓰고, 패딩은 없으며, 헷갈리게도 전부 같은 순서가 아닙니다. 클래식한
crypt(3)의 "hash64" 알파벳은./0-9A-Za-z인데, bcrypt는 같은 64개 문자를./A-Za-z0-9로 대신 섞어 놓습니다. 사용자 비밀번호를 위해 많은 JavaScript 프로젝트가 저장하는$2b$해시에서 bcrypt 버전을 만나게 되는데, 이것이 보안 컨텍스트에서 "Base64"가 두 개가 아니라 여러 다른 알파벳을 뜻할 수 있는 이유입니다.
남은 한 방향
JavaScript와 Node.js에서 Base64를 디코딩하는 일은 정직한 도구 세 개의 스택입니다: Buffer.from(string, 'base64') - 두 알파벳 모두를 받아들이고 들쭉날쭉한 문자를 전부 건너뛰는 관대한 일꾼, strict 정규식으로 지키는 편이 가장 좋습니다; 어떤 캐릭터셋으로든 옛 웹이 만들어 낸 진짜 텍스트를 위한 TextDecoder, 손상이 아프게 느껴져야 할 때는 fatal 모드와 함께; 그리고 바이트 우선 코드가 strict 알파벳과 strict 패딩 비트, 곡예 없는 스트리밍을 원할 때를 위한 새로운 Uint8Array.fromBase64(). 캐릭터셋을 확정하고, 낯선 이들이 보내는 것을 검증하고, 서명을 timingSafeEqual로 비교하면, 브라우저/서버 갈림의 양쪽에서 그 포맷은 더 이상 미스터리가 아니게 됩니다.
그리고 포장을 다 풀고 나면, 그 포장을 누군가 묶어 두어야 했다는 것을 기억하세요. 인코딩 쪽은 자기만의 함정들이 있습니다: btoa()를 문장 중간에서 멈추게 하는 Unicode 벽, MIME 줄 래핑, base64url 패딩 규칙, 그리고 omitPadding 옵션을 가진 새로운 Uint8Array.toBase64(). 그 이야기, 모든 단계에 코드 예시와 함께, 자매 사이트의 관련 Base64 인코딩 글에서 깊이 다룹니다. 다음은 그것을 읽으세요. 알파벳 반대편의 함정들은 더 다르고, 더 재미있으니까요.
마지막 업데이트: 2026-09-07