예전에 이메일 인증 링크가 일부 사용자에게만 "유효하지 않은 토큰"으로 뜨는 버그를 잡은 적이 있어요. 재현이 안 돼서 한참 헤맸는데, 원인은 토큰 안의 + 한 글자였어요. 표준 Base64로 만든 토큰을 URL 쿼리에 그대로 붙였고, 서버에서 쿼리를 파싱할 때 + 가 공백으로 바뀌어 들어왔던 거죠.
결론부터 말하면 URL, 파일명, 쿠키에 넣을 값은 처음부터 Base64URL(+ → -, / → _, 패딩 = 생략)로 만들고, 디코딩할 때는 길이를 4의 배수로 맞추도록 = 를 다시 붙이면 돼요. 이번 글에서는 왜 "일부 사용자만" 실패하는지 확률로 계산해 보고, 언어별 변환 코드와 검색하게 되는 에러 메시지(Incorrect padding, InvalidCharacterError, Illegal base64 character 2d)의 원인을 정리했어요.
핵심 요약
표준 Base64와 Base64URL은 62·63번 문자(+ / 대신 - _)와 패딩 처리만 달라요.
32바이트 랜덤 토큰은 약 74% 확률로 + 나 / 를 포함해서, 표준 Base64를 URL에 넣으면 일부 토큰만 깨지는 버그가 생겨요.
패딩 복원은 길이를 4로 나눈 나머지가 2면 ==, 3이면 = 를 붙이고, 나머지가 1이면 잘린 데이터예요.
Python b64decode는 기본값에서 - _ 를 조용히 버려 엉뚱한 바이트를 돌려줄 수 있으니 urlsafe_b64decode를 쓰세요.
표준 Base64의 + / = 가 URL에서 문제인 이유
표준 Base64(RFC 4648 4절)는 대소문자 52개, 숫자 10개, 그리고 + 와 / 를 써요. 문제는 이 두 개와 패딩 = 예요.
+는 쿼리 문자열에서 공백을 뜻하는 관례가 있어요(HTML 폼 인코딩application/x-www-form-urlencoded)./는 경로 구분자라 URL 경로에 들어가면 라우팅이 꼬여요.=는 쿼리에서 키와 값을 나누는 문자라서 파서에 따라 애매하게 처리돼요.
실제로 확인해 보면 파이썬과 브라우저 모두 쿼리의 + 를 공백으로 바꿔요.
from urllib.parse import parse_qs
parse_qs('t=ab+cd%2Bef') # {'t': ['ab cd+ef']} ← 생 + 는 공백, %2B 만 +
new URLSearchParams('t=ab+cd').get('t'); // 'ab cd'
퍼센트 인코딩(%2B, %2F, %3D)으로 감싸면 안전하게 실을 수는 있어요. 하지만 글자 하나가 세 글자가 돼서 길어지고, 어딘가에서 이중 인코딩이나 디코딩 누락이 생기기 쉬워요. URL 인코더로 직접 바꿔 보면 ab+/= 가 ab%2B%2F%3D 로 두 배 이상 늘어나는 걸 볼 수 있어요.
Base64URL은 세 글자만 바꾼 것
그래서 같은 RFC 5절에 URL과 파일명에 안전한 변형이 정의돼 있어요.
| 구분 | 표준 Base64 | Base64URL |
|---|---|---|
| 62번째 문자 | + | - |
| 63번째 문자 | / | _ |
패딩 = | 붙임 (필수인 구현 많음) | 생략하는 경우가 많음 (JWT는 생략 필수) |
| 대표 사용처 | 이메일, PEM, data URI | JWT, URL 토큰, 파일명 |
| Node.js | toString('base64') | toString('base64url') |
| Python | b64encode | urlsafe_b64encode (패딩은 남김) |
나머지 62글자는 같아요. 그래서 대부분의 문자열은 두 방식에서 똑같이 생겼고, 하필 62·63번 값이 나올 때만 차이가 나요. 테스트에서 안 잡히고 운영에서 가끔 터지는 이유가 바로 이거예요.
일부 토큰만 깨지는 이유 — 확률로 계산해 보기
"가끔"이 얼마나 가끔인지 직접 재 봤어요. 길이별로 랜덤 바이트 2만 개씩 표준 Base64로 인코딩해서 + 나 / 가 하나라도 들어간 비율을 셌어요.
흔히 쓰는 32바이트 토큰이면 4개 중 3개에 문제 문자가 있다
표로 보기
| 구분 | 포함 확률 |
|---|---|
| 6B | 22.9 |
| 9B | 31.6 |
| 12B | 39.5 |
| 16B | 48.6 |
| 24B | 64.1 |
| 32B | 73.6 |
| 48B | 86.9 |
| 64B | 93.4 |
16바이트 토큰은 대략 절반, 32바이트 토큰은 74%가 문제 문자를 포함해요. 그중 + 만 공백으로 바뀌는 환경이라면 실패율은 그보다 조금 낮아지고, 그래서 "재현이 될 때도 있고 안 될 때도 있는" 버그가 돼요. 테스트용 토큰 몇 개로 확인하면 운 좋게 다 통과할 수도 있어요.
길이도 비교해 볼게요. 32바이트 토큰을 URL에 넣는 세 가지 방법이에요.
Base64URL에 패딩 생략이면 항상 43자로 고정돼요. DB 컬럼 길이나 로그 파싱 규칙을 정하기도 편하죠. 파이썬 secrets.token_urlsafe(32) 가 정확히 이 43자 Base64URL 토큰을 만들어 줘요.
언어별 변환 코드 (JS, Node, Python, Java)
브라우저 JavaScript
브라우저의 btoa/atob 는 표준 Base64만 알아요. 문자 치환과 패딩 처리를 직접 해야 해요.
function toBase64Url(bytes) {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function fromBase64Url(s) {
s = s.replace(/-/g, '+').replace(/_/g, '/');
if (s.length % 4 === 1) throw new Error('잘린 Base64URL 문자열');
s += '='.repeat((4 - (s.length % 4)) % 4);
return Uint8Array.from(atob(s), c => c.charCodeAt(0));
}
const t = toBase64Url(new TextEncoder().encode('한글 토큰?'));
// '7ZWc6riAIO2GoO2BsD8'
new TextDecoder().decode(fromBase64Url(t)); // '한글 토큰?'
atob() 결과는 바이트를 한 글자씩 담은 문자열이라, 한글 같은 UTF-8 텍스트는 그대로 출력하면 깨져요. 위처럼 Uint8Array 로 바꾼 뒤 TextDecoder 로 한 번 더 풀어야 해요.
Node.js
Node.js(v15.7 이상, v14.18 이상)는 base64url 인코딩을 기본 지원해서 한 줄이면 끝나요.
Buffer.from('한글 토큰?').toString('base64url'); // '7ZWc6riAIO2GoO2BsD8'
Buffer.from('7ZWc6riAIO2GoO2BsD8', 'base64url').toString(); // '한글 토큰?'
require('node:crypto').randomBytes(32).toString('base64url'); // 43자 토큰
Python
import base64
def b64url_encode(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b'=').decode()
def b64url_decode(s: str) -> bytes:
return base64.urlsafe_b64decode(s + '=' * (-len(s) % 4))
-len(s) % 4 는 파이썬에서 "4의 배수까지 모자란 개수"를 구하는 관용구예요. 길이가 22면 2, 23이면 1, 24면 0이 나와요. 파이썬 urlsafe_b64encode 는 패딩을 남겨 두니 JWT처럼 패딩이 없어야 하는 곳에선 rstrip 이 필요해요.
Java
Java 8 이상은 Base64.getUrlEncoder().withoutPadding() 과 Base64.getUrlDecoder() 를 쓰면 돼요. Java 디코더는 패딩이 없어도 잘 받아 줘요.
패딩 복원 규칙과 언어별 에러 메시지
Base64 출력은 원래 항상 4의 배수 길이예요. 패딩을 뺀 문자열의 길이를 4로 나눈 나머지로 몇 개를 붙일지 정해져요.
| 길이 % 4 | 원래 마지막 묶음 | 붙일 패딩 |
|---|---|---|
| 0 | 3바이트 | 없음 |
| 2 | 1바이트 | == |
| 3 | 2바이트 | = |
| 1 | 불가능 | 없음 — 중간에 잘린 데이터 |
나머지 1은 올바른 Base64에서 절대 나올 수 없어요. 그런 입력이 오면 복사하다 한 글자가 빠졌거나 URL이 잘린 거예요. 실제 런타임별 반응을 확인해 봤어요.
| 입력 | Python 3.9 b64decode | 브라우저·Node atob | Java 17 getDecoder() |
|---|---|---|---|
YQ (패딩 없음) | binascii.Error: Incorrect padding | "a" (허용) | 허용 |
a-b_ (URL 문자) | 조용히 버림 → 패딩 에러 | InvalidCharacterError: Invalid character | IllegalArgumentException: Illegal base64 character 2d |
| 길이 % 4 = 1 | number of data characters (5) cannot be 1 more than a multiple of 4 | The string to be decoded is not correctly encoded. | Last unit does not have enough valid bits |
Java의 character 2d 는 16진수로 - 문자(0x2D)라는 뜻이에요. 이 메시지가 보이면 Base64URL 문자열을 표준 디코더에 넣은 거니 getUrlDecoder() 로 바꾸면 돼요. 5f 가 보이면 _ 예요.
가장 위험한 함정: Python b64decode의 조용한 실패
에러가 나면 차라리 다행이에요. 진짜 위험한 건 에러 없이 틀린 값이 나오는 경우예요. 파이썬 b64decode 는 기본값(validate=False)에서 Base64 문자표에 없는 문자를 조용히 버려요.
import base64
tok = base64.urlsafe_b64encode(b'\xfb\xef\xbe\x01\x02\x03').decode()
print(tok) # ----AQID
print(base64.b64decode(tok)) # b'\x01\x02\x03' ← 앞 3바이트 증발, 에러 없음
print(base64.urlsafe_b64decode(tok)) # b'\xfb\xef\xbe\x01\x02\x03' ← 정상
base64.b64decode(tok, validate=True) # binascii.Error: Non-base64 digit found
- 네 개가 버려지고 남은 AQID 가 정상 디코딩돼서 아무 경고 없이 엉뚱한 바이트가 나왔어요. 서명 검증이나 토큰 비교에 쓰이면 "가끔 검증 실패"라는 최악의 버그가 돼요. Node.js Buffer.from(s, 'base64') 도 두 문자표를 다 받아 주고 잘못된 문자를 무시하는 관대한 디코더라서, 입력 검증 용도로는 믿으면 안 돼요. 외부 입력을 디코딩할 땐 엄격 모드(validate=True)나 전용 URL 디코더를 쓰세요.
JWT가 대표적인 Base64URL 사용처
JWT의 헤더·페이로드·서명 세 부분은 모두 Base64URL로 인코딩되고 패딩을 빼요(RFC 7515). 그래서 JWT 페이로드를 표준 Base64 디코더에 넣으면 길이가 4의 배수가 아니라서 Incorrect padding 이 나거나, - 와 _ 를 모르는 디코더라면 엉뚱한 바이트가 나와요.
import json
payload = token.split('.')[1]
claims = json.loads(b64url_decode(payload)) # 위에서 만든 함수
JWT 디코더는 이 처리를 알아서 해 주지만, 직접 코드로 디코딩할 땐 꼭 URL 변형과 패딩을 챙기세요. JWT 구조 전체는 JWT 구조와 서명 원리 글에서 자세히 다뤘어요.
내가 지키는 규칙과 디버깅 순서
- URL, 파일명, 쿠키 값에 넣을 거면 처음부터 Base64URL로 만든다.
- 받는 쪽 디코더가 패딩 유무를 둘 다 받아 주는지 확인하고, 안 받으면 길이로 복원한다.
- 외부 입력은 엄격한 디코더로 검증한다 — 관대한 디코더는 틀린 값을 돌려줄 수 있다.
- 실패한 토큰을 볼 땐
+,/,-,_, 공백이 섞였는지부터 본다.
마지막 항목이 제일 쓸모 있어요. 실패한 토큰에 공백이 섞여 있다면 거의 확실히 + 가 중간에 변한 거예요. Base64 디코더에 붙여 넣고 표준과 URL-safe 모드를 번갈아 디코딩해 보면 어느 규칙으로 만들어졌는지 금방 판별돼요.
자주 묻는 질문
Python에서 binascii.Error: Incorrect padding 은 어떻게 해결하나요?
패딩 = 가 빠진 Base64URL 문자열을 표준 디코더에 넣었을 때 주로 나요. base64.urlsafe_b64decode(s + '=' * (-len(s) % 4)) 처럼 길이를 4의 배수로 맞춰 디코딩하세요. 그래도 나면 문자열이 중간에 잘렸는지 확인하세요.
Base64와 Base64URL을 구분하는 방법이 있나요?
- 나 _ 가 있으면 Base64URL, + 나 / 가 있으면 표준 Base64예요. 둘 다 없으면 어느 쪽으로 디코딩해도 결과가 같아요. 끝에 = 가 없고 길이가 4의 배수가 아니면 패딩을 생략한 Base64URL일 가능성이 커요.
URL에 표준 Base64를 퍼센트 인코딩해서 넣으면 안 되나요?
넣을 수는 있지만 길이가 늘고, 중간 시스템에서 이중 인코딩이나 디코딩 누락이 생기기 쉬워요. 받는 쪽 코드와 프록시, 로그 도구까지 모두 일관되게 처리해야 해서 실수할 여지가 많아요. 처음부터 Base64URL을 쓰는 게 단순하고 안전해요.
패딩을 빼도 원래 데이터를 복원할 수 있나요?
네. Base64 출력 길이는 원래 4의 배수라서, 남은 길이를 4로 나눈 나머지로 빠진 = 개수를 정확히 알 수 있어요. 나머지 2면 두 개, 3이면 한 개를 붙이면 돼요. 나머지가 1이면 데이터가 잘린 거라 복원할 수 없어요.
Java에서 Illegal base64 character 2d 에러는 무엇인가요?
16진수 2d는 - 문자예요. Base64URL로 만든 문자열을 Base64.getDecoder() 로 디코딩해서 생기는 에러라, Base64.getUrlDecoder() 로 바꾸면 해결돼요. 5f(_)도 같은 원인이에요.
정리
표준 Base64와 Base64URL은 + / = 세 글자만 달라요. 그런데 그 세 글자가 URL에서 공백·경로·구분자로 오해되면서, 32바이트 토큰 4개 중 3개꼴로 깨질 위험을 안고 있어요. URL에 들어갈 값은 처음부터 Base64URL로, 디코딩은 패딩을 복원해서, 외부 입력은 엄격한 디코더로. 이 세 가지면 "일부 사용자만 실패" 같은 버그를 미리 막을 수 있어요.
값을 직접 확인할 땐 Base64 인코더/디코더와 URL 인코더를 같이 쓰면 편해요. Base64가 왜 33% 커지는지는 Base64가 쓰이는 곳과 용량 증가 원리에서 볼 수 있어요.