본문으로 건너뛰기
yutils

URL 인코딩 정밀 가이드 — 왜 +가 아니라 %20인가, encodeURIComponent vs encodeURI

퍼센트 인코딩 문자 집합, encodeURI 와 encodeURIComponent 차이, '+' 가 공백이 되는 맥락, IDN/Punycode, 안전한 URL 조립.

약 8분 읽기

URL 안에 한글, 공백, 특수 문자를 어떻게 표현하는지 — 답은 percent encoding (URL encoding) 이다. 이 가이드는 어떤 문자가 그대로 가는지, 어떤 문자는 인코딩이 강제되는지, 왜 공백이 어떨 땐 %20이고 어떨 땐 + 인지, JS 의 두 함수 (encodeURI/ encodeURIComponent) 차이까지 정리한다.

RFC 3986 의 문자 집합

URL 안에서 그대로 등장 가능한 문자는 두 부류.

Unreserved (인코딩 불필요)

A-Z  a-z  0-9  -  _  .  ~

모든 환경에서 그대로 유지. 인코딩하면 도리어 의미가 달라질 수 있음 (URL 정규화 시 이 문자들의 percent 인코딩은 자동 디코드).

Reserved (의미 충돌 시 인코딩 필요)

gen-delims:  :  /  ?  #  [  ]  @
sub-delims:  !  $  &  '  (  )  *  +  ,  ;  =

URL 의 구조 를 표현하는 문자들. 위치에 따라 다른 의미.

  • ? 가 path 안에 있으면 query 시작으로 오해. 인코딩 필수.
  • & 가 query value 안에 있으면 다음 파라미터로 오해.
  • # 가 어디든 있으면 fragment 시작.

그 외 (모두 인코딩)

한글·중국어·이모지·공백·제어 문자 — 전부 percent encode. UTF-8 바이트 열로 변환 후 각 바이트를 %HH.

예: 은 UTF-8 로 EC 95 9C %EC%95%9C.

URL 인코딩 / 디코딩 에 임의 문자열을 넣으면 encode/decode 결과를 즉시 확인 가능 — 한 번에 어떤 바이트가 나오는지 본다.

공백 — %20 vs +

악명 높은 혼동.

  • path: 공백은 %20. + 는 그냥 plus 문자.
  • query string (application/x-www-form-urlencoded): 공백은 +. %20 도 동작하지만 + 가 form 인코딩 표준.
  • fragment: %20.

그래서 같은 "hello world" 가

  • https://example.com/hello%20world (path)
  • https://example.com/?q=hello+world (form-style query)
  • https://example.com/?q=hello%20world (RFC 3986 query — 더 안전)

모두 같은 의미로 해석된다. 단, encodeURIComponent("hello world") 는 항상 %20 만 출력 — JS 에서 + 가 필요하면 직접 replace.

JS 의 세 함수

encodeURI

