본문 바로가기
Converter

URL 인코더 · 디코더

퍼센트 인코딩을 양방향으로, 쿼리는 표로

&, =, ?, /, # 까지 인코딩합니다 — 값 하나를 넣을 때

두 모드는 어디서 갈리나

‘갈림’ 표시가 붙은 줄

문자값 하나 (Component)전체 URL (URI)역할
&갈림%26&쿼리 파라미터 구분자
=갈림%3D=key 와 value 구분자
?갈림%3F?쿼리 시작
#갈림%23#프래그먼트 시작
/갈림%2F/경로 구분자
:갈림%3A:스킴·포트 구분자
+갈림%2B+폼 인코딩에서 공백
(공백)%20%20URL 에 그대로 못 들어감
%%25%25이스케이프 자신
%ED%95%9C%ED%95%9C비 ASCII — UTF-8 3바이트

갈리는 문자는 전부 URL 구조를 나누는 문자입니다. 값 하나를 넣는데 전체 URL 모드를 쓰면 &가 살아남아 값이 파라미터 두 개로 쪼개집니다.

encodeURI와 encodeURIComponent, 이름이 비슷해서 생기는 버그

URL 인코딩에서 나오는 버그의 압도적 1위는 둘 중 잘못된 걸 골랐다입니다. 자동완성 목록에 나란히 뜨고, 이름이 다섯 글자 차이고, 대부분의 입력에서 결과가 똑같기 때문입니다. 그러다 하필 값에 &가 들어간 날 터집니다.

두 함수가 서로 다르게 생각하는 지점은 단 하나입니다.

  • encodeURI는 “네가 준 건 완성된 주소 전체”라고 가정합니다. 그래서 : / ? # & =를 건드리지 않습니다. 건드리면 주소의 구조가 무너지니까요.
  • encodeURIComponent는 “네가 준 건 값 하나”라고 가정합니다. 그래서 저 문자들을 전부 퍼센트로 바꿉니다. 값 안의 &가 구분자로 오해받으면 안 되니까요.

구체적으로 보면 이렇습니다.

const q = "커피 & 디저트";

// ✗ 값에 encodeURI 를 썼다
"/search?q=" + encodeURI(q)
// → /search?q=커피%20&%20디저트
//   & 가 살아남았다. 서버는 파라미터를 두 개로 읽는다:
//   q = "커피 "   그리고   " 디저트" = (값 없음)

// ✓ 값에는 encodeURIComponent
"/search?q=" + encodeURIComponent(q)
// → /search?q=%EC%BB%A4%ED%94%BC%20%26%20%EB%94%94%EC%A0%80%ED%8A%B8
//   q = "커피 & 디저트"  ← 통째로 하나의 값

이 버그가 특히 고약한 이유는 인코딩한 쪽에서 터지지 않기 때문입니다. 프런트엔드 코드는 아무 오류 없이 잘 돌아가고, URL 도 겉보기엔 멀쩡하고, 서버 로그에도 예외가 없습니다. 그냥 검색 결과가 이상할 뿐입니다. 그러다 검색어에 &를 쓴 사용자가 나타나야 재현됩니다.

반대 실수도 있습니다. 완성된 URL 에 encodeURIComponent를 쓰면 https://a.com/bhttps%3A%2F%2Fa.com%2Fb가 됩니다. 이건 최소한 눈에 보이게 망가져서 5초 만에 발견됩니다. 위험한 쪽은 언제나 조용한 쪽입니다.

%20과 +는 같은 공백이 아니다

공백 하나를 두고 표기가 두 개 있습니다. 그리고 이 둘은 “같은 뜻의 다른 표기”가 아니라 서로 다른 명세에 속한 다른 규칙입니다.

  • %20은 RFC 3986, 즉 URI 문법 자체의 퍼센트 인코딩입니다. URL 어디에서나 공백을 뜻합니다.
  • +application/x-www-form-urlencoded의 규칙입니다. HTML 폼을 GET 으로 제출할 때 브라우저가 만드는 그 형식입니다. 이 규칙은 쿼리 문자열에만 적용됩니다.

