Python에서의 Base64 디코딩: 완전한 가이드
어딘가, 당신의 코드 속에, 아예 텍스트 같지 않은 문자열 하나가 떨어졌습니다. A에서 Z까지 길게 이어진 문자, 숫자 몇 개, 가끔 나타나는 +나 /, 어쩌면 -나 _, 그리고 끝에 주차해 둔 동등 기호 = 하나둘. 그 문자열 뒤에는 게이트웨이가 받아주지 않은 JWT의 페이로드, HTML 페이지 속에 숨겨진 이미지, 누군가 .b64 첨부 파일로 보내 온 파일, 헬프 데스크 세 개를 거쳐 온 티켓 속 인증서 블록이 있을 수 있습니다. 당신의 일은 원래 바이트를, 그 모습 그대로 되돌려 주는 것입니다. 그리고 Python은 이 일 최고의 컨디션입니다. 도구함 전체가 수십 년째 표준 라이브러리에 들어 있기 때문이죠. import base64 한 줄이면 모든 플랫폼에서 준비 완료, 설치할 것도 설정할 것도 없습니다.
자리를 잡는 동안 빠른 복습을 하나. 일 년에 한 번쯤은 누구나 필요하니까요. Base64는 데이터 3바이트마다 64자 알파벳에서 고른 문자 4개로 다시 씁니다. 마지막 3바이트 그룹이 채워지지 않았을 때는 = 패딩이 빈 자리를 메우니, 출력은 항상 4의 배수로 나옵니다. 이것이 전부인 트릭입니다. 압축도 아니고, 비밀도 아니며, 텍스트밖에 받아주지 않는 채널에서도 바이너리가 살아남게 해 주는 방법일 뿐입니다. 이 사이트의 홈 페이지는 알파벳과 패딩 계산까지 포맷을 아주 깊게 다루므로, 우리는 에너지를 실제로 아픈 곳에 씁니다. Python 쪽 디코딩, 그리고 결과를 정직하게 지켜 두는 데요.
세 가지 사실이 뒤의 모든 것을 규정하니, 한 줄 더 읽기 전에 외울 가치가 있습니다. 첫째, 디코더에는 두 가지 성향이 있습니다. 알아볼 수 없는 것은 조용히 버려 주는 예의 바르고 관대한 기본값, 그리고 그런 입력을 단번에 거부하는 엄격 모드. 둘째, 디코딩의 결과는 항상 bytes 객체이며 절대 문자열이 아니라, 거기서 진짜 텍스트를 뽑아 내고 싶은 순간은 의도적으로 내려야 할 결정입니다. 셋째, 거의 똑같이 생긴 두 알파벳이 있습니다. 표준 알파벳과 URL-safe 알파벳인데, 이 둘을 혼동하는 것은 에러 하나 없이 데이터를 잃는 단골 방법입니다. 이 가이드는 셋 다 건너게 해 줍니다. 다음에 터미널에 알아들을 수 없는 문자 벽이 떨어져도, 눈을 찡그리기 대신 웃을 수 있도록 말이죠.
디코딩 전체 메뉴
base64 모듈을 열면, 두 세대의 인터페이스가 나란히 앉아 있습니다. b64decode를 중심으로 한 현대 쪽은 바이트 계열 객체(그리고 단순한 ASCII 문자열)를 다시 바이트로 되돌리며, RFC 4648이 정의한 두 가지 Base64 변형 모두를 말합니다. 레거시 쪽은 더 낡았고 파일 지향적입니다. 파일 객체를 다루고, 표준 알파벳만 알며, 1996년 MIME 메일 표준인 RFC 2045가 인코딩된 출력에 요구했던 76자 줄바꿈 줄을 중심으로 만들어졌습니다. 오래된 코드에서 레거시 이름을 제법 자주 만나게 될 테니, 여기 메뉴의 디코딩쪽 전체를 정리합니다:
| 함수 | 하는 일 | 참고 |
|---|---|---|
base64.b64decode(s, altchars=None, validate=False) |
주력 일꾼: Base64 덩어리를 원시 바이트로 되돌린다 | bytes 또는 ASCII 문자열을 받아, 항상 bytes를 반환한다 |
base64.standard_b64decode(s) |
같은 일을, 표준 알파벳으로 고정해 한다 | 변형을 확실하게 알 때 유용하다 |
base64.urlsafe_b64decode(s) |
-와 _를 쓰는 URL-safe 알파벳을 읽는다 |
JWT를 읽는 쪽 |
base64.decodebytes(s) |
줄바꿈된 Base64 한 줄 이상을 디코딩한다 | Python 3.1에 추가, MIME 친화적 경로, 관대 |
base64.decode(input, output) |
Base64 파일을 원시 파일로 스트림한다 | 레거시, 줄마다 읽음, 관대 |
base64.b32decode(s, casefold=False) |
작은 사촌 Base32를 디코딩한다 | casefold는 소문자 입력을 허용 |
base64.b16decode(s, casefold=False) |
Base16, 즉 순수 16진수를 디코딩한다 | Python 3.14에서 최대 6배 빠름 |
binascii.a2b_base64(s, strict_mode=False) |
진짜 일을 하는 C 레벨 함수 | strict_mode로 엄격함에 직접 접근, Python 3.11부터 |
아래의 모든 것은 첫 줄 위에서 세워집니다. 더 깊어지기 전에 알 가치가 있는 사실이 하나 있습니다. 공식 문서에서 이 모듈은 "인터넷 데이터 처리" 아래에, binascii 바로 옆에 있습니다. 그 자리는 우연이 아닙니다. b64decode는 얇은 래퍼입니다. 알파벳을 번역해 주고(altchars를 넘길 때), 무거운 일은 C 레벨의 binascii.a2b_base64가 하도록 내버려 두죠. 그래서 이 함수는 빠르고, 그 에러 메시지는 C 특유의 아삭하고 무감각한 맛을 냅니다.
주력 일꾼: b64decode
전체 계약이 여기 있습니다. 머릿속에 들어갈 만큼 짧습니다. 이 함수는 바이트 계열 객체 또는 ASCII 문자열, 선택적인 두 문자 알파벳 교체, 그리고 검증 플래그를 받습니다. 돌려 주는 것은 bytes 객체입니다. 실패할 때는 binascii.Error를 던지는데, 예외 한 가지를 한 번에 붙잡아야 할 경우를 대비해 ValueError의 하위 클래스로 되어 있습니다:
import base64
data = base64.b64decode("Zm9vYmFy")
print(data)
# b'foobar'
print(type(data))
# <class 'bytes'>
바로 그 마지막 줄이 이 글에서 가장 중요한 한 줄입니다. 결과는 바이트이고 문자열이 아닙니다. Python은 딱 마땅한 선까지만 손을 잡아 줍니다. 객체를 출력하면 b'...' 표현을 보여 주고, 문자열에 붙여 보려 하면 TypeError가 나죠. 진짜 텍스트가 필요해지는 순간, 그 결정은 당신의 것입니다. 아래 인코딩 섹션은 그 결정이 언제 쉬운지, 언제 함정인지 다룹니다.
선택 인자 altchars는 표준 알파벳의 +과 /를 다른 글자 쌍으로 바꿉니다. 정확히 그 손잡이가 URL-safe 변형을 만들어 내고, urlsafe_b64decode가 b64decode 위에 이렇게 세워져 있는 것입니다. 직접 altchars를 꺼낼 일은 드물지만, 그 기계가 있다는 사실은 알아 두면 좋습니다. 그 밖의 모든 일에서 이 함수는 그저 일을 해 냅니다. 빠르고, C로.
기본은 관대하게, 요청 시 엄격하게
기본적으로 b64decode는 예의 바르고 잘 잊는 쪽입니다. 64자 알파벳에 없는 문자(altchars에도 없는 것)는 디코딩이 시작되기 전 조용히 버려지고, 살아남은 것만 디코딩됩니다. 경고도, 통보도, 확인할 반환값도 없고, 그저 결과가 있을 뿐입니다. 그 관대함에는 고귀한 조상이 있습니다. RFC 2045의 6.8절은 디코더에게 "표 1에 없는 모든 줄바꿈 문자나 기타 문자는 무시해야 한다"고 말합니다. SMTP는 역사적으로 긴 줄을 줄바꿈해 왔고, 그 사이로 이질적인 문자가 뿌려졌기 때문입니다. 메일 클라이언트, 채팅 앱, PDF 복사를 거쳐 온 페이로드는 아무 준비 없이도 디코딩되는 경우가 많은데, 이것이 진정한 초능력입니다.
그 호의는 동시에, 기본 디코더가 검증기로는 쓸모없는 이유이기도 합니다. RFC 4648의 12절은 그 위험을 명백히 말합니다. 알파벳 밖의 문자를 거부하는 대신 무시하면 정보를 새길 수 있는 은밀한 채널이 열리고, 두 가지 다른 입력이 같은 바이트로 디코딩될 수 있어 문자열 동일성 검사가 깨질 수 있다는 것입니다. 본인이 인코딩하지 않은 것은 무엇이든, validate=True를 넘기고 예외를 정답으로 취급하세요. 여기가 피해 보고서입니다. 모든 줄이 어떤 현대 Python에서든 재현됩니다:
| 들어가는 것 | 관대 (기본값) | validate=True |
|---|---|---|
Zm9vYmFy (깨끗한 페이로드) |
b'foobar' |
b'foobar' |
Zm9v\r\nYmFy (가운데 줄바꿈) |
b'foobar' |
binascii.Error |
Zm9v YmFy (여분 공백) |
b'foobar' |
binascii.Error |
Zm9v!YmFy (새어 들어온 느낌표) |
b'foobar' |
binascii.Error |
junkZm9vYmFy (페이로드 앞에 온 단어) |
b'\x8e\xe9\xe4foobar' |
b'\x8e\xe9\xe4foobar' |
Zm9v=YmFy (가운데 패딩) |
b'foobar' |
binascii.Error |
=Zm9v (앞에 온 패딩) |
b'foo' |
binascii.Error |
==== (패딩 네 개, 데이터 없음) |
b'' |
binascii.Error |
(빈 입력) |
b'' |
b'' |
관대 열이 조용히 일을 하는 모습을 보세요. 사람들의 눈을 가장 먼저 빼앗는 줄은 앞에 단어가 온 줄입니다. junk의 네 글자는 우연히도 모두 Base64 알파벳에 있으니, 이 "쓰레기"는 진짜 바이트 3개로 디코딩되어 태연한 얼굴로 페이로드에 붙습니다. 엄격 모드는 이 줄에서는 구원군이 아닙니다. 입력이 진짜로 유효한 Base64이기 때문이죠. 단호하게 거부당하는 것은 다른 줄들이고, 거부는 딱 한 가지 모양을 띱니다. 몇 가지 기억하기 쉬운 메시지 중 하나를 싣고 오는 binascii.Error입니다:
Incorrect padding- 버림 이후 길이가 4의 배수가 아니거나, 마지막 그룹이 너무 짧을 때. 패딩이 전혀 없는Zm9vYmE같은 문자열은 여기 떨어집니다.Invalid base64-encoded string: number of data characters (N) cannot be 1 more than a multiple of 4- 입력이 다음 그룹까지 정확히 한 문자 모자랄 때. 잘리거나 복사-붙여넣기된 페이로드의 전형적인 지문입니다.Only base64 data is allowed- 알파벳 밖의 문자가 엄격 모드까지 살아남았을 때. 줄바꿈 하나도 한 문자로 잡힙니다.Excess padding not allowed- 문자열 중간에 패딩이 있거나, 마지막 그룹이 허용하는 것보다 패딩이 많을 때.Leading padding not allowed- 문자열이=로 시작할 때.- 그리고 다른 가문의 하나:
ValueError: string argument should contain only ASCII characters. ASCII가 아닌 글자가 든 문자열을 넘기면 이것을 받습니다. 문자열은 받지만, ASCII인 것만 받습니다.
막후로, validate=True는 아예 별도의 코드 경로가 아닙니다. 이 모듈은 그 플래그를 binascii.a2b_base64의 strict_mode 파라미터로 전달하는데, 바로 Python 3.11에서 binascii에 추가된 엄격 체크입니다. base64 레이어를 거치지 않고 엄격함을 원할 때 직접 손이 닿게 되는 것이죠:
import binascii
line = b"Zm9vYmFy"
print(binascii.a2b_base64(line, strict_mode=True))
# b'foobar'
엄격 모드를 눈 감고 믿기 전에 확인할 특이점이 하나 있습니다. 끝에 붙은 줄바꿈 한 줄마저 거부합니다. 그래서 MIME 줄바꿈 블록은 validate=True의 몫이 아니라, 관대 경로나 decodebytes의 몫입니다. 엄격 경로는 완벽히 깨끗할 것으로 기대하는 데이터, 당신 코드에서 막 나온 갓 만든 토큰 같은 데에 남겨 두세요.
base64url, URL에 들어가는 알파벳
표준 알파벳에는 URL이 싫어하는 두 문자가 있습니다. + 기호는 어떤 폼 디코더든 공백으로 읽습니다. / 기호는 경로 구분자로 예약되어 있습니다. RFC 4648의 5절은 사촌 변형을 정의합니다. +가 -가 되고 /가 _가 되는 변형이죠. 데이터 길이를 컨텍스트에서 알 수 있을 때는 패딩을 뺍니다. RFC는 이 변형에 그럴듯한 정식 이름, base64url까지 붙여 주며, 그냥 "base64"라고 부르지 말라고 못 박습니다. 가장 자주 만날 곳은 JSON Web Token 안입니다. 토큰의 모든 부분이 패딩 없는 base64url이죠. OAuth 토큰이나 API 커서 파라미터에서도 나타납니다.
Python은 이를 위해 전용 함수를 들어 줍니다. urlsafe_b64decode입니다. 하이픈과 밑줄을 다시 더하기와 슬래시로 번역한 뒤 디코딩하지만, 패딩을 다시 붙여 주지는 않습니다. 패딩 없는 입력이 JWT의 정상적인 경우이므로, 산술 한 줄이 먼저 옵니다. PyJWT 같은 라이브러리가 밑에서 사용하는 것과 같은 줄입니다:
import base64
segment = "Zm9vYmE"
padded = segment + "=" * (-len(segment) % 4)
print(base64.urlsafe_b64decode(padded))
# b'fooba'
"=" * (-len(segment) % 4) 식은 트릭처럼 보이지만, 그것이 전부입니다. 패딩을 0개, 1개, 2개 만들어 내고 절대 3개는 만들지 않으므로, 이미 패딩된 문자열은 손대지 않고 그대로 통과합니다. 모든 길이의 문자열에서 동작하게 해 주는 것이 음수 나머지 연산이고, 이것은 Python 개발자가 누구나 적어도 한 번은 직접 쓰는 Base64 산술의 한 줄입니다.
이제 위험한 혼동을 볼까요. 두 알파벳이 헷갈릴 만큼 비슷해 보이니까요. base64url 문자열을 표준 디코더에 넣으면, 하이픈과 밑줄은 표준 알파벳에 그냥 없습니다. 그래서 관대 디코더는 그것들을 삼켜 버리고, 남은 것으로 디코딩합니다. 어떤 페이로드는 깨진 바이트 스트림이 되고, 어떤 페이로드는 아예 아무것도 되지 않습니다:
import base64
tricky = base64.urlsafe_b64encode(b"\xfb\xff\xfe")
print(tricky)
# b'-__-'
print(base64.standard_b64decode(tricky))
# b'' - 모든 문자가 조용히 버려짐
print(base64.urlsafe_b64decode(tricky))
# b'\xfb\xff\xfe'
반대 방향은 관대합니다. 그래서 이 혼동이 눈치채지지 않죠. urlsafe_b64decode는 먼저 자기 알파벳을 번역하고 나서 관대하게 디코딩하므로, +와 /가 든 표준 알파벳 문자열을 기꺼이 받아들입니다. 교훈은 즉흥적으로 하지 않는 것입니다. 변형마다 함수 하나를 골라 고수하는 것, 외국 통화를 쓰듯 말이죠. 엔이 통하는 곳에서 엔을 쓰지, 틀린 환전소에서 쓰지 않는 것.
출력은 바이트다: 인코딩 대화
사람들이 Base64에 가져오는 인코딩 질문의 절반을 결정짓는 문장이 여기 있습니다. b64decode는 바이트를 디코딩할 뿐, 텍스트를 디코딩하지 않습니다. 인코딩 인자도 없고, 변환도 없으며, 그 바이트가 무엇을 뜻해야 한다고 Python에 알려 주는 것은 아무것도 없습니다. 의미는 당신이 컨텍스트에서 제공해야 할 것이며, 그 컨텍스트는 거의 항상 셋 중 하나입니다. 그렇게 말하는 헤더, 그렇게 약정한 API 계약, 혹은 바이트 속에 숨어 있는 매직 넘버.
import base64
raw = base64.b64decode("w6l0w6k=")
print(raw)
# b'\xc3\xa9t\xc3\xa9'
print(raw.decode("utf-8"))
# été
같은 생각으로 틀린 라벨을 붙이면 시끄러운 실패가 나는데, 그거야말로 은혜입니다. 유효한 UTF-8이 아닌 바이트는 문자열이 되는 것을 거부하고, 예외는 정확히 어느 바이트가 문제를 일으켰는지 알려 줍니다:
import base64
raw = base64.b64decode("/w==")
try:
raw.decode("utf-8")
except UnicodeDecodeError as caught:
print(caught)
# 'utf-8' codec can't decode byte 0xff in position 0: invalid start byte
세 가지 실전 규칙이 이 섹션을 호러 쇼에서 지켜 줍니다. 첫째: 디코딩된 데이터가 JSON이면, 수동 디코딩은 아예 필요 없습니다. json.loads는 Python 3.6부터 바이트를 직접 받아 왔고, UTF-8, UTF-16, UTF-32는 알아서 감지하니까요. 둘째: 바이너리는 텍스트가 아니므로, PNG의 "감지된" 인코딩은 사실보다 운 좋은 추측에 가깝습니다. 라벨이 아니라 바이트를 확인하세요. 셋째: 보낸 사람이 인코딩을 알려 주었다면, 보낸 사람을 믿으세요. content-type 헤더든 API 문서든, 매번, 예외 없이 모든 감지기보다 우선하기 때문입니다.
디코딩된 Base64가 Python 코드에서 나타나는 곳
잠시 뒤면 그 모양들을 알아보게 됩니다. Python 애플리케이션에서 디코딩된 Base64가 나타나는 곳과, 각각에 대한 한 줄 레시피를 담은 현장 가이드입니다. 이어지는 섹션들이 가장 흔한 것들을 온전히 다룹니다:
| 어디서 만나나 | 무엇인가 | 어떻게 읽나 |
|---|---|---|
| JWT | 헤더, 페이로드, 서명 부분 (RFC 7519) | 마침표로 나누고, 패딩 수정을 더한 urlsafe_b64decode |
Authorization 헤더 |
HTTP Basic 자격 증명, user:pass (RFC 7617) |
Basic 접두어를 걷어 내고, 디코딩, 첫 콜론에서 나누기 |
data: URI |
HTML이나 CSS 안의 인라인 미디어 (RFC 2397) | 첫 콤마에서 잘라 내고, 나머지를 디코딩 |
| 메일 첨부 파일 | Content-Transfer-Encoding: base64 본문 (RFC 2045) |
메시지 파트에 get_payload(decode=True) |
| 메일 헤더 값 | =?charset?b?...?= 인코딩된 단어 (RFC 2047) |
email 패키지가 대신 디코딩하게 |
| PEM 파일 | 갑옷 입은 키 또는 인증서 (RFC 7468) | 갑옷 줄을 덜어 내고, 본문을 DER로 디코딩 |
| JSON API 필드 | 문자열로 밀수된 바이너리 | 디코딩한 뒤, 결과를 텍스트가 아니라 바이트로 취급 |
| TEXT 컬럼이나 환경 변수 | 텍스트만 허용하는 곳에 저장된 바이너리 또는 JSON | 디코딩한 뒤, 합의한 인코딩으로 파싱하거나 쓰기 |
JSON Web Token 읽기
JWT는 마침표로 연결된 base64url 세 조각입니다. 헤더, 페이로드, 서명. 앞의 둘은 평범한 JSON이므로, 살짝 들여다보는 것은 각 한 줄이면 됩니다. 위 섹션의 패딩 수정을 쓰면서요:
import base64
import json
def read_part(segment):
padded = segment + "=" * (-len(segment) % 4)
return base64.urlsafe_b64decode(padded)
token = ("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0."
"8Rmup2hf8jZvoBgoCRqRWlBFNtvUYmA0eR7YKellPMs")
head, body, _signature = token.split(".")
print(json.loads(read_part(head)))
# {'alg': 'HS256', 'typ': 'JWT'}
print(json.loads(read_part(body)))
# {'sub': '1234567890', 'name': 'John Doe'}
범위에 대해 주석 하나, 왜냐하면 이것이 중요하니까요. 이렇게 토큰을 검사하는 것은 디버깅 도구이지, 인증 메커니즘이 아닙니다. 페이로드가 읽힌다는 것은 그것이 진품이라는 뜻이 아니죠. 공격자는 당신의 시크릿을 한 번도 알지 못한 채 앞의 두 세그먼트를 위조할 수 있습니다. 진정한 검증을 원한다면, 토큰을 PyJWT(pip install pyjwt)에게 맡기세요. 서명을 확인하고, 명시적인 알고리즘 목록이 없으면 디코딩을 거부하니까요:
import jwt
# 32바이트 미만의 키는 PyJWT의 InsecureKeyLengthWarning을 받습니다 (PyJWT 2.11+), 데모 키에게는 합당한 불평이죠.
decoded = jwt.decode(token, "super-secret-key", algorithms=["HS256"])
print(decoded)
# {'sub': '1234567890', 'name': 'John Doe'}
키를 틀리면 딕셔너리 대신 예외가 나는데, 이것이 프로덕션 코드에서 정확히 원하는 동작입니다. 그리고 토큰이 만료된 타임스탬프와 함께 도착했다면, PyJWT는 그것에 대해서도 예외를 던지므로, 클레임 이름을 스스로 기억할 필요가 영원히 없습니다.
Data URI 열기
Data URI는 브라우저가 두 번째 요청을 보내지 않도록, 미디어를 HTML이나 CSS 안에 직접 넣습니다: data:, 미디어 타입, base64라는 단어, 콤마, 그리고 인코딩된 바이트. 나눌 곳은 첫 콤마, 딱 거기까지. 그 뒤의 모든 것은 단순한 표준 알파벳 페이로드입니다:
import base64
uri = ("data:image/png;base64,"
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
"AAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==")
mime, payload = uri.split(",", 1)
data = base64.b64decode(payload)
print(mime)
# data:image/png;base64
print(data[:8])
# b'\x89PNG\r\n\x1a\n'
결과의 앞부분, 8바이트 PNG 시그니처는 당신이 맞는 것을 디코딩했는지 확인하는 값싸고 경쾌한 체크입니다. 언급할 가치가 있는 함정이 두 개 있습니다. URI가 크롤링한 페이지나 채팅 메시지에서 왔다면, 먼저 HTML 엔티티와 새어 들어온 공백을 제거하세요. 관대 디코더는 쓰레기 대다수를 용서하고, 에러 대신 깨진 이미지를 건네 줄 테니깐요. 그리고 신뢰할 수 없는 입력을 대량으로 디코딩하고 있다면, validate=True를 넘기세요. 엄격 검증을 통과하지 못하는 data URI는 애초에 올바르게 형성된 적이 없는 data URI이며, 직감 하나에 그것을 디스크에 쓰고 싶지는 않을 테니까요.
Authorization 헤더 풀기
Basic 인증(RFC 7617)은 HTTP에서 가장 오래된 방식입니다. 그럼에도 여전히 상당수의 API 연동, 웹훅, CI 파이프라인을 지탱하고 있죠. 클라이언트는 자신의 자격 증명을 user:pass로, base64 인코딩한 뒤, Basic이라는 단어 뒤에 싣어 보내습니다:
import base64
header = "Basic amFuZTpwYTpzcw=="
decoded = base64.b64decode(header[len("Basic "):]).decode("utf-8")
user, _, password = decoded.partition(":")
print(user, password)
# jane pa:ss
partition에 주목하세요. 나중에 당신을 구해주는 디테일이니까요. 비밀번호에는 콜론이 들어 있을 수 있고, 사용자 ID에는 들어 있지 않으며, 구분점은 첫 콜론 하나뿐입니다. RFC 자체가 불퉁하게 말하고 있기 때문에, 정직한 주석 하나: base64는 암호화가 아닙니다. RFC 4648은 base 인코딩이 "비밀번호처럼 쉽게 알아볼 수 있는 정보를 시각적으로 가리지만, 계산적인 기밀성은 어떤 것도 제공하지 않는다"고 말합니다. Basic 헤더는 트래픽을 보는 누구나 디코딩할 수 있으므로, 보안 경계가 아니라 TLS로 보호된 연결을 위한 편의 기능으로 취급하세요. 헤더를 보내는 쪽이 당신이면, requests가 auth=("jane", "pa:ss")로 대신 만들어 줍니다. 이 라이브러리가 이미 스택에 들어 있다면 쓰느 값이 있습니다.
이메일, 원조 고객
Base64가 1993년에 표준화된 이유는 딱 한 가지 일 때문이었습니다. 바이너리를 이메일에서 살아남게 만드는 것. MIME 표준인 RFC 2045가 Content-Transfer-Encoding: base64 본문 인코딩을 정의했고, 여전히 첨부 파일이 인터넷을 건너는 기본 방식입니다. Python의 email 패키지는 그 일을 통째로 해 줍니다. 헤더를 파싱하고, RFC 2047이 헤더 필드에 숨긴 =?utf-8?b?...?= 인코딩된 단어를 디코딩하며, 요청하면 본문을 base64 디코딩합니다:
import email
from email import policy
raw = (b"Subject: =?utf-8?b?w6l0w6k=?=\r\n"
b"From: sender@example.com\r\n"
b"To: reader@example.com\r\n"
b"Content-Transfer-Encoding: base64\r\n"
b"\r\n"
b"w6l0w6kgbWFpbA==\r\n")
msg = email.message_from_bytes(raw, policy=policy.default)
print(msg["Subject"])
# été
print(msg.get_payload(decode=True))
# b'\xc3\xa9t\xc3\xa9 mail'
get_payload(decode=True) 호출은 Content-Transfer-Encoding 헤더를 읽고 본문을 base64 디코딩해 주는데, 그 길에서 76자 줄들도 풀어 줍니다. policy=policy.default 인자는 Python 3.6부터의 현대적 인터페이스를 고르는데, 여기서 새 정책 기반 email API가 임시 상태를 벗어났죠. 덕분에 디코딩된 헤더 값을 박스에서 바로 받습니다. 레거시 파서는 여전히 동작하지만, 인코딩된 단어를 손으로 디코딩하게 되죠. 완전한 메시지가 아니라 맨 단편, 누군가 티켓에 붙여 넣은 블록 같은 것을 파싱할 때만 decodebytes로 내려갑니다. 멀티파트 메시지는 iter_attachments()로 반복하며, 각 파트에 같은 한 줄 대우를 해 주세요.
PEM 갑옷과 cryptography 패키지
PEM 파일은 헤더 줄, 줄바꿈된 Base64 몇 줄, 푸터 줄, 그리고 그것뿐입니다. 갑옷은 장식이고, Base64가 이야기 전체입니다. 밑의 원시 DER 구조로 디코딩되기 때문이죠. cryptography 패키지(pip install cryptography)는 그 결과를 바로 로드할 수 있어서, 인증서와 키가 관련된 모든 것의 표준 도구가 되었습니다:
import base64
from cryptography import x509
pem = b"""-----BEGIN CERTIFICATE-----
MIIBGzCBwaADAgECAgEBMAoGCCqGSM49BAMCMBcxFTATBgNVBAMMDGV4YW1wbGUu
dGVzdDAeFw0yNjA4MjkxNzIxMzZaFw0yNjA4MzAxNzIxMzZaMBcxFTATBgNVBAMM
DGV4YW1wbGUudGVzdDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABPvNHjdF4b1n
SkBDT6UWtG2k8ICe45eL3kSkVfuhriev1uO9PBLMP50HWnrLbCXtl3lhWaVibctl
QbWRG4xqGLcwCgYIKoZIzj0EAwIDSQAwRgIhAKdFm5GLecg2fF7qUhSmKGtgNFaL
qVyKtDXK07N6GZd/AiEAtRXemnYqDMz77o9+VpM/NsNEwDi0yaVB+tKGLbdKJb0=
-----END CERTIFICATE-----
"""
body = b"".join(pem.splitlines()[1:-1])
der = base64.b64decode(body)
cert = x509.load_der_x509_certificate(der)
print(cert.subject.rfc4514_string())
# CN=example.test
대부분의 프로덕션 코드에서 갑옷을 벗기고 디코딩하는 일을 손으로 하지 않습니다. load_pem_x509_certificate가 갑옷 입은 바이트를 받아, Base64 단계를 밑에서 대신 처리하니까요. 수동 경로의 가치가 드러나는 순간은, DER 바이트가 이미 당신의 손에 있는 경우(데이터베이스 컬럼, 설정 파일, 프로토콜에서 온 바이트 버퍼)이거나, 블록이 문자열로 감겨서 도착했을 때, 신뢰하기 전에 안에 무엇이 있는지 보고 싶을 때입니다. 키도 같은 방식으로 동작하며, 같은 디코딩의 반대편에 load_der_private_key가 기다리고 있습니다.
파일, 매직 넘버, 그리고 .b64 습관
디코딩은 일의 절반에 불과합니다. 바이트는 보통 파일을 원하니까요. 패턴은 읽기, 디코딩, 확인, 쓰기이고, 확인이 중요한 이유는, 깨진 페이로드가 그렇지 않으면 조용히 잘못된 파일을 만들어, 그 파일을 몇 주 뒤에야 발견하게 되기 때문이에요:
import base64
import binascii
with open("payload.b64", "rb") as handle:
encoded = handle.read()
try:
data = base64.b64decode(encoded, validate=True)
except binascii.Error:
data = base64.b64decode(encoded)
with open("payload.bin", "wb") as out:
out.write(data)
빠른 일회용 변환에는, 레거시 파일-대-파일 함수가 줄바꿈된 줄까지 통째로 한 번의 호출로 여정을 마칩니다:
import base64
with open("photo.b64", "rb") as src, open("photo.png", "wb") as dst:
base64.decode(src, dst)
그런데 아까 디코딩한 것은 대체 무엇이었을까요? 거의 모든 일반 포맷의 앞 바이트는 고정된 시그니처이고, Base64가 결정론적이므로 인코딩된 시그니처도 고정되어 있습니다. 이 접두사 중 하나를 보는 것은 멀리서 번호판을 알아보는 것과 같습니다:
| Base64가 이렇게 시작하면 | 아마도 이것은 |
|---|---|
iVBORw0KGgo |
PNG 이미지 |
/9j/ |
JPEG 이미지 |
R0lGODlh |
GIF 이미지 |
JVBERi0 |
PDF 문서 |
UEsDBA== |
ZIP 아카이브 |
UklGRg== |
RIFF 컨테이너 (WAV, WEBP, AVI) |
LS0tLS1CRUdJTg== |
ASCII 갑옷 블록 ("-----BEGIN ...") |
그리고 파일이 쓰이는 동안 크기 계산을 해 두세요. 디스크가 차면 사람들을 놀라게 하는 숫자가 바로 그것이기 때문입니다. 인코딩은 데이터를 대략 3분의 1 팽창시키므로, 300 KB 파일은 약 400 KB의 Base64 텍스트로 여행하고, 다시 디코딩한 파일은 더 작은, 원래 크기입니다. 당신의 디스크, 그리고 파일을 통째로 한 번에 읽는다면 당신의 메모리도, 그 차이를 예산으로 남겨 두세요.
데이터베이스, 설정 파일, 환경 변수
Base64는 바이너리(또는 JSON)를 텍스트만 허용하는 저장소로 밀수하는 데 가장 사랑받는 방법입니다. TEXT 컬럼, .ini 파일의 값, 배포 파이프라인의 환경 변수. 디코딩 레시피는 파일과 같고, 디스크만 빼면 됩니다:
import base64
import json
stored = "eyJyb2xlIjogImFkbWluIiwicHJvamVjdCI6Im15c2l0ZSJ9"
payload = json.loads(base64.b64decode(stored))
print(payload)
# {'role': 'admin', 'project': 'mysite'}
이 구석에 대한 두 가지 메모. 저장된 값이 JSON이면, 중간 단계인 .decode("utf-8")를 건너뛰고 json.loads가 바이트를 직접 받으게 하세요. Python 3.6부터 그렇게 해 왔으니까요. 그리고 정직한 경고 하나, 이 글 전체에서 가장 비싼 오해가 여기에 살고 있으니까요. 환경 변수나 설정 파일의 Base64는 파일을 흘겨보는 사람을 위한 방패이지, 파일을 읽는 사람을 위한 방패가 아닙니다. 값이 진짜로 민감하다면, 먼저 암호화하세요(cryptography 패키지는 정확히 이를 위해 Fernet을 함께 줍니다). 그리고 나서야, 저장소가 텍스트를 요구한다면 암호문을 Base64로 만드세요.
페이로드가 조각조각 도착할 때
표준 라이브러리에 증분 Base64 디코더는 없습니다. update()를 하고 finish()로 끝내는 짝도 없으니, 스트리밍 데이터는 당신 자신의 작은 장부 관리가 필요합니다. 산술은 단순하고 동시에 엄격합니다. 인코딩된 문자 4개가 바이트 3개이므로, 완전한 4자 그룹만 디코딩할 수 있고, 나머지는 다음 청크로 넘겨야 합니다:
import base64
def chunked_decode(chunks):
out = []
leftover = b""
for chunk in chunks:
buffer = leftover + chunk
whole = len(buffer) // 4 * 4
if whole:
out.append(base64.b64decode(buffer[:whole]))
leftover = buffer[whole:]
if leftover:
out.append(base64.b64decode(leftover + b"=" * (-len(leftover) % 4)))
return b"".join(out)
소켓 버퍼, 64 KB씩 읽은 파일, 줄바꿈을 걷어 낸 줄 생성기를 넣어도, 출력이 통째로 한 번에 디코딩한 것과 동일합니다. 입력이 깨끗하고 줄바꿈이 없음을 보장한다면, 각 완전한 그룹을 validate=True로 디코딩해 엄격함을 지켜 두세요. 그리고 마지막 나머지에는 패딩 수정이 필요할 수 있다는 것도 기억하세요. 그래서 헬퍼는 마지막 디코딩 전에 그것을 더합니다. 이것은 인코더가 반대편에서 쓰는 것과 같은 이음매 로직이고, 다만 3바이트 대신 4문자인 차이입니다.
명령줄에서
base64 모듈은 작은 명령줄 도구도 겸합니다. 페이로드가 코드 대신 터미널에 앉아 있을 때 편하죠. 인코딩이 기본이고, -d(또는 그 쌍둥이 -u)가 디코딩합니다:
echo -n "hello world" | python3 -m base64
aGVsbG8gd29ybGQ=
echo -n "aGVsbG8gd29ybGQ=" | python3 -m base64 -d
hello world
파일을 주지 않으면 표준 입력에서, 이름을 주면 그 파일에서 읽습니다. 밑에서는 레거시 파일-대-파일 인터페이스이므로, 출력은 76자에서 줄바꿈되고 각 줄 끝에 줄바꿈 문자가 붙어 옵니다. 엄격성을 최대로 올린 세션에 페이로드를 붙여 넣을 때, 디코더의 원라이너 버전은 좋은 습관입니다:
import base64
import sys
print(base64.b64decode(sys.stdin.read(), validate=True))
태워지는 아홉 가지 방법
Python의 모든 Base64 디코딩 버그는 이것들 중 하나입니다. 이 목록을, 패닉 상태에서도 찾을 수 있는 곳에 두세요. 올해 당신이 읽을 다른 어떤 문서보다 더 많은 오후를 잡아먹었으니까요. 처음 셋은 코드가 함께합니다. 폐허를 한 번 보면 기억하기가 쉬우니까요:
빠진 패딩. 가장 흔한 크래시입니다. 보통 JWT 부분이나 API 값이 패딩 없이 도착해서 그렇습니다:
import base64
import binascii
segment = "Zm9vYmE"
try:
base64.urlsafe_b64decode(segment)
except binascii.Error as caught:
print(caught)
# Incorrect padding
padded = segment + "=" * (-len(segment) % 4)
print(base64.urlsafe_b64decode(padded))
# b'fooba'
잘린 문자열. 에러가 데이터 문자 수가 "4의 배수보다 1 커서는 안 된다"고 말할 때, 페이로드는 이동 중에 잘렸거나, 복사-붙여넣기가 끝에서 한 문자를 떨어뜨린 것입니다. 길이가 4로 나눈 나머지가 1인 문자열을 패딩이 얼마나 달아도 고칠 수 없습니다. 데이터가 그저 없으며, 정직한 답은 페이로드를 다시 달라는 것입니다.
조용한 쓰레기. 관대 모드는 살아남은 것을 무엇이든 디코딩하고, 평범한 영어 단어는 Base64 알파벳 글자로 가득하므로, 페이로드 앞에 새어 들어온 단어는 당신의 데이터에 붙은 진짜 바이트가 됩니다:
import base64
print(base64.b64decode("junkZm9vYmFy"))
# b'\x8e\xe9\xe4foobar' - 순수한 허구의 3바이트, 그리고 그 뒤에 진실
나머지 여섯은 코드가 전혀 필요하지 않습니다:
- base64url 문자열을 표준 디코더로 디코딩했다. 하이픈과 밑줄은 표준 알파벳에 없으니, 조용히 사라지고 페이로드는 깨져 나와, 아니면 아예 빈 것이 됩니다. 패딩 수정을 더한
urlsafe_b64decode를 쓰세요. - 결과가 바이트인 것을 잊었다. 문자열에 붙이면
TypeError가 나고, JSON 응답에 넣으면b'...'표현이 직렬화됩니다. 경계에서, 의도적으로, 진짜로 의미하는 인코딩으로.decode(encoding)을 호출하세요. - ASCII가 아닌 문자열을 넘겼다. 디코더는 문자열을 받지만, ASCII인 것만 받습니다. 나머지는 전부
ValueError입니다. 페이로드가 틀린 인코딩으로 읽은 텍스트 파일에서 나왔다면, 디코딩이 아니라 읽기를 고치세요. - 두 번 디코딩했다. 데이터는 이미 앞 단계에서 디코딩되어 있거나, Base64의 Base64였고, 두 번째 디코딩이 당신의 비밀번호를 다시는 아무도 읽지 못할 6바이트로 만들었습니다.
- 줄바꿈된 데이터에 엄격 모드를 썼다. 줄바꿈 한 줄이면
validate=True가 예외를 던지기에 충분합니다. 그래서 MIME 블록과 PEM 본문은 엄격한 도구가 아니라 관대한 도구의 몫입니다. - 가운데 패딩을 신뢰했다. 관대 모드에서는 문자열 어딘가의
=는 조용히 버려지므로, 패딩 자리가 어긋난 깨진 페이로드가 "올바른" 답으로 디코딩될 수 있습니다. 알아채는 것은 엄격 모드뿐이고, 그 알아채기는 거부하는 방식으로 합니다.
당신의 일이 관문을 지키는 일이라면, 두 가지 성향을 함께 일하게 만드는 작은 헬퍼를 드리죠. 먼저 엄격, 그다음 패딩 수정, 그리고 어느 쪽도 소용없으면 시끄러운 실패:
import base64
import binascii
def safe_decode(text):
candidate = text.strip()
try:
return base64.b64decode(candidate, validate=True)
except binascii.Error:
padded = candidate + "=" * (-len(candidate) % 4)
return base64.b64decode(padded, validate=True)
print(safe_decode("Zm9vYmE"))
# b'fooba'
print(safe_decode("Zm9vYmFy"))
# b'foobar'
헬퍼는 여전히 말 그대로 신뢰하라고 주어진 알파벳을 신뢰한다는 점에 주목하세요. 입력이 base64url일 수 있다면, urlsafe_b64decode에 넘기세요. 검증은 계약이고, 계약은 데이터가 어떤 변형인지 말합니다.
조용한 모듈의 삼십 년
이 모듈은 25년째 표준 라이브러리에 있었고, 대부분 그냥 앉아 있었습니다. 움직일 때면 움직임은 작았지만 실제였고, 오래된 포럼을 떠도는 몇 가지 "나만 되는" 이야기를 설명해 줍니다:
- 1995 - Jack Jansen가
base64.py를 다시 써, 진짜 일을 C 레벨의binascii모듈에 위임하도록 했다. 그 주석은 아직 파일에 있고, 위임은 오늘도 사실이다. - 2003, Python 2.4에 포함 - Barry Warsaw가 완전한 RFC 3548 지원을 더했다:
b16,b32,b64가문, 그리고 당신이 오늘 쓰는standard_*과urlsafe_*변형. - Python 3.1 -
encodestring과decodestring는encodebytes와decodebytes로 비추천이 됐다. 붙은 이름이 바로 이것들이다. - Python 3.3 - 디코딩 함수들이 ASCII 문자열을 받기 시작했다. 모든 디코딩이 바이트 리터럴로 시작하던 시대가 끝났다.
- Python 3.4 - 어떤 바이트 계열 객체(memoryview 포함)든 어디서든 받아 준다. Base85 사촌
a85와b85가 모듈에 합류했다. - Python 3.9 - 오래된 비추천 상태의
encodestring와decodestring가 마침내 삭제됐다. 그것들을 부르는 오래된 튜토리얼은 한 단어 이름 바꾸기가 필요하다. - Python 3.10 -
b32hexencode와b32hexdecode가 확장된 16진수 알파벳과 함께 도착했다. 인코딩된 데이터를 사전순으로 정렬할 수 있게 해 주는 알파벳이 바로 그것이다. - Python 3.11 -
binascii.a2b_base64가strict_mode를 얻었다.validate=True가 밑에서 타는 것이 바로 그것이다. - Python 3.13 -
z85encode와z85decode가 ZeroMQ의 Z85 변형을 표준 라이브러리로 데려왔다. 오래된uu모듈은 PEP 594 아래에서 삭제됐고, 대신base64를 쓰라는 날카로운 주석이 따라 왔다. - Python 3.14 -
b16decode가 최대 6배 빨라졌다: 검증이 이제 정규식 대신bytes.translate위에서 돌아가고, 모듈은re를 아예 더는 임포트하지 않는다. 임포트 시간도 개선된 모듈 목록에 올랐다.
이 모든 것은 함수가 하는 일을 바꾸지 않았고, 그것이 이렇게 오래된 모듈의 조용한 사치입니다. 2005년에 Base64를 디코딩한 코드는 2026년에도, 같은 줄에서, 같은 결과로 그것을 디코딩합니다.
여백의 즐거움
진지한 일은 끝났으니, 모듈이 여백에 숨긴 작은 즐거움들을 꺼내 볼까요:
- 모듈의 공식 문서 자체는 10년 넘게 같은 데모를 보여 왔습니다:
b'data to be encoded'가 들어가b'ZGF0YSB0byBiZSBlbmNvZGVk'가 나옵니다. 지난 20년 동안 어떤 Python 릴리스의 base64 페이지를 읽어 보았다면, 당신은 이미 이 쌍을 만난 적 있습니다. junk라는 단어는 완전히 유효한 Base64 문자열입니다. 네 글자가 모두 알파벳에 있기에, 페이로드 앞에 새어 들어온 단어가 에러 대신 허구의 3바이트가 되는 것이고, 관대 모드가 그 별명을 얻은 이유이기도 합니다.urlsafe_b64decode는 우연히 이중 언어를 구사합니다. 먼저 자기 알파벳을 번역하고 나서 관대하게 디코딩하므로,+와/가 든 표준 알파벳 문자열도 읽습니다. 함수 하나, 변형 둘, 불평 제로.- 에러 메시지는 C 구현 이후로 한 번도 움직이지 않은 안정된 미니 사전입니다:
Incorrect padding,Only base64 data is allowed,Excess padding not allowed,Leading padding not allowed. 이것들을 익히면 코드 한 줄도 돌리지 않고 깨진 페이로드를 분류할 수 있습니다. - 빈 문자열은 아무 반응도 얻지 못하는 유일한 입력입니다:
b''가 들어가b''가 나옵니다. 두 성향 모두. 들어가는 것도 없고, 나오는 것도 없고, 경보도 없습니다. - 모듈의 문서 문자열은 여전히 RFC 3548, 즉 2003년판 스펙을 이름으로 부릅니다. 현행 표준은 2006년부터 RFC 4648이며, 모듈은 그 문장을 업데이트할 번거로움 없이 그것을 충실히 따르고 있습니다.
- Python 2에는 디코딩 쪽에 타입 벽이 없었습니다: 평범한
str가 들어가 평범한str가 나옵니다. Python 3 개발의 2007년 바이트 대정비가 그것을 바꿨고, "왜 내 디코딩이 깨졌지" 스레드의 대부분은 여전히 오래된 Python 2 튜토리얼을 가리킵니다.
그러면, 온전한 철학을 네 가지 규칙으로 남깁니다. 본인이 인코딩하지 않은 것은 무엇이든 validate=True를 넘기고, 예외를 제안이 아니라 진짜 정답으로 취급하세요. 당신이 손에 쥔 것이 어떤 변형인지 알아 두세요. 표준, base64url, 아니면 MIME 줄바꿈된 것. 디코더가 알려 주지 않으니까요. 안 맞는 것은 버리면서 추측할 뿐입니다. 결과가 텍스트임을 증명하기 전까지는 바이트로 취급하고, 그러면 그 인코딩이 누구의 것인지 물어보세요. 그리고 이 함수의 가장 친근한 특징, Base64가 아닌 것조차 디코딩하려는 의지가 바로 그것이 위험한 이유이기도 하다는 것을 기억하세요. 그래서 매 호출마다, 그 입력이 얼마나 많은 신뢰를 얻었는지 결정하세요.
어느 순간 반대 방향으로 가야 한다면, 갓 나온 바이트를 토큰이나 첨부 파일, 인라인 이미지를 위해 그 친근한 글자 띠로 다시 감아야 할 때, b64encode의 온전한 이야기는 이 페이지 아래 관련 Base64 인코딩 글에서 상세히 다룹니다. 두 방향은 거울상이지만, 각자 자신의 놀라움 한 세트를 가지고 있고, 당신은 이제 이쪽을 마음속으로 압니다. 즐거운 디코딩을.
마지막 업데이트: 2026-09-08