JavaScript/브라우저에서의 Base64 디코딩: 완전한 가이드
십여 가지 다른 차림으로 찾아옵니다. Authorization 헤더에 밀어 넣어둔 JWT, JSON 응답 안에 있는 image/png 블롭, 핸드셰이크 로그 속 Sec-WebSocket-Accept 값, MIME으로 감긴 이메일 첨부 파일, 백엔드가 정중하게 쿼리 문자열에 밀어 넣은 값이 모두 그렇습니다. 문자열 자체는 언제나 같은 얼굴입니다. 알파벳과 숫자가 길게 이어지고, 가끔 +나 /가 끼며, 끝에는 =가 한두 개 붙어 있을 수도 있죠. 이 사이트의 홈 페이지가 Base64가 무엇인지 - 바이트 3개마다 인쇄 가능한 문자 4개가 대신 서고, 마지막 그룹은 = 패딩으로 마무리 - 가르쳐 주었다면, 이 글은 실제로 코드로 하는 일에 대한 것입니다. 브라우저가 이미 싣고 다니는 것만으로, 그 문자를 다시 바이트로, 그리고 바이트를 다시 의미로 바꾸는 일 말입니다.
시작 전에 빠른 기본 규칙 두 가지만. 첫째, 디코딩은 줄어드는 방향입니다. 읽어들인 문자 4개마다 바이트 3개가 나오므로, 출력은 언제나 입력보다 적은 메모리를 씁니다. 둘째, 디코딩된 Base64 문자열은 자동으로 텍스트가 아닙니다. 그것은 바이트이며, 그 바이트는 UTF-8일 수도 있고, Windows-1252일 수도 있고, PNG 헤더일 수도 있고, 암호화 서명이기도 합니다. Base64 코드에서 가장 흔한 버그는 지금 손에 쥔 것이 그것 중 무엇인지 잊어버리는 것이므로, 아래 절들은 그 질문을 중심으로 배치했습니다.
디코딩의 세 가지 레이어
모던 브라우저는 네이티브 레이어 세 가지를 줍니다. 좋은 소식은 어떤 패키지라도 필요 없다는 것입니다. 각 레이어는 조금씩 다른 질문에 답하며, 알맞은 것을 고르면 여기저기 복사해 붙여 넣은 Stack Overflow 조각 코드에서 벗어날 수 있습니다:
| 레이어 | 먹는 것 | 건네주는 것 | 기질 | 사용 가능 범위 |
|---|---|---|---|---|
atob() |
표준 Base64 문자열 | "바이너리 문자열" (문자 하나당 바이트 1개) | 매우 관대: ASCII 공백은 건너뛰고, 패딩이 빠져도 수용 | 2000년대 이후 모든 브라우저, IE 10 이상, Node 16 이상 |
TextDecoder |
바이트 (Uint8Array) |
읽을 수 있는 JavaScript 텍스트 | 구성 가능: 캐릭터셋을 지정하는 레이블, 엄격도를 정하는 fatal 플래그 |
Firefox 18, Chrome 38, Safari 10.1 이상 (IE는 없음) |
Uint8Array.fromBase64() |
Base64 문자열과 옵션 | 진짜 Uint8Array |
엄격하지만 조절 가능: 알파벳과 마지막 청크 처리 | Baseline 2025: Chrome 140, Firefox 133, Safari 18.2, Node 25 |
이 글 전체의 형상은 바로 그 표에서 비롯됩니다. atob()는 옛 코드에서까지 어디서나 만나게 되는 일꾼이고, TextDecoder는 바이트에서 단어로 넘어가는 다리입니다. 그리고 Uint8Array.fromBase64()는 애초에 바이트만 원했다면 중간 단계를 아예 건너뛰게 해 주는 2025년 업그레이드죠.
atob: 빠르고, 관대하고, 아주 오래된
전체 계약은 한 줄에 담긴다: atob(encodedData). Base64로 인코딩된 문자열을 받아 "바이너리 문자열"을 반환한다. 각 문자가 디코딩된 바이트를 정확히 1개씩, 0에서 255까지의 코드 포인트를 가진, 평범한 JavaScript 문자열이다. 이 반환 타입이 중요한 이유는, 읽을 수 있는 텍스트와 같은 것이 아니기 때문이다 (그 이야기는 아래에서). 함수 자체는 빠를 수 있는 한 빠르고, 아주 오랜 세월 동안 존재해 왔다: Chrome 4, Firefox 1, Safari 3, 그리고 - 대부분이 기억하는 바로 그것 - Internet Explorer는 10 버전부터만 지원했다. 그래서 2012년 이전에 작성된 코드에는 손으로 만든 Base64 테이블이 가득하다.
atob()를 쓸 만하게 만드는 것은, 포기하기 전까지 얼마나 많이 용서해 주느냐다. WHATWG HTML 표준은 디코딩 전에 모든 ASCII 공백 - 공백, 탭, 줄바꿈, 폼 피드, 캐리지 리턴 - 을 무시하라고 규정한다. 그래서 76자마다 줄바꿈이 있는 MIME 랩핑 문자열은 당신 쪽에서 아무 정리 작업 없이 디코딩된다. 패딩이 빠져 있는 것도 용서받는다. 다만 알파벳 밖의 문자를 보거나, 절대 유효할 수 없는 길이를 보는 순간, InvalidCharacterError라는 이름의 DOMException을 던진다. 조용히 나오는 쓰레기도, 중간 결과도 없다.
손상 보고서, 한 줄씩 살펴보자:
| 입력 | 결과 |
|---|---|
"SGVsbG8sIFdvcmxkIQ==" |
"Hello, World!" - 교과서의 경우 |
"aGVsbG8" (패딩 없음) |
"hello" - 빠진 =는 용서 |
"SGVs\nbG8s\nIFdvcmxkIQ==" (줄바꿈이 있는 랩핑) |
"Hello, World!" - ASCII 공백이 먼저 건너뛴다 |
"" (빈 문자열) |
"" - 빈 입력은 유효하며 왕복 성립 |
"A" (남은 문자 하나) |
InvalidCharacterError 발생 - 문자 하나로는 아무것도 인코딩할 수 없다 |
"Zm9vYmFy!" (혼입된 !) |
InvalidCharacterError 발생 - 알파벳 밖 |
"ZGFua29nYWk-" (URL-safe 문자가 섞임) |
InvalidCharacterError 발생 - 두 알파벳은 섞어서는 안 된다 |
"Zm9v====" (패딩 과다) |
InvalidCharacterError 발생 - 끝의 =는 2개까지 |
실용적인 참고 하나: 오류 메시지 자체는 엔진마다 다르다 (Firefox는 "String contains an invalid character"라고 하고, Chrome은 비-Latin1 입력에 대해 문자열이 "contains characters outside of the Latin1 range"라고, 유효하지 않은 base64에 대해 "is not correctly encoded"라고 한다). 그러므로 메시지 텍스트가 아니라 예외 이름으로 잡아야 한다.
생짜 바이트에서 진짜 텍스트로
그 "바이너리 문자열" 반환 타입은 한 걸음 멈춰서 볼 만하다. 디코딩 혼동의 대부분이 여기서 나오기 때문이다. JavaScript 문자열은 UTF-16이므로, atob()가 건네는 문자열의 문자는 읽을 수 있는 글자가 아니라 바이트 값이다. 페이로드가 텍스트 "hello 你好"의 UTF-8 인코딩이라면, 결과를 그대로 출력하면 모지바케가 된다. 해결책은 두 단계 디코딩이다: Base64에서 바이트로, 그리고 바이트에서 텍스트로.
먼저 Base64에서 바이트로 가는 단계. 이 작은 헬퍼가 바로 클래식한 레시피로, 주머니에 넣어 둘 가치가 있다. 이 글의 예제 대부분은 이 부품 위에 세워져 있기 때문이다:
function base64ToBytes (base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
다음은 TextDecoder를 이용한 바이트에서 텍스트로 가는 단계. UTF-8의 경우 (기본값이며, JSON, JWT 페이로드, 웹 데이터 대부분에 맞는 선택지), 호출은 한 줄이다:
const bytes = base64ToBytes('aGVsbG8g5L2g5aW9');
const text = new TextDecoder('utf-8').decode(bytes);
console.log(text); // "hello 你好"
왜 두 단계인가? atob()는 그 바이트가 어떤 캐릭터셋으로 만들어졌는지 알 수단이 없기 때문이다. 이것은 순전한 비트 변환기다. TextDecoder가 바이트를 캐릭터셋으로 해석하는 컴포넌트이며, 그 용무를 위해 레이블을 받는다: utf-8, windows-1252, iso-8859-1, utf-16le, 그리고 220여 개의 다른 레이블. 1990년대 애플리케이션에서 나온 데이터는 보통 Windows-1252이며, 필요한 것은 생성자 인자 하나뿐이다:
const decoder = new TextDecoder('windows-1252');
const text = decoder.decode(bytes); // 같은 바이트, 다른 해석
TextDecoder 생성자는 fatal 플래그도 받는데, 디코딩된 텍스트가 중요한 곳에 흘러 들어갈 때는 true로 두는 것이 좋다. 기본값에서는 디코더가 관대하다: 유효하지 않은 바이트 시퀀스는 Unicode 치환 문자 U+FFFD로 조용히 대체되고, 당신에게 알려 주는 것은 아무것도 없다. fatal: true라면, 같은 손상은 숨기는 대신 TypeError를 던진다:
const strict = new TextDecoder('utf-8', { fatal: true });
try {
strict.decode(corruptedBytes);
} catch (error) {
console.log(error.name); // "TypeError"
}
이것은 문서에서는 사소해 보이지만 프로덕션에서는 데이터 사고처럼 보이는 그런 스위치다. 입력이 사용자가 제공하는 것이거나 네트워크에서 오는 것이라면, 엄격하게 디코딩하고 오류는 의도적으로 처리해야 한다.
URL-safe 입력은 우회로가 필요하다
Base64의 한 변형은 단독 섹션을 받을 만하다. 실제로는 끊임없이 출몰하지만 atob()은 읽지 못하기 때문이다. RFC 4648 5절의 URL 및 파일명 안전 알파벳, 보통 base64url이라 불리는 그것이다: 같은 64개 문자인데, +와 / 대신 -와 _를 쓰며, 데이터 길이는 암묵적으로 알려져 있으므로 = 패딩은 흔히 생략된다. 이 교체에는 구체적인 이유가 있다: URL에서는 +가 공백을 의미하고 /는 경로 세그먼트를 시작하므로, 표준 알파벳이라면 문자 하나하나를 퍼센트 인코딩해야 한다. Base64url은 쿼리 문자열, 경로 세그먼트, 프래그먼트, 파일명 속으로 깨끗하게 지나간다.
문제는 두 알파벳이 서로 대체될 수 없다는 점이고, atob()는 표준 알파벳만 안다. -나 _를 주면 InvalidCharacterError를 받는다. 깔끔한 선택지는 두 가지다.
첫 번째 옵션, 어디에서나 동작한다: atob()를 호출하기 전에 알파벳을 변환하고 패딩을 복원한다:
function fromUrlBase64 (segment) {
let s = segment.replace(/-/g, '+').replace(/_/g, '/');
const missing = (4 - (s.length % 4)) % 4;
return atob(s + '='.repeat(missing));
}
console.log(fromUrlBase64('aGVsbG8')); // "hello"
(4 - (s.length % 4)) % 4 표현식이 트릭의 전부다: 그 길이의 잘 패딩된 문자열이 몇 개의 = 문자가 필요한지, 0에서 최대 2까지 계산해 준다.
두 번째 옵션, 2025년 이후 브라우저용: 새로운 네이티브 디코더가 알파벳을 옵션으로 받으므로, 문자열 수술은 아예 없다:
const bytes = Uint8Array.fromBase64('P3-0', { alphabet: 'base64url' });
console.log(Array.from(bytes).join(', ')); // "63, 127, 180"
두 가지 규칙이 골탕을 피워 준다. 하나의 값 안에서 알파벳을 섞지 마라 - +와 -를 모두 보는 디코더는 어느 집안 문자를 읽고 있는지 알 방도가 없고, 명세상 올바른 동작은 실패하는 것이다. 그리고 패딩이 있는지를 와이어 반대편의 상대와 합의하라: base64url에서 패딩을 뺀 것은 합법이라, 수신 쪽은 두 형태 모두를 준비해 두어야 한다. atob()는 이미 준비되어 있고, 아래 네이티브 옵션은 그에 대한 조절 스위치를 준다.
2025년 단축로: Uint8Array.fromBase64
base64ToBytes 헬퍼를 되돌아보면, 두 가지를 하고 있다는 것이 보인다: Base64를 디코딩하고, JavaScript로 문자를 하나씩 바이트 배열에 복사하는 것이다. 그 복사 루프가 느리고 피할 수 있는 부분이며, 새로운 ECMAScript 메서드가 정확히 제거하는 것이 그것이다. Uint8Array.fromBase64(string, options)는 인코딩된 문자열에서 바이트 배열로 바로 직행하며, Chrome 140, Edge 140, Firefox 133, Safari 18.2, Node 25, Deno 2.5에 탑재되었다. 이 종류의 첫 JavaScript 플랫폼 기능으로, 브라우저 벤더들의 Baseline 프로그램에서 Baseline Newly available로 표시되었다.
옵션 객체에는 조절 스위치가 두 개 있다. 첫 번째는 alphabet: "base64" (기본값) 또는 "base64url". 두 번째는 lastChunkHandling으로, 문자의 마지막 불완전 그룹에 무슨 일이 일어나는지를 제어한다:
| 모드 | 마지막 청크의 규칙 |
|---|---|
"loose" (기본값) |
문자 2~3개, 또는 패딩을 가진 4개; 남은 오버플로 비트는 무시 |
"strict" |
문자 정확히 4개 (길이가 요구할 때만 패딩), 오버플로 비트는 전부 0이어야 함 |
"stop-before-partial" |
완전한 4자 그룹만 디코딩; 불완전한 꼬리는 읽지 않은 채로 남김 |
atob()처럼, 이 메서드도 입력의 ASCII 공백을 무시하므로 줄바꿈이 있는 랩핑도 문제없다. atob()와 달리 나머지에 대해서는 단단한 기질을 보인다: 선택된 알파벳 밖의 문자, 혹은 선택한 모드를 위반하는 마지막 청크는 SyntaxError를 던지며, 문자열이 아닌 것을 넘기면 TypeError를 던진다. 아래는 strict 모드의 실제 모습으로, 패딩이 빠진 청크를 거부한다:
const ok = Uint8Array.fromBase64('SGVsbG8=', { lastChunkHandling: 'strict' });
try {
Uint8Array.fromBase64('VR', { lastChunkHandling: 'strict' });
} catch (error) {
console.log(error.name); // "SyntaxError"
}
성능도 그것을 선호할 또 다른 이유다. 저자의 머신에서 최신 Firefox를 기준으로, 10 메가바이트 페이로드를 디코딩하는 데 fromBase64는 1자리 밀리초가 걸리고, 클래식한 atob에 문자 단위 바이트 매핑을 더하면 약 20배 오래 걸린다. 느린 부분은 Base64 계산이 아니라 JavaScript 레벨의 루프이기 때문이다. 데이터가 바이트라면, 문자열을 아예 건너뛰자.
오래된 브라우저에서는 상황이 간단하다: 위의 base64ToBytes 헬퍼를 유지하거나, 어디서든 새 스타일 코드를 쓰고 싶다면 작은 폴리필을 끌어오자 (core-js와 es-shims 프로젝트의 es-arraybuffer-base64 패키지 모두 fromBase64용 폴리필을 싣고 있다). API는 안정적이다 - 이미 ECMAScript 명세에 들어갔으니까 - 따라서 그 위에 작성한 것은 비표준 처리되지 않을 것이다.
JWT 읽기
애플리케이션 로그에서 가장 흔한 "수수께끼 문자열"은 JSON Web Token이다: 점으로 구분된 세 개의 세그먼트, header.payload.signature, 여기서 앞의 두 개는 base64url로 인코딩된 JSON 객체다. 하나를 디코딩하는 것은 5줄이면 되는 일이며, 지금까지 배운 모든 것의 완벽한 예습이다:
function jwtSegmentToBytes (segment) {
let s = segment.replace(/-/g, '+').replace(/_/g, '/');
s += '='.repeat((4 - (s.length % 4)) % 4);
return base64ToBytes(s);
}
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
const [header64, payload64] = token.split('.');
const payload = JSON.parse(new TextDecoder().decode(jwtSegmentToBytes(payload64)));
console.log(payload.name); // "John Doe"
그리고 여기서, 입문자는 건너뛰고 프로덕션 시스템은 고생 끝에 깨닫는 부분이 온다: 페이로드가 디코딩된다 해서 검증된 것은 아니다. 누구든 원하는 페이로드로 JWT를 쓸 수 있으며, 그것을 시크릿에 묶어 두는 것은 서명 세그먼트다. 브라우저에서 HS256 토큰을 검증하려면 Web Crypto API를 쓰는데, 서명을 바이트로 필요로 한다 - 세그먼트를 바이트로 바꾸는 헬퍼가 제값을 하는 또 다른 이유다:
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode('shared-secret'),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const [h, p, sig64] = token.split('.');
const valid = await crypto.subtle.verify(
'HMAC',
key,
jwtSegmentToBytes(sig64),
encoder.encode(h + '.' + p)
);
console.log(valid); // 서명이 시크릿과 일치할 때만 true
이름 붙여 줄 만한 함정이 세 개 있다. 첫째, 검증 전에 헤더를 확인하라: alg: "none"라고 주장하는 토큰은 서명 없이 페이로드를 믿어 달라고 요구하는 것이며, 순진한 코드는 정확히 그렇게 속아 왔다. 둘째, 시간 클레임 - exp, nbf, iat - 은 검증 전에 아니라 검증 후에 존중해야 한다. 셋째, 클래식한 키 혼동 공격: RS256으로 설정되어 있으면서도 HS256을 받아 주는 서버에서는, 공격자가 공용 키(의도적으로 공개된 것)를 HMAC 시크릿으로 써서 토큰에 서명할 수 있다. 한마디로: 자유롭게 디코딩하고, 아무것도 믿지 말고, 모든 것을 검증하라.
데이터 URL 열기
데이터 URL은 URL 안에 파일을 통째로 넣는다: data:, 선택적인 미디어 타입, 선택적인 ;base64 플래그, 쉼표, 그리고 페이로드. 텍스트 페이로드는 퍼센트 인코딩이고, 바이너리 페이로드는 Base64이며, 브라우저는 HTTP 요청 하나 없이 그것들을 렌더링한다 - fetch도, 서버 왕복도, 캐시할 것도 없다. 브라우저는 각 데이터 URL을 고유하고 불투명한 오리진으로 대우하며, 이것이 바로 얌체 콘텐츠의 애호한 채널인 이유이기도 하다: iframe에서 여는 data:text/html 문서는 그 스크립트를 실행하며, 제한적인 Content-Security-Policy는 데이터 URL을 아예 막을 수 있다. 이것들을 사용자가 제어하는 마크업에 건네기 시작한다면, CSP를 기억해 두라.
하나를 디코딩하는 것은 대부분 문자열 수술이고, 그다음은 앞과 같은 바이트 파이프라인이다:
const url = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADgQFY/fWoOgAAAABJRU5ErkJggg==';
const comma = url.indexOf(',');
const meta = url.slice(5, comma); // "image/png;base64"
const bytes = base64ToBytes(url.slice(comma + 1));
const blob = new Blob([bytes], { type: 'image/png' });
const objectUrl = URL.createObjectURL(blob);
meta 조각이 미디어 타입을 알려 준다 (여기서는 image/png, ;base64 마커가 페이로드가 Base64임을 확인시켜 준다). 페이로드가 Blob이 되면, 평소의 모든 것이 적용된다: <img>용 오브젝트 URL, 다운로드 링크, 혹은 서버로 보내기. 데이터 URL 루트의 유일한 실질적 비용은 크기다 - 페이로드는 원본 파일보다 약 33퍼센트 더 큰 상태로 얹혀 있다 - 그리고 큰 이미지를 URL에 넣는 것은 페이지의 문자열 한계에 부담을 줄 수 있다. 파일이 브라우저에서 나올 필요가 없는 경우, 이것이 오브젝트 URL을 지지하는 또 다른 투표다.
텍스트로 오는 파일 디코딩하기
파일이 브라우저에 오는 길은 두 가지다. 근대적인 길은 생짜 바이트다: ArrayBuffer으로 읽는 fetch, 혹은 피커에서 받은 File을 file.arrayBuffer()로 읽는 것. 그 길에 있다면 축하한다 - Base64는 그림에도 없고, 그 길에 머물러 있어야 한다. 바이트는 싣고 다니는 대가가 전혀 없지만, Base64는 그 자격으로 대역폭과 메모리의 추가 3분의 1을 청구한다. 다른 길은 채널이 텍스트 전용일 때다: {"attachment": "data:application/pdf;base64,JVBERi..."}를 돌려 주는 JSON API, 이메일 첨부 파일, 설정 문자열, 데이터베이스 컬럼의 값. 그때 Base64가 프로토콜이 되며, 당신의 일은 그저 바이트를 꺼내는 것뿐이다:
async function loadRemoteBytes (fileUrl) {
const response = await fetch(fileUrl);
return new Uint8Array(await response.arrayBuffer());
}
const record = JSON.parse(await (await fetch('/api/record/42')).text());
const pdfBytes = base64ToBytes(record.attachment.split(',')[1]);
그 스니펫에 대해 세 가지 노트. 첫 번째 쉼표에서 나누는 것만으로 데이터 URL 헤더를 벗길 수 있다 (미디어 타입은 쉼표를 포함할 수 없으므로, 첫 번째 쉼표가 언제나 구분자다). 그리고 값이 데이터 URL 접두어 없는 평범한 Base64라면, 나누기를 건너뛰면 된다. 마지막으로, 그 맨 앞에 놓인 await는 최상위 await이며, 브라우저는 모듈 안에서만 그것을 허용한다. 그래서 그 스니펫에는 <script type="module"> 태그, 혹은 그 두 줄을 감싸는 async 래퍼가 필요하다. 이메일 MIME 부분은 단계가 몇 개 더 있는 같은 이야기다: 첨부 본문은 1줄 76자로 랩핑된 Base64인데, atob()가 공백을 건너뛰므로, 원시 메시지에 도착한 그대로 랩핑된 텍스트를 넘겨 주면 된다 - 언랩핑은 필요 없다. 그 행동 하나가 조용히 정규표현식 한 다발을 절약해 준다.
WebSocket 핸드셰이크 검증하기
브라우저에서의 디코딩 중 매력이 큰 용도 중 하나는 WebSocket 핸드셰이크 자체를 확인하는 일이다. RFC 6455는 클라이언트가 Sec-WebSocket-Key 헤더(무작위 바이트 16개, Base64 인코딩)를 보내고, 서버가 Sec-WebSocket-Accept로 답하도록 요구한다: 고정된 매직 GUID에 키를 이어 붙인 것의 SHA-1 해시를, Base64 인코딩한 값. 값이 일치하지 않으면 핸드셰이크가 실패하고, 연결은 업그레이드되지 않는다. 이 의식의 취지 전부는, HTTP만 말하는 서버가 우연히 이를 완성하지 못하게 하는 데 있다 - 매직 GUID는 그 계산이 의도적으로 과하게 복잡해 보이도록 존재하는 것이다. 게다가 브라우저에는 해싱도 인코딩도 다 있으니까, 기대하는 답을 스스로 계산할 수 있고, 그래서 프록시와 게이트웨이의 디버깅은 한 줄이 된다:
const MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
async function expectedAccept (clientKey) {
const digest = await crypto.subtle.digest(
'SHA-1',
new TextEncoder().encode(clientKey + MAGIC)
);
return btoa(String.fromCharCode(...new Uint8Array(digest)));
}
const accept = await expectedAccept('dGhlIHNhbXBsZSBub25jZQ==');
console.log(accept); // "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
그 마지막 줄은 우연이 아니다 - RFC의 정확한 예시로, 바이트 하나하나까지 그대로 재현한 것이다. 게이트웨이가 다른 것으로 답한다면, 이제 방정식의 어느 쪽이 거짓말하는지 정확히 알게 된다.
HTTP 헤더와 쿼리 문자열
헤더는 ASCII여야 하므로, Base64는 HTTP 헤더에서 인기 많긴 하다. 가장 유명한 경우가 Basic 인증이다: Authorization: Basic의 뒤를 username:password의 Base64 인코딩이 따른다. 그런 헤더를 읽는 것은 (예를 들어, 요청이 무엇을 싣고 다니는지 표시하면서) 나누기 한 번, 디코딩 한 번이다:
const header = 'Basic YWxpY2U6c2VjcmV0MTIz';
const [user, ...rest] = atob(header.slice(6)).split(':');
const password = rest.join(':');
console.log(user, password); // "alice secret123"
스프레드 후 다시 이어 붙이는 패턴은, 비밀번호에 콜론이 들어간 불편하지만 합법적인 경우를 처리한다. 분할 지점이 언제나 사용자 이름 뒤의 첫 번째 콜론이기 때문이다. 헤더가 구조화된 값을 밀수하는 모든 곳에 같은 패턴이 적용된다: Proxy-Authorization, 어떤 벤더 전용 헤더, 간간이 보이는 쿠키. 쿼리 문자열과 딥 링크에서는, 서버 없이 상태를 공유하고 싶은 앱이 있을 때 Base64가 등장한다: OAuth state 값, 복원된 검색 폼, "중단한 자리에서 이어가기" 마커. 방어적으로 디코딩하라 - 값은 네트워크 경계를 건넜고 무슨 일이든 겪었을 수 있으니, try/catch로 감싸라 - 그리고 얻은 것을 신뢰할 수 없는 입력으로 취급하라 - 그 이상도 이하도 아니다.
이제 모든 터미널 위에 붙여 두어야 할 문장에 다다랐다: Base64는 암호화가 아니다. "복호화"가 이 세상의 모든 언어가 구현하는 함수 호출 한 번인데, 어떤 실질적 의미에서조차 오블리페이션이 아니다. 값이 비밀로 남아야 한다면, 먼저 Base64 인코딩하는 것은 그것을 더 안전하게 만드는 것이 아니라 덜 안전하게 만든다 - 그것은 비밀의 환상을 만들고, 원본을 원하는 이에게 정확히 한 개의 자명한 단계만을 추가할 뿐이다.
URL과 스토리지에 있는 상태
같은 논리는, 페이지 새로 고침이나 공유 링크를 견뎌야 하는 모든 것에 확장된다. 늘 용의자 명단에 오르는 것들: 구조화되거나 바이너리 데이터를 싣는 localStorage와 sessionStorage의 값, 싱글 페이지 앱의 라우팅 상태를 위한 URL의 해시 프래그먼트, 빌드 도구가 페이지에 박아 넣는 설정 블롭. 스토리지 이야기는 구체적인 예 하나를 값할 만하다. 읽는 쪽은 기억해 두어야 할 쓰는 쪽과 짝이 되기 때문이다:
const raw = localStorage.getItem('profile');
const profile = JSON.parse(new TextDecoder().decode(base64ToBytes(raw)));
마음에 두어야 할 것이 세 가지다. 첫째, 예산: 브라우저는 오리진마다 localStorage로 대략 5 메가바이트를 주며, 저장한 Base64 문자열은 원본 데이터보다 약 33퍼센트 더 차지하므로, 3.5 메가바이트 파일이 조용히 4.6 메가바이트의 저장 용량이 된다 - 게다가 그 문자열은 페이지가 열려 있는 동안 메모리에서 UTF-16으로 살며, 점유 공간을 또 두 배로 만든다. 둘째, 일치: 양쪽이 같은 캐릭터셋으로 인코딩하고 디코딩해야 한다. 그렇지 않으면 완벽히 좋은 바이트를 저장해 두고, 읽어 올 때는 모지바케를 보게 된다. 셋째, 공유 링크: 상태가 URL을 타고 간다면, 값이 복사-붙여넣기를 견뎌야 하므로 URL-safe 알파벳을 쓰고, 짧게 유지하라. 몇 천 자를 넘어가는 URL 길이는 오래된 클라이언트와 로깅 도구를 긴장시키기 시작한다.
데이터가 조각조각 도착할 때
때로는 Base64가 하나의 문자열로 오지 않는다: WebSocket 메시지의 경계가 그것을 반으로 자르고, 서버 전송 이벤트 스트림이 조금씩 떨어뜨리고, 청크 업로드가 한 번에 몇 킬로바이트씩 먹여 준다. 조각에 atob()를 호출할 수 없다. Base64의 그룹은 3바이트 단위를 4문자 블록으로 표현하는 것이기 때문에, 그룹을 중간에서 자르면 불완전한 꼬리가 남는다. 올드스쿨의 해결책은, 4의 배수가 될 때까지 문자를 버퍼에 쌓아 두고 버퍼를 조각조각 디코딩하는 것이었다. 2025년 API가 이것을 깔끔하게 만든다: Uint8Array.prototype.setFromBase64(string, options)는 디코딩된 바이트를 기존 배열에 쓰고, 숫자 두 개 - read(소비한 문자 수)와 written(생성된 바이트 수) - 를 가진 객체를 돌려 준다. lastChunkHandling: "stop-before-partial"이면 완전한 그룹만 디코딩하고 불완전한 꼬리는 읽지 않은 채로 두는데, 이것은 스트림 디코더가 원하는 행동과 정확히 같다:
const parts = [];
let carry = '';
for (const piece of incomingPieces) {
let pending = carry + piece;
for (;;) {
const room = new Uint8Array(8);
const result = room.setFromBase64(pending, {
lastChunkHandling: 'stop-before-partial'
});
parts.push(room.subarray(0, result.written));
pending = pending.slice(result.read);
if (result.read === 0) {
carry = pending;
break;
}
}
}
const size = parts.reduce((sum, part) => sum + part.length, 0);
const bytes = new Uint8Array(size);
let at = 0;
for (const part of parts) {
bytes.set(part, at);
at += part.length;
}
const text = new TextDecoder().decode(bytes);
내부 루프를 천천히 읽자. 그것이 전부 패턴이므로: 이월된 나머지와 새 조각을 넣고, 디코더가 들어맞는 만큼의 완전한 그룹을 소비하게 하고, result.read개 문자를 잘라내어 얼마가 남았는지 기억한다. 완전한 것이 더 이상 남지 않았을 때 (result.read === 0), 나머지를 새 carry로 저장하고 다음 조각을 기다린다. Uint8Array(8)는 그저 스크래치 버퍼다 - 문자 4개의 한 그룹은 최대 3바이트를 만들므로, 8은 넉넉하다. 마지막에 carry는 스트림이 끝내 완성하지 못한 것을 가지고 있으며, 그것은 당신의 오류 신호이거나, "연결이 깨끗하게 끝났다"는 검사다.
Base64를 디코딩하지 않아야 할 때
좋은 참고서는 도구를 내려놓을 때를 가르쳐 준다. 채널 양쪽을 모두 통제한다면, 대신 생짜 바이트를 잡으라: 다운로드는 fetch에 response.arrayBuffer(), 피커 파일은 file.arrayBuffer(), WebSocket은 ArrayBuffer 페이로드, 업로드는 multipart FormData. 그것들은 Base64에 손도 대지 않으며, 크기 세금도, 문자열을 메모리에 얹는 부담도 없이 데이터를 전체 속도로 얻는다. Base64가 제값을 하는 곳은 정확히 채널이 텍스트 전용일 때다: JSON 본문, 쿼리 문자열, 이메일, 스토리지, 레거시 API, 그리고 계약서가 "ASCII 아니면 없다"고 말하는 모든 것. 바이트 하나로 충분한 순간, Base64 문자열은 인쇄 가능한 특권을 위해 33퍼센트 할증을 내고 있으며, 그 할증은 대역폭과 메모리, CPU로 청구된다 - 세 장의 청구서, 전부 피할 수 있는 것들이다.
흔한 디코딩 함정
모든 순탄한 경로 뒤에, 이것이 물리는 방식들의 목록을 대략 만날 순서대로 정리한다:
atob()의 결과를 텍스트로 취급하는 것. 그것은 바이너리 문자열이다.TextDecoder를 거치면 텍스트가 되고, 그대로 출력하면 모지바케가 된다. 이 혼동 하나로 대부분의 "Base64가 동작하지 않는다" 보고가 생긴다.- Unicode가 알아서 되기를 기대하는 것. "你好"의 바이트는 잘 디코딩되지만, 디코더가 그것이 UTF-8이라고 알려줄 때까지 여전히 바이트다. 양쪽이 같은 캐릭터셋으로 인코딩하고 디코딩해야 한다.
- base64url을
atob()에 먹이는 것.-나_하나면 예외를 던진다. 먼저 알파벳을 변환하거나, 알맞은 옵션을 가진fromBase64를 쓰자. - 길다고 Base64라고 믿는 것. 유효하고 패딩이 있는 Base64 문자열의 길이는 4의 배수이며 (패딩 없는 base64url은 2나 3에서 끝날 수 있다), 알파벳은 고작 하나를 쓴다. 4로 나눈 나머지가 1인 길이는 즉시 실패다 - try/catch에 돈을 쓰기 전에 그것을 검사하자.
- 합의하지 않은 패딩을 믿는 것. 어떤 시스템은
=를 떼고, 어떤 시스템은 유지하며, 어떤 시스템은 랩핑된 문자열의 중간, 있어야 할 곳이 아닌 곳에 그것을 붙인다. 보내는 쪽과 합의한 뒤, 관대하게 (atob) 갈지 엄격하게 (fromBase64) 갈지 정하자. - 관대한 디코더의 조용한 손상. 기본
TextDecoder는 유효하지 않은 바이트를 U+FFFD로 바꿔 놓고 아무 말도 하지 않는다. 데이터가 중요하다면fatal: true를 설정하자. - Base64가 무언가를 보호한다고 생각하는 것. 그것은 그렇지 않다. 직렬화 포맷이며, 평문에서 함수 호출 한 번 거리다. "사용자가 못 읽게 Base64로 인코딩해 둔다"는 보안 태세일 뿐, 통제 수단이 아니다.
- 메모리를 잊는 것. 디코딩된 바이너리 문자열 1메가바이트는 UTF-16 문자열로 2메가바이트를 차지하는 반면, 같은 데이터의
Uint8Array는 1메가바이트를 차지한다. 큰 페이로드는fromBase64로 직행하자. - 렌더링할 때마다 다시 디코딩하는 것. 몇 메가바이트를 디코딩하는 것은 빠르지만, 공짜는 아니다 - 프레임당 한 번씩 할 일도 아니다. 한 번 디코딩하고, 바이트를 캐시에 두고, 캐시에서 렌더링하자.
성능 노트
짧은 버전: 네이티브 디코더는 빠르고, 옛 코드의 느린 부분은 보통 Base64 자체가 아니라 그 주변의 JavaScript다. 중요한 크기에서 그림은 같다: 10 메가바이트 페이로드는 Uint8Array.fromBase64로 1자리 밀리초 만에 디코딩되며, atob 단독은 몇 배 느리고, 문자를 바이트 배열로 매핑하는 클래식한 후속 루프는 같은 입력에 대해 fromBase64보다 약 20배 오래 걸린다. 메인 스레드에서 약 1,300만 번의 프로퍼티 쓰기를 하기 때문이다. 실질적 함의: 청중에게 그것이 있다면 fromBase64를 선호하고, 없다면 atob 헬퍼를 유지하라; 루프에서 문자열을 이어서 바이트 배열을 만드는 일은 결코 하지 마라; 그리고 거대한 페이로드를 처리해야 한다면, 디코딩된 Uint8Array를 Web Worker에 넘기는 것을 생각하라 - 바이트는 복사 없이 전송되고, 메인 스레드는 UI를 초당 60프레임으로 유지할 수 있게 자유로워진다. 그리고 산술의 방향을 기억하라: 디코딩은 줄이므로, 디코딩된 버퍼는 그것이 온 문자열보다 언제나 적은 메모리를 쓴다. 디코딩 때문에 메모리가 터지는 일은 결코 없고, 문자열과 바이트 둘 다를 필요 이상으로 오래 붙잡아 두어서야 메모리가 터진다.
브라우저에서의 디코딩, 짧은 역사
Base64는 근대 웹의 대부분보다 오래됐지만, 브라우저 디코더의 이야기는 아는 가치가 있다. 생태계에 유물이 넘치는 이유를 설명해주기 때문이다. atob와 그 형제 btoa는 이제 그것들을 덮고 있는 명세보다 오래됐다: WHATWG HTML 표준은 2011년 2월에야 그것들을 정의했는데, 오랫동안 정립된 브라우저 동작을 역공학해 표준으로 흡수한 것이다. 엔진들은 어쨌든 일찍 싣고 나왔다: Firefox는 2004년 버전 1부터, Safari 3, Chrome 4. Internet Explorer는 2012년 IE 10까지 그것들을 아예 건너뛰었으니, 2012년 이전의 JavaScript는 손으로 만든 Base64의 박물관이다 - 조회 테이블, String.fromCharCode 체조, 그리고 Unicode를 위한 악명 높은 unescape(encodeURIComponent()) 주문. 언어에서는 비권장 처리됐으면서도, 순수한 관성으로 브라우저에서 10년을 버틴 함수 쌍이다. 그리고 캐릭터셋 레이어가 왔다: Encoding 표준의 TextEncoder와 TextDecoder가 2013년과 2017년 사이(Firefox 18, Chrome 38, Safari 10.1, IE는 어떤 버전에서도 없음)에 도착해, 플랫폼이 마침내 바이트를 단어로 바꾸는 원칙 있는 방법을 갖게 했다. Node.js는 2021년 버전 16까지 atob나 btoa를 전역으로 가지지 못해, 그 이전 생을 Buffer와 npm 심(Shim) 두 개로 보냈다. 그리고 마침내 고리가 닫혔다: Firefox 133(2024년 11월)과 Safari 18.2(2024년 12월)가 먼저 Uint8Array.fromBase64, toBase64와 친구들을 싣고 나왔고, 2025년 후반에 Chrome 140(9월)과 Node 25(10월 중순)가 도착해 Baseline 프로그램이 그것들을 Newly available로 표시하면서 세트를 완성했다. 언어 자체 - 웹 플랫폼이 아니라 - 가 Base64를 내장한 최초의 순간이었다. 수십 년 된 포맷이 막상 언어의 표준 라이브러리 기능이 됐고, 다음 10년의 코드는 헬퍼를 이리저리 복사하는 일을 멈출 수 있게 됐다.
재미있는 사실들
- 존재하는 가장 빠른 "이거 정말 Base64인가?" 테스트는
string.length % 4 === 0다. 모든 유효하고 패딩이 있는 Base64 문자열은 통과하고, 나머지는 낯선 이다. atob('')은''를 반환한다. 빈 문자열은 바이트가 없는 유일한 입력이며, 전체 파이프라인을 깨끗하게 왕복한다 - 특수 케이스는 결코 필요 없다.- WebSocket의 매직 GUID
258EAFA5-E914-47DA-95CA-C5AB0DC85B11은 RFC에 굽어 들어간 고정 값으로, 평범한 HTTP 서버가 우연히 핸드셰이크를 완성하지 못하도록 고른 것이다. 프로토콜 엔지니어링에서 아무도 생성해 본 적 없는 가장 유명한 상수다. - Chrome과 Firefox는 같은 실패에 같은 예외를 던지지만, 메시지는 다르다. 메시지 문자열이 아니라
error.name으로 잡지 않으면, 당신의 오류 처리에 브라우저 발음이 붙는다. - 바이너리 문자열 1메가바이트는 메모리에서 2메가바이트의 무게를 지닌다. JavaScript 문자열은 UTF-16이므로, 디코딩된 바이트 하나마다 쓰이지 않은 여유 바이트 하나가 동승하기 때문이다.
Uint8Array에는 그런 세금이 없다. - "Data URI"는 은퇴한 이름이다. WHATWG는 대대적인 URI-URL 조화의 일환으로 그것을 "data URL"로 이름을 바꾸었는데, 그래서 명세와 포스트, 패키지 이름에서 두 표기를 모두 마주치게 된다.
- RFC 4648은 테스트 벡터 표를 싣고 있다 - "f", "fo", "foo", "foob", "fooba", "foobar"와 친구들, 각각 알려진 인코딩을 가진 - 디코더 작성자들이 20년 동안 그것과 대조해 왔다. 당신의 디코더가 그 행들을 통과한다면, 거의 확실하게 올바른 것이다.
- 컴퓨팅 역사에서 가장 많이 생산된 Base64 문자열은 거의 확실하게
aGVsbG8=, "hello"의 인코딩이다. 지구상의 모든 "시작하기" 튜토리얼, 테스트 스위트, Stack Overflow 답변이 자신의 한 표를 던진다.
마무리
그러니 브라우저에서의 디코딩이라는 전 공예가 한 페이지에 담긴다: 빠르고 관대하며 범용인 디코딩에는 atob(), 바이트를 실제로 원하는 단어로 바꾸는 데에는 TextDecoder (데이터가 중요하다면 fatal: true), 문자열을 아예 건너뛰는 근대적이고 엄격하며 빠른 길에는 Uint8Array.fromBase64. 그 사이에서 변형들도 이름과 규칙을 가진다: URL을 여행하는 모든 것에는 base64url, 있기도 하고 없기도 한 패딩, 옛 디코더가 조용히 삼키는 공백. 그리고 그것들 아래에는 두 가지 자세가 있다: 바이트는 텍스트가 아니고, 텍스트는 비밀이 아니다. 의도적으로 디코딩하고, 믿기 전에 검증하며, 채널이 허락할 때 Base64를 건너뛰고 바이트를 받으라.
여정의 다른 한 편 - 당신의 바이트와 텍스트를 모든 것이 시작되었던 인쇄 가능한 문자열로 바꾸는 일 - 은 JavaScript에서의 Base64 인코딩 동반 가이드에서 자세히 다룬다. 아래에 링크되어 있다.
마지막 업데이트: 2026-09-08