CS · Encoding · Backend

Base64와 퍼센트 인코딩 - 바이트를 글자로 포장하기

글자만 지나갈 수 있는 길에 바이너리를 실어 보내야 할 때가 있다. 그럴 때 쓰는 포장이 Base64와 퍼센트 인코딩이다. 왜 크기가 늘어나는지, 그리고 왜 이걸 암호로 착각하면 안 되는지를 본다.

·0과 1로 어떻게 담나 8편
목차
  1. 글자만 통과시키는 검문소
  2. 3바이트를 4글자로 바꾼다
  3. 그래서 33% 커진다
  4. Base64는 암호가 아니다
  5. URL에서는 다른 포장을 쓴다
  6. 두 번 포장하면 벗기기 어려워진다
  7. 실무에서: 어디에 쓰고 어디에 안 쓰나
  8. 정리

지금까지는 글자를 바이트로 담았다. 이번에는 반대다. 바이트를 글자로 담아야 하는 상황이 있다.

글자만 통과시키는 검문소

어떤 길목은 텍스트만 지나갈 수 있다. 짐을 그대로 들고 가면 검문소에서 막힌다. 그럴 때 짐을 뜯어 글자 봉투에 나눠 담아 통과시키고, 반대편에서 다시 조립한다.

컴퓨터 세계에도 그런 길목이 여럿이다.

  • 이메일 본문 - 원래 영문 텍스트만 나르도록 설계됐다. 첨부 파일은 그래서 글자로 바꿔 실린다
  • JSON 문자열 - 값이 텍스트로 오가는 형식이라 이미지 같은 바이너리를 그대로 못 넣는다
  • URL - 공백이나 & 같은 글자는 주소 안에서 다른 뜻으로 읽힌다
  • HTTP 헤더 - 줄바꿈이 헤더 경계라, 값에 줄바꿈이 있으면 구조가 무너진다

공통점은 하나다. 어떤 바이트는 그 길에서 다른 뜻으로 해석된다. 그래서 안전한 글자로 바꿔 실어야 한다.

3바이트를 4글자로 바꾼다

Base64가 하는 일은 단순하다. 바이트를 6비트씩 끊어 읽고, 각 조각을 안전한 글자 하나로 바꾼다.

원래 담긴 모습 - 8비트씩 세 덩어리
01001000 01100101 01111001
Base64가 자르는 모습 - 같은 줄을 6비트씩 네 덩어리로
010010 000110 010101 111001
SGV5
바이트 3개들어간 것
글자 4개나온 것 - 3분의 1이 늘었다

비트는 하나도 안 늘었다. 늘어난 것은 6비트짜리 그릇에 담느라 필요해진 칸 수다.

핵심은 끊는 자리가 어긋난다는 것이다. 원래 데이터는 8비트씩 묶여 있는데 6비트씩 끊으니 경계가 안 맞는다. 그래서 최소 공배수인 24비트, 즉 3바이트를 한 덩어리로 잡아 4조각으로 나눈다.

6비트로 표현할 수 있는 값은 64가지다. 여기에 AZ, az, 0~9, 그리고 기호 둘을 배정한 것이 이름의 유래다. 전부 어디서나 안전한 글자들이다.

3바이트로 안 떨어지면 남는 자리를 =로 채운다. 문자열 끝의 =이나 ==가 그 흔적이다.

그래서 33% 커진다

8비트짜리 정보를 6비트 그릇에 담으니 그릇이 더 필요하다. 3바이트가 4글자가 되므로 정확히 3분의 1이 늘어난다. 1MB 이미지를 Base64로 만들면 약 1.33MB가 된다.

이게 실무에서 중요한 이유는 눈에 잘 안 띄기 때문이다. 이미지를 JSON에 담아 보내는 API는 잘 돌아가는데 전송량만 33% 더 쓴다. 게다가 이런 문자열은 대개 압축도 잘 안 먹는다.

Base64는 암호가 아니다

가장 흔한 오해다. Base64는 숨기지 않는다. 키도 없고 비밀도 없다. 규칙이 공개되어 있으니 누구나 원래대로 되돌린다.

plaintext
YWRtaW46cGFzc3dvcmQ=   →   admin:password

읽기 어려워 보이는 건 사람 눈에만 그렇다. 브라우저 콘솔 한 줄이면 풀린다.

그런데도 오해가 계속되는 건 비밀스러워 보이는 자리에 자주 등장하기 때문이다. 인증 헤더의 값, 토큰의 몸통, 쿠키에 담긴 데이터가 다 이렇게 생겼다. 하지만 이것들이 안전한 이유는 Base64 때문이 아니라 서명이나 암호화가 따로 되어 있어서다.

