URL 인코딩(퍼센트 인코딩) 완전 정리
URL에 쓸 수 있는 문자는 생각보다 적습니다. 그래서 그 밖의 문자는 %XX 형태의 16진수로 바꿔서 넣는데, 이것이 퍼센트 인코딩입니다. 문제는 '어디에 넣느냐'에 따라 인코딩해야 하는 문자 집합이 달라진다는 점이고, JavaScript가 서로 다른 함수 네 개를 제공하는 이유도 여기에 있습니다.
예약 문자와 비예약 문자
RFC 3986은 URL 문자를 두 부류로 나눕니다. 비예약 문자(unreserved)는 A-Z a-z 0-9 와 - _ . ~ 네 개로, 언제나 그대로 써도 되고 인코딩하면 오히려 같은 URL이 다르게 취급될 수 있습니다. 예약 문자(reserved)는 : / ? # [ ] @ ! $ & ' ( ) * + , ; = 로, URL의 구조를 나타내는 구분자 역할을 합니다.
핵심은 예약 문자를 '구분자로 쓸 때는 그대로 두고, 데이터로 쓸 때는 인코딩해야 한다'는 것입니다. 예를 들어 쿼리 문자열을 나누는 &는 그대로 두지만, 값 안에 들어 있는 &는 %26으로 바꿔야 합니다. 그러지 않으면 파서가 그 지점에서 파라미터를 잘라 버립니다.
JavaScript 함수 네 개의 차이
encodeURI는 URL 전체를 인코딩한다고 가정하므로 구분자(: / ? # & =)를 건드리지 않습니다. encodeURIComponent는 값 하나를 인코딩한다고 가정하므로 구분자까지 모두 인코딩합니다. 실무에서 필요한 것은 거의 항상 encodeURIComponent입니다.
const value = "a&b=c/d?e"; encodeURI(value) // "a&b=c/d?e" ← 그대로, 값으로 쓰면 위험 encodeURIComponent(value) // "a%26b%3Dc%2Fd%3Fe" ← 안전 // 올바른 조립 const url = "/search?q=" + encodeURIComponent(value);
| 함수 | 인코딩하지 않는 문자 | 쓰는 곳 |
|---|---|---|
| encodeURIComponent | A-Z a-z 0-9 - _ . ! ~ * ' ( ) | 쿼리 파라미터 값, 경로 세그먼트 하나 |
| encodeURI | 위 + : / ? # [ ] @ & = + $ , | 이미 조립된 URL 전체 |
| escape (폐기) | 유니코드를 %uXXXX로 잘못 처리 | 쓰지 말 것 |
| encodeURIComponent + 보정 | ! ' ( ) * 까지 추가 인코딩 | RFC 3986 엄격 준수가 필요할 때 |
사용 방법
- 인코딩할 문자열이나 디코딩할 %XX 문자열을 입력합니다.
- Encode/Decode 방향을 고릅니다.
- 값 하나만 변환한다면 Component 방식(모든 예약 문자 인코딩)이 대개 정답입니다.
- 결과를 복사해 URL에 붙여넣습니다.
공백이 %20인가 +인가
둘 다 존재하는데 문맥이 다릅니다. %20은 RFC 3986의 정식 표기이고 URL 어디에서나 유효합니다. +는 HTML 폼이 application/x-www-form-urlencoded 형식으로 제출할 때 쓰는 관례로, 쿼리 문자열에서만 공백으로 해석됩니다.
실무에서 문제가 되는 지점은 경로입니다. 경로에 들어간 +는 공백이 아니라 문자 그대로의 플러스로 해석되므로, /files/my+file.pdf 는 이름에 +가 들어간 파일을 가리킵니다. 반대로 서버가 쿼리 파라미터를 파싱할 때 +를 공백으로 바꾸기 때문에, 값에 실제 플러스 기호를 담아야 한다면 %2B로 인코딩해야 합니다. 전화번호 +82나 Base64 문자열을 파라미터로 넘길 때 자주 겪는 버그입니다.
한글과 이모지 처리
퍼센트 인코딩은 바이트를 대상으로 하므로 문자를 먼저 UTF-8 바이트로 바꾼 뒤 각 바이트를 %XX로 표기합니다. 한글 한 글자는 UTF-8에서 3바이트이므로 '한'은 %ED%95%9C처럼 %가 세 번 나옵니다. 이모지는 4바이트여서 네 번 나옵니다.
브라우저 주소창은 한글을 사람이 읽기 쉽게 그대로 보여주지만, 복사해서 다른 곳에 붙이면 인코딩된 형태가 나오는 경우가 많습니다. 둘은 같은 URL이며 서버에 전달될 때는 인코딩된 형태입니다.
자주 생기는 실수
- 이중 인코딩: 이미 인코딩된 문자열을 한 번 더 인코딩하면 %가 %25로 바뀌어 %2520 같은 형태가 됩니다. 디코딩 결과에 %25가 보이면 이중 인코딩을 의심하세요.
- URL 전체에 encodeURIComponent 적용: https://가 https%3A%2F%2F로 바뀌어 링크가 깨집니다.
- 쿼리 값에 그대로 넣은 # : 서버는 # 이후를 전달받지 못합니다. 프래그먼트는 브라우저 내부에서만 쓰이기 때문입니다.
- 인코딩 후 다시 인코딩된 문자열을 DB에 저장: 저장은 원본으로, 인코딩은 출력 시점에 하는 것이 원칙입니다.
- 비예약 문자를 굳이 인코딩: %41은 A와 같은 의미지만 캐시 키나 서명 검증에서 다른 문자열로 취급될 수 있습니다.
자주 묻는 질문
- encodeURI와 encodeURIComponent 중 무엇을 써야 하나요?
- 값 하나를 URL에 끼워 넣는다면 encodeURIComponent입니다. 검색어, ID, 리다이렉트 대상 URL 등 파라미터 값은 모두 여기에 해당합니다. encodeURI는 이미 완성된 URL 문자열을 마지막에 한 번 정리할 때만 쓰며, 실무에서 필요한 경우는 드뭅니다.
- 리다이렉트할 URL을 파라미터로 넘기려면?
- 반드시 encodeURIComponent로 감싸야 합니다. ?next=https://example.com/a?b=c 처럼 그냥 넣으면 두 번째 ?부터가 원래 URL의 일부인지 새 파라미터인지 서버가 구분할 수 없습니다. ?next=https%3A%2F%2Fexample.com%2Fa%3Fb%3Dc 로 넘기면 명확해집니다. 덧붙여, 넘겨받은 리다이렉트 대상은 반드시 허용 목록으로 검증하세요. 검증 없이 이동시키면 오픈 리다이렉트 취약점이 됩니다.
- 왜 어떤 사이트 URL은 %EC%95%88 같은 문자로 가득한가요?
- 한글 등 ASCII 밖의 문자가 UTF-8 바이트 단위로 퍼센트 인코딩된 것입니다. 한글 한 글자가 3바이트라 세 개의 %XX로 표기되어 길어 보입니다. 브라우저 주소창에서는 대개 원래 글자로 보기 좋게 표시됩니다.
- 디코딩할 때 오류가 나는 이유는?
- % 뒤에 16진수 두 자리가 오지 않으면 잘못된 인코딩입니다. 예를 들어 '100% 완료' 같은 문자열을 디코딩하려 하면 '% ' 부분에서 실패합니다. 인코딩되지 않은 원본 텍스트를 디코딩하려는 상황인지 확인하세요.
- 쿼리 파라미터를 직접 조립하는 게 최선인가요?
- 아닙니다. 브라우저와 Node.js 모두 URLSearchParams를 제공하며, 이쪽이 인코딩을 자동으로 처리하고 실수 여지가 적습니다. new URLSearchParams({q: '검색 어&'}).toString() 형태로 쓰면 됩니다. 다만 URLSearchParams는 공백을 +로 인코딩한다는 점을 기억하세요.
- 인코딩된 URL의 길이 제한이 있나요?
- 표준에는 없지만 현실적인 상한이 있습니다. 대체로 브라우저와 서버 구현이 2,000자 안팎에서 안전하며, 일부 서버는 8KB에서 헤더를 거부합니다. 인코딩은 길이를 최대 3배까지 늘리므로, 긴 데이터를 URL에 담기보다 POST 본문을 쓰는 편이 안전합니다.
💡 참고: 결과에 %25가 보이면 거의 항상 이중 인코딩입니다. 한 번 디코딩한 뒤 다시 확인해 보세요.