본문 바로가기
Converter

Base64 인코더 · 디코더

텍스트와 Base64를 양방향으로, URL-safe 지원

한글·이모지 모두 UTF-8로 정확히 처리합니다

먼저 짚고 갈 것: Base64는 보안이 아닙니다

이 문장부터 시작하는 이유는, 이걸 오해한 코드가 실제로 운영 중인 서비스에 들어가 있기 때문입니다. Base64는 암호화가 아니라 인코딩입니다. 둘의 차이는 취향의 문제가 아니라 정의의 문제입니다.

  • 암호화는 키가 있어야 되돌릴 수 있습니다. 키를 모르면 원문을 얻을 수 없습니다.
  • 인코딩은 키가 없습니다. 규칙만 알면 누구나 되돌립니다. 그리고 그 규칙은 공개 표준(RFC 4648)입니다.

그래서 cGFzc3dvcmQxMjM=는 비밀이 아닙니다. 이 페이지 위쪽 변환기에 붙여넣으면password123이 즉시 나옵니다. 설정 파일에 API 키를 Base64로 넣어 두는 것, 로그에 Base64로 감싼 개인정보를 남기는 것, “평문은 아니니까”라며 넘어가는 것 — 전부 아무것도 보호하지 않습니다. 오히려 보호했다고 착각하게 만든다는 점에서 평문보다 위험할 수 있습니다.

Base64가 하는 일은 딱 하나, “아무 바이트나 안전한 글자들로만 표현하기”입니다. 그게 왜 필요했는지를 알면 나머지 특성이 전부 따라 나옵니다.

왜 만들어졌나 — 8번째 비트를 못 믿던 시절

Base64는 이메일에서 왔습니다. 초기 SMTP는 7비트 ASCII만 안전하게 통과시키는 전제로 설계됐습니다. 중간의 메일 서버가 8번째 비트를 그냥 떨어뜨리거나, 특정 바이트를 제어 문자로 해석해서 전송을 끊어 버리는 일이 실제로 있었습니다. 줄바꿈으로 보이는 바이트가 나오면 거기서 줄을 접기도 했습니다.

이런 통로로 이미지나 실행 파일 같은 임의의 바이트를 보내려면 방법이 하나뿐입니다. 어떤 서버도 건드리지 않는 안전한 글자 집합을 고르고, 모든 데이터를 그 글자들로 바꿔서 보내는 것. 그 안전한 집합이 A–Z, a–z, 0–9, +, / — 정확히 64글자입니다.

33% 증가는 줄일 수 없습니다

64글자는 6비트로 표현됩니다(26 = 64). 그런데 원본 데이터의 단위는 8비트짜리 바이트입니다. 6과 8의 최소공배수는 24이므로, 변환의 최소 단위는 이렇게 정해집니다:

3바이트 (8 × 3 = 24비트)  →  4글자 (6 × 4 = 24비트)

M  a  n                        원본 3바이트
01001101 01100001 01101110     = 24비트
010011 010110 000101 101110    6비트씩 다시 자르면 4묶음
19     22     5      46        각각을 알파벳 표에서 찾으면
T      W      F      u         → "TWFu"

비트는 하나도 늘지 않았습니다. 늘어난 건 글자 수입니다. 3바이트가 4바이트 분량의 글자가 되니 4/3, 즉 약 33% 증가입니다. 이건 구현이 게을러서 생긴 오버헤드가 아니라6비트 알파벳으로 8비트 데이터를 표현한다는 선택에서 수학적으로 확정되는 값입니다. 라이브러리를 바꾸든 압축을 하든 이 비율 자체는 사라지지 않습니다. 위 변환기가 입력과 결과의 바이트 수를 나란히 보여 주는 것도 이 숫자를 눈으로 확인하시라는 뜻입니다.

실무에서 가장 자주 부딪히는 것: btoa와 한글

