이미지가 안 뜨는데 알고 보니 Base64 문제였던 썰 — 인코딩 완전 정복
이메일 템플릿 이미지가 모든 클라이언트에서 깨져서 두 시간 삽질했는데, 원인이 Base64 변형 하나를 잘못 쓴 거였어요. 이 글에서는 Base64가 비트 레벨에서 어떻게 동작하는지, 언제 쓰면 좋고 언제 쓰면 오히려 안 좋은지, URL-safe 변형이 왜 필요한지, 크기 오버헤드 계산법까지 전부 정리했어요.
이 글에서 알 수 있는 것
- ✅3바이트 입력 → 4문자 출력 알고리즘, Base64가 비트 레벨에서 어떻게 동작하는지
- ✅언제 쓰면 좋고 언제 쓰면 오히려 성능이 나빠지는지 — 크기 오버헤드 실제 수치로
- ✅표준 Base64 vs URL-safe Base64: JWT, 쿼리 파라미터, 파일명에서 왜 다른 버전이 필요한지
이메일 템플릿 작업을 하다가 이미지가 모든 클라이언트에서 깨져 보이는 문제가 생겼어요. img 태그는 멀쩡해 보이고, URL도 이상 없어 보이고, HTML 구조도 문제없었어요. 서버도 확인하고, CDN도 확인하고, HTML도 세 번 읽었는데 원인을 못 찾겠는 거예요. 두 시간쯤 지나서 src 값을 디코더에 붙여넣어 봤더니 바로 보였어요. 표준 Base64 문자열을 URL 컨텍스트에 그냥 넣은 거였어요. + 기호가 공백으로, / 기호가 경로 구분자로 해석되면서 데이터가 조용히 뭉개졌던 거죠. 두 시간, 문자 하나. 그날 Base64를 제대로 공부했어요.
Base64가 뭔지, 그리고 뭐가 아닌지
Base64는 바이너리 데이터를 텍스트로 바꾸는 인코딩이에요. 이미지, 오디오, PDF 같은 바이트 스트림을 64개 출력 가능한 ASCII 문자로 표현해요. 그 목적이 전부예요. 텍스트만 처리할 수 있는 시스템 — 이메일, URL, HTML 속성, JSON 문자열 — 에서 바이너리 데이터를 안전하게 주고받기 위한 거예요.
64개 문자는 이렇게 구성돼요: A-Z (26개), a-z (26개), 0-9 (10개), + 와 / (2개). 합쳐서 64. 패딩에는 = 를 써요. 어떤 바이너리 데이터든 이 문자들만으로 표현할 수 있어요.
원본 텍스트: 안녕 (UTF-8)
바이트: EC 95 88 EB 85 95
Base64 출력: 7JWI65WV
원본 텍스트: Hello
Base64 출력: SGVsbG8=
원본 JSON: {"id": 1}
Base64 출력: eyJpZCI6IDF9지금 이 도구를 사용해 보세요:
Base64 인코더/디코더 →알고리즘이 실제로 어떻게 동작하나
Base64는 입력을 3바이트(24비트) 단위로 처리해요. 24비트를 6비트씩 4개로 잘라서 각각 64개 문자 중 하나로 매핑하는 거예요. 그래서 입력 3바이트가 항상 출력 4문자가 되는 거고요.
'Man' 인코딩 (3바이트: 77, 97, 110):
바이너리: 01001101 01100001 01101110
└──────────────────────┘
24비트 합계
6비트씩 4개로 분리:
010011 | 010110 | 000101 | 101110
19 | 22 | 5 | 46
T | W | F | u
결과: 'TWFu'
패딩 예시 — 'Ma' 인코딩 (2바이트):
바이너리: 01001101 01100001 [없는 바이트 → 0으로 채움]
010011 | 010110 | 000100 | (패딩)
T | W | E | =
결과: 'TWE='
패딩 예시 — 'M' 인코딩 (1바이트):
바이너리: 01001101 [없는 2바이트 → 0으로 채움]
010011 | 010000 | (패딩) | (패딩)
T | Q | = | =
결과: 'TQ=='끝에 붙는 = 기호는 디코더한테 실제 데이터 바이트가 몇 개인지 알려줘요. = 하나면 마지막 그룹이 2바이트, == 이면 1바이트였다는 뜻이에요. = 없으면 입력이 3의 배수였다는 거고요.
언제 쓰면 좋고 언제 쓰면 손해인가
Base64는 도구예요. 무조건 좋거나 무조건 나쁜 게 아니라 상황에 따라 달라요. 명확하게 정리했어요:
| 용도 | Base64? | 이유 |
|---|---|---|
| HTML/CSS 안의 작은 아이콘 (data URI) | 써도 좋음 | HTTP 요청 하나를 아낄 수 있어서, 33% 오버헤드보다 이득이 커요 |
| JWT 토큰 헤더 + 페이로드 | 필수 | JWT 명세에서 Base64url 인코딩을 요구해요. 선택의 여지가 없어요 |
| 이메일 첨부 파일 (MIME) | 필수 | SMTP는 7비트 ASCII만 전송해요. 바이너리를 안전하게 보내려면 Base64가 필요해요 |
| JSON API 응답 안의 이미지 | 작은 것만 | 썸네일 5KB 이하는 괜찮아요. 그 이상이면 URL 참조로 처리하세요 |
| 웹 페이지의 큰 이미지 | 쓰면 안 됨 | 33% 오버헤드 + 브라우저 파싱 블록. CDN URL로 서빙하는 게 맞아요 |
| 설정에 비밀번호나 API 키 숨기기 | 절대 안 됨 | Base64는 암호화가 아니에요. 10초면 디코딩돼요 |
| URL 쿼리 파라미터 | URL-safe 변형만 | 표준 Base64 (+, /) 는 URL에서 깨져요. -, _ 쓰는 URL-safe 버전 써야 해요 |
data URI 쓸 때 5KB 기준
이미지가 5KB 이하면 Base64 data URI로 인라인하면 네트워크 요청 하나를 줄일 수 있어요. 5KB 넘으면 오버헤드가 더 커서 별도 파일로 CDN에서 서빙하는 게 나아요. SVG는 Base64보다 원본 마크업을 인라인으로 넣는 게 대부분 더 좋아요.
표준 Base64 vs URL-safe Base64
표준 Base64는 알파벳에 + 와 / 를 써요. 이 두 문자가 URL, HTML 폼, 쿼리 파라미터에서 특수하게 해석돼서 데이터가 조용히 깨지는 거예요. 제가 두 시간을 날린 이유예요.
표준 Base64 알파벳:
A-Z a-z 0-9 + / (= 패딩)
URL-safe Base64 알파벳 (RFC 4648 §5):
A-Z a-z 0-9 - _ (= 패딩, 생략 가능)
같은 데이터, 다른 인코딩:
표준: SGVsbG8+V29ybGQ= ← + 기호가 URL에서 깨져요
URL-safe: SGVsbG8-V29ybGQ= ← 어디서나 안전해요
URL-safe를 써야 하는 경우:
- JWT 토큰 (헤더.페이로드.서명 전부 URL-safe)
- 쿼리 파라미터 (?token=SGVsbG8-V29ybGQ)
- 파일명이나 URL 경로 구성 요소
- HTML 속성 값에 들어가는 경우
표준 Base64로 충분한 경우:
- 이메일 MIME 첨부 파일
- HTTP 응답 바디 (URL 아닌 경우)
- CSS/HTML 안의 data URIBase64는 암호화가 아니에요 — 비밀번호 숨기려고 쓰면 안 돼요
Base64 결과물이 영문 대소문자 숫자로 뒤섞여 있어서 암호화된 것처럼 보이는데, 전혀 아니에요. 누구나 10초 만에 디코딩할 수 있어요. API 키, 비밀번호, 개인정보를 Base64로 인코딩해서 '안전하다'고 생각하는 건 보안 취약점이에요. 데이터를 보호해야 하면 실제 암호화를 쓰세요. 대칭 암호화는 AES-256, 비밀번호 해싱은 bcrypt나 argon2예요. Base64는 전송 포맷이에요. 보안 도구가 아니에요.
크기 오버헤드 계산
33% 오버헤드라는 말을 정확히 이해하면 의사결정이 쉬워져요. 입력 3바이트 → 출력 4문자, 각 문자는 1바이트. 비율이 4/3 = 1.333이니까 항상 정확히 약 33% 커지는 거예요.
크기 오버헤드 실제 계산:
원본 크기 Base64 크기 오버헤드
1 KB 1,368 B +344 B (+33.6%)
10 KB 13,336 B +3,336 B (+33.4%)
100 KB 133,400 B +33,400 B (+33.4%)
1 MB 1.33 MB +334 KB (+33.4%)
실제 예시 — 200x200 PNG 아이콘:
원본 파일: ~15 KB
Base64 변환: ~20 KB
추가 용량: ~5 KB
아낀 HTTP 요청: 1회
빠른 네트워크 왕복 시간: 20~50ms
결론: 인라인하는 게 이득. 5KB < 요청 1회.
실제 예시 — 히어로 이미지:
원본 파일: ~250 KB
Base64 변환: ~333 KB
추가 용량: ~83 KB
아낀 HTTP 요청: 1회
결론: 인라인하면 손해. 83KB 다운로드 > 캐시된 요청 1회.자주 묻는 질문
자주 묻는 질문
자바스크립트에서 Base64 인코딩/디코딩 어떻게 해요?
브라우저에서는 btoa()로 인코딩, atob()으로 디코딩해요. 이건 표준 Base64만 지원해요. URL-safe 버전이 필요하면 인코딩 후 + 를 - 로, / 를 _ 로 바꾸면 돼요. 이미지나 파일 같은 실제 바이너리 데이터는 btoa()가 ASCII 문자열만 제대로 처리하기 때문에 Uint8Array나 FileReader를 써야 해요. Node.js에서는 Buffer.from(string).toString('base64')로 인코딩하고 Buffer.from(base64string, 'base64').toString('utf8')로 디코딩해요.
Base64 문자열 끝에 == 이 붙는 건 왜예요?
패딩이에요. Base64는 3바이트 단위로 처리하는데, 대부분의 입력 길이는 3의 배수가 아니에요. = 하나는 마지막 그룹에서 1바이트가 부족해서 채운 것, == 는 2바이트가 부족했다는 뜻이에요. = 없으면 입력 길이가 3의 배수였다는 거고요. 디코더가 원본 데이터 길이를 정확히 알 수 있게 해주는 장치예요.
Base64랑 hex 인코딩이랑 뭐가 달라요?
둘 다 바이너리를 텍스트로 변환하는데, 알파벳 크기와 크기 오버헤드가 달라요. hex는 16개 문자를 써서 1바이트를 2자리 hex로 표현해요. 크기가 2배로 늘어나요. Base64는 64개 문자로 3바이트를 4문자로 표현해서 33% 늘어나요. Base64가 더 효율적이에요. hex는 체크섬, 메모리 주소, 색상 코드처럼 사람이 읽을 저수준 바이너리 표현에 쓰고, Base64는 데이터 전송에 써요.
이미지, PDF 같은 파일도 Base64로 인코딩할 수 있나요?
어떤 바이너리 파일이든 가능해요. Base64는 바이트 값이 뭘 나타내는지 신경 안 써요. 그냥 바이트를 64개 문자로 변환하는 거니까요. 이메일에 어떤 파일이든 첨부할 수 있는 이유가 이거예요. MIME 프로토콜이 원본 바이트를 Base64로 인코딩해서 텍스트로 보내고, 받는 쪽 클라이언트가 디코딩해서 원본 파일로 복원하는 거예요.
JWT가 왜 Base64를 쓰나요? 암호화 아닌가요?
JWT는 Base64url, 즉 URL-safe Base64를 써요. 이유가 두 가지예요. 첫째, JWT 토큰은 HTTP 헤더, URL, 쿠키 안에서 이동하는데 전부 텍스트 전용 환경이에요. Base64가 바이너리 서명과 JSON 페이로드를 그 환경에서 안전하게 만들어줘요. 둘째, 토큰이 작아야 해요. hex 인코딩은 크기가 2배라서 안 맞아요. 중요한 건, JWT 페이로드는 인코딩만 된 거지 암호화가 된 게 아니에요. 누구나 디코딩해서 읽을 수 있어요. 서명은 변조를 감지해주는 거지 내용을 숨기는 게 아니에요.
▶이 글에서 다룬 도구 바로 사용하기
민재
개발자 겸 테크 라이터. 개발 도구와 파일 변환 기술을 깊이 있게 다룹니다.
이 글이 도움이 되셨나요? 새 가이드 알림 받기
스팸 없이, 새 소식만 보내드립니다. 언제든 취소 가능. · 구독 시 개인정보처리방침에 동의합니다.