Base64·URL 인코딩: 왜 필요하고 언제 잘못 쓰는가
요약 (TL;DR)
echo -n "Hi" | base64은 정확히 4문자(SGk=)를 돌려줍니다. 입력 2바이트(16비트)가 6비트 단위로 잘려 출력 4문자가 되고, 마지막 한 자리는 패딩 = 하나로 채워집니다 — 비트 산수는 거기에서 끝납니다. 이 단순한 규칙을 “안전을 위해 일단 감싸 두자”로 확장하는 순간 비용이 따라붙는데, 입력 3바이트마다 출력 4문자라는 비율은 정확히 약 33% 팽창이고, 한 운영 트래픽에서 1.2MB JPEG을 Base64로 감싼 결과가 약 1.64MB로 기록된 적이 있습니다. 인코딩은 암호화도 압축도 아닙니다 — 어떤 알파벳의 데이터를 그걸 그대로는 통과시키지 못하는 채널을 지나가게 다른 알파벳으로 표현하는 규칙일 뿐입니다. Base64(RFC 4648 §4)는 이메일 본문, JSON 문자열 필드, 인라인 이미지처럼 텍스트만 허용하는 채널을 위한 것이고, Base64URL(같은 RFC §5)은 두 문자(+→-, /→_)를 바꿔 URL·파일명·JWT 안에 안전하게 들어가도록 만든 변형입니다. 퍼센트 인코딩(RFC 3986)은 별개의 도구로, URL 문법에서 의미를 가지는 ASCII 문자(?, &, #, 공백, 비-ASCII)를 %XX로 감싸 데이터임을 표시합니다. 채널에 맞춰 고르세요. 바이너리가 지원되면 그대로, 텍스트 채널이면 Base64, URL 문맥이면 Base64URL, 이미 URL 안에 있다면 퍼센트 인코딩. 그리고 알파벳이 공개되어 있으니 드러난 Base64 문자열은 누구든 복호할 수 있습니다 — 비밀 유지 수단으로 사용해선 안 됩니다.
배경/개념
텍스트 전용 채널은 이미지를 실어 나르기 한참 전부터 존재했습니다. 이메일 은 역사적으로 7비트 ASCII를 가정해 상위 비트가 켜진 바이트를 손상시키곤 했고, HTTP 헤더는 아직도 일부 문자를 금지하며, JSON 문자열 필드는 UTF-8이지만 날것의 제어 바이트(0x00–0x1F)를 안전하게 담지 못합니다. RFC 4648로 표준화된 Base64는 입력 3바이트(24비트)를 정확히 6비트씩 잘라 출력 문자 4개로 매핑해 이 문제를 풉니다. 알파벳은 64자: A-Z(0–25), a-z(26–51), 0-9(52–61), +(62), /(63), 그리고 길이가 4의 배수가 되도록 채우는 패딩 =. 입력 1바이트는 XX==(8비트 → 6+2비트), 2바이트는 XXX=(16비트 → 6+6+4비트), 3바이트는 XXXX(24비트 → 6+6+6+6비트)가 되고, 3바이트가 끊기지 않으면 패딩이 생기지 않습니다.
URL 안전(URL-safe) 변형은 알파벳 색인 62와 63만 바꿉니다 — +→-, /→_. 레거시 폼 제출에서 +가 공백으로 해석되고 /는 경로 구분자이기 때문입니다. URL 안전 문맥에서는 = 패딩도 생략하는 경우가 많습니다 — 이 형태가 JWT(RFC 7519)에서 쓰이는 방식으로, 헤더.페이로드.서명 3단 구조가 Authorization: Bearer ... 헤더에 압축적으로 들어가야 하기 때문입니다.
퍼센트 인코딩(종종 URL 인코딩이라고도 부릅니다)은 별개입니다. RFC 3986은 ASCII를 둘로 나눕니다. 그대로 쓸 수 있는 비예약(unreserved) 문자(A-Z, a-z, 0-9, -, ., _, ~)와, 구조적으로 의미를 지녀 데이터로 쓰일 땐 인코딩이 필요한 예약(reserved) 문자(:, /, ?, #, [, ], @, !, $, &, ', (, ), *, +, ,, ;, =)입니다. 이 집합 바깥의 바이트는 — UTF-8로 인코딩된 비-ASCII 바이트 포함 — 16진수로 %XX로 표기합니다. 자바스크립트의 encodeURIComponent는 예약 문자까지 공격적으로 인코딩해 한 컴포넌트 안에 안전하게 넣도록 설계됐고, encodeURI는 URL 전체를 다룬다고 가정해 더 너그럽습니다.
세 인코딩은 출력 바이트가 겹쳐 보여도 서로 교환 가능하지 않습니다. 퍼센트 인코딩된 쿼리 파라미터는 여전히 ASCII 문자로 다른 ASCII 문자를 표현한 것일 뿐이라, 이를 Base64 디코더에 넣으면 의미 없는 바이트가 나옵니다. 표준 Base64 문자열을 URL 경로에 그대로 붙이면 안에 있는 / 때문에 경로가 엉뚱하게 잘립니다 — 제 동료가 이 한 문자에 한나절을 잃은 적이 있습니다. 운영에서 생기는 인코딩 버그의 절반은 이런 혼동에서 나옵니다.
비교/데이터
| 항목 | Base64(표준) | Base64URL | 퍼센트 인코딩 |
|---|---|---|---|
| 쓸 때 | 바이너리를 거부하는 텍스트 채널(MIME 본문, JSON 안 바이트) | 같은 용도지만 URL·파일명·JWT 안에 들어갈 때 | URL 한 컴포넌트 안의 예약 문자·비-ASCII 문자 |
| 알파벳 | A–Z a–z 0–9 + / = | A–Z a–z 0–9 - _(패딩 선택적) | 비예약 + 나머지는 %XX |
| 오버헤드 | 약 33%(3바이트 → 4문자), MIME에서는 76자 줄바꿈 추가 | 약 33%, 보통 패딩 없음 | 가변 — UTF-8 1바이트가 %XX 3문자로 |
| 깨지는 경우 | URL에 들어간 +·/, 엄격 디코더에서 패딩 누락 | +/= 기반 표준 디코더로 돌려 넣을 때 | 이중 인코딩(이미 인코딩된 값을 다시 인코딩) |
| 일반 용도 | MIME 첨부, data URI, JSON 안 인라인 바이너리 | JWT, URL 안전 ID, 단축 링크 | 쿼리 파라미터, 경로 세그먼트, 폼 바디 |
숫자는 무시할 수 없습니다. 1MB 이미지를 Base64로 인코딩하면 약 1.37MB이고, MIME 76자 줄바꿈까지 더하면 거기에 추가로 2% 정도가 더 붙습니다. HTTP 응답에서 이 팽창은 서버 대역폭과 클라이언트 파서 양쪽에 모두 영향을 줍니다. 퍼센트 인코딩은 보통 문자열 레벨에서는 덜 과격하지만 CJK 텍스트에서 바이트가 크게 늘어납니다. 한글 한 글자는 UTF-8로 3바이트이고, 이는 ASCII 9바이트(%XX × 3)로 인코딩됩니다. “안녕”이라는 두 글자가 URL 안에서는 %EC%95%88%EB%85%95라는 18바이트가 됩니다.
실전 시나리오
시나리오 1 — 이메일 첨부. PDF가 MIME 파트 안에 Content-Transfer-Encoding: base64로 실립니다. 메일 클라이언트가 파일을 Base64로 인코딩하고, 고전적인 MIME 규칙대로 76자에서 줄을 바꾸고 전송합니다. 수신자는 역과정으로 복원합니다. SMTP의 “텍스트 전제”가 Base64를 이 영역의 사실상 기본값으로 만들었고, 8BITMIME·BINARYMIME 같은 확장이 있는 지금도 많은 메일 서버가 안전을 위해 여전히 Base64를 씁니다. 33% 오버헤드는 확실한 전달을 위한 비용으로 받아들여집니다.
시나리오 2 — JSON Web Token. JWT(RFC 7519)는 헤더.페이로드.서명이고, 각 세그먼트는 패딩 없는 Base64URL로 인코딩됩니다. URL 안전 알파벳 덕분에 토큰이 Authorization 헤더·쿼리 파라미터·로그 라인에 재이스케이프 없이 들어갈 수 있습니다. 패딩 생략은 토큰을 짧게 유지해 줍니다. 한 가지 경고: JWT를 디코드한 사람은 누구든 클레임을 읽을 수 있습니다. 인코딩은 보안이 아니고, HMAC/RSA 서명이 보안입니다. 페이로드에 비밀 정보를 넣지 마세요.
시나리오 3 — 작은 이미지용 data URI. background-image: url("data:image/png;base64,iVBORw0K...")는 PNG를 CSS에 바로 인라인합니다. 아주 작은 아이콘에 대해 왕복 비용을 줄여 주지만, Base64의 33% 오버헤드와 브라우저 캐싱·병렬 요청 상실을 고려하면 HTTP 왕복이 더 비쌀 만큼 작은 자산에서만 유리합니다. 제 경험으로 손익 분기는 약 1–2KB 근방이고, 그 위로는 별도 파일이나 SVG가 보통 더 빠릅니다.
시나리오 4 — 사용자 아바타 업로드. 흔한 안티패턴은 FileReader.readAsDataURL로 파일을 읽어 data:image/png;base64,... 문자열로 바꾸고, 이를 JSON 필드로 POST하는 것입니다. 동작은 하지만 거의 올바른 선택이 아닙니다. 페이로드가 33% 커지고, 서버는 디스크에 쓰기 전에 Base64 디코딩을 한 번 더 해야 하며, 클라이언트와 서버 양쪽에서 메모리를 문자열 사본이 차지합니다. 제가 본 한 케이스에서는 5MB 이미지가 약 6.7MB JSON으로 부풀어 모바일 회선에서 타임아웃을 일으켰습니다 — multipart/form-data로 바꾸자 같은 파일이 5MB 그대로 올라갔습니다. 제3자 API가 JSON 문자열만 받는 등 전송 계층이 진짜로 텍스트를 요구할 때에만 Base64를 고려하세요.
자주 하는 오해
“Base64는 암호화다.” 아닙니다. 매핑이 RFC 4648에 공개돼 있고 알파벳은 고정이며, 어떤 디코더도 원본 바이트를 돌려줍니다. 인코딩은 전송을 보호할 뿐 내용을 보호하지 않습니다. 민감 정보라면 먼저 암호화하고, 암호문을 전송용으로 Base64로 감싸세요.
“URL 인코딩은 비-ASCII 문자에만 필요하다.” ASCII 예약 문자도 데이터로 쓰일 땐 인코딩이 필요합니다. &를 그대로 두면 서버가 새 파라미터로 해석하고, #을 그대로 두면 그 뒤가 모두 프래그먼트로 처리됩니다. 규칙은 “문자 집합”이 아니라 구조에 기반합니다.
“HTTP로 나갈 때는 전부 Base64가 안전하다.” HTTP는 바이너리 바디를 기꺼이 실어 나릅니다 — Content-Type: application/octet-stream, 청크 전송, 임의의 바이트 값. Base64는 이를 못 하는 채널을 위한 우회로인데, 이미 바이트를 다루는 채널에 33% 오버헤드를 얹는 건 순전히 세금입니다. 파일 업로드는 multipart/form-data나 날것의 바디로, JSON 필드·URL 파라미터·MIME 본문처럼 채널이 진짜로 텍스트를 요구할 때에만 Base64로.
“Base64와 Base64URL은 서로 바꿔 쓸 수 있다.” 아닙니다. +/를 기대하는 엄격 Base64 디코더에 URL 안전 문자열을 넣으면 실패하거나 쓰레기 값이 나옵니다. 라이브러리는 보통 둘 다 제공하니 인코더와 디코더를 끝에서 끝까지 맞추세요 — Node Buffer.from(s, 'base64')는 양쪽 알파벳을 모두 받지만, 모든 언어의 표준 라이브러리가 그렇게 너그럽지는 않습니다.
체크리스트 또는 의사결정 플로우
- 채널이 7비트 전용이거나 구조화된 텍스트인가? 이메일 본문, JSON 안 바이트, CSS
data:URI → Base64. - 값이 URL·파일명·JWT 안에 들어가나? Base64URL, 패딩 허용 여부는 소비자에 맞추세요.
- 값 자체가 URL 컴포넌트인가? 필요한 부분만 퍼센트 인코딩.
- 데이터가 큰가? 채널이 정말 텍스트를 요구하는지 먼저 확인하세요. 날것의 바이너리 바디로 33%를 아낄 수 있습니다.
- 디코더가 엄격한가 관대한가? 표준/URL 안전 알파벳을 끝에서 끝까지 맞추세요.
- 비밀 유지가 목적인가? 먼저 암호화하세요. 인코딩만으로는 절대 비밀이 되지 않습니다.
관련 도구
Patrache Studio의 Base64 인코더·디코더는 표준·URL 안전 변형을 모두 로컬에서 처리하므로 입력 바이트가 브라우저를 떠나지 않습니다 — 토큰이나 작은 키를 들여다볼 때 유용합니다. 다루는 페이로드가 JWT라면 JSON 포매팅·검증·JSON Schema의 차이와 실무 활용에서 복호된 클레임을 깔끔하게 검사하는 방법을 함께 보세요. Base64URL로 인코딩된 값이 UUID에서 파생된 짧은 ID라면, UUID v1·v4·v7 비교와 DB 기본키 설계에서 원래 바이트의 정렬·인덱스 특성이 왜 그대로 중요해지는지 확인할 수 있습니다.
참고 자료
- IETF RFC 4648, “The Base16, Base32, and Base64 Data Encodings” — https://datatracker.ietf.org/doc/html/rfc4648
- IETF RFC 3986, “Uniform Resource Identifier (URI): Generic Syntax” — https://datatracker.ietf.org/doc/html/rfc3986
- MDN, “Base64” 용어 설명 — https://developer.mozilla.org/en-US/docs/Glossary/Base64
- MDN, “encodeURIComponent()” — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent