Base64가 푸는 문제
이메일 본문, JSON 문자열, HTTP 헤더, URL, HTML 속성, XML처럼 텍스트를 전제로 설계된 통로가 많습니다. 임의의 바이트는 이런 통로를 온전히 통과하지 못합니다. 0x00 바이트가 문자열의 끝으로 해석될 수 있고, 0x7F를 넘는 바이트는 문자셋 변환에서 바뀔 수 있으며, 줄바꿈이 끼어들거나 사라지기도 합니다. RFC 4648에 규정된 Base64는 이 통로들이 모두 그대로 전달하는 64개 문자만 써서 문제를 피합니다.
대가는 크기이고, 얻는 것은 안정성이지 비밀 유지가 아닙니다. Base64에는 키도 난수도 비밀도 없습니다. 같은 바이트를 되돌릴 수 있게 다시 표기한 것이며, 변환표 전체가 한 쪽에 들어갑니다.
3바이트가 4글자가 되는 과정
알고리즘은 입력을 3바이트(24비트)씩 읽어서, 이 24비트를 6비트짜리 네 덩어리로 자릅니다. 6비트는 0부터 63까지의 값을 담을 수 있고, 각 값은 64글자 알파벳의 인덱스가 됩니다. 3바이트는 항상 4글자가 되므로 출력 길이는 4의 배수입니다.
ASCII 텍스트 Man의 전체 변환 과정입니다. 실제로 실행해서 확인했습니다.
text M a n
hex 0x4D 0x61 0x6E
bits 01001101 01100001 01101110
regroup 010011 010110 000101 101110
index 19 22 5 46
output T W F u -> TWFu64글자 알파벳
RFC 4648 4절의 표준 알파벳은 대문자, 소문자, 숫자, 기호 두 개 순서입니다. 65번째 문자인 =는 패딩에만 쓰이며 64개 값에 포함되지 않습니다. 순서는 ASCII 순서(숫자, 대문자, 소문자)와 다르므로 Base64 문자열을 정렬해도 원래 바이트의 순서가 유지되지 않습니다.
| 인덱스 범위 | 문자 | 예시 |
|---|---|---|
| 0 ~ 25 | A ~ Z | 19는 T |
| 26 ~ 51 | a ~ z | 46은 u (26 + 20) |
| 52 ~ 61 | 0 ~ 9 | 52는 0, 61은 9 |
| 62 | + | 이진수 111110 |
| 63 | / | 이진수 111111 |
| 패딩 | = | 값이 아님, 마지막 묶음을 채움 |
패딩 규칙: M, Ma, Man으로 따라가기
입력 길이가 3의 배수가 아니면 마지막 묶음이 24비트보다 짧습니다. 인코더는 비트가 6비트 단위로 딱 나눠질 때까지 0을 덧붙이고, 출력 길이가 4의 배수가 될 때까지 =를 붙입니다. 남는 바이트가 1개면 두 글자에 ==, 2개면 세 글자에 =가 됩니다.
아래 세 예시는 Node의 Buffer와 브라우저 함수 btoa로 각각 인코딩해서 두 결과가 일치하는 것을 확인했습니다. 앞의 두 줄에서 덧붙인 0비트가 패딩이 생기는 유일한 이유입니다.
- 입력 길이를 3으로 나눈 나머지가 0이면 패딩이 없습니다.
- 나머지가 1이면 두 글자와
==입니다. - 나머지가 2이면 세 글자와
=입니다.
M 0x4D 01001101
regroup 010011 01|0000 (0 네 개 추가)
indexes 19 16
output TQ==
Ma 0x4D 0x61 01001101 01100001
regroup 010011 010110 0001|00 (0 두 개 추가)
indexes 19 22 4
output TWE=
Man 0x4D 0x61 0x6E (24비트, 패딩 없음)
output TWFu크기 증가를 정확히 계산하기
입력 3바이트가 출력 4글자가 되고 ASCII에서 한 글자는 1바이트이므로, n바이트의 인코딩 길이는 4 x ceil(n / 3)입니다. 비율은 4/3, 즉 약 33.3% 증가에 수렴하고, 짧은 입력에서는 패딩 때문에 조금 더 커집니다. 1,000바이트는 1,336글자가 되고, 1,048,576바이트(1 MiB)는 1,398,104글자로 33.4% 커집니다. 이 값들은 실제 인코더 출력과 대조해서 확인했습니다.
거꾸로 디코딩된 바이트 수는 floor(L x 6 / 8)입니다. L은 패딩을 뺀 글자 수입니다. TQ==의 데이터 글자는 2개로 12비트이므로 1바이트, TWE=는 3개로 18비트이므로 2바이트입니다. 남는 비트(앞의 경우 4개, 뒤의 경우 2개)는 정규 인코딩(RFC 4648 3.5절)에서는 0이어야 하지만, atob를 포함한 대부분의 디코더는 이를 무시합니다. atob('TR==')도 atob('TQ==')와 같은 M을 돌려줍니다.
| 입력 바이트 | Base64 글자 수 | 비율 |
|---|---|---|
| 1 | 4 | 4.000 |
| 3 | 4 | 1.333 |
| 57 | 76 | 1.333 |
| 1,000 | 1,336 | 1.336 |
| 100,000 | 133,336 | 1.333 |
| 1,048,576 | 1,398,104 | 1.334 |
Base64URL과 JWT가 이것을 쓰는 이유
+와 /는 주소에서 문제를 일으킵니다. 쿼리 문자열에서 +는 공백으로 읽힐 수 있고 /는 경로 구분자입니다. RFC 4648 5절은 +를 -로, /를 _로 바꾼 URL·파일명 안전 알파벳을 정의합니다. 패딩 =도 URL에서 골칫거리라서, 이 변형을 쓰는 프로토콜은 대개 패딩을 생략합니다. 받는 쪽이 길이로 잘려 나간 패딩 개수를 계산할 수 있기 때문입니다.
바이트 FB FF FE가 차이를 보여 줍니다. 표준 Base64는 +//+, Base64URL은 -__-입니다. JSON Web Token(RFC 7519)은 모든 세그먼트에 패딩 없는 Base64URL을 써서, 토큰을 HTTP 헤더, 쿠키, URL에 그대로 넣을 수 있습니다. 예를 들어 JWT 헤더 {"alg":"HS256","typ":"JWT"}는 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9로 인코딩되며, 일반적인 헤더를 쓰는 HS256 토큰은 모두 이 문자열로 시작합니다. 이 사이트의 JWT 디코더가 이런 세그먼트를 읽어 줍니다.
data: URI와 성능이 나빠지는 경우
data URI(RFC 2397)는 파일을 참조 안에 직접 넣습니다. data:image/svg+xml;base64, 뒤에 인코딩된 바이트가 붙는 형태입니다. HTTP 요청을 하나 줄일 수 있어서 이메일 템플릿, 단일 파일 HTML, 아주 작은 아이콘이 필요한 CSS 규칙에서 편리합니다.
비용도 분명합니다. 압축 전에는 바이트가 33% 늘고, 그 바이트가 포함된 문서 안에 들어 있어서 브라우저가 이미지를 따로 캐시하지 못합니다. HTML이나 CSS 파일이 바뀔 때마다 이미지도 다시 받습니다. 스타일시트에 큰 data URI를 넣으면 스타일시트 자체가 커지고, CSS는 로드가 끝날 때까지 렌더링을 막습니다. 다만 압축은 바이트 증가분의 대부분을 되돌립니다. 한 번 실행한 결과, 무작위 10,000바이트는 Base64로 13,336글자가 되었고 gzip을 거치면 10,070바이트였습니다. 원본 바이트를 gzip한 값은 10,023바이트였습니다. 그러니 전송량은 핵심 문제가 아니고, 캐시와 렌더링 차단이 문제입니다.
SVG 같은 텍스트 형식은 Base64 없이 퍼센트 인코딩한 data URI가 나을 때도 있습니다. 96글자짜리 작은 SVG 원은 Base64로 128글자, 헤더를 붙인 data URI가 154글자였고 encodeURIComponent로 퍼센트 인코딩하면 189글자였습니다. 이 경우에는 Base64가 더 짧았습니다. 따옴표 규칙을 손으로 다듬으면 더 줄어들 수 있으니 자기 파일로 직접 재 보세요.
- 잘 맞는 경우: 1~2KB 안팎의 아주 작은 자산, 일회용 페이지, 이메일, 추가 요청이 불가능한 환경.
- 맞지 않는 경우: 사진, 여러 페이지에서 공유하는 자산, 따로 캐시하고 지연 로드하고 싶은 이미지.
btoa·atob의 UTF-8 함정
브라우저의 btoa와 atob는 모든 문자가 U+0000부터 U+00FF 범위에 있는 문자열을 대상으로 하며, 문자 하나를 바이트 하나로 취급합니다. btoa('안녕')은 InvalidCharacterError를 던집니다. 더 고약한 것은 범위 안에 있는 문자입니다. 실패하지 않고 엉뚱한 바이트를 인코딩합니다. btoa('é')는 Latin-1 바이트 하나 E9에 해당하는 6Q==를 돌려주지만, é의 UTF-8은 두 바이트이고 인코딩하면 w6k=입니다. UTF-8로 읽는 쪽에서는 잘못된 글자가 나옵니다.
올바른 방법은 TextEncoder로 문자열을 먼저 UTF-8 바이트로 바꾸고, 그 바이트를 바이너리 문자열로 옮겨 btoa에 넘기며, 디코딩에서는 반대로 하는 것입니다. 아래 코드는 Node 24에서 실행했고 안녕 🌱가 7JWI64WVIPCfjLE=로 갔다가 정확히 돌아옵니다.
function encodeUtf8(text) {
const bytes = new TextEncoder().encode(text);
let binary = "";
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary);
}
function decodeUtf8(base64) {
const binary = atob(base64);
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
encodeUtf8("안녕 🌱"); // "7JWI64WVIPCfjLE="
decodeUtf8("7JWI64WVIPCfjLE="); // "안녕 🌱"MIME과 PEM의 줄바꿈 Base64
이메일 전송은 짧은 줄을 전제로 만들어져서, MIME(RFC 2045 6.8절)은 Base64 출력의 한 줄을 76글자 이하로 하고 CRLF로 구분하도록 요구합니다. 76글자에는 57바이트가 들어갑니다. 57바이트가 정확히 76글자가 되고 58바이트는 벌써 80글자가 필요합니다. 1 MiB 첨부파일은 Base64로 1,398,104글자이고 18,397줄로 나뉘며, 줄마다 약 2바이트가 더해져 전체가 약 1,434,896글자, 원본보다 36.8% 큽니다. PEM 파일은 MIME과 다른 관례로 64글자 줄을 씁니다.
디코딩할 때 MIME Base64는 줄바꿈을 무시해야 하고, RFC 4648 3.3절은 구현이 그 밖의 문자를 거부해도 된다고 허용합니다. 브라우저의 atob는 ASCII 공백을 걸러 내므로 줄바꿈된 문자열도 디코딩됩니다. 이 사이트의 도구도 줄바꿈과 탭은 지우고 디코딩하므로 MIME이나 PEM 블록을 그대로 붙여넣어도 됩니다. 다만 문자열 중간의 공백은 여전히 거부합니다. 공백은 URL이나 폼 디코딩으로 +가 바뀐 결과인 경우가 많아서, 말없이 지우면 엉뚱한 바이트가 나오기 때문입니다.
Base64는 암호화도 해시도 아니다
Base64는 인코딩입니다. 디코딩에 키는 필요 없고 위의 표만 있으면 됩니다. cGFzc3dvcmQ6aHVudGVyMg==를 디코더에 붙여 넣으면 누구에게나 password:hunter2입니다. HTTP Basic 인증은 user:pass를 dXNlcjpwYXNz로 보내는데, 이는 호환성 때문이지 보호 때문이 아니어서 반드시 TLS 위에서 보내야 합니다. Base64로만 감싼 비밀은 평문으로 저장한 것과 같습니다.
해시도 아닙니다. SHA-256 같은 해시는 단방향이고 출력 크기가 고정입니다. Man의 해시는 16진수 64자 20fe1bd2...2183이고 여기서 Man을 되살릴 수 없습니다. Base64 출력은 입력에 따라 커지고 언제나 정확히 되돌아갑니다. 해시 값을 Base64로 표시하는 일이 흔해서(같은 SHA-256 다이제스트가 IP4b0gHNkAvb/+rsC0LkC1HL9rN65fvq3dg6q5oiGDc=입니다) 혼동이 생기지만, 이때 Base64는 다이제스트의 표시 형식일 뿐입니다.
| Base64 | 해시 (SHA-256) | 암호화 (AES) | |
|---|---|---|---|
| 키 필요 | 아니오 | 아니오 | 예 |
| 되돌리기 | 항상 가능 | 불가능 | 키가 있으면 가능 |
| 출력 크기 | 입력의 4/3 | 고정, 32바이트 | 입력 크기에 오버헤드 추가 |
| 목적 | 텍스트 통로로 바이트 운반 | 지문, 무결성 확인 | 기밀성 |