그래서 다음 두 주소는 의미가 다릅니다.

/search/a+b     ← 경로. + 는 리터럴 플러스. 찾는 건 "a+b"
/search?q=a+b   ← 쿼리. + 는 공백. 찾는 건 "a b"

더 헷갈리는 건 표준 라이브러리 안에서도 규칙이 갈린다는 점입니다. encodeURIComponent("a b")는 언제나 a%20b를 내놓습니다 — 절대 +를 만들지 않습니다. 반면 new URLSearchParams({ q: "a b" }).toString() q=a+b를 내놓습니다. 같은 브라우저 API 인데 답이 다릅니다. 둘 다 맞습니다 — 전자는 URI 문법을 따르고 후자는 폼 인코딩을 따르니까요.

실무에서 여기 걸리는 지점은 디코딩입니다. decodeURIComponent("a+b")"a+b"를 돌려줍니다. 폼 인코딩을 모르기 때문입니다. 쿼리 문자열을 직접 잘라 파싱하면서 decodeURIComponent만 돌리면 사용자가 친 공백이 전부 +로 남습니다. 쿼리는 URLSearchParams에 맡기세요. 그게 +를 공백으로 풀어주는 유일한 표준 경로입니다.

한글 URL이 부풀어 오르는 이유

퍼센트 인코딩은 글자가 아니라 바이트를 인코딩합니다. 한글 “한”은 UTF-8 에서 ED 95 9C 세 바이트이고, 각 바이트가 %XX 세 글자가 되므로 결과는 %ED%95%9C, 아홉 글자입니다. 한 글자가 아홉 배가 됩니다.

20자짜리 한글 경로는 180자가 됩니다. 이게 그냥 보기 싫은 문제로 끝나지 않는 경우가 있습니다 — 오래된 서버나 프록시는 URL 길이 상한(2,048자 언저리)이 있고, 한글 검색어 몇 개가 쿼리에 쌓이면 생각보다 빨리 닿습니다.

주소창은 보여주는 것과 보내는 것이 다르다

브라우저 주소창에 위키백과.org/wiki/한글이라고 예쁘게 떠 있는데, 복사해서 다른 곳에 붙이면 %ED%95%9C%EA%B8%80이 나옵니다. 버그가 아닙니다. 브라우저는 읽기 좋은 형태로 디코딩해서 보여주고, 실제로 서버에 보낼 때와 클립보드에 복사할 때는 인코딩된 원본을 씁니다. 화면의 한글은 사람을 위한 표시이고, 네트워크에 흐르는 건 언제나 퍼센트 인코딩된 ASCII 입니다.

도메인은 퍼센트 인코딩이 아니다

여기서 자주 섞이는 게 있습니다. 한글.kr 같은 도메인 부분은 퍼센트 인코딩되지 않습니다. 완전히 다른 메커니즘인 Punycode(IDN)를 씁니다.

https://한글.kr/한글
        ↓                ↓
Punycode 로 변환    퍼센트 인코딩
        ↓                ↓
https://xn--bj0bj06e.kr/%ED%95%9C%EA%B8%80

도메인 앞에 붙는 xn--이 그 표식입니다. DNS 는 ASCII 만 다루는데 퍼센트는 DNS 가 허용하는 문자가 아니라서, 도메인은 아예 다른 인코딩을 쓰도록 정해졌습니다. 그래서 URL 하나 안에서 호스트와 경로에 서로 다른 인코딩 규칙이 동시에 적용됩니다. 이 도구를 포함해 어떤 URL 인코더도 도메인을 Punycode 로 바꿔주지 않는 이유입니다 — 그건 다른 작업입니다.

이중 인코딩: %25가 보이면 의심하라

