URL의 모든 구성 요소와 각 부분에 허용되는 문자

실제 예제 위에 URL의 모든 구성 요소를 표시하고, 어느 위치에서 어떤 문자를 퍼센트 인코딩해야 하는지, 쿼리 문자열이 실제로 어떻게 동작하는지, 호스트를 속이는 링크를 어떻게 알아보는지 설명합니다.

URL은 어떤 프로토콜로 통신할지, 어떤 기기와 통신할지, 무엇을 요청할지를 담은 하나의 문자열입니다. URL을 정확히 읽는 일은 대부분 각 부분이 어디에서 끝나는지 아는 문제입니다. 구분자가 모두 한 글자이고, 가장 먼저 나온 구분자가 이기기 때문입니다.

https://alex:s3cret@www.example.co.uk:8443/docs/intro?lang=en&page=2#notes
│       │           │                 │   │           │              └─ fragment
│       │           │                 │   │           └──────────────── query
│       │           │                 │   └──────────────────────────── path
│       │           │                 └──────────────────────────────── port
│       │           └────────────────────────────────────────────────── host
│       └────────────────────────────────────────────────────────────── userinfo
└────────────────────────────────────────────────────────────────────── scheme

구성 요소

부분구분 방식역할서버까지 전달되는지
스킴첫 번째 :에서 끝남어떤 프로토콜을 쓰고 나머지 부분에 어떤 규칙이 적용되는지연결 방식을 결정합니다
사용자 정보// 뒤에서 시작해 @에서 끝남자격 증명이며 httphttps에서는 오래전에 폐기됨클라이언트가 보내기로 선택한 경우에만
호스트:, /, ?, #에서 끝남이름을 해석해 연결할 도메인 이름 또는 IP 주소예, Host 헤더에 실립니다
포트: 뒤에서 시작해 /, ?, #에서 끝남TCP 포트이며 기본값은 http가 80, https가 443연결에 사용됩니다
경로/에서 시작해 ? 또는 #에서 끝남해당 호스트의 어떤 리소스인지예, 요청 라인에 실립니다
쿼리?에서 시작해 #에서 끝남해당 리소스에 넘기는 매개변수예, 요청 라인에 실립니다
프래그먼트#에서 시작해 문자열 끝까지리소스 내부의 위치아니요

호스트와 포트를 합쳐 오소리티라고 부릅니다. 프래그먼트는 예외입니다. 요청을 만들기 전에 잘려 나가므로 서버는 프래그먼트를 볼 일이 없습니다. 브라우저는 이를 앵커와 클라이언트 측 라우팅에 사용하며, 일부 인증 흐름은 토큰이 서버 로그에 남지 않도록 일부러 프래그먼트로 토큰을 돌려줍니다. 그래도 브라우저 기록에는 남으므로 비공개가 아니라 전송되지 않을 뿐입니다.

예약 문자와 비예약 문자

퍼센트 인코딩은 문자의 UTF-8 바이트를 사용해 한 바이트를 %와 16진수 두 자리로 씁니다. é%C3%A9가 되며, 두 바이트이므로 이스케이프도 두 개입니다.

영문자와 숫자와 더불어 네 개의 문자가 비예약 문자이며, 어디에서도 인코딩할 필요가 없습니다. - . _ ~가 그것입니다. 나머지는 모두 URL 어딘가에서 구조적 역할을 맡는 예약 문자이거나, 인코딩해야 하는 문자입니다. 예약 문자 집합은 : / ? # [ ] @ 그리고 ! $ & ' ( ) * + , ; = 입니다.

"예약"이라는 말은 "항상 금지"라는 뜻이 아닙니다. 그 문자가 어떤 부분에서는 구분자라는 뜻이며, 구분자로 읽힐 수 있는 부분에서만 인코딩하면 됩니다.

문자경로 세그먼트에서쿼리 값에서
공백항상 %20%20 또는 +
/%2F, 아니면 세그먼트가 갈라집니다그대로 써도 적법합니다
?%3F, 아니면 경로가 거기서 끝납니다그대로 써도 적법합니다
#항상 %23항상 %23
&=그대로 써도 적법합니다%26%3D, 아니면 쌍이 갈라집니다
+적법하며 더하기 기호를 뜻합니다%2B, 아니면 공백으로 디코딩될 수 있습니다

