교과서적인 퍼센트 인코딩 — RFC 3986의 예약 문자와 비예약 문자
URI 표준인 RFC 3986은 문자를 정확히 두 개의 의미 있는 집합으로 나눕니다. 비예약(unreserved) 집합 — 영문자, 숫자, 그리고 네 개의 기호 - . _ ~ — 은 인코딩 없이 URL 어디에나 올 수 있습니다. 이들을 인코딩하는 것은 합법이지만 무의미합니다. %41과 A는 같은 것을 식별하고, URL 정규화기는 이를 도로 디코딩하도록 되어 있습니다. 설계의 핵심은 예약(reserved) 집합에 있습니다. gen-delims인 : / ? # [ ] @는 URL 구성요소 사이의 경계를 표시하고, sub-delims인 ! $ & ' ( ) * + , ; =는 구성요소 내부에서 의미를 나릅니다. 그 밖의 모든 것 — 공백, 따옴표, 꺾쇠, 모든 비-ASCII 문자 — 은 날것으로 등장할 수 없고 %HH 바이트 값으로 퍼센트 인코딩되어야 합니다.
결정적인 미묘함은, 예약 문자의 경우 인코딩형과 날것형이 동등하지 않다는 점입니다. 날것 /는 경로 세그먼트를 구분하지만, 인코딩된 %2F는 슬래시처럼 보일 뿐인 데이터입니다. 즉 문자가 "인코딩이 필요한지"는 전적으로 어느 구성요소 안에 있느냐에 달렸습니다. /는 경로에서는 괜찮지만 URL을 담은 쿼리 파라미터 값 안에서는 %2F가 되어야 하고, ?는 처음 등장할 때는 구조이지만 그 뒤로는 평범한 데이터입니다.
URL 구성의 제1 실무 규칙이 존재하는 이유도 이것입니다. 조립이 끝난 URL 전체를 인코딩하지 마세요. 올바르게 할 방법이 없습니다 — 한 번의 패스는 데이터 문자를 놓치거나 구조 문자를 파괴하거나 둘 중 하나입니다. 동적 값 각각을 따로 인코딩한 다음 조립하세요.
encodeURIComponent, encodeURI, 그리고 URLSearchParams
JavaScript에는 세 세대의 인코딩 API가 실려 있고, 잘못 고르는 것이 프런트엔드 코드에서 가장 흔한 URL 버그입니다. encodeURI는 입력을 완성된 URL로 취급합니다. 절대 등장할 수 없는 문자(공백, 따옴표, 비-ASCII)는 이스케이프하지만 모든 예약 구분자는 보존합니다 — : / ? # & =가 손대지 않은 채 통과합니다. 그래서 값 인코딩에는 무용지물입니다. 앰퍼샌드가 든 검색어를 넣으면 앰퍼샌드가 살아남아 파라미터를 둘로 쪼갭니다. encodeURIComponent가 값 인코더입니다. 비예약 문자와 ! ' ( ) *를 제외한 전부를 이스케이프하므로 &, =, /, ? 등이 모두 안전해집니다. 요령은 간단합니다. 거의 언제나 원하는 것은 encodeURIComponent이고, URL에 이어 붙이기 전에 각 값에 적용하는 것입니다.
두 함수 모두 완벽한 RFC 3986 준수자는 아닙니다. encodeURIComponent는 RFC가 sub-delims로 분류하는 ! ' ( ) *를 날것으로 남깁니다. OAuth 1.0 서명 같은 바이트 단위 정밀 프로토콜은 추가 replace 패스로 이 다섯 개를 보정해야 합니다. 고대의 escape() 함수는 아예 쓰면 안 됩니다 — UTF-8 보급 이전의 유물로, 비라틴 텍스트를 망가뜨립니다. 그리고 decodeURIComponent는 잘못된 시퀀스(외톨이 %, %ZZ)에 URIError를 던지므로, 신뢰할 수 없는 입력의 디코딩은 try/catch 안에 두어야 합니다.
현대적인 답은 수동 인코딩을 아예 건너뜁니다. URL과 URLSearchParams 클래스는 URL을 구성요소별로 파싱·구축·직렬화하며 각 부분을 올바른 규칙으로 자동 인코딩합니다.
const q = 'fish & chips?';
encodeURI(q) // 'fish%20&%20chips?' & and ? survive!
encodeURIComponent(q) // 'fish%20%26%20chips%3F' safe as a value
// The failure mode:
'https://x.io/s?q=' + encodeURI(q)
// → ?q=fish%20&%20chips? server sees TWO params: q and ' chips?'
// The modern way — encoding handled per component:
const u = new URL('https://x.io/s');
u.searchParams.set('q', q);
u.toString() // 'https://x.io/s?q=fish+%26+chips%3F'+ 대 %20 — 공백 하나에 표준이 둘
공백 문자에 인코딩이 두 개인 이유는 서로 다른 두 명세가 공백을 차지했기 때문입니다. RFC 3986 퍼센트 인코딩은 공백이 어디서나 %20이라고 말합니다. 하지만 HTML 폼이 그 규칙보다 먼저입니다. 1990년대 초 폼 제출용으로 정의되었고 오늘날에도 WHATWG URL 표준에 명시된 application/x-www-form-urlencoded 포맷은 공백을 +로(그리고 리터럴 플러스를 %2B로) 인코딩합니다. 두 인코딩 모두 각자의 맥락에서는 올바릅니다. 폼 제출과 URLSearchParams가 만드는 쿼리 스트링은 +를 쓰고, 경로와 encodeURIComponent로 인코딩된 모든 것은 %20을 씁니다.
버그는 맥락을 건널 때 생깁니다. 고전적 희생자는 정당하게 플러스 기호를 포함하는 데이터입니다. 국제 형식 전화번호(+8210...)와 base64 문자열이죠. phone=+82101234567을 인코딩 없이 보내면 서버의 폼 디코더가 +를 공백으로 바꿔 ' 82101234567'이 됩니다 — 누군가 알아채기 전에 데이터베이스까지 살아남곤 하는 오염입니다. 플러스는 %2B로 보내야 합니다. 반대 방향으로, JavaScript의 decodeURIComponent는 폼 인코딩을 전혀 모릅니다. +를 리터럴 플러스로 남겨두므로, 폼 인코딩된 쿼리 스트링을 이것으로 디코딩하면 모든 공백이 조용히 사라집니다. URLSearchParams는 +를 올바르게 디코딩합니다.
언어 라이브러리들은 이 분열을 API에 새겨 두었습니다. PHP에는 urlencode(폼 스타일, +)와 rawurlencode(RFC 3986, %20)가 있고, Python의 urllib.parse.quote는 %20을, quote_plus는 +를 씁니다. 이름이 오해를 부르는 Java의 URLEncoder는 폼 인코더라서 +를 만듭니다 — 이걸로 경로를 만드는 것은 잘 알려진 실수입니다. 헷갈릴 때는 %20이 더 안전한 출력입니다. 폼 디코더는 둘 다 받지만 경로 파서는 %20만 받으니까요.
// The international phone number bug
fetch('/api/sms?to=+821012345678')
// server-side form decoding: '+' → ' '
// to = ' 821012345678' ← corrupted, silently
fetch('/api/sms?to=%2B821012345678')
// to = '+821012345678' ← correct
// Decoding mismatch:
decodeURIComponent('a+b%20c') // 'a+b c' (+ kept)
new URLSearchParams('x=a+b%20c').get('x') // 'a b c' (+ = space)이중 인코딩 — %2520 버그와 그 보안 쌍둥이
퍼센트 인코딩은 멱등이 아닙니다. 두 번 돌리면 다른 출력이 나옵니다. % 문자 자체가 %25로 인코딩되기 때문이죠. 공백을 한 번 인코딩하면 %20이고, 그 결과를 다시 인코딩하면 %가 %25가 되어 %2520이 됩니다. 사용자가 페이지나 이메일 제목에서 "New%20York"을 보게 되었다면, 파이프라인 어딘가에서 값이 두 번 인코딩되고 한 번만 디코딩된 것입니다. 통상적인 용의자는 encodeURIComponent를 손수 호출한 값을 프레임워크, HTTP 클라이언트, 템플릿 엔진이 다시 인코딩하는 코드 — 또는 각 홉이 이미 인코딩된 next-URL 파라미터를 재인코딩하는 리다이렉트 체인입니다.
거울상 버그가 더 위험합니다. 어떤 계층이든 한 번보다 많이 디코딩하면 %2520이 %20으로, 다시 진짜 공백으로 무너집니다 — 공격자들이 정확히 이것을 무기화합니다. 경로에서 ../ 시퀀스를 검사하는 보안 필터는 %252e%252e%252f로 우회할 수 있습니다. 필터는 한 번 디코딩된 문자열을 검사해 무해해 보이는 %2e%2e%2f를 보고 통과시키고, 이후의 컴포넌트가 다시 디코딩하면 디렉터리 순회가 되살아납니다. 프록시, 앱 서버, 프레임워크 전반에 이 모양의 실제 CVE들이 존재하며, 그래서 보안 지침은 단호합니다. 신뢰 경계에서 정확히 한 번 디코딩하고, 디코딩된 값을 검증하고, 검증 후에는 절대 다시 디코딩하지 마세요.
양방향을 모두 막는 엔지니어링 규율은 소유권입니다. 인코딩은 URL이 코드를 떠나기 직전 마지막 순간에 정확히 한 계층이 소유하고, 디코딩은 요청이 들어오는 순간 정확히 한 계층이 소유합니다. 중첩할 의도가 없던 URL에서 %25 뒤에 16진수 두 자리가 보인다면 거의 확실히 이중 인코딩 버그를 보고 있는 것입니다 — 단계별로 디코딩하면서 평문에 도달하기까지 몇 번의 패스가 필요한지 세어 보세요.
'New York'
→ encode once: 'New%20York' correct
→ encode twice: 'New%2520York' bug: % became %25
// Path traversal hidden by double encoding:
'../etc/passwd'
→ once: '%2E%2E%2Fetc%2Fpasswd' filter catches %2E%2E%2F
→ twice: '%252E%252E%252Fetc...' filter sees nothing;
second decode revives ../
// Diagnosis: decode until stable, count the passes
decodeURIComponent('New%2520York') // 'New%20York' (pass 1)
decodeURIComponent('New%20York') // 'New York' (pass 2 → double-encoded)URL 속 비-ASCII — UTF-8 이스케이프, 모지바케, 퓨니코드
퍼센트 인코딩은 문자가 아니라 바이트에 작동합니다. 그래서 비-ASCII 문자를 인코딩하려면 먼저 바이트로 직렬화해야 하는데 — 이 인코딩의 선택이 역사가 어질러 놓은 지점입니다. RFC 3986에 명시되고 브라우저가 구현하는 WHATWG URL 표준이 강제하는 현대의 규칙은 UTF-8입니다. 한국어 음절 '한'은 세 바이트 ED 95 9C가 되어 %ED%95%9C로 인코딩되고, 이모지 하나가 십수 개의 퍼센트 이스케이프로 부풀 수 있습니다. 브라우저는 이를 사용자에게 숨깁니다 — 주소창은 디코딩된 유니코드를 표시하지만 실제 요청은 이스케이프를 실어 나릅니다.
모지바케(문자 깨짐)는 양 끝이 바이트 직렬화에 합의하지 못할 때 일어납니다. EUC-KR이나 Latin-1 바이트로 인코딩된 값을 UTF-8로 디코딩하면 쓰레기 문자나 아예 디코드 오류가 나옵니다. UTF-8 이전 웹에서는 풍토병이었고, 레거시 시스템과 기본 URI 문자셋이 ISO-8859-1이던 옛 Java 서블릿 컨테이너에서 지금도 나타납니다. 더 미묘한 변종은 유니코드 정규화입니다. café는 미리 조합된 코드 포인트 하나(NFC)로도, e + 결합 악센트(NFD)로도 직렬화될 수 있고, 두 형태는 서로 다른 바이트 시퀀스로 퍼센트 인코딩되어 문자열 비교에서 일치하지 않습니다. 인코딩 전에 NFC로 정규화하세요.
호스트명은 완전히 다른 규칙서를 따릅니다. 호스트 구성요소에서는 퍼센트 인코딩이 아예 허용되지 않습니다. 국제화 도메인 이름은 대신 IDNA를 거쳐 유니코드 레이블을 ASCII 호환 퓨니코드 형태로 변환합니다 — 한글.example은 xn--bj0bj06e.example이 됩니다. 인증서나 로그의 그 xn-- 접두사는 손상이 아니라 유니코드 도메인의 DNS 안전 표기이며, 닮은꼴 문자 피싱(IDN 동형이의 공격) 때문에 브라우저가 의심스러운 혼합 문자 호스트명을 퓨니코드 형태로 렌더링하는 이유이기도 합니다.