Base64는 포장이지 자물쇠가 아니다. 상자를 예쁘게 쌌다고 잠긴 건 아니다.

URL에서는 다른 포장을 쓴다

URL에도 안전하지 않은 글자가 있는데, 여기서는 Base64가 아니라 다른 방식을 쓴다. 문제 되는 글자만 골라 % 뒤에 16진수로 적는 것이다.

주소창에 보이는 것
?q= 공백 &p=2
실제로 전송되는 것
?q= %ED%95%9C %20 %EA%B8%80 &p=2
그대로 한 글자 → 9자 3자 한 글자 → 9자 그대로
안 바뀐 칸주소 안에서 다른 뜻으로 안 읽히는 글자들이다
바뀐 칸공백은 구분자로, 한글은 안전한 글자가 아니라서 바이트마다 %XX가 된다

문제 되는 글자만 바꾸기 때문에 주소의 나머지는 사람이 읽을 수 있게 남는다. 대신 바뀐 자리는 눈에 띄게 길어진다.

%20이 공백이라는 건 여기서 나온다. 공백의 아스키 번호가 16진수로 20이다.

방식이 다른 이유는 목적이 달라서다. Base64는 전부 바꾼다. 퍼센트 인코딩은 문제 되는 것만 바꾼다. 그래서 URL은 대부분 사람이 읽을 수 있는 상태로 남는다.

한글은 안전한 글자가 아니라서 전부 바뀐다. UTF-8로 3바이트니 한 글자가 %XX%XX%XX 아홉 자가 된다. 주소창에서는 한글로 보여도 실제로 전송되는 건 이 모양이다.

두 번 포장하면 벗기기 어려워진다

여기서 실무 사고가 하나 나온다. 이미 인코딩된 값을 또 인코딩하는 것이다.

% 자체도 안전한 글자가 아니라서 인코딩 대상이다. 그래서 %20을 한 번 더 인코딩하면 %2520이 된다. 받는 쪽이 한 번만 디코딩하면 %20이라는 글자 그대로가 남는다.

증상은 이렇게 나타난다.

  • 검색어에 %20이 글자로 박혀 나온다
  • 리다이렉트 주소가 이상하게 늘어난다
  • 파라미터에 URL을 담아 넘길 때만 깨진다

원인은 대개 프레임워크가 이미 해 준 일을 손으로 한 번 더 한 것이다. 어느 층이 인코딩을 책임지는지 정해 두는 게 유일한 예방이다.

실무에서: 어디에 쓰고 어디에 안 쓰나

쓸 만한 자리

  • 작은 이미지를 문서에 심을 때 - data: URI로 넣으면 요청이 한 번 준다. 아이콘처럼 작은 것에만 값이 있다.
  • 텍스트 통로에 바이너리를 실을 때 - 이메일 첨부가 원조이고, 지금도 JSON에 파일을 담을 때 쓴다.
  • 헤더에 값을 실을 때 - 헤더는 줄바꿈에 민감해서 임의의 바이트를 그대로 넣으면 안 된다.

쓰면 안 되는 자리

  • 큰 파일 - 33% 손해에 메모리까지 통째로 잡아먹는다. 파일은 파일대로 올리는 게 맞다.
  • 숨기는 용도 - 앞에서 본 그대로다. 아무것도 안 숨긴다.
  • 이미 안전한 것을 또 - 평범한 텍스트를 Base64로 감싸면 크기만 늘고 읽기만 어려워진다.

정리

  • 어떤 길목은 텍스트만 통과시킨다. 그래서 바이트를 글자로 포장한다.
  • Base64는 6비트씩 끊어 안전한 글자로 바꾼다. 8비트와 경계가 안 맞아 3바이트가 4글자가 된다.
  • 그래서 크기가 33% 늘어난다. 눈에 안 띄는 비용이라 더 조심한다.
  • Base64는 암호가 아니다. 누구나 되돌린다. 안전한 것들은 서명이나 암호화가 따로 되어 있어서 안전한 것이다.
  • URL은 문제 되는 글자만 %XX 바꾼다. 그래서 사람이 읽을 수 있는 부분이 남는다.
  • **두 번 인코딩하면 %2520**이 된다. 어느 층이 책임지는지 정해 두는 게 유일한 예방이다.

다음 글은 이 시리즈를 닫는다. 글자가 깨졌을 때 어디부터 의심할지를 한 장으로 정리한다.