이메일에 Base64가 필요한 이유
인터넷 메일은 길이가 제한된 줄로 된 7비트 ASCII 텍스트를 위해 만들어졌습니다. RFC 5322는 지금도 한 줄을 998자로 제한하고, SMTP는 받는 서버가 8BITMIME 확장을 알리지 않는 한 7비트 데이터의 안전한 전달만 보장합니다. 8비트 전송이라도 메시지에 NUL 바이트나 단독 캐리지 리턴과 라인 피드가 있어서는 안 되는데, 바이너리 파일이라면 언젠가는 반드시 이런 바이트를 담게 됩니다. 원시 바이트를 보내려면 BINARYMIME 확장이 필요하며, 보내는 쪽은 서버에서 서버로 이어지는 전체 경로에서 이를 기대할 수 없습니다.
MIME(RFC 2045)은 Content-Transfer-Encoding 헤더로 이 문제를 해결합니다. 이 헤더는 파트 본문이 어떻게 안전한 텍스트로 바뀌었는지 받는 쪽에 알려 줍니다. 값은 다섯 가지입니다.
| 값 | 의미 |
|---|---|
7bit | 일반 ASCII 줄, 인코딩 없음 |
8bit | 짧은 줄의 8비트 텍스트, 8BITMIME 필요 |
binary | 원시 바이트, BINARYMIME 필요 |
quoted-printable | 대부분 읽을 수 있는 텍스트, 비 ASCII 바이트는 =XX로 이스케이프 |
base64 | 최대 76자 줄의 Base64 |
첨부 파일에는 메일 소프트웨어가 거의 언제나 base64를 고릅니다. 알고리즘 자체는 Base64의 작동 원리에서 다룹니다.
Base64와 quoted-printable 비교
quoted-printable은 ASCII 문자는 그대로 두고 다른 모든 바이트를 =와 16진수 두 자리로 씁니다. UTF-8의 é(바이트 C3 A9)는 =C3=A9가 됩니다. 76자보다 긴 줄은 줄 끝에 =를 두는 소프트 줄 바꿈으로 나뉩니다.
그래서 가끔 악센트가 섞인 영어 텍스트에는 이상적이지만, 그 밖의 텍스트에는 낭비가 큽니다. 비 ASCII 바이트 하나에 3문자가 들기 때문입니다. Base64는 내용과 관계없이 일정하게 4/3배입니다. 세 언어의 문장을 20번 반복한 것을 Python의 quopri.encodestring과 base64.encodebytes(둘 다 LF 줄 끝)로 인코딩해 보았습니다.
| 샘플 (UTF-8) | 바이트 | Quoted-printable | Base64 |
|---|---|---|---|
| 영어 팬그램 | 900 | 926 | 1,216 |
| 악센트가 있는 프랑스어 문장 | 1,060 | 1,830 | 1,435 |
| 러시아어 팬그램 | 1,600 | 4,602 | 2,165 |
보통은 메일 클라이언트가 대신 고릅니다. 대부분 ASCII인 텍스트 본문에는 quoted-printable이나 7bit를, 첨부 파일과 라틴 문자가 아닌 문자의 텍스트에는 Base64를 씁니다. quoted-printable을 직접 읽거나 만들어야 한다면 quoted-printable 도구로 양방향 변환할 수 있습니다.
원본 메시지 속의 첨부 파일
첨부 파일이 있는 메시지는 multipart/mixed 본문입니다. 각 파트에는 자체 헤더, 빈 줄, 그리고 내용이 있습니다. 다음은 Python의 email 패키지로 만든 완전한 최소 메시지입니다(읽기 쉽도록 경계를 줄였습니다).
From: Ana <ana@example.com>
To: bo@example.org
Subject: =?utf-8?b?Q2Fmw6kgbWVudSDigJMgR3LDtsOfZQ==?=
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="b1"
--b1
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: quoted-printable
Hi Bo,
The caf=C3=A9 menu is attached.
--b1
Content-Type: application/pdf
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="menu.pdf"
JVBERi0xLjcKJeLjz9MKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyA+PgplbmRvYmoKdHJhaWxl
cgo8PCAvUm9vdCAxIDAgUiA+PgolJUVPRgo=
--b1--
살펴볼 부분:
Content-Type은 파일 형식을,Content-Disposition은 파일 이름을 알려 줍니다.Content-Transfer-Encoding: base64는 본문이 Base64임을 확인해 줍니다.- Base64는 파트의 첫 번째 빈 줄 다음에 시작해서 다음
--boundary줄 앞에서 끝납니다. - 마지막 줄을 제외한 줄은 정확히 76자입니다. 온전한 한 줄에는 파일의 57바이트가 담깁니다.
- 앞부분의
JVBERi0는%PDF-의 Base64이므로 이 파일은 PDF입니다. 다른 시그니처는 흔한 Base64 접두사에 정리되어 있습니다.
HTML 메일에 넣은 이미지도 multipart/related 파트 안에서 같은 방식을 씁니다. 각 이미지에 Content-ID 헤더가 붙고, HTML은 이를 <img src="cid:...">로 참조합니다. 실제 전송 시에는 이 줄들이 CRLF로 끝나므로 Base64 76자마다 78바이트가 듭니다. 전체 계산은 Base64 크기 증가에 있습니다. 줄 바꿈된 출력을 직접 만들려면 MIME Base64 인코더를 사용하세요.
인코딩된 헤더: =?UTF-8?B?…?=
Subject, From의 표시 이름, 일부 파일 이름 같은 헤더는 ASCII여야 합니다. RFC 2047은 그 밖의 텍스트를 encoded-word로 인코딩합니다.
=?charset?encoding?encoded-text?=
encoding은 Base64면 B, _가 공백을 뜻하는 quoted-printable 변형이면 Q입니다. 두 글자 모두 대소문자를 구분하지 않으므로 위에서 Python이 =?utf-8?b?라고 쓴 것입니다. 실제 예시이며, 모두 Python의 email.header.decode_header로 확인했습니다.
| 인코딩된 값 | 디코딩 결과 |
|---|---|
=?UTF-8?B?0J/RgNC40LLQtdGC?= | Привет |
=?utf-8?q?Caf=C3=A9_menu?= | Café menu |
=?ISO-8859-1?Q?Caf=E9_menu?= | Café menu |
=?utf-8?b?Q2Fmw6kgbWVudSDigJMgR3LDtsOfZQ==?= | Café menu – Größe |
encoded-word 하나는 최대 75자이므로 긴 제목은 접힌 헤더 줄마다 하나씩, 여러 개로 나뉩니다. RFC 2047에 따르면 인접한 encoded-word 사이의 공백은 무시되며, 각 word는 온전한 문자를 담아야 하므로 여러 바이트로 된 UTF-8 문자가 두 word에 걸쳐 쪼개지는 일은 없습니다. 44자짜리 러시아어 제목은 Python에서 이렇게 나왔습니다.
Subject: =?utf-8?b?0KHRh9GR0YIg0LfQsCDQvtC60YLRj9Cx0YDRjCDQuCDQsNC60YIg0YE=?=
=?utf-8?b?0LLQtdGA0LrQuCDQtNC70Y8g0LHRg9GF0LPQsNC70YLQtdGA0LjQuA==?=
각 word는 자체 패딩이 있는 완전한 Base64 문자열입니다. 따로 디코딩하면 첫 번째는 Счёт за октябрь и акт с, 두 번째는 верки для бухгалтерии가 되고, 디코더가 이를 이어 붙여 원래 제목을 만듭니다.
첨부 파일 이름에는 두 번째 표준인 RFC 2231이 있으며, filename*=UTF-8''%D0%BE%D1%82%D1%87%D1%91%D1%82.pdf 같은 모양입니다. 많은 메일 프로그램이 filename="..." 안에 RFC 2047 encoded-word를 넣기도 하므로 파서는 둘 다 처리해야 합니다.
.eml 파일에서 첨부 파일을 추출하는 방법
브라우저에서. EML 디코더로 .eml을 열면 메시지 본문을 읽고 첨부 파일을 저장할 수 있습니다. 페이지 안에서 로컬로 실행되므로, 메시지에 청구서나 개인정보가 들어 있을 때 특히 중요합니다.
Python으로. 표준 라이브러리가 MIME을 파싱하고 Base64 본문과 RFC 2047 헤더를 모두 디코딩합니다.
import sys
from email import policy
from email.parser import BytesParser
with open(sys.argv[1], "rb") as f:
msg = BytesParser(policy=policy.default).parse(f)
print("Subject:", msg["Subject"]) # encoded-words decoded
for part in msg.iter_attachments():
name = part.get_filename()
data = part.get_content() # Base64 already decoded to bytes
with open(name, "wb") as out:
out.write(data)
print("saved", name, len(data), "bytes")
위의 샘플 메시지에 실행하면 Subject: Café menu – Größe와 saved menu.pdf 83 bytes를 출력했습니다. 실제 코드에서는 name이 보낸 사람에게서 온 값이므로 파일을 쓰기 전에 정리하세요.
직접. 파트의 Base64 줄을 파일로 복사해 디코딩하세요. 원본 .eml 파일은 CRLF 줄 끝을 쓰는데, GNU base64 -d는 첫 번째 캐리지 리턴에서 invalid input으로 멈춥니다. 먼저 이를 제거하거나, 알파벳이 아닌 문자를 건너뛰는 -i를 쓰세요.
tr -d '\r' < block.txt | base64 -d > menu.pdf
테스트해 보니 openssl base64 -d -in block.txt -out menu.pdf는 CRLF 버전을 그대로 받아들였습니다. Base64 텍스트만 있다면 앞 바이트로 형식을 감지하는 Base64 파일 변환에 붙여넣거나, PDF라면 바로 Base64 PDF 변환에 붙여넣으세요.
원본 이메일의 헤더, 본문, 첨부 파일을 브라우저에서 로컬로 디코딩해 확인하세요.
자주 묻는 질문
이메일 원본에서 Base64 첨부 파일을 열려면 어떻게 하나요?
해당 파트의 빈 줄과 다음 경계(boundary) 사이에 있는 Base64 줄을 복사해 파일로 디코딩하세요. EML 디코더는 .eml 파일에서 첨부 파일을 대신 추출해 줍니다.
이메일 제목의 =?UTF-8?B?는 무슨 뜻인가요?
RFC 2047 encoded-word의 시작입니다. 문자 집합은 UTF-8이고, B는 다음 ?와 ?= 사이의 텍스트가 Base64라는 뜻입니다. B 대신 Q라면 quoted-printable 변형입니다.
이메일의 Base64 줄이 76자인 이유는 무엇인가요?
RFC 2045가 MIME의 Base64 줄을 76자로 제한하기 때문입니다. 76자에는 정확히 57바이트가 들어가며, 이 제한 덕분에 줄 길이가 이메일이 허용하는 최대 998자보다 훨씬 짧게 유지됩니다.
이메일 본문에는 quoted-printable과 Base64 중 무엇이 더 좋나요?
대부분 ASCII인 텍스트에는 읽기 쉽고 거의 커지지 않는 quoted-printable이 낫습니다. 바이너리 데이터와 한글처럼 라틴 문자가 아닌 문자의 텍스트에는 Base64가 낫습니다. 이런 텍스트는 quoted-printable로 하면 거의 세 배까지 커질 수 있습니다.