JavaScript로 Base64를 처음 다루면 거의 반드시 여기서 막힙니다.

btoa("hello");   // "aGVsbG8="  — 잘 된다
btoa("한글");    // ❌ InvalidCharacterError

버그처럼 보이지만 btoa는 명세대로 동작한 것입니다. 이 함수는 Latin-1 범위(코드포인트 0~255)의 문자열만받도록 정의돼 있습니다. 문자열의 각 글자를 “바이트 하나”로 간주하겠다는 뜻입니다. 그런데 ‘한’은 U+D55C, 십진수로 54,620입니다. 바이트 하나에 들어갈 수가 없으니 던지는 것 말고 할 수 있는 일이 없습니다.

문제의 뿌리는 이겁니다. Base64는 바이트를 다루는 규격인데, JavaScript 문자열은 바이트가 아닙니다. 유니코드 코드포인트의 나열이죠. 그러니 인코딩 전에 문자열을 바이트로 바꾸는 단계가 반드시 필요합니다. 그 단계를 생략할 수 있었던 건 입력이 우연히 전부 ASCII였기 때문일 뿐입니다.

옛날 해법과 지금 해법

// 예전에 돌아다니던 방법 — 동작은 하지만 unescape는 폐기됨
btoa(unescape(encodeURIComponent("한글")));

// 지금 쓸 방법
const bytes = new TextEncoder().encode("한글");  // UTF-8 바이트
// bytes → Base64

옛날 방법이 왜 통했는지 알아 두면 개념이 정리됩니다. encodeURIComponent("한글")"%ED%95%9C%EA%B8%80"을 만듭니다 — 이미 UTF-8 바이트를 퍼센트 표기로 늘어놓은 것입니다. 여기에 unescape를 씌우면 각 %XX코드포인트 0~255짜리 글자 하나로 바뀝니다. 즉 “글자 하나 = 바이트 하나”인 문자열이 되고, 그건 정확히 btoa가 요구하는 모양입니다. 우회로였지만 논리는 맞았습니다.

오늘날 TextEncoder는 그 두 단계 우회 없이 원하는 것을 바로 줍니다 — 문자열을 UTF-8 바이트 배열로. 우연한 성질에 기대지 않고 의도를 그대로 적을 수 있다는 점에서, 이건 문법 설탕이 아니라 올바른 도구입니다.

=는 데이터가 아닙니다

3바이트씩 묶는다고 했는데, 원본이 3의 배수가 아니면 마지막 묶음이 모자랍니다. 남은 바이트가 1개면 6비트 두 칸, 2개면 세 칸만 나옵니다. 이때 4칸을 맞추려고 =를 채웁니다.

"abc"  → "YWJj"      (3바이트, 딱 맞음)
"ab"   → "YWI="      (2바이트, 한 칸 부족)
"a"    → "YQ=="      (1바이트, 두 칸 부족)

=는 알파벳 64글자에 포함되지 않습니다. 어떤 비트도 나르지 않습니다. 존재 이유는 오직 길이를 항상 4의 배수로 만들기입니다. 여러 Base64 덩어리를 이어 붙여 놓고 앞에서부터 읽어 나갈 때, 4글자씩 끊으면 된다는 보장이 있어야 하나가 어디서 끝나고 다음이 어디서 시작하는지 알 수 있으니까요.

따라서 덩어리 하나만 따로 디코딩하는 상황에서 패딩은 사실 불필요합니다. 길이만 봐도 몇 바이트가 남았는지 계산할 수 있기 때문입니다. 대부분의 디코더가 패딩 없는 입력을 받아 주고, 위 변환기의 디코더도 마찬가지입니다.

URL-safe: +와 /가 URL에서 사고를 치는 이유

