Base64와 URL 인코딩 차이, 한글 깨짐 해결법
Base64, Base64URL, 퍼센트 인코딩의 차이와 쓰이는 곳, 한글 UTF-8 바이트 처리, 이중 인코딩 버그를 계산 가능한 예제로 설명합니다.
같은 "인코딩"이라도 목적이 다르다
API를 연동하다 보면 인코딩이라는 단어가 여러 번 나옵니다. 어떤 값은 Base64로 바뀌고, 어떤 값은 %EC%95%88 같은 형태로 바뀝니다. 두 방식 모두 "문자열을 다른 문자열로 바꾼다"는 점에서 같지만 해결하려는 문제가 다릅니다.
- Base64는 바이너리 데이터(이미지, 파일, 임의의 바이트)를 ASCII 문자 64종류로 표현하는 방식입니다. 목적은 바이너리를 텍스트 전송 경로에 실어 보내는 것입니다.
- Base64URL은 Base64에서 URL과 파일 이름에 문제가 되는 문자(
+,/, 패딩=)를 바꾼 변형입니다. JWT의 각 부분이 이 형식을 씁니다. - **퍼센트 인코딩(percent-encoding)**은 URL에서 예약된 문자나 허용되지 않은 문자를
%뒤에 16진수 바이트 값을 붙여 표현하는 방식입니다. 목적은 URL의 구조(?,&,=,/)를 안전하게 지키는 것입니다.
이 글에서는 세 방식의 차이를 실제 값으로 비교하고, 한글이 들어갔을 때 무엇이 바뀌는지 확인합니다. 마지막으로 가장 많이 나오는 버그인 이중 인코딩을 다룹니다.
먼저 바이트 단위로 생각하기
Base64와 퍼센트 인코딩은 모두 문자가 아니라 바이트를 다룹니다. 영문 알파벳은 1바이트지만, 한글은 UTF-8에서 한 글자당 보통 3바이트입니다. 따라서 "한글 두 글자"는 문자 수로는 2이지만 바이트 수로는 6입니다.
Node.js에서 확인하면 다음과 같습니다.
Buffer.byteLength('안녕') // 6
Buffer.from('안녕').toString('hex') // 'ec9588eb8595'
안은 EC 95 88, 녕은 EB 85 95입니다. 이 바이트 열을 기준으로 Base64와 퍼센트 인코딩이 계산됩니다. 같은 "안녕"이라도 인코딩 방식이나 서버가 해석하는 문자 집합이 다르면 결과가 완전히 달라집니다.
Base64 계산 예제
Base64는 입력 바이트를 3바이트씩 묶어 6비트 단위 4개로 나누고, 각 6비트 값을 문자표에서 찾습니다. 입력이 3바이트의 배수가 아니면 끝에 =를 붙입니다. 실제 값을 보겠습니다.
| 입력 | UTF-8 바이트 | Base64 |
|---|---|---|
x |
78 |
eA== |
xy |
78 79 |
eHk= |
ab |
61 62 |
YWI= |
abc |
61 62 63 |
YWJj |
hello |
68 65 6c 6c 6f |
aGVsbG8= |
안녕 |
EC 95 88 EB 85 95 |
7JWI64WV |
한글 |
ED 95 9C EA B8 80 |
7ZWc6riA |
안녕하세요 |
15바이트 | 7JWI64WV7ZWY7IS47JqU |
안녕의 결과에 =가 없는 이유는 6바이트가 3의 배수이기 때문입니다. 안녕하세요는 15바이트라서 3의 배수이므로 역시 패딩이 없습니다. 반대로 hello는 5바이트라서 끝에 =가 붙습니다.
이 값들은 Node.js의 Buffer로 직접 계산해 확인했습니다. 여러분이 손으로 계산해 볼 때도 같은 결과가 나와야 합니다. 바이트 경계를 잘못 나누면 완전히 다른 문자열이 됩니다.
Base64URL과 URL 안전 문자
Base64 문자표에는 +와 /가 포함됩니다. 두 문자 모두 URL에서 특별한 의미를 가집니다. +는 쿼리 문자열에서 공백으로 해석되기도 하고, /는 경로 구분자입니다. 그래서 URL이나 파일 이름에 Base64를 넣을 때는 다음과 같이 바꾸는 것이 일반적입니다.
+를-로 바꿉니다./를_로 바꿉니다.- 패딩
=를 제거합니다.
예를 들어 ?>> 세 바이트는 Base64로 Pz4+가 됩니다. Base64URL로 바꾸면 Pz4-가 됩니다. 입력이 ???이면 Base64는 Pz8/이고 Base64URL은 Pz8_입니다.
Base64 문자열 변환기에는 "Encode URL safe"와 "Decode URL safe" 스위치가 있습니다. 소스를 보면 인코딩할 때는 = 제거와 +, / 치환을 하고, 디코딩할 때는 -와 _를 되돌린 뒤 A-Za-z0-9+/ 이외의 문자를 제거합니다. 따라서 Pz4-처럼 URL 안전 형식의 값을 넣었는데 스위치가 꺼져 있으면 "Invalid base64 string" 검증 메시지가 나옵니다. 이 값은 스위치를 켜야 ?>>로 복원됩니다. 표준 형식의 Pz4+는 스위치를 켠 상태에서도 같은 결과로 디코딩되므로, 두 형식을 확실히 구분하려면 입력 값에 -나 _가 있는지부터 보는 것이 빠릅니다.
Base64는 암호화가 아닙니다. 누구나 바로 디코딩할 수 있습니다. 비밀번호나 토큰을 Base64로 바꾸는 것은 보호 수단이 되지 않습니다.
퍼센트 인코딩 예제
JavaScript의 encodeURIComponent는 입력을 UTF-8 바이트로 바꾼 뒤, 예약 문자와 ASCII 이외의 바이트를 %XX 형태로 바꿉니다. 영문 알파벳, 숫자, -_.!~*'()는 그대로 둡니다.
encodeURIComponent('이름=홍 길동&a=1')
// '%EC%9D%B4%EB%A6%84%3D%ED%99%8D%20%EA%B8%B8%EB%8F%99%26a%3D1'
이 결과를 해석하면 다음과 같습니다.
이는EC 9D B4이므로%EC%9D%B4가 됩니다.=는%3D, 공백은%20,&는%26이 됩니다.- 쿼리 값 안의
=와&가 구분자로 오해받지 않게 됩니다.
URL 인코더는 이 방식을 그대로 사용합니다. 소스를 보면 입력을 encodeURIComponent로 바꾸고, 디코딩에는 decodeURIComponent를 씁니다. 따라서 쿼리 값 하나를 넣을 때 적합하고, 전체 URL을 넣는 용도로는 맞지 않습니다. 전체 URL을 인코딩하면 https://의 :와 /까지 바뀌어 버립니다.
Base64와 퍼센트 인코딩의 차이를 한 줄로 정리하면 이렇습니다. Base64는 바이너리를 텍스트로 바꾸고, 퍼센트 인코딩은 텍스트 안의 위험한 문자를 URL 안에서 안전하게 바꿉니다.
이중 인코딩 버그
가장 흔한 버그는 같은 값을 두 번 인코딩하는 것입니다. 다음 예제에서 시작 문자열은 이름=홍 길동&a=1입니다.
const once = encodeURIComponent('이름=홍 길동&a=1');
// '%EC%9D%B4%EB%A6%84%3D%ED%99%8D%20%EA%B8%B8%EB%8F%99%26a%3D1'
const twice = encodeURIComponent(once);
// '%25EC%259D%25B4%25EB%25A6%2584%253D%25ED%2599%258D%2520%25EA%25B8%25B8%25EB%258F%2599%2526a%253D1'
두 번째 인코딩에서는 %가 %25로 바뀝니다. 서버가 한 번만 디코딩하면 %EC%9D%B4... 같은 문자열이 그대로 남습니다. 화면에 "이름"이 아니라 %EC%9D%B4 같은 값이 보이는 증상이 나타나는 이유입니다.
디코딩을 두 번 하면 원래 값으로 돌아오지만, 이는 근본 해결이 아닙니다. 어느 단계에서 인코딩이 두 번 되었는지 찾아야 합니다. 대표적인 원인은 다음과 같습니다.
- 프론트엔드가
encodeURIComponent로 쿼리를 만든 뒤, HTTP 클라이언트 라이브러리가params옵션으로 한 번 더 인코딩하는 경우 - 리다이렉트 URL을 인코딩한 값을 다시 인코딩해서
redirect_uri파라미터로 넘기는 경우 - 폼 데이터를
application/x-www-form-urlencoded로 보내면서, 값이 이미 퍼센트 인코딩된 상태인 경우
점검 방법은 간단합니다. 네트워크 탭에서 실제 요청 URL을 보고, 값 안에 %25가 보이면 이중 인코딩을 의심합니다. 그다음 인코딩을 수행하는 코드를 한 곳으로 모읍니다.
한글 파일과 Base64 데이터 URL
파일을 Base64로 바꿔 JSON에 넣는 경우도 많습니다. 이때 결과는 보통 data: URL 형식입니다.
data:text/plain;base64,7ZWc6riA
Base64 파일 변환기는 파일을 올리면 이런 형식의 문자열을 만들고, 반대로 Base64 문자열을 넣으면 파일로 내려받거나 이미지 미리보기를 보여 줍니다. 소스를 보면 data:...;base64, 접두사에서 MIME 타입을 읽고, 접두사가 없으면 앞부분 시그니처로 PDF, GIF, PNG, JPG를 추정합니다.
한글 텍스트를 Base64로 바꿀 때 문제가 생기는 경우는 대부분 문자 인코딩이 맞지 않는 경우입니다. 예를 들어 CP949(EUC-KR) 파일을 UTF-8로 잘못 읽은 뒤 다시 Base64로 바꾸면, 원래 파일과 다른 바이트 열이 만들어집니다. Base64는 바이트를 옮길 뿐 문자 인코딩을 바꾸지 않는다는 점을 기억해야 합니다.
실무 점검 목록
- 값이 URL 경로나 쿼리에 들어가면 퍼센트 인코딩인지 확인합니다. Base64를 그대로 넣으면
+와/가 문제가 됩니다. - Base64URL과 표준 Base64를 혼용하지 않았는지 확인합니다. 디코딩 실패 메시지가 나오면 URL 안전 옵션부터 확인합니다.
- 한글은 바이트 기준으로 계산합니다. 길이 제한이 바이트 기준인지 문자 기준인지 명확히 합니다. 한글 한 글자는 UTF-8에서 3바이트입니다.
- 인코딩 함수는 한 계층에서만 호출합니다. 라이브러리가 이미 인코딩하는지 먼저 확인합니다.
- 개인정보를 Base64나 퍼센트 인코딩으로 가리려고 하지 않습니다. 이 둘은 암호화가 아니므로 누구나 되돌릴 수 있습니다.
- 로그나 디버깅 화면에 개인정보가 포함된 URL을 그대로 남기지 않습니다. 예를 들어
phone=010-1234-5678같은 값은010-****-5678처럼 마스킹한 뒤 확인합니다.
인코딩 문제는 대부분 "어느 계층이 무엇을 한 번 더 했는가"의 문제입니다. 입력 바이트, 인코딩 결과, 서버가 받은 값을 한 번씩 나란히 비교하면 원인을 빠르게 찾을 수 있습니다.