퍼센트 인코딩: 예약 문자와 이중 인코딩 버그
URL 퍼센트 인코딩이 어떻게 작동하는지, 왜 공백이 경로에서는 %20이고 폼 본문에서는 +인지, encodeURIComponent vs encodeURI, 그리고 이중 인코딩 버그가 어떻게 %2520을 만드는지 정리합니다.
URL은 제한된 ASCII 문자 집합만 담을 수 있습니다. 그 집합 밖의 것, 즉 공백,
비라틴 문자, 또는 URL 문법이 구조용으로 예약한 문자 중 하나는 퍼센트
인코딩으로 표현돼야 합니다. % 뒤에 바이트를 가리키는 16진수 두 자리가 붙는
형태입니다. 바이트 시퀀스는 문자를 먼저 UTF-8로 인코딩한 다음 각 바이트를
%XX로 쓴 것에서 나옵니다. 그래서 공백(바이트 0x20)은 %20이 되고,
é(UTF-8 바이트 0xC3 0xA9)는 %C3%A9가 됩니다. 이 글은 어떤 문자가
인코딩이 필요한지, 폼 처리를 깨뜨리는 %20 vs + 모호성, 오용되는
자바스크립트 함수들, 그리고 인코딩이 한 계층 이상에서 일어날 때 나타나는 이중
인코딩 버그를 다룹니다.
URL이 그것을 필요로 하는 이유
RFC 3986은 URI의 문법을 정의하고, 그 문법은 작고 고정된 문자 목록을 허용합니다. 문자는 세 무리로 나뉩니다.
- 비예약(unreserved) — 항상 안전하고 결코 인코딩이 필요 없음:
A-Z a-z 0-9 - . _ ~ - 예약(reserved) — URL에서 합법이지만 구조적 의미를 지니므로, 구분자가 아니라 값 안에 나타날 때 인코딩돼야 함.
- 그 밖의 모든 것 — 공백, 제어 문자, 그리고 모든 비ASCII. 원시 URL에 자리가 없고 인코딩돼야 함.
예약 문자가 까다로운 무리인 이유는, 같은 바이트가 위치에 따라 구분자일 수도
데이터일 수도 있기 때문입니다. 경로 세그먼트 사이의 /는 구조이고, 한
세그먼트의 값 안 /는 데이터여서 %2F가 돼야 합니다.
예약 문자
이들은 값 안에 원시로 남으면 URL의 의미를 바꾸는 문자입니다. 데이터의 일부일 때 인코딩하세요.
| 문자 | 인코딩 | 구조적 역할 |
|---|---|---|
| 공백 | %20 |
원시로는 불법, 많은 파서에서 URL을 끝냄 |
? |
%3F |
쿼리 문자열 시작 |
# |
%23 |
프래그먼트 시작 |
& |
%26 |
쿼리 파라미터 구분 |
= |
%3D |
키와 값 구분 |
/ |
%2F |
경로 세그먼트 구분 |
+ |
%2B |
폼 인코딩 데이터에서 공백을 뜻함 |
% |
%25 |
퍼센트 이스케이프를 시작함 |
+와 % 행이 조용한 손상을 일으키는 것들입니다. 인코딩을 잊은 쿼리 값 안의
문자 그대로의 +는 폼 디코딩을 하는 무엇에든 공백으로 읽힙니다. %25로
인코딩되지 않은 문자 그대로의 %는 오류를 내거나, 더 나쁘게는 의도되지 않은
이스케이프의 시작으로 해석됩니다.
%20 vs + 모호성
이것은 인코딩 버그의 가장 흔한 원천이고, 공백을 어떻게 표현할지에 대해 서로 다른 두 명세가 의견을 달리하는 데서 옵니다.
- 일반 URI에서, 즉 경로와 RFC 3986 아래의 쿼리 문자열에서 공백은
%20입니다. 끝입니다. application/x-www-form-urlencoded데이터에서, 즉 HTML 폼POST의 본문과 오랜 관례로 폼 필드를 나르는 쿼리 문자열에서 공백은+입니다. 그 맥락에서 문자 그대로의+는%2B입니다.
그래서 q=hello world는 둘 중 하나로 올바르게 나타날 수 있습니다.
?q=hello%20world 일반 URI 규칙
?q=hello+world 폼 인코딩 규칙
둘 다 유효합니다. 중요한 것은 인코더와 디코더가 합의하는 것입니다. 버그는
한쪽이 공백을 +로 인코딩(폼 규칙)하고 다른 쪽이 일반 URI 규칙으로 디코딩해
문자 그대로의 +가 데이터에 남을 때, 또는 진짜 +를 담은 값(전화번호
+1 555..., c++ 검색)이 +를 공백으로 바꾸는 무언가에 디코딩될 때
나타납니다.
실용 규칙입니다. 양 끝을 통제하고 쿼리 문자열을 손으로 만든다면, %20을
선호하고 문자 그대로의 +는 %2B로 인코딩하세요. 진짜 폼 본문을 제출한다면,
브라우저는 +를 쓸 것이고 당신의 서버 프레임워크는 그것을 기대합니다.
자바스크립트의 encodeURIComponent vs encodeURI
자바스크립트는 두 인코더를 제공하고, 서로 다른 일을 위해 존재합니다.
encodeURI(url)은 이미 구조적으로 완성된 URL 전체를 인코딩하기 위한 것입니다. 예약된 구조 문자: / ? # [ ] @ & = + $ ,와 그 밖의 몇 개를 그대로 둡니다. 그것들이 구분자로 제 일을 하고 있기 때문입니다.encodeURIComponent(value)는 URL에 떨어질 데이터 한 조각, 즉 경로 세그먼트 하나, 쿼리 값 하나를 인코딩하기 위한 것입니다. 예약된 구조 문자도 인코딩합니다. 값 안에서 그것들은 구조가 아니라 데이터이기 때문입니다.
encodeURI("https://x.com/a b?q=c/d&e=f")
// "https://x.com/a%20b?q=c/d&e=f" (슬래시, ?, & 그대로)
encodeURIComponent("c/d&e=f")
// "c%2Fd%26e%3Df" (전부 이스케이프)
모든 개별 쿼리 값과 경로 세그먼트에는 encodeURIComponent를 쓰세요.
encodeURI는 완성된 URL 문자열을 가졌고 그 구조를 건드리지 않으면서 떠도는
공백과 비ASCII만 이스케이프하고 싶을 때에만 쓰세요. 대부분의 사람이 가정하는
것보다 좁은 필요입니다.
한 가지 함정입니다. encodeURIComponent는 +를 인코딩하지 않습니다. +는
비예약처럼 보이는 문자여서 그대로 둡니다. 일반 URI 규칙 아래에서는 괜찮지만,
당신의 서버가 쿼리 문자열을 폼 규칙으로 디코딩하면 인코딩되지 않은 +는 공백이
됩니다. 폼 디코딩 엔드포인트를 겨냥할 때는 후처리하세요.
encodeURIComponent("a+b").replace(/%20/g, "+") // 폼 스타일
encodeURIComponent("a+b").replace(/\+/g, "%2B") // 문자 그대로의 + 보호
기본값에 기대지 말고 한 관례를 의도적으로 고르세요.
경로와 쿼리를 다르게 인코딩하라
경로와 쿼리 문자열은 서로 다른 예약 집합을 가지므로, 전체 문자열에 함수 하나를
돌리기보다 따로 인코딩하세요. 경로 세그먼트에서 /는 구분자이고 한 세그먼트의
값의 일부일 때 %2F가 돼야 합니다. +와 =는 평범한 데이터입니다. 쿼리에서
&와 =는 구분자이고 값 안에서 인코딩돼야 합니다. /는 보통 원시로
허용됩니다.
안전한 접근은 이미 인코딩된 조각들로 URL을 짓는 것입니다. 각 경로 세그먼트와
각 쿼리 값에 개별적으로 encodeURIComponent를 돌린 다음, 당신이 통제하는 원시
구분자로 잇습니다. 조립된 문자열을 두 번째로 인코딩하지 마세요. 바로 거기서
다음 버그가 옵니다. 경로용 URL 안전 식별자(이스케이프 %가 전혀 없는)가 정말
필요하다면, 우리 URL 슬러그 생성기로 슬러그로 정규화하세요.
이중 인코딩 버그
% 자체가 %25로 인코딩되므로, 이미 인코딩된 문자열에 인코더를 돌리면
망가집니다. hello world는 한 번 인코딩되어 hello%20world가 됩니다. 그
결과를 다시 인코딩하면 %20 안의 %가 %25가 되어 hello%2520world를
만듭니다. 그 문자열을 한 번 디코딩하면 hello world가 아니라 문자 그대로의
텍스트 hello%20world를 얻습니다.
찾아볼 신호는 단일 이스케이프였어야 할 것 뒤의 %25입니다.
hello world 원본
hello%20world 한 번 인코딩 (올바름)
hello%2520world 두 번 인코딩 (버그 — %20이 %2520이 됨)
hello%252520... 세 번 인코딩
이것은 인코딩이 한 계층 이상에서 돌고 누구도 문자열이 몇 번 건드려졌는지 추적하지 않을 때마다 일어납니다. 프런트엔드가 쿼리 값을 인코딩하고, API 게이트웨이나 리버스 프록시가 전달된 URL을 다시 인코딩하고, 백엔드 프레임워크가 저장이나 리디렉션 전에 세 번째로 인코딩합니다. 각 계층은 개별적으로 "올바르고", 스택은 틀립니다.
탐지하려면, 의심스러운 값을 한 번 디코딩해 결과에 여전히 %XX 이스케이프가
있는지 확인하세요. 단일 디코드가 데이터에 보이는 %20이나 %2F를 남기면,
적어도 두 번 인코딩된 것입니다. 해법은 두 번째 replace가 아니라
구조적입니다. 정확히 한 번, 원시 데이터가 URL이 되는 경계에서 인코딩하고, 그
뒤로는 어디서나 값을 불투명하게 다루세요. 계층을 더하지 말고 벗기세요. 이중
인코딩을 두 번 디코딩해 "고치지" 마세요. %25를 정당하게 담은 값이 그 추가
패스에 손상되기 때문입니다.
이것은 다른 전송 인코딩에서 사람들을 걸려 넘어지게 하는 같은 규율입니다. 한 계층이 인코딩된 데이터를 평문으로 오인하는 실패 양상은 Base64는 암호화가 아니다의 혼동도 이끕니다.
요약
퍼센트 인코딩은 안전하지 않은 문자를 %에 그 UTF-8 바이트의 16진수를 더한
것으로 매핑합니다. 예약 문자는 값 안에 나타날 때마다 인코딩하고, 공백은 일반
URI 규칙에서 %20이지만 폼 규칙에서 +임을 기억하고, 개별 구성요소에는
encodeURIComponent를 완성된 URL에만 encodeURI를 쓰고, %2520 부류 버그를
피하려 각 값을 정확히 한 번 인코딩하세요.
값을 인코딩하거나 디코딩하며 어떤 바이트가 바뀌는지 정확히 보고 싶을 때, 그리고 한 계층씩 디코딩해 이중 인코딩을 잡고 싶을 때, 우리 URL 인코더/디코더를 쓰세요.