표준 알파벳의 마지막 두 글자가 하필 +/입니다. 이메일에서는 아무 문제가 없었지만, Base64 결과를 URL에 넣기 시작하면서 전부 지뢰가 됐습니다.

  • /경로 구분자입니다. /api/token/a/b+c에서 어디까지가 값인지 서버가 알 수 없습니다.
  • +는 쿼리스트링에서 공백으로 해석되는 관례가 있습니다. ?t=a+b를 받은 서버가 a b로 읽으면 그 순간 데이터가 변조됩니다. 디코딩은 당연히 실패합니다.
  • =키와 값의 구분자입니다. ?t=YQ==는 파서에 따라 값이 YQ로 잘리거나 =로 시작하는 두 번째 값이 있다고 해석됩니다.

그래서 RFC 4648은 base64url이라는 변형을 따로 정의했습니다. +-로, /_로 바꾸고 패딩은 뺍니다. 나머지 62글자는 그대로라 인코딩 결과의 대부분은 두 변형이 동일합니다 — 이게 함정입니다. 테스트 데이터에 우연히 +/가 안 나오면 잘못된 변형을 써도 통과합니다. 그러다 운영에서 특정 값만 깨지는 버그로 돌아옵니다.

JWT가 base64url인 것, 그리고 그 함의

JWT는 헤더.페이로드.서명 세 조각을 점으로 이은 것이고, 세 조각 모두 base64url입니다. URL과 HTTP 헤더에 실려 다닐 물건이니 당연한 선택입니다.

여기서 맨 처음 이야기로 돌아옵니다. JWT의 페이로드는 암호화되어 있지 않습니다. 토큰의 가운데 조각을 잘라서 위 변환기에 붙여넣으면 사용자 ID도, 권한도, 만료 시각도 그대로 읽힙니다. 서명이 보장하는 것은 위조 방지이지 기밀이 아닙니다. 내용을 고치면 서명 검증이 깨질 뿐, 보는 것은 아무도 못 막습니다. JWT 페이로드에 주민등록번호나 내부 비밀값을 넣으면 안 되는 이유가 이것입니다.

data URI: 요청 하나를 아끼고 33%를 내는 거래

data:image/png;base64,iVBORw0KG... 형태로 이미지를 HTML이나 CSS 안에 직접 박을 수 있습니다. 파일이 따로 없으니 HTTP 요청이 하나 줄어듭니다. 다만 거래 조건을 정확히 볼 필요가 있습니다.

  • 얻는 것: 요청 왕복 한 번. 이미지가 아주 작으면 파일 자체보다 요청 오버헤드가 더 클 수 있어서, 이 이득이 실질적입니다.
  • 내는 것 1: 용량 33% 증가. 원본이 클수록 절대 손해액이 커집니다.
  • 내는 것 2 (이쪽이 더 큽니다): 캐시 분리를 잃습니다. 이미지가 HTML·CSS 안에 박혀 버리니, 브라우저가 이미지만 따로 캐시할 수 없습니다. 문서를 다시 받을 때마다 이미지도 매번 같이 받습니다. 반대로 이미지 하나만 바꾸려 해도 CSS 파일 전체가 무효화됩니다.

결론은 크기에 달렸습니다. 수 KB 이하의 아이콘, 커서, 로딩 스피너, 반복되는 배경 패턴 정도가 이득 구간입니다. 그 이상은 별도 파일로 두고 브라우저가 캐시하게 두는 편이 거의 항상 빠릅니다. “요청을 줄이면 좋다”는 규칙만 기억하고 캐시 쪽 비용을 빼먹으면 오히려 느려집니다.

정리

Base64는 바이트를 안전한 글자로 옮기는 규격입니다. 그 이상도 이하도 아닙니다. 암호화가 아니고, 압축은 정반대이며(33% 늘어납니다), 유효성 검사도 아닙니다. 인코딩할 때는 문자열을 UTF-8 바이트로 먼저 바꾸고, URL에 실을 값이면 base64url을 쓰고, 그 안에 든 내용은 누구나 읽을 수 있다고 가정하고 설계하면 됩니다.

