본문으로 건너뛰기
개발자

Base64는 암호화가 아니다 — 인코딩·암호화·해시 구분

“비밀번호를 Base64로 바꿔 저장했으니 안전하다”는 말은 왜 틀릴까요? Base64 결과는 알아볼 수 없는 글자 뭉치처럼 보이지만 키 없이 누구나 1초 만에 되돌릴 수 있습니다. 인코딩·암호화·해시는 겉모습이 비슷해 자주 뒤섞이고, 한글이 URL에서 깨지는 사고도 이 셋을 가르지 못한 데서 시작합니다.

수정 · 약 11분 · 작성 Fixit Dev

인코딩·암호화·해시는 답하는 질문이 다르다

셋이 헷갈리는 이유는 결과물이 비슷하게 생겼기 때문입니다. 무엇을 넣든 읽을 수 없는 글자 뭉치가 나오니 “알아볼 수 없으면 안전하다”는 착각이 생깁니다. 구분 기준은 겉모습이 아니라 “누가, 무엇을 가지고, 되돌릴 수 있는가”입니다.

구분대표 방식목적되돌리기키
문자 인코딩UTF-8, EUC-KR글자를 바이트로 적는 약속누구나불필요
전송 인코딩Base64, 퍼센트 인코딩바이트를 안전한 문자로 옮김누구나불필요
암호화AES 등키 없는 사람이 못 읽게 함키 소지자만필요
해시SHA-256 등내용의 지문을 만들어 대조불가(단방향)불필요

컴퓨터는 글자가 아니라 바이트를 저장합니다. “가”는 UTF-8에서 EA B0 80 세 바이트, EUC-KR에서 B0 A1 두 바이트입니다. Base64와 퍼센트 인코딩은 이 바이트를 받아 바꾸므로, 한글 문제는 늘 “어떤 문자 인코딩으로 바이트를 만들었는가”가 먼저입니다.

password123을 Base64로 바꾸면 cGFzc3dvcmQxMjM=이고 즉시 원문으로 돌아옵니다. SHA-256으로 해시하면 ef92b778…e94f이며, 한 글자만 바꾼 password124는 33631376…0087로 전혀 다릅니다. 앞은 표현을 바꾼 것, 뒤는 지문을 남긴 것입니다.

Base64가 존재하는 이유와 3바이트→4문자 규칙

Base64는 숨기려고가 아니라 통과시키려고 만든 것입니다. 이메일 규격 RFC 2045 제6.8절은 Base64를 “임의의 바이트 열을 사람이 읽을 필요는 없는 형태로 나타내는” 전송 인코딩으로 정의합니다. 7비트 텍스트만 지나가던 통로에 8비트 바이너리를 실으려면 영문·숫자 65자 안으로 바꿔야 했고, 오늘날 data: 이미지나 JWT도 같은 이유로 텍스트입니다.

규칙은 RFC 4648 제4절에 있습니다. 입력을 24비트(3바이트)씩 끊어 6비트 넷으로 다시 나누고, 각 값(0~63)을 64개 문자 중 하나로 바꿉니다. 문자표는 A~Z 0~25, a~z 26~51, 0~9 52~61, + 62, / 63입니다.

  1. 1UTF-8 바이트: EA B0 80 (24비트)
  2. 28비트씩 2진수: 11101010 10110000 10000000
  3. 36비트씩 재분할: 111010 101011 000010 000000
  4. 410진수 값: 58 · 43 · 2 · 0
  5. 5문자표 대응: 58−52=6→“6”, 43−26=17→“r”, 2→“C”, 0→“A” → 6rCA

3바이트가 4문자가 되므로 길이는 4/3배, 약 33% 늘어납니다. 1,000바이트는 4×⌈1,000÷3⌉=1,336자, 1MiB(1,048,576바이트) 이미지를 data: 주소로 넣으면 1,398,104자입니다. 이미지를 Base64로 박은 페이지가 무거운 이유입니다.

입력이 3바이트로 떨어지지 않으면 = 패딩이 붙습니다. 제4절은 8비트만 남으면 문자 2개 뒤에 ==, 16비트가 남으면 문자 3개 뒤에 =를 붙이라고 정합니다. 그래서 H는 SA==, He는 SGU=이고, 한글은 한 글자가 3바이트라 “한글”은 7ZWc6riA로 패딩 없이 끝납니다. 제3.2절은 참조 규격이 달리 정하지 않는 한 패딩을 반드시 붙이라고 하며, 그 예외의 대표가 JWT입니다.

URL-safe Base64와 JWT: 서명은 있어도 비밀은 없다