실무 규칙은 이렇습니다. 지금 있는 부분을 끝내버릴 문자를 인코딩하십시오. 경로의 공백은 반드시 %20이어야 합니다. 대부분의 파서에서 날것의 공백은 URL을 끝내기 때문입니다. 반면 쿼리 값의 공백은 %20으로 써도 되고 예전 방식인 +로 써도 됩니다. 이 작업은 URL 인코더로 처리하는 편이 확실합니다. URL 전체를 인코딩하는 것과 구성 요소 하나를 인코딩하는 것은 서로 다른 작업이고, 필요한 쪽은 거의 언제나 후자이기 때문입니다.

쿼리 문자열은 관례일 뿐입니다

명세가 정하는 것은 쿼리가 ?# 사이의 전부이고 허용된 문자로 이루어진다는 것뿐입니다. key=value&key=value라는 모양은 URI 표준이 아니라 HTML 폼 인코딩에서 왔습니다. 서버가 ?a:1;b:2를 원하는 방식으로 해석하는 것을 막는 규칙은 없습니다.

모양이 관례이다 보니 키가 반복될 때의 의미가 정해져 있지 않고, 스택마다 각자의 답을 골랐습니다.

?id=1&id=2의 처리해당 환경
두 값 모두, 리스트로Python parse_qs, Node querystring, Express
첫 번째 값만Go Query().Get, Java getParameter
마지막 값만PHP, Rails
1,2로 이어 붙임ASP.NET 요청 컬렉션

배열도 같은 문제를 물려받습니다. ids=1&ids=2, ids[]=1&ids[]=2, ids[0]=1&ids[1]=2, ids=1,2가 모두 널리 쓰이며, 어느 것을 이해하는지는 오직 서버가 정합니다. 대괄호 표기는 엄밀히 말해 인코딩해야 적법하지만(ids%5B%5D=1), 대부분의 서버는 날것의 대괄호도 받아들입니다. 받는 쪽이 문서화한 형태를 고른 다음 일관되게 유지하십시오.

더하기 기호는 따로 경고할 만합니다. 폼 인코딩된 요청 본문인 application/x-www-form-urlencoded 데이터에서 +는 공백을 뜻합니다. URL 경로에서는 더하기 문자 자체를 뜻합니다. 쿼리 문자열에서는 파서에 따라 다르며, 대부분의 웹 프레임워크가 거기에 폼 디코딩을 적용하므로 +가 조용히 공백으로 바뀝니다. 표준 Base64 출력이나 전화번호처럼 더하기를 정당하게 포함할 수 있는 값은 반드시 %2B로 보내야 합니다.

상대 참조

상대 참조는 기준 URL의 마지막 / 뒤에 있는 모든 것을 바꿔 넣는 방식으로 해석됩니다. 그 마지막 슬래시가 규칙의 전부이며, 끝의 슬래시가 중요한 이유도 바로 이것입니다.

기준참조결과
https://ex.com/docs/introguidehttps://ex.com/docs/guide
https://ex.com/docs/intro/guidehttps://ex.com/docs/intro/guide
https://ex.com/docs/intro/guidehttps://ex.com/guide
https://ex.com/docs/intro/../guidehttps://ex.com/docs/guide
https://ex.com/docs/intro?a=1?b=2https://ex.com/docs/intro?b=2
https://ex.com/docs/intro?a=1#tophttps://ex.com/docs/intro?a=1#top
https://ex.com/docs/intro//cdn.ex.com/x.jshttps://cdn.ex.com/x.js

마지막 행은 스킴 상대 참조입니다. 앞의 슬래시 두 개는 기준의 스킴을 유지하면서 오소리티만 교체합니다. ?로 시작하는 참조는 경로를 유지하고 기존 쿼리를 버리며, #로 시작하는 참조는 둘 다 유지합니다.

두 URL이 같은 리소스인 경우

스킴과 호스트는 대소문자를 구분하지 않으므로 HTTPS://Example.COMhttps://example.com은 하나의 주소입니다. 호스트 뒤의 모든 것은 표준상 대소문자를 구분하며, 특정 서버가 대소문자를 무시하기로 정했더라도 그 사실은 달라지지 않습니다.

다음 쌍은 동등합니다.

  • https://example.com:443/ahttps://example.com/a. 포트가 기본값이기 때문입니다
  • https://example.comhttps://example.com/. 빈 경로는 루트를 뜻하기 때문입니다
  • /a/%7Euser/a/~user. ~는 비예약 문자여서 퍼센트 인코딩해도 달라지는 것이 없기 때문입니다

