아마 공백이나 특수 문자가 포함된 URL 때문에 버그가 발생한 경험이 있을 겁니다. 검색어에 &가 들어가서 이상한 동작을 했을 수도 있고요. 아니면 URL에서 %20을 보고 그게 뭔지 궁금했을 수도 있죠. 이 모든 것을 깔끔하게 정리해 봅시다.
URL에 인코딩이 필요한 이유
URL에는 제한된 문자만 사용할 수 있습니다. RFC 3986 명세는 항상 안전한 "비예약" 문자를 정의합니다: A-Z, a-z, 0-9, -, _, ., ~. 그 외의 모든 것 — 공백, &, =, ?, ñ이나 日本語 같은 비ASCII 문자 — 은 퍼센트 인코딩이 필요합니다.
퍼센트 인코딩의 작동 방식
아주 간단합니다: 문자의 UTF-8 바이트 값을 가져와서 각 바이트를 % 뒤에 두 자리 16진수로 표현하면 됩니다.
예시:
- 공백 →
%20 &→%26=→%3Dé→%C3%A9(UTF-8에서 2바이트)日→%E6%97%A5(UTF-8에서 3바이트)
따라서 Hello World는 URL 경로에서 Hello%20World가 됩니다.
가장 흔한 버그: 이중 인코딩
제가 가장 많이 보는 URL 인코딩 실수이고, 정말 미묘합니다. 문자열을 인코딩한 후 다시 인코딩하는 함수에 전달하면, %20(이미 인코딩된 공백)이 %2520이 됩니다 — % 자체가 인코딩되기 때문이죠.
문제의 예시:
해결책: 값을 올바른 레벨에서 한 번만 인코딩하세요. 이미 인코딩된 것은 다시 인코딩하지 마세요.
플러스 기호 vs. %20 — 네, 헷갈립니다
URL 쿼리 문자열에서 공백은 + 또는 %20으로 표현할 수 있습니다. + 규약은 HTML 폼 인코딩(application/x-www-form-urlencoded)에서 유래했습니다. URL 경로에서는 %20만 유효합니다.
그래서 https://example.com/hello world → 경로는 https://example.com/hello%20world로 인코딩됩니다
하지만 https://example.com/search?q=hello world → 쿼리는 ?q=hello+world 또는 ?q=hello%20world가 될 수 있습니다
encodeURI vs. encodeURIComponent
JavaScript는 두 가지 함수를 제공하는데, 잘못된 것을 사용하면 전형적인 실수가 됩니다:
encodeURI()— 전체 URL을 인코딩합니다.:,/,?,#,&,=는 URL의 구조적 부분이므로 그대로 둡니다.encodeURIComponent()— URL 구성 요소(쿼리 매개변수 값 등)를 인코딩합니다.:,/,?,#,&,=도 데이터의 일부일 수 있으므로 인코딩합니다.
기본 원칙: encodeURIComponent()는 값에 사용하고, 전체 URL에는 절대 사용하지 마세요. MDN 문서에서 차이점을 아름답게 설명하고 있습니다.
다른 프로그래밍 언어
- Python: 경로에는
urllib.parse.quote(), 쿼리 문자열에는urllib.parse.urlencode()— 문서 여기 - Java:
URLEncoder.encode()(공백에+를 사용하므로 주의!) - PHP: 쿼리 문자열에는
urlencode(), 경로에는rawurlencode()
빠른 팁
- 항상 매개변수 값만 인코딩하고, 전체 URL은 인코딩하지 마세요
- 수신 측에서는 한 번만 디코딩하세요 — 인코딩된 값을 여러 레이어에 걸쳐 전달하지 마세요
- 공백,
&,=,#, 비ASCII 문자, 이모지 등의 엣지 케이스로 테스트하세요
JavaScript에서 안전하게 URL 구성하기
쿼리 매개변수가 있는 URL을 구성하는 올바른 방법은 내장된 URL과 URLSearchParams API를 사용하는 것입니다. 모든 인코딩을 알아서 처리해 줍니다:
URLSearchParams가 공백에 +를 사용하고(HTML 폼 인코딩 규약) 값 안의 &를 올바르게 인코딩하여 매개변수를 구분하는 &와 혼동되지 않도록 하는 것을 주목하세요. 이것은 문자열 연결보다 훨씬 안전합니다.
흔한 URL 인코딩 함정
1. 값만이 아닌 전체 URL을 인코딩하기. 전체 URL에 encodeURIComponent()를 실행하면 ://, /, ?, & 문자가 인코딩되어 URL이 완전히 사용할 수 없게 됩니다. 개별 매개변수 값만 인코딩하세요.
2. 해시 프래그먼트 인코딩을 잊기. URL의 # 문자는 프래그먼트 식별자를 시작합니다. 데이터에 #이 포함되어 있는데 인코딩하지 않으면, 그 이후의 모든 것이 서버 관점에서 사라집니다. 서버는 프래그먼트 식별자를 절대 보지 못합니다 — 클라이언트 측 전용입니다.
3. 국제화 도메인 이름(IDN) 처리를 놓치기. example.日本과 같은 비ASCII 문자가 포함된 도메인 이름은 Punycode 인코딩이라는 특별한 처리가 필요합니다. 이것은 퍼센트 인코딩과는 별개이며 URL의 호스트명 부분에만 적용됩니다.
4. 공백 인코딩이 일관되지 않기. 시스템의 일부는 공백을 +로, 다른 부분은 %20으로 인코딩할 수 있습니다. 보통 디코딩에는 문제가 없지만 URL 비교와 캐싱에서 이슈가 생길 수 있습니다. 하나의 규약을 선택하고 일관되게 사용하세요.
퍼센트 인코딩 빠른 참조
| 문자 | 인코딩 | 인코딩이 필요한 이유 |
| 공백 | %20 또는 + | URL에서 허용되지 않음 |
& | %26 | 쿼리 매개변수 구분 |
= | %3D | 키와 값 구분 |
? | %3F | 쿼리 문자열 시작 |
# | %23 | 프래그먼트 식별자 시작 |
% | %25 | 이스케이프 문자 자체 |
/ | %2F | 경로 구분자 |
@ | %40 | 사용자 정보 섹션에서 사용 |
다양한 컨텍스트에서의 URL 인코딩
URL 인코딩은 브라우저 URL만을 위한 것이 아닙니다. 다음과 같은 일반적인 상황에서 마주치게 됩니다:
- API 요청: REST API 호출의 쿼리 매개변수는 적절한 인코딩이 필요하며, 특히 사용자 입력을 포함할 때 그렇습니다
- 리다이렉트 URL: 반환 URL을 매개변수로 전달할 때(
?redirect=https://...처럼) 전체 리다이렉트 URL이 값으로 인코딩되어야 합니다 - OAuth 플로우: OAuth 콜백 URL과 state 매개변수는 여러 겹의 URL 인코딩을 포함하므로 악명 높게 까다롭습니다
- 딥 링크: 모바일 딥 링크는 동일한 URL 인코딩 규칙을 따르지만, 일부 플랫폼에는 추가 요구사항이 있습니다
URL 인코딩 문제 디버깅
URL 인코딩에 문제가 생겼을 때, 제 디버깅 체크리스트는 이렇습니다:
1. 이중 인코딩 확인 — URL에서 %25를 찾으세요. %가 두 번 인코딩되었다는 의미입니다
2. 원시 요청 확인 — 브라우저 DevTools의 Network 탭을 사용해 브라우저가 디코딩된 버전을 표시하기 전에 실제로 전송된 URL을 확인하세요
3. 인코딩 vs. 디코딩 비교 — 문제가 되는 URL을 디코더에 붙여넣어 실제로 무엇이 포함되어 있는지 확인하세요
4. 서버 측 디코딩 확인 — 일부 프레임워크는 URL 매개변수를 자동으로 디코딩하는데, 그 위에 수동으로 디코딩하면 문제가 생깁니다
URL의 구조
좋아요, 한 발 뒤로 물러서 봅시다. 인코딩에 더 깊이 들어가기 전에, URL이 무엇으로 구성되어 있는지 정확히 이해해야 합니다. 알아요, 알아요 — 경력 내내 URL을 사용해 왔죠. 하지만 모든 부분의 공식 명칭을 알고 있지는 않을 겁니다. 대부분의 개발자가 모르고, 바로 거기서 인코딩 혼란이 시작됩니다.
RFC 3986에 따르면, URL은 이런 구조를 가집니다:
각 부분을 살펴봅시다:
- Scheme (
https,ftp,mailto) — 프로토콜입니다. 여기서는 인코딩이 필요 없고, 항상 ASCII 문자입니다. - Authority — 선택적인
user:password@(2026년에는 기본적으로 사용하면 안 됩니다), 호스트(도메인 이름 또는 IP), 선택적인 포트 번호를 포함합니다. - Path (
/search/results) — 계층적 부분입니다. 슬래시/가 세그먼트를 구분합니다. 각 세그먼트 내에서는 특수 문자를 인코딩해야 하지만, 슬래시 자체는 인코딩하지 않습니다. - Query (
?q=hello&lang=en) —?뒤의 키-값 쌍입니다.&가 쌍을 구분하고,=가 키와 값을 구분합니다. 키와 값은 인코딩하지만 구조적인&와=는 인코딩하지 않습니다. - Fragment (
#section-2) —#뒤의 부분입니다. 이것은 흥미로운데 — 서버에 절대 전송되지 않습니다. 순전히 클라이언트 측입니다. 하지만 그 안의 특수 문자는 여전히 인코딩해야 합니다.
사람들을 혼란스럽게 하는 핵심은: URL의 다른 부분에는 다른 인코딩 규칙이 적용됩니다. /는 경로에서 완벽하게 괜찮지만(구분자니까요!) 쿼리 매개변수 값 안에 나타나면 %2F로 인코딩되어야 합니다. @ 기호는 authority 섹션에서는 괜찮지만 경로에서는 인코딩해야 합니다. 이것이 바로 만능 인코딩 함수가 그렇게 많은 버그를 유발하는 이유입니다.
이렇게 생각해 보세요: URL은 문법 규칙이 있는 문장입니다. 특수 문자는 구두점입니다. 실제로 구두점으로 사용되는 쉼표를 인코딩하지는 않겠죠 — 데이터의 일부이면서 구두점과 혼동될 수 있는 쉼표만 인코딩합니다.
encodeURI vs. encodeURIComponent: JavaScript 지뢰밭
솔직히 이 주제는 저를 정말 미치게 합니다. 개발자들이 항상 틀리는 것을 보거든요. JavaScript는 두 가지 인코딩 함수를 제공하는데, 거의 동일하게 들리지만 잘못된 것을 사용하면 하루를 망칩니다.
명확하게 설명해 드리겠습니다:
encodeURI()는 전체 URL을 인코딩하도록 설계되었습니다. 안전하지 않은 문자를 인코딩하지만 구조적 문자는 그대로 둡니다 — :, /, ?, #, &, =, @ 같은 것들이요. 전체 URL을 인코딩할 때 URL 구조를 깨뜨리고 싶지는 않으니까요.
encodeURIComponent()는 URL 안에 들어가는 단일 값을 인코딩하도록 설계되었습니다. 문자, 숫자, - _ . ~를 제외한 모든 것을 인코딩합니다. :, /, ?, #, &, =를 포함합니다 — 이것들이 값에 나타나면 구조가 아닌 데이터이기 때문입니다.
명확하게 비교해 봅시다:
| 문자 | encodeURI() | encodeURIComponent() |
: | : (변경 없음) | %3A |
/ | / (변경 없음) | %2F |
? | ? (변경 없음) | %3F |
# | # (변경 없음) | %23 |
& | & (변경 없음) | %26 |
= | = (변경 없음) | %3D |
@ | @ (변경 없음) | %40 |
| 공백 | %20 | %20 |
é | %C3%A9 | %C3%A9 |
잘못된 함수를 사용하면 무슨 일이 일어나는지 보세요:
반대 실수도 마찬가지로 나쁩니다:
황금률: 값에는 encodeURIComponent, 전체 URL에는 encodeURI. 아니면 더 좋은 방법으로, URL API를 사용하고 브라우저에게 맡기세요. 진심으로, URL과 URLSearchParams 클래스는 이유가 있어서 존재합니다. 모든 세부사항은 MDN encodeURIComponent 문서와 MDN encodeURI 문서를 참조하세요.
다양한 언어에서의 URL 인코딩
JavaScript만 혼란스러운 URL 인코딩 함수를 가진 것이 아닙니다. 모든 언어에는 고유한 특성이 있고, 정말이지 일부는 JavaScript의 쌍보다 더 혼란스럽습니다.
Python — 올바른 모듈을 찾으면 사실 꽤 합리적입니다:
Java — 여기서 좀 이상해집니다. URLEncoder는 일반 URL 인코딩이 아니라 HTML 폼 인코딩용으로 설계되었습니다. 그래서 공백이 %20 대신 +가 됩니다:
C# — .NET은 실제로 올바른 도구를 제공하지만, 약간씩 다른 작업을 하는 다섯 가지 정도의 메서드가 있습니다:
PHP — 아, PHP. 당연히 이름이 거의 같으면서 약간 다른 일을 하는 두 개의 함수가 있죠:
Go — Go답게 깔끔하고 합리적입니다:
결론은? 모든 언어가 + vs %20 문제를 다르게 처리하고, 모든 언어에는 여러분을 놀라게 할 함수가 최소한 하나 있습니다. 사용 중인 언어의 문서를 항상 확인하세요. JavaScript와 같은 방식으로 작동한다고 가정하지 마세요.
URL에서의 유니코드: 무법지대
좋아요, 여기서 정말 재미있어집니다. URL에 비ASCII 문자를 넣으면 어떻게 될까요? 일본어, 아랍어, 또는 — 농담이 아니라 — 이모지가 포함된 URL을 원한다면?
답은 완전히 다른 두 시스템을 포함하며, 이 둘을 혼동하는 것은 전형적인 실수입니다.
경로와 쿼리 부분: 퍼센트 인코딩을 사용합니다. 문자의 UTF-8 바이트를 가져와서 각각 인코딩합니다. 그래서 café는 caf%C3%A9가 됩니다. 브라우저는 이것을 자동으로 처리하고 주소 표시줄에 보기 좋은 버전을 보여주지만, 실제로는 퍼센트 인코딩된 버전을 전송합니다.
도메인 이름: 퍼센트 인코딩을 사용하지 않습니다. 대신 Punycode라는 시스템이 있습니다. 유니코드 도메인 이름을 xn--으로 시작하는 ASCII 호환 문자열로 변환합니다.
이것 좀 보세요:
café.com→xn--caf-dma.commünchen.de→xn--mnchen-3ya.de例え.jp→xn--r8jz45g.jp
왜 두 개의 다른 시스템이 있을까요? DNS(도메인 이름을 IP 주소로 변환하는 시스템)가 1980년대에 만들어졌고 ASCII만 지원하기 때문입니다. 그래서 유니코드를 ASCII 문자열에 넣는 방법을 발명해야 했고 — Punycode가 바로 그 발명입니다. URL의 경로와 쿼리 부분은 퍼센트 인코딩된 바이트를 처리할 수 있는 웹 서버에서 처리됩니다.
URL의 유니코드를 위한 전체 명세인 IRI (Internationalized Resource Identifiers)가 RFC 3987에 정의되어 있습니다. IRI는 기본적으로 유니코드 문자를 직접 허용하는 URL입니다. 브라우저는 내부적으로 IRI를 URI로 변환합니다.
그리고 네, 이모지 도메인이 존재합니다. 💩.la는 실제 도메인입니다(또는 한때 그랬습니다). Punycode로 xn--ls8h.la로 인코딩됩니다. 진지한 용도로 이모지 도메인을 사용하는 것은 권장하지 않지만, 시스템이 작동한다는 재미있는 증거입니다.
URL에서 유니코드의 한 가지 주의할 점: "같은" 문자의 다른 유니코드 표현. 예를 들어, é는 단일 코드포인트(U+00E9)로 표현하거나 e + 결합 악센트(U+0065 + U+0301)로 표현할 수 있습니다. 이것들은 다른 퍼센트 인코딩 문자열을 생성합니다! WHATWG URL 표준은 NFC 정규화를 권장하지만, 모든 시스템이 이를 일관되게 따르지는 않습니다.
이중 인코딩: 꿈에서도 괴롭히는 버그
이중 인코딩을 앞서 언급했지만, 이 버그가 다른 URL 관련 문제보다 더 많은 개발자 시간을 낭비했을 것이기 때문에 별도로 깊이 다룰 가치가 있습니다. 저도 여러 번 겪었습니다.
기본 시나리오는 이렇습니다:
무슨 일이 일어난 걸까요? hello%20world를 두 번째로 인코딩하면 % 문자가 %25로 인코딩됩니다. 그래서 %20이 %2520이 됩니다. 서버는 공백 대신 문자 그대로의 %20 문자열을 봅니다.
이렇게 설명하면 당연해 보이지만, 실제 코드베이스에서는 놀라울 정도로 교활합니다. 이중 인코딩에 당하는 시나리오들입니다:
프록시 체인. 서버 A에 요청을 보내면 서버 B로 전달합니다. 두 서버 모두 URL을 인코딩하면, 짠 — 이중 인코딩입니다. Kong, AWS API Gateway, nginx 리버스 프록시 같은 API 게이트웨이가 흔한 원인입니다.
리다이렉트 체인. 사용자가 페이지 A에 가면 ?returnUrl=... 매개변수와 함께 페이지 B로 리다이렉트되고, 페이지 B는 반환 URL을 매개변수로 하여 다시 리다이렉트합니다. 각 리다이렉트가 URL을 재인코딩할 수 있습니다. 세 번의 리다이렉트 후에 URL은 삼중 인코딩되어 완전히 엉망이 됩니다.
프레임워크 "헬퍼". 일부 웹 프레임워크는 URL 매개변수를 자동으로 인코딩합니다. 프레임워크에 전달하기 전에 수동으로 인코딩하면 이중 인코딩이 됩니다. Spring Boot, Express.js 미들웨어, Django의 URL reversing에서 이런 일을 본 적 있습니다.
이중 인코딩을 감지하는 방법? URL에서 %25를 찾으세요. 그것은 인코딩된 퍼센트 기호이며, 보통 무언가가 두 번 인코딩되었다는 의미입니다. %2520이 보이면 이중 인코딩된 공백이고, %253D가 보이면 이중 인코딩된 = 기호입니다.
해결 방법:
최선의 방어는 코드에 명확한 경계를 설정하는 것입니다: 가장자리에서 인코딩하고(HTTP 요청을 보내기 직전이나 표시할 URL을 구성하기 직전에), 내부적으로는 어디서나 인코딩되지 않은 원시 문자열을 전달하세요.
URL 길이 제한과 대처 방법
놀라운 사실을 알려드리겠습니다: HTTP 명세 자체는 최대 URL 길이를 정의하지 않습니다. RFC 3986은 URL이 "무제한 길이"여야 한다고 합니다. 하지만 현실은 동의하지 않습니다.
체인의 다른 구성 요소에는 각각의 제한이 있으며, 가장 짧은 것이 이깁니다:
| 구성 요소 | 최대 URL 길이 |
| Internet Explorer (RIP) | 2,083자 |
| Chrome, Firefox, Safari | ~65,000+자 |
| Apache (기본) | 8,190자 |
| Nginx (기본) | 8,192자 |
| IIS (기본) | 16,384자 |
| AWS ALB | 8,192자 |
| Cloudflare | 32,768자 |
IE의 오래된 2,083자 제한이 모든 것을 지배하던 시절이 있었습니다. IE가 사실상 죽었지만, 일부 개발자와 도구는 여전히 이를 절대적으로 따릅니다. 하지만 실제로 대부분의 최신 스택은 훨씬 긴 URL을 처리할 수 있습니다.
그렇긴 해도, 65,000자 URL을 만들 수 있다고 해서 만들어야 하는 것은 아닙니다. URL을 짧게 유지해야 하는 실질적인 이유들입니다:
- 서버 로그. 많은 로깅 시스템이 긴 URL을 잘라내어 디버깅을 악몽으로 만듭니다.
- 복사 및 붙여넣기. 사용자들은 URL을 복사하고 공유합니다. 매우 긴 URL은 이메일, 채팅 메시지, 문서에서 깨집니다.
- SEO. 검색 엔진은 일반적으로 URL을 2,000자 미만으로 유지하는 것을 권장합니다.
- 캐싱. 일부 CDN 및 프록시 캐시 키 제한은 URL 기반입니다. URL이 길수록 = 캐시 미스가 많아집니다.
URL이 너무 길어지면 어떻게 해야 할까요? 물어봐 주셔서 감사합니다:
1. GET 대신 POST를 사용하세요. 많은 데이터를 보내고 있다면 요청 본문에 넣으세요. 요청 본문에는 실질적인 크기 제한이 없습니다. 이것은 많은 필터가 있는 복잡한 검색 양식의 가장 일반적인 해결책입니다.
2. URL 단축기 또는 참조 ID를 사용하세요. 서버 측에 저장된 전체 매개변수 세트에 매핑되는 짧은 토큰을 생성하세요. /search?filter1=abc&filter2=def&filter3=... 대신 /search/saved/abc123을 얻습니다.
3. 매개변수를 압축하세요. 일부 앱은 압축된 JSON 블롭을 base64 인코딩하여 URL에 넣습니다. 예쁘지는 않지만 작동합니다. Grafana 대시보드 URL 같은 도구에서 이것을 볼 수 있습니다.
폼 인코딩: application/x-www-form-urlencoded
URL 인코딩을 더 혼란스럽게 만드는 코끼리에 대해 이야기해 봅시다: HTML 폼 인코딩입니다. HTML 폼을 method="POST"로 제출하면, 브라우저는 application/x-www-form-urlencoded라는 형식으로 폼 데이터를 인코딩합니다. 이 형식은 표준 퍼센트 인코딩과 거의 같지만, 모두를 미치게 만드는 한 가지 핵심 차이가 있습니다.
공백이 %20 대신 +가 됩니다.
그게 다입니다. 그게 주요 차이입니다. 하지만 정말이지, 엄청난 혼란을 야기합니다.
왜 이런 차이가 존재할까요? 물론 역사적인 이유입니다. HTML 폼 인코딩 명세가 URL 인코딩 명세보다 먼저 나왔고, 90년대에 누군가가 +가 더 보기 좋다고 생각해서 공백에 사용했습니다. 그리고 이제 우리는 영원히 이것에 묶여 있습니다.
HTML 폼을 제출하면 브라우저는 Content-Type: application/x-www-form-urlencoded 헤더를 보내고, 서버는 +를 공백으로 해석합니다. 하지만 JavaScript에서 URL을 수동으로 구성하면서 경로 부분에서 공백 대신 +를 사용하면, 서버는 문자 그대로의 더하기 기호를 봅니다. 재미있죠.
실제로 중요한 부분은 여기입니다:
그리고 multipart/form-data는 완전히 다른 이야기입니다. 폼에 파일 업로드()가 포함되면, 브라우저는 & 대신 경계를 사용하여 필드를 구분하는 multipart/form-data 인코딩으로 전환합니다. URL 인코딩을 전혀 사용하지 않습니다 — 각 부분에는 자체 헤더와 본문이 있습니다. multipart 요청을 수동으로 파싱해 본 적이 있다면 그 고통을 아실 겁니다.
실용적인 조언: 쿼리 문자열과 폼 데이터에는 URLSearchParams를 사용하세요. 파일 업로드에는 FormData를 사용하세요. 정말, 정말 해야 하는 경우가 아니라면 이것들을 직접 인코딩하려고 하지 마세요.
직접 해보세요
이상해 보이는 URL을 작업하고 계신가요? URL 디코더에 붙여넣어 실제 문자를 확인하세요. URL에 넣기 전에 값을 인코딩해야 하나요? URL 인코더가 즉시 처리합니다. 그리고 복잡한 URL을 구성 요소로 분해하려면, URL 파서를 사용하여 각 부분을 명확하게 확인하세요.