표준 문자표의 +, /, =는 URL에서 역할이 있는 글자입니다. RFC 3986 제2.2절 예약 문자 목록에서 /는 gen-delims, +와 =는 sub-delims라, 바이트 FB FF의 표준 Base64 +/8=를 쿼리 값에 그대로 붙이면 +는 폼 파서에서 공백으로, /는 경로 구분으로, =는 키와 값의 경계로 읽힐 수 있습니다. RFC 4648 제5절은 그래서 +를 -로, /를 _로 바꾼 문자표를 두고(FB FF는 -_8=), 패딩은 “길이를 이미 안다면 생략할 수 있다”고만 말합니다. JWT는 RFC 7515 제2절이 base64url을 “제5절 문자표에서 끝의 =를 모두 제거한 것”으로 정의해 패딩 없이 다닙니다.

JWT는 RFC 7515 제7.1절대로 헤더·페이로드·서명을 각각 base64url로 바꿔 점으로 이은 문자열입니다. 헤더 {"alg":"HS256","typ":"JWT"}는 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9, 페이로드 {"name":"홍길동","role":"admin"}는 eyJuYW1lIjoi7ZmN6ri464-ZIiwicm9sZSI6ImFkbWluIn0입니다. 표준 Base64라면 eyJuYW1lIjoi7ZmN6ri464+ZIiwicm9sZSI6ImFkbWluIn0=로 가운데 +와 끝의 =가 다릅니다. 두 번째 조각을 디코더에 넣으면 이름과 권한이 그대로 나옵니다.

세 번째 조각인 서명은 “앞의 두 조각이 발급 후 바뀌지 않았는가”를 키를 가진 서버가 확인하는 장치이지 내용을 가리는 장치가 아닙니다. RFC 7519 제3절은 JWS 형태면 서명, JWE 형태여야 암호화라고 구분하고, 제12절은 민감한 정보가 든 JWT는 암호화하라고 합니다. 토큰 대부분은 서명만 있는 JWS이므로 주민등록번호·비밀번호 같은 값은 페이로드에 넣지 않아야 합니다.

퍼센트 인코딩: 한글은 %ED%95%9C, 공백은 %20인가 +인가

RFC 3986 제2.1절은 한 바이트를 % 뒤에 16진수 두 자리로 적고 대문자를 권합니다(%ED이지 %ed가 아닙니다). 그대로 둬도 되는 글자는 제2.3절의 비예약 문자인 영문·숫자와 - . _ ~ 뿐이고, 제2.2절의 예약 문자 : / ? # [ ] @ ! $ & ' ( ) * + , ; =는 값 안에서는 인코딩해야 합니다.

한글은 먼저 UTF-8 바이트가 되고 바이트마다 %XX가 붙습니다. 제2.5절이 문자 데이터는 UTF-8 바이트로 만든 뒤 비예약 문자가 아닌 바이트만 인코딩하라고 정했기 때문입니다. “한글”은 ED 95 9C EA B8 80이므로 %ED%95%9C%EA%B8%80, 18자로, Base64의 7ZWc6riA(8자)보다 길지만 URL 구분자와 충돌하지 않습니다.

공백은 규격이 둘이라 헷갈립니다. RFC 3986에 +가 공백이라는 말은 없습니다. 공백을 +로 적는 규칙은 WHATWG URL 표준 제5절의 application/x-www-form-urlencoded, 즉 HTML 폼 전송 형식에만 있어서 직렬화 때 0x20을 +로, 파싱 때 +를 0x20으로 바꿉니다. 그래서 “한 글+”는 encodeURIComponent에서 %ED%95%9C%20%EA%B8%80%2B, URLSearchParams에서 %ED%95%9C+%EA%B8%80%2B가 됩니다. 둘 다 옳지만 폼 파서가 아닌 곳은 +를 더하기로 두므로, 상대를 모르면 공백은 %20, 더하기는 %2B로 보냅니다.

encodeURI와 encodeURIComponent의 차이도 “무엇을 남겨 두는가”입니다. 둘 다 영문·숫자와 - . _ ~ ! ' ( ) *는 그대로 두지만, encodeURI는 : / ? # & = 같은 구분자까지 남겨 URL 전체에, encodeURIComponent는 그것들도 인코딩해 값 하나에 씁니다. 값 하나를 encodeURI로 처리하면 값 안의 &가 구분자로 남아 보호되지 않고, URL 전체를 encodeURIComponent로 처리하면 https%3A%2F%2F…처럼 구조가 무너집니다. 기준은 “URL 전체인가, 값 하나인가”입니다.

한글이 깨지는 세 경로: EUC-KR, 이중 인코딩, 주소창