퍼센트 인코딩에는 자기 자신을 이스케이프해야 하는 문제가 있습니다. %가 특수한 의미를 가지므로, 문자 그대로의 %%25로 써야 합니다.

여기서 이중 인코딩이 나옵니다.

"한"
  → encodeURIComponent → "%ED%95%9C"
  → encodeURIComponent → "%25ED%2595%259C"   ← % 가 %25 가 됐다

"%25ED%2595%259C"
  → decodeURIComponent → "%ED%95%9C"   ← 한글이 아니라 이 '글자'가 나온다
  → decodeURIComponent → "한"          ← 두 번 풀어야 한다

화면에 %ED%95%9C이 글자로 보이는 증상은 전부 이겁니다. 대개는 값이 두 개의 계층을 통과하면서 각 계층이 “내가 인코딩해야지”라고 생각했기 때문입니다. 프런트에서 한 번 하고 HTTP 클라이언트 라이브러리가 또 하거나, 서버가 받은 값을 디코딩하지 않고 그대로 다른 URL 에 끼워 넣는 식입니다.

중요한 건 이중 인코딩이 항상 버그는 아니라는 점입니다. 리다이렉트 URL 을 쿼리 파라미터에 담는 경우를 보세요.

https://a.com/login?next=https%3A%2F%2Fb.com%2Fx%3Fy%3D1
                         └─ 이 안의 ? 와 = 는 반드시 인코딩돼야 한다.
                            아니면 y=1 이 a.com 의 파라미터가 돼 버린다.

이건 정당한 — 그리고 필수적인 — 이중 인코딩입니다. next의 값 자체가 URL 이니 한 번 인코딩되고, 그게 다시 바깥 URL 의 일부가 되니 또 인코딩됩니다. 그래서 이 도구는 %25를 발견해도 자동으로 두 번 풀지 않고 알려주기만 합니다. 몇 번 풀어야 하는지는 그 값이 몇 겹으로 싸여 있는지에 달렸고, 그건 도구가 아니라 사람이 압니다.

무엇이 예약문자인가 — 그리고 표준 함수가 빼먹는 것

