블로그로 돌아가기
Reference 2026-04-27

URL vs URI vs URN: 차이점은

세 가지 식별자 사양 간의 관계를 한 번에 정리.

개발자들은 대화에서 "URL"과 "URI"를 섞어 쓰며, 일상적 용도로는 문제없습니다. 하지만 구분은 실재하고 RFC 3986에 명문화되어 있으며, 그 아래 계층인 일반 문법과 인코딩 규칙에 대한 오해야말로 깨진 쿼리 문자열부터 서버 측 요청 위조(SSRF)까지 실제 프로덕션 버그를 만들어냅니다.

한 문단으로 정리하는 위계

URI(Uniform Resource Identifier)는 자원을 식별하는 모든 문자열의 우산 용어입니다. URL(Uniform Resource Locator)은 자원에 도달하는 방법까지 알려주는 URI입니다. 스킴과 호스트 같은 위치 지정 메커니즘을 갖습니다. URN(Uniform Resource Name)은 위치 독립적이고 영속적인 방식으로 자원의 이름을 붙이는 URI입니다. 모든 URL은 URI이고, 모든 URN도 URI이며, URN은 URL이 아닙니다.

URI ─┬─ URL: https://example.com/spec.html

└─ URN: urn:ietf:rfc:3986

일반 문법 (RFC 3986)

모든 URI는 다섯 구성 요소로 분해됩니다.

  https://[email protected]:8443/v2/items?sort=asc#top

\___/ \__________________________/\_______/ \______/ \_/

scheme authority path query fragment

  • scheme — 대소문자 무관, 정규형은 소문자. 나머지의 해석 방식을 정의합니다.
  • authority — 선택적 userinfo, 호스트(등록 이름, IPv4, [::1] 같은 대괄호 IPv6), 선택적 포트. user:password@ 형태는 자격 증명 용도로는 폐기되었고 주로 피싱과 SSRF 페이로드에서 살아남았습니다.
  • path — 계층적. ... 세그먼트는 정규화 중 해석됩니다.
  • query — 관례상 앰퍼샌드로 이은 key=value 쌍이지만, RFC 3986은 어떤 구조도 강제하지 않습니다.
  • fragment — 서버로 전송되지 않습니다. 클라이언트 전용이라 # 뒤의 SPA 라우트가 접근 로그에 나타나지 않는 이유입니다.

퍼센트 인코딩은 구성 요소별

가장 많이 오해되는 사실: "인코딩해야 하는 문자"의 단일 집합은 존재하지 않습니다. 각 구성 요소마다 자체 예약 집합이 있습니다. 리터럴 ?는 경로에서는 구분자지만 쿼리 안에서는 완전히 합법적인 데이터입니다. &는 경로 세그먼트에서는 데이터 문자지만 쿼리에서는 구분자입니다. 전체 URL에 encodeURIComponent를 한 번에 돌리면 URL이 망가지는 이유이고, 올바른 실천이 조립하면서 각 동적 부분을 인코딩하는 것인 이유입니다.

const url = new URL('https://api.example.com/search');

url.searchParams.set('q', 'a&b=c?');

url.toString(); // https://api.example.com/search?q=a%26b%3Dc%3F

공백의 두 가지 표기도 기억하세요. %20은 RFC 3986 인코딩이고, +는 오직 application/x-www-form-urlencoded 데이터 안에서만 공백을 의미합니다. 경로에서 +를 공백으로 디코딩하는 것은 버그입니다.

URN: 주소 없는 이름

URN(RFC 8141)은 등록된 네임스페이스 안에 존재하며, 해석 가능성이 아니라 영속성을 약속합니다.

urn:isbn:9780134685991

urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6

urn:ietf:rfc:3986

ISBN은 그 책을 영원히 지칭합니다. 사본을 찾는 것은 별도의 해석 단계입니다. 실무에서 URN은 XML 네임스페이스, UUID 직렬화, 표준 문서에 등장합니다. 내면화할 패턴: 호스팅이 옮겨져도 절대 깨지면 안 되는 식별자가 필요하면, URN 스타일 이름(또는 위치 독립적인 어떤 ID든)이 URL보다 낫습니다.

두 개의 사양, 하나의 웹

대부분의 글이 건너뛰는 부분입니다. 현대 브라우저와 JavaScript는 RFC 3986을 구현하지 않습니다. 브라우저의 실제 동작에 맞춰 작성된 리빙 스펙인 WHATWG URL Standard를 구현합니다. 문제가 되는 차이들:

  • WHATWG 파싱은 더 관대합니다. 특수 스킴에서 백슬래시를 슬래시로 수용하고(https:\\example.com이 파싱됨), 탭과 개행 문자를 제거하며, 호스트를 소문자화합니다.
  • new URL(relative)는 base 인자 없이는 예외를 던집니다. WHATWG 파서에는 독립적인 상대 참조 개념이 없는 반면, RFC 3986은 이에 한 섹션 전체를 할애합니다.
  • 검증기, 프록시, 오래된 서버 프레임워크는 RFC 3986을 따르는 경향이 있어, 같은 URL이 홉마다 다르게 판정될 수 있습니다.

이 불일치는 악용 가능합니다. URL 파서 혼동은 반복되는 SSRF의 근본 원인입니다. 한 파서를 쓰는 검증기가 https://trusted.com\@evil.com/을 승인하고, 다른 파서를 쓰는 HTTP 클라이언트가 요청을 evil.com으로 보냅니다. 방어는 아키텍처 차원입니다. 한 번만 파싱하고, 파싱된 구성 요소를 검증하고(스킴 허용 목록, 호스트 허용 목록), 요청을 만드는 코드에는 원시 문자열이 아니라 파싱된 객체를 전달하세요.

실용 규칙

  • 조립과 파싱 모두에서 문자열 연결 대신 URLURLSearchParams(또는 언어별 등가물)를 사용하세요.
  • 비교 전에 정규화: 스킴과 호스트 소문자화, 점 세그먼트 해석, 퍼센트 인코딩 16진수 대문자화, 기본 포트 제거.
  • /blog/blog/는 별개 자원으로 취급하고, 하나의 정규형을 정한 뒤 나머지는 301.
  • 전체 URL을 한 번에 디코딩한 뒤 다시 쪼개지 마세요. 먼저 쪼개고, 각 구성 요소를 디코딩하세요.

국제화된 식별자

RFC 3986은 ASCII만 허용하므로, 비ASCII 식별자는 그 위에 계층으로 얹힙니다. IRI(RFC 3987)는 유니코드를 직접 허용합니다. IRI를 URI로 변환하면 경로와 쿼리는 퍼센트 인코딩되고 호스트는 Punycode로 인코딩됩니다. bücher.example은 IDNA를 거쳐 xn--bcher-kva.example이 됩니다. 두 가지 운영상 결과가 따라옵니다. 첫째, 같은 논리적 주소가 여러 인코딩을 가지므로 비교는 정규화 후에 해야 합니다. 둘째, 호모그래프 공격: 키릴 문자 а는 라틴 a와 시각적으로 동일해 аpple.comapple.com을 사칭할 수 있습니다. 브라우저는 혼합 스크립트 호스트를 Punycode로 표시해 완화하며, 사용자 제공 링크를 표시하는 모든 시스템은 글리프를 믿지 말고 같은 정책을 적용해야 합니다.

까다로운 구성 요소 인코딩은 sdk.is/url-encoder 의 URL 인코더에서 실험해 보세요.