Base64, URL 인코딩, JWT 알아보기
데이터를 텍스트로 주고받아야 하는 곳, 즉 URL, HTTP 헤더, JSON, 로그인 토큰에는 이 세 가지가 늘 등장합니다. 하지만 어느 것도 데이터를 숨기지 않습니다. 이 가이드는 각각이 어떻게 동작하는지, 어디에서 만나게 되는지, 그리고 대부분의 문제를 일으키는 실수를 설명합니다.
인코딩은 암호화가 아닙니다
인코딩은 특정 문자만 허용하는 경로를 데이터가 무사히 지나가도록 표기 방식을 바꾸는 것입니다. 키가 필요 없으므로 누구나 되돌릴 수 있습니다. 암호화도 데이터를 바꾸지만, 키를 가진 사람만 되돌릴 수 있습니다. 해시는 또 다릅니다. 되돌릴 수 없는 일방향 지문이며, 자세한 내용은 해시 함수 알아보기에서 설명합니다.
Base64와 URL 인코딩은 인코딩입니다. JWT는 보통 서명되어 있어 누가 발급했는지와 내용이 바뀌지 않았음을 증명하지만, 내용 자체는 인코딩되어 있을 뿐입니다. 값을 비밀로 지켜야 한다면 이런 형식이 아니라 암호화가 필요합니다.
Base64의 동작 원리
Base64는 데이터를 3바이트(24비트)씩 읽어 6비트짜리 묶음 4개로 나눕니다. 각 묶음은 0~63 사이의 숫자이며, 64개 문자로 된 알파벳의 한 글자로 씁니다. 0~25는 A~Z, 26~51은 a~z, 52~61은 0~9이고, 그다음이 +와 /입니다. "Man"이라는 단어로 과정을 살펴보면 다음과 같습니다.
| 단계 | M | a | n |
|---|---|---|---|
| 바이트 값 | 77 | 97 | 110 |
| 비트 | 01001101 | 01100001 | 01101110 |
| 6비트 묶음 4개 | 010011 · 010110 · 000101 · 101110 = 19 · 22 · 5 · 46 | ||
| Base64 | T W F u → TWFu | ||
입력 길이가 3의 배수가 아니면 마지막 묶음을 채우고 패딩으로 표시합니다. 1바이트가 남으면 2글자와 ==, 2바이트가 남으면 3글자와 =가 됩니다. "Ma"는 TWE=, "M"은 TQ==가 됩니다.
3바이트가 4글자가 되므로 Base64는 데이터를 약 3분의 1만큼 키우고, 형식에 따라 줄바꿈이 더해지기도 합니다. 이메일에 첨부한 3MB 사진은 약 4MB의 텍스트로 전송되며, 스타일시트에 데이터 URL로 넣은 이미지는 원래 파일보다 약 3분의 1 무거워집니다.
Base64를 만나게 되는 곳
- 이메일 첨부 파일(MIME)은 한 줄에 76자씩 끊은 Base64 텍스트입니다.
- 데이터 URL은
data:image/png;base64,iVBORw0…처럼 작은 파일을 HTML이나 CSS에 직접 넣습니다. - HTTP 기본 인증은
user:password를 Base64로 바꿔 Authorization 헤더에 보냅니다. 비밀번호를 슬쩍 보는 눈만 피할 뿐이므로 HTTPS에서만 안전합니다. - 인증서와 키를 담은 PEM 파일은
-----BEGIN …-----와-----END …-----줄 사이에 64자씩 끊은 Base64입니다. - JSON과 XML은 바이너리 형식이 없어서 서명이나 썸네일 같은 바이너리 값을 Base64 문자열로 담습니다.
Base64URL과 패딩
표준 Base64의 +와 /는 URL과 파일 경로에서 의미가 있으므로, 두 번째 알파벳인 Base64URL은 그 대신 -와 _를 씁니다. 디코더가 길이를 계산할 수 있어서 = 패딩도 보통 생략합니다. JWT, 여러 API 토큰, 일부 파일 이름이 Base64URL을 씁니다. 표준 Base64를 Base64URL로 바꾸려면 +를 -로, /를 _로 바꾸고 끝의 =를 지우면 됩니다. 반대로 바꿀 때는 치환을 되돌린 뒤 길이가 4의 배수가 될 때까지 =를 붙입니다.
URL의 퍼센트 인코딩
URL에는 정해진 문자만 쓸 수 있고, 그중 일부는 주소의 구조를 나타냅니다. /와 ?는 경로와 쿼리를 나누고, &와 =는 매개변수를 나누며, #은 프래그먼트의 시작입니다. 값에 이런 문자나 ASCII 밖의 문자가 들어 있으면 퍼센트 인코딩을 합니다. 값을 UTF-8로 바꾼 각 바이트를 % 뒤에 16진수 두 자리로 쓰는 방식입니다. é가 C3 A9 두 바이트이므로 "café"는 caf%C3%A9가 됩니다.
영문자, 숫자, 그리고 - . _ ~ 는 인코딩할 필요가 없습니다. 문제를 가장 자주 일으키는 문자는 다음과 같습니다.
| 문자 | 인코딩 결과 | 문제가 되는 이유 |
|---|---|---|
| 공백 | %20 | URL에 쓸 수 없음 |
| & | %26 | 새 매개변수가 시작됨 |
| = | %3D | 매개변수 이름이 끝남 |
| ? | %3F | 쿼리가 시작됨 |
| # | %23 | 프래그먼트가 시작되며, 이 부분은 서버로 전송되지 않음 |
| / | %2F | 경로 단계가 추가됨 |
| + | %2B | 폼 데이터에서는 공백으로 읽힘 |
| % | %25 | 이스케이프 시퀀스가 시작됨 |
값을 하나씩 인코딩한 뒤 URL을 조립하세요. JavaScript에서 encodeURIComponent는 값 하나를 인코딩하며 / ? & = #도 함께 인코딩합니다. 반면 encodeURI는 주소 전체를 위한 함수라 이런 문자를 그대로 둡니다. 값 하나에 encodeURI를 쓰는 것은 쿼리 문자열이 깨지는 흔한 원인입니다.
더하기 기호, 공백, 이중 인코딩
HTML 폼은 필드를 application/x-www-form-urlencoded 형식으로 보내며, 이때 공백은 +가 됩니다. 일반 URL 문법에서 +는 그냥 더하기 기호입니다. 그래서 "C++" 검색어를 q=C++로 보내면 폼 데이터를 디코딩하는 서버에는 "C" 뒤에 공백 두 개가 붙은 값으로 도착합니다. 올바르게 보내려면 q=C%2B%2B로 써야 합니다. 쿼리 문자열을 직접 디코딩할 때는 어떤 규칙으로 만들어졌는지 먼저 확인하세요.
이중 인코딩은 이미 인코딩된 값을 다시 인코딩할 때 생깁니다. % 자체가 %25가 되므로 %20은 %2520이 됩니다. URL에서 %25 뒤에 16진수 두 자리가 보이면 처리 과정 어딘가에서 두 번 인코딩한 것이니, 한 번 디코딩해 보고 어디에서 단계가 더해졌는지 찾아보세요.
JWT에 담긴 내용
JSON Web Token(JWT)은 사용자가 누구인지, 토큰이 언제까지 유효한지 같은 클레임을 발급하는 시스템에서 이를 받아들이는 시스템으로 전달합니다. 흔히 쓰는 서명된 형태는 점으로 구분된 Base64URL 세 부분, 즉 헤더, 페이로드, 서명으로 이루어집니다. 앞의 두 부분을 디코딩하면 평범한 JSON입니다.
Header: {"alg": "HS256", "typ": "JWT"}
Payload: {"sub": "user-42", "role": "editor", "iat": 1767225600, "exp": 1767229200}
표준(RFC 7519)은 몇 가지 등록 클레임을 정의하며, 애플리케이션은 위의 role처럼 자체 클레임을 추가합니다.
| 클레임 | 의미 |
|---|---|
| iss | 발급자: 토큰을 만든 쪽 |
| sub | 주체: 보통 사용자나 계정 ID |
| aud | 대상: 토큰을 사용할 서비스 |
| exp, nbf, iat | 만료, 유효 시작, 발급 시각: 초 단위 유닉스 시간 |
| jti | 토큰 ID: 재사용을 감지하거나 토큰 하나를 폐기할 때 사용 |
페이로드는 인코딩만 되어 있으므로 토큰을 가진 사람은 누구나 읽을 수 있습니다. 개인정보와 비밀은 넣지 말고, 토큰은 작게 유지하세요. 토큰은 요청마다 함께 전송되며, 많은 서버가 요청 헤더의 크기를 제한합니다.
서명은 비밀이 아닙니다: JWT를 검증하는 방법
서명은 헤더와 페이로드를 바탕으로 계산합니다. HS256에서는 발급자와 수신자가 하나의 비밀 키를 공유하고 SHA-256 기반 HMAC을 씁니다. RS256이나 ES256에서는 발급자가 개인 키로 서명하고 수신자는 짝이 되는 공개 키로 확인하므로, 수신자가 직접 토큰을 만들 수는 없습니다.
토큰을 받는 서버는 어떤 클레임이든 믿기 전에 예상한 알고리즘과 키로 서명을 검증하고 exp, nbf, iss, aud를 확인해야 합니다. LeedyBox 도구처럼 디코딩만 하는 것은 이 과정을 모두 건너뜁니다. 토큰에 무엇이 적혀 있는지는 보여 주지만 진짜인지는 알려 주지 않습니다. 라이브러리는 서명되지 않은 토큰을 뜻하는 "none" 알고리즘도 거부해야 합니다. 그런 토큰을 쓰기로 한 경우가 아니라면 말입니다. 내용 자체를 숨겨야 할 때는 토큰을 암호화(JWE)하며, 이 경우 토큰은 다섯 부분으로 이루어지고 키 없이는 읽을 수 없습니다.
자주 생기는 문제와 해결 방법
| 증상 | 가능한 원인 | 해결 방법 |
|---|---|---|
| Base64를 디코딩하면 글자가 깨짐 | 바이너리 데이터이거나 다른 인코딩의 텍스트 | 무엇을 인코딩했는지 확인하고, 바이너리 데이터는 파일로 저장 |
| 디코딩 중 "잘못된 문자" 오류 | data: 접두사, 따옴표, 또는 Base64URL 알파벳 | 쉼표 앞부분을 모두 지우고, -와 _를 받아들이는 디코더 사용 |
| 매개변수가 중간에서 잘림 | 값에 인코딩하지 않은 &나 # | 값마다 encodeURIComponent로 인코딩 |
| URL에 %2520이 보임 | 값을 두 번 인코딩함 | URL을 만드는 지점에서 한 번만 인코딩 |
| exp가 미래인데도 토큰이 거부됨 | 대상이나 발급자 불일치, 시계 차이, 키 불일치 | aud, iss, 서명 키를 서버가 기대하는 값과 비교 |
| 만료일이 수천 년 뒤로 나옴 | exp를 초가 아닌 밀리초로 기록함 | exp, nbf, iat를 초 단위로 저장 |
자주 묻는 질문
Base64로 비밀번호나 API 키를 보호할 수 있나요?
아니요. Base64는 누구나 순식간에 되돌릴 수 있습니다. 설정 파일이나 URL에 Base64로 들어 있는 자격 증명은 평문만큼 노출된 것입니다.
JWT 페이로드에 개인정보를 넣어도 되나요?
피하는 것이 좋습니다. 브라우저 확장 프로그램, 로그, 요청을 보는 프록시처럼 토큰을 가진 쪽은 누구나 페이로드를 읽을 수 있습니다. 토큰에는 ID만 넣고 자세한 정보는 서버에서 조회하세요.
URL 인코딩이 인젝션 공격을 막아 주나요?
값이 URL의 구조를 깨뜨리지 못하게 한다는 의미에서만 그렇습니다. 서버는 디코딩한 값을 SQL, HTML, 셸 명령처럼 실제로 쓰는 곳에 맞게 검증하고 이스케이프해야 합니다.
Base64 문자열이 원래 파일보다 긴 이유는 무엇인가요?
3바이트가 4글자가 되므로 텍스트가 약 33% 커지고, 줄바꿈이 조금 더 더해집니다. 큰 파일은 Base64보다 바이트를 그대로 보내는 편이 효율적입니다.