API 문서에 "Base64로 인코딩해서 보내세요"라고 적혀 있습니다. 로그를 보다가 7JWI64WV7ZWY7IS47JqU 같은 글자 뭉치를 만납니다. 쿼리 파라미터에 값을 담아 보냈는데 서버가 받은 값이 원본과 다릅니다.
셋 다 같은 주제입니다. 이 글은 Base64와 URL 인코딩이 각각 무엇을 하는 일이고, 어디서 조용히 망가지는지를 실제로 돌려본 결과와 함께 정리한 것입니다.
1. Base64는 숨기는 게 아니라 옮기는 것이다
가장 흔한 오해부터 짚고 가겠습니다. Base64는 암호화가 아닙니다. 키도 없고 비밀도 없습니다.
Base64가 하는 일은 아무 데이터나 텍스트로 안전하게 옮기는 것입니다. 이메일 본문, JSON 문자열, HTML 속성처럼 "텍스트만 들어갈 수 있는 통로"에 이미지나 바이너리를 실어 보내야 할 때 씁니다. 그래서 결과에 쓰이는 글자가 A-Z a-z 0-9 + / = 64종뿐입니다. 어디에 넣어도 안 깨지는 글자만 고른 것이죠.
되돌리는 데는 아무것도 필요 없습니다. 브라우저 콘솔 한 줄이면 됩니다.
atob('SGVsbG8=') // 'Hello'
그러니 비밀번호나 토큰을 Base64로 바꿔서 저장하거나 로그에 남기는 건 아무 보호가 되지 않습니다. 눈으로 못 읽을 뿐, 기계에는 평문과 같습니다. 반대로 말하면 로그에서 이런 문자열을 발견했을 때 내용을 확인하는 것도 그만큼 쉽습니다.
2. 🔴 한글이 유독 길어지는 이유
Base64로 바꾸면 길이가 늘어납니다. 원본 3바이트가 4글자가 되므로 약 33% 증가입니다. 그런데 한글에서는 체감이 훨씬 큽니다.
실제로 넣어본 결과입니다.
| 원문 | 글자 수 | 바이트 | Base64 길이 |
|---|---|---|---|
Hello |
5 | 5 | 8 |
안녕하세요 |
5 | 15 | 20 |
안녕하세요 제이입니다 |
11 | 31 | 44 |
같은 5글자인데 결과는 8자와 20자로 갈립니다. 2단계로 늘어나기 때문입니다.
- UTF-8에서 한글 한 글자는 3바이트입니다 (영문·숫자는 1바이트)
- 그 바이트를 Base64가 다시 4/3배로 늘립니다
그래서 한글은 대략 글자 수 × 4가 됩니다. 이미지를 data: URL로 인라인할 때 원본보다 파일이 커지는 것도 같은 이유입니다.
용량이 중요한 자리에 Base64를 쓰고 있다면 이 증가분을 계산에 넣어야 합니다. 원문의 바이트 수가 궁금하다면 글자수 세기로 확인할 수 있습니다.
3. btoa('안녕')이 에러가 나는 이유
개발자라면 이걸 먼저 만납니다.
btoa('안녕하세요')
// ❌ InvalidCharacterError: Invalid character
btoa는 이름 그대로 바이트를 다루는 함수입니다. 한 글자를 1바이트(0~255)로 취급하기 때문에, 그 범위를 넘는 한글이 들어오면 그냥 거부합니다.
그래서 한글을 Base64로 바꾸려면 먼저 UTF-8 바이트로 펼친 다음 넘겨야 합니다.
btoa(unescape(encodeURIComponent('안녕하세요')))
// '7JWI64WV7ZWY7IS47JqU'
이 글의 인코더·디코더 도구도 안에서 이 처리를 합니다. 그래서 한글을 그대로 넣어도 에러 없이 변환됩니다. 반대로 이 처리를 하지 않은 다른 도구에 한글을 넣으면 에러가 나거나 글자가 깨져서 나옵니다 — 같은 문자열인데 도구마다 결과가 다르다면 이 차이일 가능성이 높습니다.
4. 🔴 Base64를 URL에 그대로 넣으면 깨진다
여기가 가장 많이 당하는 함정입니다. Base64가 쓰는 64글자 중에 URL에서 다른 뜻을 가진 글자가 섞여 있습니다.
'~~~???' → fn5+Pz8/
결과에 +와 /가 있습니다. 이걸 그대로 주소에 붙이면 이렇게 됩니다.
| 글자 | URL에서 벌어지는 일 |
|---|---|
+ |
쿼리 문자열에서 공백으로 해석되는 관례가 있음 |
/ |
경로 구분자로 먹힘 |
= |
키·값 구분자와 헷갈림 (끝에 붙는 패딩) |
특히 +가 고약합니다. 에러가 나는 게 아니라 공백으로 바뀐 채 조용히 디코딩에 실패하거나, 운이 나쁘면 값이 미묘하게 달라진 채 통과합니다.
해결책은 둘 중 하나입니다.
- URL 인코딩을 한 번 더 씌운다 —
+는%2B,/는%2F,=는%3D가 됩니다 - Base64URL을 쓴다 —
+를-로,/를_로 바꾼 변종입니다. JWT 토큰이 이 방식을 씁니다
"토큰을 URL에 붙였더니 가끔 실패한다"는 증상은 대개 여기서 옵니다. 가끔인 이유는 +나 /가 들어가는 입력일 때만 터지기 때문입니다. 테스트용 짧은 문자열에서는 안 나오고 실제 데이터에서 나옵니다.
5. URL 인코딩의 함정 네 가지
Base64 이야기가 나온 김에, 같은 도구로 처리하는 URL 인코딩도 정리하겠습니다. 실제로 돌려본 결과입니다.
① +는 공백이 아니다 (표준 함수 기준)
encodeURIComponent('hello world') // 'hello%20world'
decodeURIComponent('hello+world') // 'hello+world' ← 공백이 되지 않음
공백은 %20이 됩니다. +를 공백으로 읽는 규칙은 HTML 폼 전송 방식(application/x-www-form-urlencoded)에만 있는 별도 관례입니다. 그래서 폼으로 넘어온 값을 표준 디코더에 그대로 넣으면 공백이 +로 남습니다. 반대로 서버는 +를 공백으로 읽고 있을 수 있고요. 같은 문자열을 서로 다르게 읽는 셈입니다.
② 전체 URL과 파라미터 값은 다른 함수를 쓴다
encodeURIComponent('https://a.kr/b?c=d') // 'https%3A%2F%2Fa.kr%2Fb%3Fc%3Dd'
encodeURI('https://a.kr/b?c=d') // 'https://a.kr/b?c=d' (그대로)
encodeURIComponent는 값 하나를 감쌀 때 쓰는 것이라 / ? : @ & = + $ # , ;까지 전부 바꿉니다. 주소 전체에 쓰면 URL 구조 자체가 문자로 바뀌어 못 쓰게 됩니다. 반대로 파라미터 값에 encodeURI를 쓰면 값 안의 &가 살아남아 파라미터가 하나 더 생긴 것처럼 잘립니다.
리다이렉트 주소를 파라미터로 넘길 때 자주 겪는 문제입니다. 이때는 값 쪽에 encodeURIComponent가 맞습니다.
③ 두 번 인코딩하면 %가 %25가 된다
encodeURIComponent(encodeURIComponent('a b')) // 'a%2520b'
%20의 %가 다시 인코딩되어 %2520이 됩니다. 화면에 %2520이나 %25EC%25...가 보인다면 어딘가에서 인코딩이 두 번 걸린 것입니다. 프레임워크가 자동으로 처리해 주는데 코드에서 한 번 더 감쌌을 때 잘 생깁니다. 반대로 디코딩을 한 번만 하면 값이 반쯤 풀린 채로 남습니다.
④ %가 들어간 문자열은 디코딩이 아예 실패한다
decodeURIComponent('100%') // ❌ URIError: URI malformed
decodeURIComponent('50%25') // '50%'
% 뒤에 16진수 두 자리가 오지 않으면 디코더는 에러를 냅니다. 할인율·수익률처럼 %가 들어간 텍스트를 인코딩 없이 URL에 붙이면 페이지가 통째로 500으로 죽는 경우가 여기서 나옵니다. 사용자 입력을 그대로 주소에 붙이면 안 되는 이유이기도 합니다.
6. 디코딩이 되는데 결과가 이상할 때
Base64 디코딩은 생각보다 관대합니다. 실측 결과입니다.
| 입력 | 결과 |
|---|---|
SGVsbG8= |
Hello |
SGVsbG8 (패딩 없음) |
Hello — 그냥 통과 |
SGVs bG8= (중간 공백) |
Hello — 공백을 무시 |
안녕 |
❌ 에러 |
패딩(=)이 잘려도 대부분 복원됩니다. 그래서 "패딩이 없어서 안 되는 것 같다"는 추측은 대개 틀립니다. 진짜 원인은 다른 데 있습니다 — 앞 절에서 본 +가 공백으로 바뀐 경우가 대표적입니다. 공백은 무시되니까 에러 없이 통과하고 결과만 조용히 달라집니다.
디코딩 결과가 깨진 한글로 나온다면 인코딩 단계에서 UTF-8 처리를 안 한 것이고, 아예 에러가 난다면 Base64가 아닌 문자열이 섞인 것입니다.
7. 정리하면 이렇게 씁니다
- 보안이 필요하면 Base64를 쓰지 마세요. 인코딩은 보호가 아닙니다
- 길이를 계산에 넣으세요. 한글은 대략 글자 수 × 4입니다
- URL에 넣을 Base64는 반드시 한 번 더 감싸거나 Base64URL을 쓰세요.
+/=가 문제입니다 - 파라미터 값에는
encodeURIComponent, 주소 전체에는encodeURI를 씁니다 %2520이 보이면 이중 인코딩입니다. 감싼 곳을 한 군데 찾아 지우세요
값을 바로 확인하고 싶다면 인코더·디코더를 써보세요. Base64와 URL 인코딩을 한 화면에서 오갈 수 있고, 인코딩·디코딩 방향을 바꿀 때 결과를 그대로 입력으로 넘겨주기 때문에 두 번 인코딩된 값을 한 단계씩 벗겨내면서 확인할 수 있습니다. 잘못된 입력이면 그 자리에서 알려줍니다. 브라우저 안에서만 처리하므로 붙여넣은 토큰이나 응답이 서버로 가지 않습니다.
디코딩한 결과가 JSON이라면 JSON 포맷터로 이어서 보면 되고, 두 값이 어디서 달라졌는지 찾아야 한다면 텍스트 비교가 같은 계열의 도구입니다.
정리
- Base64는 암호화가 아닙니다 — 한 줄로 되돌아갑니다. 숨기는 용도로 쓰면 안 됩니다
- 한글은 UTF-8 3바이트 × 4/3 이라 대략 글자 수 × 4로 길어집니다
- 🔴 결과에 섞인
+/=가 URL에서 값을 망가뜨립니다 — 한 번 더 인코딩하거나 Base64URL을 쓰세요 +를 공백으로 읽는 건 폼 전송 방식만의 규칙입니다. 표준 디코더는+를 그대로 둡니다%2520은 이중 인코딩,%가 든 원문은 디코딩 자체가 실패합니다