URL 전체를 인코딩. 구조 문자 (:/?#[]@!$&'()*+,;=) 는 그대로 유지. 이미 만들어진 URL 을 손볼 때 사용.

encodeURI("https://example.com/검색?q=하늘")
// "https://example.com/%EA%B2%80%EC%83%89?q=%ED%95%98%EB%8A%98"

?= 는 그대로 — 구조 보존. 한글만 인코딩.

encodeURIComponent

URL 의 한 조각 (path segment, query value 등) 을 인코딩. 구조 문자도 모두 인코딩.

encodeURIComponent("https://example.com/검색?q=하늘")
// "https%3A%2F%2Fexample.com%2F%EA%B2%80%EC%83%89%3Fq%3D%ED%95%98%EB%8A%98"

URL 을 다른 URL 의 query value 로 박을 때 (OAuth redirect, 짧은 링크 서비스) 필수.

경험 법칙

  • query value 만 만들 때 encodeURIComponent.
  • 완성된 URL 을 안전하게 만들 때 encodeURI.
  • URL 조립 자체URL / URLSearchParams 객체 사용 (가장 안전).
const url = new URL("https://example.com/search");
url.searchParams.set("q", "hello world & more");
url.searchParams.set("page", "2");
url.toString();
// "https://example.com/search?q=hello+world+%26+more&page=2"

URLSearchParams 는 form 스타일이라 공백을 + 로. 표준이고 일관됨.

국제화 도메인 — IDN / Punycode

도메인은 ASCII 만 허용. 한글.kr 같은 IDN 은 Punycode 로 변환된다.

한글.kr  →  xn--bj0bj06e.kr
münchen.de  →  xn--mnchen-3ya.de
example.한국  →  example.xn--3e0b707e

브라우저 주소창은 IDN 그대로 표시하지만, DNS 조회·HTTP host 헤더는 모두 Punycode. Punycode (국제화 도메인) 에 도메인을 넣으면 양방향 변환 결과를 본다.

IDN homograph 공격 — Cyrillic а (U+0430) 와 Latin a (U+0061) 가 시각적으로 동일. аpple.com (Cyrillic a) 같은 위장 가능. 브라우저는 의심스러운 혼합 시 Punycode 로 강제 표시.

URL 의 다섯 부분

https://user:pass@host.example.com:8080/path/to/resource?key=value#fragment
─┬───  ──┬──── ─┬─────────────── ─┬── ─┬──────────────── ─┬──────── ─┬─────
 │       │      │                  │    │                  │          └ fragment
 │       │      │                  │    │                  └ query
 │       │      │                  │    └ path
 │       │      │                  └ port
 │       │      └ host (authority)
 │       └ userinfo (deprecated for HTTP)
 └ scheme

각 부분의 인코딩 규칙이 다르다. URL 파서 가 분해해 보여주므로 어디서 인코딩이 깨졌는지 빠르게 찾을 수 있다.

흔한 함정

1. 이중 인코딩

이미 인코딩된 URL 에 encodeURIComponent 다시 — % %25 로. 받는 쪽이 디코드 한 번 만 하면 깨진 결과.

2. + 를 무조건 공백으로 디코드

path 의 a+b 는 그냥 plus. query string 의 a+b 는 form 인코딩 컨텍스트에서 공백. decodeURIComponent 는 둘 다 plus 로 두므로, form 디코드는 str.replace(/\+/g, " ") 먼저 후 디코드.

3. base path 직접 문자열 join

baseUrl + "/" + userInput 같은 패턴은 userInput?·#·../ 에 취약 (path traversal, query injection). new URL(userInput, baseUrl) 사용 권장.

4. fetch 에 raw 한글 URL

fetch("https://example.com/한글") 은 브라우저가 자동 인코딩 해 주지만, server-side fetch (Node) 는 환경에 따라 다름. 명시적 인코딩 권장.

5. + 가 base64 와 충돌

Base64 출력에 + 가 나타남. URL 에 박으려면 base64url 변종 (+-, /_) 사용. JWT 가 정확히 이 방식.

조립 예제 — 안전한 패턴

function buildSearchUrl(base, query, page) {
  const url = new URL(base);
  url.pathname = `${url.pathname.replace(/\/$/, "")}/search`;
  url.searchParams.set("q", query);
  url.searchParams.set("page", String(page));
  return url.toString();
}

buildSearchUrl("https://example.com/", "한글 검색 & 결과", 2)
// "https://example.com/search?q=%ED%95%9C%EA%B8%80+%EA%B2%80%EC%83%89+%26+%EA%B2%B0%EA%B3%BC&page=2"

문자열 concat 0 건. URL + URLSearchParams 만으로 모든 경우 처리.

요약

  • unreserved (A-Za-z0-9-._~) 만 그대로. 나머지는 percent encode.
  • 공백: path/fragment 는 %20, form-style query 는 +.
  • query value → encodeURIComponent, 완성 URL 손질 → encodeURI, 조립 → URL + URLSearchParams.
  • 한글 도메인은 Punycode. IDN homograph 주의.
  • 이중 인코딩, + 컨텍스트, base64 + URL 충돌이 단골 사고.
가이드 목록으로