Base64에 패딩이 필요한 이유
Base64는 3바이트를 4글자로 바꿉니다(전체 알고리즘은 Base64의 원리에 있습니다). 입력 길이가 3의 배수가 아니면 마지막 블록이 짧아집니다.
| 마지막 블록의 바이트 | 데이터 문자 | 패딩 | 예시 |
|---|---|---|---|
| 3 | 4 | 없음 | Man → TWFu |
| 2 | 3 | = | Ma → TWE= |
| 1 | 2 | == | M → TQ== |
=는 출력 길이를 4의 배수로 유지합니다. 덕분에 MIME 시대에 작성된 디코더는 입력을 고정된 4글자 묶음으로 읽고, 전체 길이를 추적하지 않고도 데이터가 어디서 끝나는지 알 수 있었습니다.
엄밀히 말하면 이 정보는 중복입니다. 3글자로 된 마지막 묶음은 2바이트, 2글자 묶음은 1바이트를 뜻할 수밖에 없으므로, 문자 수를 세는 디코더에는 =가 전혀 필요 없습니다. 그래서 패딩 없는 Base64가 가능하고, 많은 형식이 그것을 씁니다.
패딩이 실제 정보를 담는 경우가 하나 있습니다. 이어 붙이기입니다. 패딩된 문자열 두 개를 붙이면 가운데의 =가 첫 번째 문자열이 끝나는 곳을 표시합니다. 다만 디코더들은 이를 잘 처리하지 못합니다. TQ==TQ==(인코딩한 M 두 개)를 넣었을 때 Python의 기본 디코더는 b'M\x04\xd0'을, Node의 Buffer는 M만 돌려줬고, atob는 예외를 던졌습니다. 조각마다 따로 디코딩하세요.
패딩이 선택 사항인 경우
RFC 4648 3.2절은 Base64를 사용하는 명세가 명시적으로 달리 정하지 않는 한 인코더가 패딩을 넣어야 한다(MUST)고 말합니다. 달리 정하는 명세가 몇 가지 있습니다.
- JWT와 JOSE(RFC 7515)는 패딩을 뺀 Base64URL을 씁니다.
eyJhbGciOiJIUzI1NiJ9같은 JWT 헤더에는 길이가 4의 배수가 아니어도=가 없습니다. JWT 속 Base64를 참고하세요. - URL과 파일 이름에는 패딩 없는 Base64URL이 자주 들어갑니다.
=는 쿼리 문자열에서 의미가 있어서%3D로 이스케이프해야 하기 때문입니다. 전체 차이는 Base64와 Base64URL 페이지에서 다룹니다. - WebAuthn과 OAuth PKCE(RFC 7636의
code_challenge)도 패딩 없는 Base64URL을 씁니다.
이메일, PEM 파일, data URI, 대부분의 JSON API의 표준 Base64에는 패딩이 있습니다. 확실하지 않다면 표준 Base64는 패딩을 넣어 출력하고 Base64URL은 패딩 없이 출력하되, 디코더는 둘 다 받게 만드세요.
패딩을 요구하는 디코더
동작은 생각보다 제각각입니다. 다음은 TWE(패딩 없는 Ma)를 각 라이브러리의 기본 디코더로 디코딩한 결과입니다.
| 언어 / 도구 | 호출 | TWE의 결과 |
|---|---|---|
| Python 3.13 | base64.b64decode | binascii.Error: Incorrect padding |
| Python 3.13 | base64.urlsafe_b64decode | 역시 Incorrect padding |
| .NET | Convert.FromBase64String | FormatException |
| Go | base64.StdEncoding | 오류. 패딩 없는 입력에는 RawStdEncoding 사용 |
| Go | base64.RawStdEncoding | Ma, 하지만 TWE=는 거부 |
| Ruby | Base64.strict_decode64 | ArgumentError: invalid base64 |
| Ruby | Base64.decode64, urlsafe_decode64 | Ma |
| Java 21 | Base64.getDecoder().decode | Ma |
| Node.js | Buffer.from(s, 'base64'), atob | Ma |
| PHP 8.4 | base64_decode (strict 모드도 동일) | Ma |
| GNU coreutils 9.7 | base64 -d | Ma |
| OpenSSL 3.5 | openssl base64 -d | 빈 출력, 종료 코드 0 |
OpenSSL 행을 눈여겨보세요. 아무것도 출력하지 않고도 성공을 보고했으므로, 패딩 없는 Base64를 openssl base64 -d로 넘기는 스크립트는 조용히 빈 파일을 만듭니다. 같은 테스트에서 끝에 줄 바꿈이 없는 한 줄짜리 입력에도 아무것도 돌려주지 않았는데, 이 경우는 -A를 넘기면 해결되었습니다.
반대 방향으로는 Go가 가장 엄격합니다. RawStdEncoding은 패딩이 있는 문자열을 거부합니다. 여러 출처에서 입력을 받는다면 =를 지우고 raw 인코딩을 쓰거나, 패딩을 붙이고 표준 인코딩을 쓰세요.
언어별 예제는 Python에서 Base64나 Go에서 Base64 같은 코드 페이지에 있습니다.
빠진 패딩을 복원하는 방법
규칙은 공백을 지운 뒤의 문자열 길이에만 달려 있습니다.
len % 4 | 붙일 것 | 의미 |
|---|---|---|
| 0 | 없음 | 완전한 블록 |
| 2 | == | 마지막 블록에 1바이트 |
| 3 | = | 마지막 블록에 2바이트 |
| 1 | 고칠 수 없음 | 잘렸거나 엉뚱한 문자가 있음 |
나머지 1은 결코 올바르지 않습니다. Base64 문자 하나는 6비트를 담는데, 이는 1바이트에 모자라므로 패딩을 아무리 붙여도 디코딩되지 않습니다. 이런 경우라면 빠진 문자, 남는 문자, 함께 세어진 줄 바꿈이 있는지 찾아보세요.
대부분의 언어에서 수정은 식 하나입니다.
import base64
s = "eyJzdWIiOiIxMjM0In0" # 19 chars, 19 % 4 == 3
padded = s + "=" * (-len(s) % 4) # adds one "="
print(base64.urlsafe_b64decode(padded))
# b'{"sub":"1234"}'
const s = "eyJzdWIiOiIxMjM0In0";
const padded = s + "=".repeat((4 - (s.length % 4)) % 4);
// "eyJzdWIiOiIxMjM0In0="
Base64 검사기는 문자열의 길이와 패딩 상태를 알려 주고, Base64 복구는 빠진 =를 붙이고, 공백과 따옴표를 지우고, URL-safe 문자를 한 번에 변환합니다.
비정규 인코딩
패딩에는 눈에 덜 띄는 친척이 있습니다. 마지막 데이터 문자의 쓰지 않는 비트입니다. Ma를 인코딩하면 16비트가 6비트 묶음 두 개 반을 채웁니다. 세 번째 문자는 a의 마지막 4비트와 채움 비트 2개를 담는데, RFC 4648 3.5절은 인코더가 그 채움 비트를 0으로 설정해야 한다고 말합니다. 그래서 E(000100)가 됩니다.
디코더는 채움 비트를 무시하므로 TWF=(F는 000101, 채움 비트 01)도 같은 Ma로 디코딩됩니다. 저희 테스트에서 Python, Node, Java, PHP 모두 TWF=를 받아 Ma를 돌려줬습니다. 마찬가지로 TR==도 TQ==와 똑같이 M으로 디코딩됩니다.
TWF= 같은 문자열을 비정규(non-canonical) 문자열이라고 합니다. 몇몇 곳에서 문제가 됩니다.
- Base64 텍스트에 대한 서명과 해시. 서로 다른 두 문자열이 같은 바이트로 디코딩되므로, 데이터가 같은지 Base64 문자열로 비교하면 틀린 답이 나올 수 있습니다. 디코딩한 바이트를 비교하세요.
- 중복 제거와 캐시 키. 같은 문제입니다. 디코딩한 뒤 다시 인코딩해 정규화하세요.
- 엄격한 파서. RFC 4648은 디코더가 0이 아닌 채움 비트를 거부하도록 허용합니다. Go의
StdEncoding.Strict()와 GNUbase64 -d는 모두TWF=를 거부합니다.
패딩이 남는 경우도 비슷한 별난 사례입니다. TWE==에는 =가 하나 더 있습니다. Python과 Node의 Buffer는 받아들였고, atob는 InvalidCharacterError를 던졌습니다.
조각 단위로 인코딩할 때의 패딩
큰 파일을 여러 조각으로 나눠 Base64로 인코딩한다면 마지막 조각을 뺀 모든 조각이 3바이트의 배수여야 합니다. 그렇지 않으면 조각마다 패딩이 붙어 이어 붙인 출력이 틀어집니다. hello world!!를 4바이트 조각으로 인코딩하면 aGVsbA==byB3bw==cmxkIQ==IQ==가 나왔고, 3바이트 조각으로는 전체를 한 번에 인코딩한 것과 똑같은 aGVsbG8gd29ybGQhIQ==가 나왔습니다. MIME 줄 나누기에는 57바이트가 자연스러운 조각 크기인데, 57바이트가 정확히 76자가 되기 때문입니다.
패딩이 빠졌거나 깨진 문자열을 붙여넣으면 올바른 Base64를 돌려받습니다.
자주 묻는 질문
Base64에서 = 기호를 그냥 지워도 되나요?
데이터는 살아남습니다. 문자열 길이만으로 디코더가 마지막 블록의 바이트 수를 알 수 있기 때문입니다. 그래도 디코딩되는지는 받는 쪽에 달렸습니다. JWT의 Base64URL은 패딩이 없기를 기대하지만, Python의 b64decode와 .NET의 Convert.FromBase64String은 패딩 없는 문자열을 거부합니다.
제 Base64 문자열은 왜 끝에 =가 없나요?
입력 길이가 3바이트의 배수여서 패딩이 필요 없었거나, 만든 쪽이 패딩 없는 Base64를 쓰기 때문입니다. 토큰과 URL의 Base64URL에서 흔합니다.
Python의 Incorrect padding 오류는 어떻게 고치나요?
디코딩하기 전에 '=' * (-len(s) % 4)를 붙이세요. URL-safe 문자열이라면 base64.urlsafe_b64decode도 쓰세요. 길이 mod 4가 1이라면 패딩으로는 해결되지 않습니다. 문자열이 잘렸거나 엉뚱한 문자가 섞였습니다.
Base64 문자열이 = 세 개로 끝날 수 있나요?
아니요. 마지막 블록에는 남는 바이트가 1개나 2개 있으며, 각각 ==나 =를 만듭니다. = 세 개는 데이터가 없는 블록을 뜻하는데, 그런 블록은 존재하지 않습니다.