RFC 3986 은 URL 의 문자를 세 부류로 나눕니다.

  • unreserved (A-Z a-z 0-9 - _ . ~): 인코딩할 필요가 없고, 해도 의미가 같습니다. 이 문자들만 쓴 값은 인코딩해도 그대로입니다.
  • reserved (: / ? # [ ] @ ! $ & ' ( ) * + , ; =): 구조를 나누는 문자들. 구분자로 쓸 게 아니라면 인코딩해야 합니다.
  • 나머지 전부(한글, 이모지, 공백, <, % …): UTF-8 바이트로 바꿔 퍼센트 인코딩해야 합니다.

여기서 encodeURIComponent에 알려진 구멍이 하나 있습니다. reserved 목록에 있는 ! ' ( ) * 다섯 문자를 인코딩하지 않고 남깁니다.

encodeURIComponent("!'()*")   // → "!'()*"   ← 하나도 안 바뀐다

// RFC 3986 을 엄격히 지키려면 직접 메꾼다
function strictEncode(s) {
  return encodeURIComponent(s).replace(
    /[!'()*]/g,
    (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(),
  );
}
strictEncode("!'()*")         // → "%21%27%28%29%2A"

이건 JavaScript 가 RFC 3986 보다 오래된 RFC 2396 기준으로 만들어졌고, 호환성 때문에 고칠 수 없어 남은 흔적입니다. 웹 페이지 링크에서는 아무 문제가 없습니다 — 서버는 어차피 (를 잘 받습니다. 문제는 양쪽이 만든 문자열이 바이트 단위로 일치해야 하는 곳입니다. OAuth 1.0 서명, AWS Signature V4, 그리고 URL 을 해시해서 캐시 키로 쓰는 시스템이 여기 해당합니다. 한쪽이 (로 두고 다른 쪽이 %28로 바꾸면 서명이 어긋나고, 오류 메시지는 “signature does not match” 한 줄뿐이라 원인을 찾기 어렵습니다. 위 도구가 이 경우 엄격 모드 결과를 함께 보여주는 이유입니다.

자주 묻는 질문

encodeURI와 encodeURIComponent 중 뭘 써야 하나요?
넣으려는 게 '값 하나'면 encodeURIComponent, '이미 완성된 주소 전체'면 encodeURI입니다. 판단 기준은 간단합니다 — 그 문자열 안의 &나 ?가 구분자로 동작해야 하면 encodeURI, 그냥 글자로 취급돼야 하면 encodeURIComponent입니다. 쿼리 파라미터에 값을 끼워 넣는 상황이라면 거의 항상 encodeURIComponent가 정답이고, encodeURI를 쓰면 값 안의 &가 그대로 남아 파라미터가 쪼개집니다.
공백이 어떤 데선 %20이고 어떤 데선 +인 이유가 뭔가요?
둘은 서로 다른 명세에 속합니다. %20은 RFC 3986의 퍼센트 인코딩이고, +는 HTML 폼 제출에 쓰이는 application/x-www-form-urlencoded의 규칙입니다. 그래서 +가 공백을 뜻하는 곳은 쿼리 문자열뿐이고, 경로에서 +는 그냥 더하기 기호입니다. /search/a+b와 /search?q=a+b는 서로 다른 의미이며, encodeURIComponent는 언제나 %20을, URLSearchParams는 +를 만들어냅니다.
한글 URL을 복사했더니 %ED%95%9C 같은 문자가 나옵니다.
정상입니다. 브라우저 주소창은 읽기 편하도록 디코딩된 한글을 '보여주기만' 하고, 서버로 보낼 때와 클립보드에 복사할 때는 인코딩된 원본을 씁니다. 한글 한 글자는 UTF-8에서 3바이트라 %XX가 세 개, 즉 9글자가 됩니다. 20자짜리 한글 경로가 180자로 부푸는 건 이 때문이며, 링크가 깨진 게 아니라 붙여 넣으면 똑같이 동작합니다.
디코딩했는데 %ED%95%9C이 글자 그대로 남습니다.
이중 인코딩된 문자열입니다. 인코딩을 두 번 거치면 첫 번째 결과의 %가 %25로 다시 바뀌어 %ED가 %25ED가 됩니다. 이걸 한 번만 디코딩하면 %25ED가 %ED라는 '글자'로 풀릴 뿐이라 한글이 나오지 않습니다. 한 번 더 디코딩하면 됩니다. 입력에 %25가 보이면 이중 인코딩을 의심하세요 — 위 도구는 이 경우를 감지해서 알려줍니다.
?a=1&a=2처럼 같은 이름이 두 번 있으면 어떻게 되나요?
URL 명세는 이걸 금지하지 않습니다. 값이 여러 개인 게 정상이고, 체크박스 여러 개를 선택한 폼이 정확히 이런 쿼리를 만듭니다. 문제는 읽는 쪽입니다. searchParams.get('a')는 첫 번째 값인 '1'만 돌려주고 나머지는 조용히 버려집니다. 전부 필요하면 getAll()을 써야 하고, 서버 프레임워크마다 첫 값·마지막 값·배열 중 무엇을 고르는지가 달라서 실제 버그가 자주 납니다.
encodeURIComponent를 쓰면 RFC 3986을 지키는 건가요?
거의 그렇지만 완전하지는 않습니다. encodeURIComponent는 !, ', (, ), * 다섯 문자를 인코딩하지 않고 남깁니다. RFC 3986에서 이들은 sub-delims에 해당해 엄격히는 인코딩 대상입니다. 웹 페이지 링크에서는 문제가 되지 않지만, OAuth 1.0 서명처럼 양쪽이 만든 문자열이 바이트 단위로 같아야 하는 곳에서는 서명이 어긋납니다. 그럴 땐 이 다섯 문자를 손으로 치환해야 합니다.