한글 주소가 %EA%B2%80으로 바뀌는 이유: URL 인코딩 정리
검색 링크를 복사하면 한글이 %EA%B2%80…으로 바뀌는 이유, encodeURI와 encodeURIComponent·URLSearchParams의 차이, 공백의 %20과 +, %2520 같은 이중 인코딩 버그를 정리했습니다.
맛집 검색 결과를 메신저에 붙여 넣으면 이런 주소가 됩니다.
https://www.google.com/search?q=%EB%A7%9B%EC%A7%91%20%EC%B6%94%EC%B2%9C
주소창에서는 분명 맛집 추천이라고 보였는데, 복사하고 나니 알 수 없는 기호가 길게 늘어섭니다. 링크가 깨진 것은 아닙니다. 그대로 열면 똑같은 검색 결과가 나옵니다. 이것이 퍼센트 인코딩, 흔히 URL 인코딩이라고 부르는 방식입니다.
%EA는 글자가 아니라 바이트다
URL은 원래 영문자와 숫자, 몇 가지 기호만으로 쓰도록 정해져 있습니다(RFC 3986). 한글이나 일본어를 넣으려면 먼저 글자를 UTF-8 바이트로 바꾸고, 바이트 하나하나를 % 뒤에 16진수 두 자리로 적습니다.
한글 음절은 UTF-8에서 대부분 3바이트이므로, 한 글자가 %XX 세 개, 9글자가 됩니다. 검색 두 글자가 %EA%B2%80%EC%83%89 18글자로 늘어나는 이유입니다. %EA만 떼어 내면 아무 뜻도 없습니다. 세 바이트가 모여야 한 글자가 됩니다.
거꾸로 풀 때 바이트가 모자라면 오류가 납니다. JavaScript에서 decodeURIComponent('%E3%81')은 URIError: URI malformed를 던집니다. 긴 주소를 중간에서 잘라 붙였다가 페이지가 오류를 내는 경우가 이것입니다.
그대로 두는 글자, 바꾸는 글자
RFC 3986은 URL에 쓰는 글자를 두 부류로 나눕니다.
| 분류 | 글자 | 다루는 법 |
|---|---|---|
| 비예약 문자 | A–Z a–z 0–9 - . _ ~ |
언제나 그대로 둔다 |
| 예약 문자 | : / ? # [ ] @ ! $ & ' ( ) * + , ; = |
구분자로 쓸 때는 그대로, 값 안에 들어갈 때는 인코딩 |
예약 문자는 주소의 뼈대입니다. ? 뒤부터 쿼리, &는 매개변수 사이, =는 이름과 값 사이를 가릅니다. 그래서 검색어 자체에 &가 들어 있으면 반드시 %26으로 바꿔야 합니다. 안 그러면 서버는 거기서 매개변수가 끝났다고 읽습니다.
함수 세 개가 서로 다른 이유
JavaScript에는 인코딩하는 방법이 여럿 있고, 남기는 글자가 조금씩 다릅니다. 같은 입력을 넣어 보면 차이가 분명해집니다.
| 방법 | 그대로 두는 것 | a b&c 결과 |
|---|---|---|
encodeURI() |
비예약 문자와 ; , / ? : @ & = + $ # 그리고 ! * ' ( ) |
a%20b&c |
encodeURIComponent() |
비예약 문자와 ! * ' ( ) |
a%20b%26c |
URLSearchParams |
영숫자와 * - . _ |
a+b%26c |
encodeURI()는 주소 전체를 받는다고 가정합니다. 주소의 구분자를 망가뜨리면 안 되니 /, ?, &를 남깁니다. 대신 값에 들어간 &도 그대로 두기 때문에, 검색어 하나를 이 함수로 인코딩하면 앞에서 말한 사고가 납니다.
encodeURIComponent()는 주소의 한 조각, 즉 쿼리 값이나 경로의 한 단계를 인코딩할 때 씁니다. 대부분의 경우 이쪽이 맞습니다. 다만 ! * ' ( )는 RFC 3986에서 예약 문자인데도 인코딩하지 않습니다. 이 기호가 특별한 의미를 갖는 시스템(일부 서명 방식 등)에 보낼 때는 따로 바꿔 줘야 합니다.
URLSearchParams는 HTML 폼이 데이터를 보내는 형식(application/x-www-form-urlencoded)을 따릅니다. 쿼리 문자열을 만들 때는 직접 문자열을 이어 붙이는 것보다 이쪽이 안전합니다.
const params = new URLSearchParams({ q: "맛집 추천", page: "2" });
`/search?${params}`; // "/search?q=%EB%A7%9B%EC%A7%91+%EC%B6%94%EC%B2%9C&page=2"
공백 하나가 %20도 되고 +도 된다
위 결과에서 공백이 +로 바뀐 것을 볼 수 있습니다. RFC 3986의 퍼센트 인코딩에서 공백은 %20이지만, HTML 폼 형식에서는 +입니다. 둘 다 표준이고, 쓰는 자리가 다를 뿐입니다.
문제는 섞일 때 생깁니다. +를 공백으로 읽는 것은 폼 형식의 규칙이라서, decodeURIComponent('a+b')는 a+b를 그대로 돌려줍니다. 반대로 new URLSearchParams('q=a+b').get('q')는 a b입니다. 전화번호 +82 10…이나 수식 1+1이 쿼리에 들어가면 받는 쪽이 어느 규칙으로 읽느냐에 따라 +가 사라집니다. 값에 +가 들어갈 수 있다면 보내는 쪽에서 %2B로 확실히 바꿔 두는 것이 안전합니다.
같은 브라우저 안에서도 갈립니다. new URL('https://example.com/?q=a b').href는 공백을 %20으로, URLSearchParams는 +로 적습니다.
%2520이 보이면 두 번 인코딩된 것
로그에 %2520이나 %25EA가 찍혀 있다면 거의 확실히 이미 인코딩된 문자열을 한 번 더 인코딩한 것입니다. %의 인코딩이 %25이기 때문입니다.
"a b" → "a%20b" → "a%2520b"
흔한 경로는 이렇습니다. 프런트엔드에서 encodeURIComponent로 값을 만들고, 그 값을 URLSearchParams나 HTTP 라이브러리의 params 옵션에 다시 넣습니다. 라이브러리는 받은 값을 날것으로 보고 또 인코딩합니다. 인코딩은 주소를 조립하는 마지막 한 곳에서 한 번만 하도록 정해 두면 이 버그는 사라집니다.
주소창은 사람을 위해 풀어서 보여 준다
그렇다면 처음에 주소창에서 맛집 추천이 보였던 것은 무엇이었을까요. 대부분의 브라우저는 읽기 쉽도록 주소창에서만 퍼센트 인코딩을 풀어 보여 주고, 서버에 요청을 보낼 때와 주소를 복사할 때는 인코딩된 형태를 씁니다. 화면에 보이는 것과 실제로 오가는 것이 다른 셈입니다.
참고로 https://한글.kr 같은 도메인 부분은 퍼센트 인코딩을 쓰지 않습니다. 도메인은 xn--로 시작하는 퓨니코드(Punycode)라는 별도의 방식으로 바뀝니다. 경로와 쿼리만 퍼센트 인코딩의 영역입니다.
붙여 넣고 바로 확인하기
URL 인코딩·디코딩 도구에 복사한 주소를 붙여 넣으면 한글로 되돌려 보여 주고, 쿼리 매개변수를 표로 나눠 줍니다. 인코딩할 때는 값 하나(encodeURIComponent)와 주소 전체(encodeURI) 중에서 고를 수 있고, 디코딩할 때 +를 공백으로 볼지도 정할 수 있습니다. 위의 a b&c를 두 방식으로 넣어 보면 표의 차이를 그대로 확인할 수 있습니다.
참고
- RFC 3986, Uniform Resource Identifier (URI): Generic Syntax
- WHATWG, URL Standard (application/x-www-form-urlencoded)
- ECMAScript 명세(ECMA-262),
encodeURI와encodeURIComponent