다음 쌍은 동등하지 않습니다.

  • /docs/docs/. 서로 다른 리소스이며, 대부분의 서버가 한쪽에서 다른 쪽으로 리다이렉트할 뿐입니다
  • /p?a=1&b=2/p?b=2&a=1. 매개변수 순서도 문자열의 일부이기 때문입니다
  • /index.html/. 서버가 달리 정하지 않는 한 다릅니다

캐시와 CDN은 정확한 바이트를 키로 삼습니다. 그래서 추적 매개변수가 하나 붙거나 쿼리 순서가 바뀌면 캐시된 객체 하나가 둘로 갈라지고 적중률은 절반이 됩니다. 검색 엔진은 canonical 링크가 한 가지 형태를 지목하지 않는 한 변형들을 중복 페이지로 취급합니다. 해결책은 하나의 형태를 정하고, 나머지는 301로 그 형태에 리다이렉트하며, 응답을 바꾸지 않는 매개변수는 제거하는 것입니다.

길이, 로그, 프라이버시

명세에는 길이 제한이 없지만 그 밖의 모든 곳에는 제한이 있습니다. 흔한 서버 기본값은 요청 라인 전체를 약 8 KB로 제한하고, 일부 프록시와 어플라이언스는 4 KB, 오래된 클라이언트는 2 KB 안팎으로 제한합니다. 이메일 클라이언트, QR 코드, 링크 단축 서비스를 모두 통과해야 하는 URL이라면 약 2000자 이하로 두는 것이 가장 안전합니다.

URL을 짧게 유지해야 할 더 강력한 이유는 쿼리 문자열이 비공개가 아니라는 점입니다. 쿼리 문자열은 액세스 로그에 기록되고, Referer 헤더로 제3자에게 전달되며, 브라우저 기록에 남고, 북마크와 함께 저장되고, 누군가 링크를 공유할 때마다 함께 복사됩니다. 따라서 세션 토큰, 비밀번호 재설정 코드, API 키, 개인 정보는 거기에 들어갈 자리가 없습니다. 크거나 민감한 데이터는 요청 본문에 넣습니다. 요청 본문에는 사실상 크기 상한이 없고 기본적으로 로그에 남지도 않습니다.

클릭하기 전에 링크 읽기

오소리티는 :// 뒤에서 시작해 맨 처음 나오는 /, ?, #에서 끝납니다. 그 구간을 읽고, 그 안의 마지막 두세 개 레이블을 읽으십시오. 그것이 진짜 호스트이며, 그 왼쪽에 있는 무엇도 이를 바꾸지 못합니다.

https://www.paypal.com@198.51.100.7/secure/login
        ^^^^^^^^^^^^^^ ^^^^^^^^^^^^
        userinfo       real host

알아두어야 할 흔한 위장 수법은 다음과 같습니다.

  • @ 앞의 텍스트는 사용자 정보이지 호스트가 아니며, 어떤 브랜드 이름이든 들어갈 수 있습니다.
  • paypal.com.secure-login.examplesecure-login.example의 서브도메인입니다. 레이블은 오른쪽에서 왼쪽으로 읽습니다.
  • https://short.example/https://www.bank.com/login은 그럴듯한 URL 전체를 경로에 집어넣은 것입니다.
  • xn--로 시작하는 호스트는 퓨니코드이며, IDNA가 만들어낸 국제화 도메인 이름의 ASCII 표현입니다. münchen.de는 전송될 때 xn--mnchen-3ya.de가 되며 이는 정당한 쓰임입니다. 다만 같은 구조가 동형 문자 공격도 가능하게 합니다. 키릴 문자 а(U+0430)는 라틴 문자 a와 시각적으로 똑같고, 이를 사용한 도메인은 xn--pple-43d.com 같은 형태로 인코딩됩니다. 브라우저는 한 레이블에 여러 문자 체계가 섞이면 퓨니코드 형태를 보여주지만, 규칙은 제각각이고 보장되는 것도 아닙니다.
  • 호스트 안에 들어간 %2F%40 같은 인코딩된 구분자는 파서를 혼란시키려는 의도적인 시도입니다.

링크를 URL 파서에 붙여 넣으면 한 번에 결론이 납니다. 진짜 파서는 브라우저와 똑같이 처음 나온 구분자가 이긴다는 규칙을 적용하고 호스트를 따로 떼어 보여주기 때문입니다. 여러 링크를 한꺼번에 확인할 때는 정규식 테스터가 어떤 링크가 실제로 기대한 오소리티를 갖고 있는지 빠르게 확인하는 방법입니다.