첫째는 문자 인코딩이 UTF-8이 아닌 경우입니다. HTML 표준은 accept-charset 속성이 없으면 문서의 문자 인코딩으로 폼을 전송하도록 정합니다. EUC-KR로 만든 오래된 페이지에서 “한글”을 검색하면 바이트가 C7 D1 B1 DB라 %C7%D1%B1%DB로 갑니다. 받는 쪽이 UTF-8을 가정하고 decodeURIComponent를 부르면 URI malformed 오류가 나고, 거꾸로 UTF-8 바이트를 EUC-KR로 읽으면 湲 같은 엉뚱한 한자가 섞입니다.

진단은 바이트 모양으로 합니다. UTF-8 한글은 %EA~%ED로 시작하는 세 바이트 묶음, EUC-KR 한글은 %B0~%C8로 시작하는 두 바이트 묶음입니다. 두 개씩 짝지어져 있으면 디코더가 아니라 문자 인코딩 변환이 필요합니다.

둘째는 이중 인코딩입니다. 이미 %ED%95%9C%EA%B8%80이 된 문자열을 다시 encodeURIComponent에 넣으면 %가 %25로 바뀌어 %25ED%2595%259C%25EA%25B8%2580, 18자가 30자가 됩니다. 한 번 디코딩하면 %ED%95%9C%EA%B8%80이 나와 “돌아온 것 같지만” 한 번 더 풀어야 한글입니다. 값을 인코딩한 뒤 URL 전체를 다시 인코딩할 때 흔히 생깁니다.

셋째는 깨진 것이 아니라 그렇게 보이는 경우입니다. 2026년 9월 기준 주요 브라우저 주소창은 %ED%95%9C%EA%B8%80을 “한글”로 풀어 보여 주고, 복사하면 인코딩된 형태가 복사되는 것으로 관찰됩니다. “주소창엔 한글인데 로그엔 %ED…”라는 문의가 여기서 나오지만 바이트는 같습니다.

비밀번호를 Base64로 저장하면 안 되는 이유

cGFzc3dvcmQxMjM=이 저장된 데이터베이스가 유출되면 붙여넣는 순간 password123입니다. 키가 없으니 “뚫는” 단계 자체가 없습니다. AES로 암호화하면 키 소지자만 풀 수 있으니 낫지만, 로그인 서버는 그 키를 늘 갖고 있어야 하고, 뚫리면 키도 넘어갑니다. 더 근본적으로 서버는 원문을 알 필요가 없습니다. 확인할 것은 “입력값이 가입 때와 같은가”뿐이고, 되돌릴 수 없는 지문으로 충분합니다.

그 지문이 해시입니다. 가입 때 SHA-256(password123)=ef92b778…e94f를 저장하고, 로그인 때 입력값을 다시 해시해 비교합니다. 원문으로 가는 길이 없고 한 글자만 달라도 결과 전체가 바뀌므로 근접할 수도 없습니다. 이 점을 OWASP 비밀번호 저장 지침도 해시가 적합한 이유로 듭니다.

다만 SHA-256을 그대로 쓰면 부족합니다. 같은 지침은 SHA-256 같은 빠른 해시가 초당 매우 많은 추측을 허용해 비밀번호 저장에 맞지 않다고 명시하고, 2026년 9월 기준 Argon2id, scrypt, bcrypt, PBKDF2 순으로 느린 알고리즘을 권합니다. 여기에 솔트(비밀번호마다 따로 만든 무작위 문자열)를 붙여 해시하면 같은 비밀번호도 저장값이 달라져 미리 계산한 표로 맞히는 공격이 막힙니다. 라이브러리 대부분은 솔트를 자동 생성합니다.

실무 판단 기준 정리

  • 숨겨야 한다면 인코딩은 후보가 아닙니다. 되돌려야 하면 암호화, 대조만 하면 솔트를 더한 느린 해시입니다.
  • 바이너리를 텍스트 채널에 실을 때만 Base64를 씁니다. 4/3배로 커지므로 큰 파일의 data: 삽입은 재고합니다.
  • URL·쿠키·파일명의 Base64는 URL-safe 변형과 패딩 유무를 상대와 맞춥니다. JWT는 패딩 없는 base64url입니다.
  • JWT 페이로드는 누구나 읽습니다. 노출되면 안 되는 값은 넣지 않거나 JWE로 암호화합니다.
  • 값 하나는 encodeURIComponent, URL 전체는 encodeURI. 폼 파서인지 불확실하면 공백은 %20, 더하기는 %2B입니다.
  • 한글이 깨지면 바이트 모양부터 봅니다. %25는 이중 인코딩, 두 바이트 짝은 EUC-KR입니다.

직접 계산해 보기

참고 자료

이 글은 공개된 법령·기관 자료를 바탕으로 Fixit Dev 이 작성했습니다. 법령과 기준은 개정될 수 있으니, 중요한 판단에는 위 원문을 함께 확인하세요.