이중 인코딩(%2520)은 왜 생기나 — encodeURIComponent와 '+'의 함정
URL 파라미터를 다루다 보면 %2520처럼 퍼센트가 두 번 겹친 이상한 값을 마주칠 때가 있습니다. 이건 오타가 아니라 이미 인코딩된 문자열을 또 인코딩했을 때 생기는 전형적인 버그입니다. 프론트엔드에서 한 번, 백엔드 프록시나 라이브러리에서 또 한 번 인코딩이 걸리는 식으로 여러 계층을 거치는 URL에서 특히 자주 발생합니다.
1. %20이 %2520이 되는 과정
공백 한 칸을 encodeURIComponent로 인코딩하면 %20이 됩니다. 여기서 % 문자 자체도 URL에서 특별한 의미를 가진 문자이므로, 이 %20 문자열을 아무 확인 없이 다시 인코딩하면 %가 그 자신의 퍼센트 인코딩 값인 %25로 바뀌어 최종적으로 %2520이 남습니다. 서버가 이 값을 한 번만 디코딩하면 공백이 아니라 %20이라는 문자 그대로의 문자열이 남고, 두 번 디코딩해야 비로소 원래의 공백으로 돌아갑니다. 이 상태에서는 검색어나 파일명이 원래 의도와 다르게 저장되거나 조회되는 문제가 생깁니다.
| 단계 | 값 | 설명 |
|---|---|---|
| 원본 | hello world | 공백 포함 원문 |
| 1차 인코딩 | hello%20world | 공백 → %20으로 정상 변환 |
| 2차 인코딩(실수) | hello%2520world | %가 %25로 다시 인코딩되어 이중 인코딩 발생 |
| 1차 디코딩만 수행 | hello%20world | 공백이 아닌 문자 그대로 %20 문자열이 남음 |
2. 폼 데이터의 '+'와 API의 '%20', 서로 다른 공백 표기
이중 인코딩과 별개로, 공백을 표현하는 방식 자체가 두 가지라는 점도 혼란의 원인입니다. HTML 폼이 method="GET"으로 제출될 때는 application/x-www-form-urlencoded 규격을 따라 공백을 + 기호로 표현합니다. 반면 REST API 요청을 만들 때 흔히 쓰는 encodeURIComponent()는 공백을 %20으로 표현합니다. 두 표기 모두 표준상 유효하지만, + 기호는 폼 데이터 맥락 밖에서는 문자 그대로의 '더하기'로 해석될 수 있어, 폼에서 만든 쿼리스트링을 REST API 파서에 그대로 넘기면 +가 공백이 아니라 실제 + 문자로 잘못 해석되는 문제가 생길 수 있습니다.
3. 이중 인코딩을 실무에서 막는 법
이 사이트의 URL 인코더가 안내하듯, encodeURIComponent는 쿼리 파라미터 값 "하나"를 인코딩할 때, encodeURI는 이미 조합된 URL "전체"를 인코딩할 때 쓰도록 설계돼 있습니다. 이중 인코딩은 대개 이 구분이 흐트러졌을 때, 즉 이미 인코딩된 값을 "혹시 안 됐을까봐" 다시 인코딩하는 방어적 코드에서 생깁니다. 가장 확실한 예방법은 값을 인코딩하기 전에 decodeURIComponent로 먼저 디코딩을 시도해, 이미 인코딩된 상태인지 확인하는 것입니다. 문자열에 %25나 연속된 %2X 패턴이 보인다면 이중 인코딩을 의심하고 원본 상태부터 다시 점검해야 합니다.
4. 정리
- 이중 인코딩은 %가 또 인코딩될 때 생긴다: %20 → %2520.
- 폼의 +와 API의 %20은 다른 표기: 맥락을 섞어 쓰면 파싱 오류가 생깁니다.
- 인코딩 전 디코딩 확인: 이미 인코딩된 값인지 먼저 확인하는 습관이 가장 확실한 예방책입니다.
- encodeURIComponent는 값 하나, encodeURI는 URL 전체: 용도를 구분해 쓰면 대부분의 이중 인코딩을 피할 수 있습니다.
자주 묻는 질문
Q. %2520은 왜 생기나요?
A. 이미 퍼센트 인코딩된 문자열을 다시 한 번 인코딩할 때 생깁니다. 공백 하나가 첫 번째 인코딩에서 %20이 되고, 이 문자열을 인코딩 여부를 확인하지 않은 채 다시 인코딩하면 %20의 % 문자가 %25로 바뀌어 최종적으로 %2520이 됩니다. 서버에서 이 값을 한 번만 디코딩하면 공백이 아니라 문자 그대로의 '%20'이라는 문자열이 남게 됩니다.
Q. HTML 폼과 REST API는 공백을 다르게 인코딩하나요?
A. 네. method=GET인 HTML 폼은 application/x-www-form-urlencoded 규격을 따라 공백을 +로 인코딩합니다. 반면 REST API 요청에서 흔히 쓰는 encodeURIComponent()는 공백을 %20으로 인코딩합니다. 둘 다 유효한 표현이지만 서버가 어느 쪽 규격으로 파싱하느냐에 따라 +를 공백이 아닌 실제 '+' 문자로 잘못 해석하는 문제가 생길 수 있습니다.
Q. encodeURIComponent와 encodeURI 중 무엇을 써야 이중 인코딩을 피할 수 있나요?
A. 둘 다 이중 인코딩 자체를 막아주진 않습니다. 핵심은 '무엇을 인코딩하느냐'를 명확히 구분하는 것입니다. 쿼리 파라미터 값 하나하나는 encodeURIComponent로, 이미 조합된 전체 URL은 encodeURI로 인코딩해야 하며, 인코딩된 값을 다른 함수로 다시 인코딩하는 상황을 피하는 것이 이중 인코딩 방지의 핵심입니다.
Q. 이중 인코딩 여부를 어떻게 확인하나요?
A. 문자열에 %25(퍼센트 기호 자체의 인코딩)나 %2520처럼 연속된 퍼센트 패턴이 보이면 이중 인코딩을 의심할 수 있습니다. 가장 확실한 방법은 인코딩하기 전에 입력값을 한 번 디코딩(decodeURIComponent)해 이미 인코딩된 상태인지 확인한 뒤, 순수한 원본 텍스트에만 인코딩을 적용하는 것입니다.
Q. 한글이 포함된 URL도 이중 인코딩 문제가 생기나요?
A. 네. 한글 '안'을 인코딩하면 %EC%95%88이 되는데, 이 값을 다시 인코딩하면 각 %가 %25로 바뀌어 %25EC%2595%2588처럼 훨씬 길고 복잡한 문자열이 됩니다. 한글이 섞인 URL일수록 이중 인코딩 시 눈에 띄게 문자열이 길어지므로 오히려 발견하기는 쉬운 편입니다.
Q. 브라우저 주소창에 직접 입력한 URL도 이중 인코딩될 수 있나요?
A. 브라우저 자체는 주소창 입력값을 한 번만 인코딩해서 요청을 보냅니다. 이중 인코딩은 주로 애플리케이션 코드에서 URL을 조합·전달하는 여러 단계(프론트엔드 → 백엔드 → 외부 API 등)를 거치며 발생하는 문제입니다.
Q. decodeURIComponent로 이미 디코딩된 문자열을 또 디코딩하면 어떻게 되나요?
A. 인코딩되지 않은 일반 문자(퍼센트 기호가 없는 문자열)에 decodeURIComponent를 적용하면 원본 문자열이 그대로 반환되어 에러는 나지 않습니다. 다만 % 뒤에 유효한 16진수 두 자리가 오지 않는 잘못된 형식이면 URIError가 발생할 수 있습니다.
Q. 쿼리스트링에서 &와 =는 왜 인코딩해야 하나요?
A. &와 =는 쿼리스트링에서 파라미터를 구분하는 예약 문자이기 때문입니다. 파라미터 값 안에 &나 =가 그대로 들어가면 URL 구조 자체가 깨져 서버가 파라미터 경계를 잘못 인식하게 되므로, encodeURIComponent로 반드시 인코딩해야 합니다.