자주 묻는 질문

Base64로 인코딩하면 안전해지나요?
전혀 아닙니다. Base64는 암호화가 아니라 인코딩입니다. 키도 비밀도 없고, 누구나 이 페이지 같은 도구에 붙여넣기만 하면 원문이 그대로 나옵니다. 비밀번호나 API 키를 Base64로 감싸는 것은 잠그는 게 아니라 봉투에 넣는 것에 가깝습니다. 값을 실제로 보호해야 한다면 전송은 HTTPS로, 저장은 해시(비밀번호) 또는 암호화(복호화가 필요한 값)로 처리해야 합니다.
btoa("한글")이 오류를 내는 이유가 뭔가요?
btoa는 Latin-1(코드포인트 0~255)에 해당하는 문자열만 받도록 만들어진 오래된 함수입니다. '한'은 U+D55C라 255를 넘고, 그래서 InvalidCharacterError를 던집니다. Base64는 바이트를 다루는 규격인데 JavaScript 문자열은 바이트가 아니기 때문에 생기는 간극입니다. 해결책은 btoa를 부르기 전에 new TextEncoder().encode(text)로 UTF-8 바이트를 먼저 만드는 것입니다. 이 페이지의 변환기는 처음부터 그 방식으로 동작합니다.
끝에 붙는 =는 무슨 의미인가요? 지워도 되나요?
=는 데이터가 아니라 길이를 4의 배수로 맞추기 위한 채움 문자입니다. Base64는 3바이트를 4글자로 옮기는데, 원본 바이트 수가 3의 배수가 아니면 마지막 묶음이 2글자나 3글자로 끝나서 =로 채웁니다. 여러 Base64 덩어리를 이어 붙여 스트림으로 읽을 때 경계를 알기 위한 장치라, 하나만 따로 디코딩하는 상황에서는 대부분의 디코더가 =가 없어도 정상적으로 처리합니다. 이 도구의 디코더도 패딩 유무를 가리지 않습니다.
URL-safe(base64url)는 언제 써야 하나요?
결과 문자열이 URL의 경로나 쿼리스트링, 또는 파일명에 들어갈 때 씁니다. 표준 알파벳의 +는 쿼리에서 공백으로 해석되고, /는 경로 구분자이며, =는 키=값 구분자와 충돌합니다. base64url은 +를 -로, /를 _로 바꾸고 패딩을 빼서 이 문제를 없앱니다. JWT의 세 부분이 모두 base64url인 이유가 이것입니다.
JWT 토큰의 내용을 아무나 볼 수 있나요?
네. JWT의 헤더와 페이로드는 base64url로 인코딩되어 있을 뿐 암호화되어 있지 않습니다. 토큰을 가진 사람은 누구나 페이로드를 디코딩해서 사용자 ID, 만료 시각, 권한 같은 내용을 읽을 수 있습니다. 서명이 막아 주는 것은 '읽기'가 아니라 '위조'입니다 — 내용을 바꾸면 서명 검증이 실패할 뿐, 보는 것 자체는 막지 못합니다. 그래서 JWT 페이로드에 개인정보나 비밀값을 넣으면 안 됩니다.
이미지를 Base64로 바꿔서 HTML에 넣는 게 좋은가요?
작은 아이콘이면 괜찮고, 큰 이미지면 대체로 손해입니다. data URI로 인라인하면 HTTP 요청 하나를 줄이는 대신 용량이 약 33% 늘어나고, 그 이미지가 HTML이나 CSS 안에 박히기 때문에 브라우저가 이미지만 따로 캐시하지 못합니다. 문서를 다시 받을 때마다 이미지도 같이 받게 되는 셈입니다. 기준을 잡자면 수 KB 이하의 아이콘·로고 정도까지가 이득 구간이고, 그 이상은 그냥 별도 파일로 두는 편이 빠릅니다.