Skip to content
블로그로 돌아가기
튜토리얼

URL 인코딩/디코딩: 퍼센트 인코딩 가이드와 온라인 도구 | Go-Tools

RFC 3986 규칙, encodeURI와 encodeURIComponent의 차이, UTF-8 바이트 매핑, JavaScript·Python·Go·Java 코드 예제까지 웹 URL 인코딩을 온라인 가이드로 정리했습니다.

12분 소요

URL 인코딩과 디코딩: 퍼센트 인코딩 완벽 가이드

서버 로그를 꼬리부터 훑다가 쿼리 스트링에서 %E4%BD%A0%E5%A5%BD 같은 문자열을 발견합니다. 깨진 데이터일까요? 버그일까요? 둘 다 아닙니다. 이것은 한자 你好이며, 각 글자가 세 개의 UTF-8 바이트로 변환된 뒤 URL이 안전하게 실을 수 있는 형식으로 퍼센트 인코딩된 결과입니다. 모든 웹 개발자가 한 번쯤 마주치는 상황입니다. 뭔가 고장난 것처럼 보이지만, URL은 설계대로 정확히 동작하고 있습니다.

URL 인코딩(공식 명칭은 퍼센트 인코딩)은 특수 문자를 URL에서 안전하게 다룰 수 있도록 만들어 주는 메커니즘입니다. 이 가이드에서는 바이트 수준의 동작 방식, encodeURIencodeURIComponent를 언제 선택해야 하는지, 네 가지 언어에서 올바르게 인코딩하는 방법, 그리고 숙련된 개발자조차 빠뜨리는 버그까지 정리합니다.

URL 디코더 & 인코더 도구에 아무 URL이나 붙여넣어 보시면, 가이드를 따라가면서 인코딩과 디코딩이 실시간으로 일어나는 과정을 확인하실 수 있습니다.

URL 인코딩(퍼센트 인코딩)이란?

URL은 ASCII 문자 중 일부만 담을 수 있습니다. 알파벳, 숫자, 그리고 소수의 기호만 별 문제 없이 인터넷을 타고 이동합니다. 그 외 모든 것, 이를테면 공백, 앰퍼샌드, 한글, 이모지는 URL이 실어 나를 수 있는 형식으로 변환되어야 합니다.

퍼센트 인코딩은 안전하지 않은 각 바이트를 % 기호 뒤에 두 자리 16진수가 붙은 형태로 대체합니다. 공백은 %20이 되고, 앰퍼샌드는 %26이 됩니다. 이름 자체가 이 % 접두사에서 왔습니다.

규칙은 2005년에 발표되어 지금까지 현행 표준으로 쓰이는 RFC 3986에 정의되어 있습니다. RFC 2396을 대체했고, 어떤 문자가 안전한지, 어떤 문자가 예약되어 있는지, 비-ASCII 텍스트를 어떻게 처리해야 하는지를 한층 더 엄밀하게 정리했습니다.

간단한 예시:

입력인코딩 결과이유
hello worldhello%20world공백은 URL에서 허용되지 않음
price=10&tax=2price%3D10%26tax%3D2=&는 구조적 의미가 있음
%E4%B8%AD비-ASCII → UTF-8 바이트 → 퍼센트 인코딩
🚀%F0%9F%9A%80이모지 → 4 UTF-8 바이트 → 퍼센트 인코딩

어떤 문자를 인코딩해야 하는가?

RFC 3986은 문자를 세 그룹으로 나눕니다.

비예약 문자 (절대 인코딩하지 않음)

이 66개 문자는 URL의 어느 부분에서도 있는 그대로 통과합니다.

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

알파벳, 숫자, 하이픈, 마침표, 밑줄, 틸드. 이게 전부입니다.

예약 문자 (문맥에 따라 다름)

이 문자들은 URL에서 구조적 구분자 역할을 합니다.

문자URL 구조에서의 역할
:스킴과 권한 부분을 구분 (https:)
/경로 세그먼트를 구분
?쿼리 스트링의 시작
#프래그먼트의 시작
&쿼리 파라미터를 구분
=파라미터 키와 값을 구분
@사용자 정보와 호스트를 구분
+ ! $ ' ( ) * , ; [ ]다양한 예약 역할

규칙은 간단합니다. 예약 문자가 구조적 목적으로 쓰일 때는 그대로 두고, 데이터로 쓰일 때(예: 파라미터 값 안에 들어가는 경우)는 인코딩합니다.

그 외 모든 문자 (항상 인코딩)

공백, 꺾쇠 괄호, 중괄호, 파이프, 역슬래시, 그리고 한글·아랍어·이모지 같은 비-ASCII 문자는 반드시 퍼센트 인코딩해야 합니다.

한 가지 미묘한 지점이 있습니다. RFC 3986은 공백을 %20으로 인코딩하지만, HTML 폼 전송은 +를 사용합니다. 이 충돌은 뒤에서 더 자세히 다룹니다.

URL 인코딩의 실제 동작: UTF-8 파이프라인

ASCII 문자의 경우 인코딩은 단순합니다. 16진수로 된 바이트 값을 찾아 %를 앞에 붙이기만 하면 됩니다. 공백(바이트 값 32, 16진수 20)은 %20이 됩니다.

비-ASCII 텍스트의 경우 인코딩은 세 단계를 거칩니다.

1단계 — 문자를 유니코드 코드 포인트로. 문자 é는 코드 포인트 U+00E9에 매핑됩니다. 이모지 🚀는 U+1F680에 매핑됩니다.

2단계 — 코드 포인트를 UTF-8 바이트로. UTF-8은 코드 포인트 범위에 따라 1~4바이트를 사용합니다. é(U+00E9)는 두 바이트(0xC3 0xA9)가 되고, 로켓 이모지(U+1F680)는 네 바이트(0xF0 0x9F 0x9A 0x80)가 됩니다.

3단계 — 각 바이트를 %XX 형태로. 2단계에서 나온 모든 바이트가 각자 퍼센트 인코딩된 세 글자짜리 조각으로 변환됩니다.

다음은 여러 문자 유형에 대한 전체 파이프라인입니다.

문자코드 포인트UTF-8 바이트인코딩 결과크기 배수
AU+004141A (인코딩 안 함)
공백U+002020%20
éU+00E9C3 A9%C3%A9
U+4E2DE4 B8 AD%E4%B8%AD
🚀U+1F680F0 9F 9A 80%F0%9F%9A%8012×

JavaScript에서 직접 확인해 보실 수 있습니다.

const char = '中';
const encoded = encodeURIComponent(char);
console.log(encoded); // '%E4%B8%AD'

// 바이트 추적
const bytes = new TextEncoder().encode(char);
console.log([...bytes].map(b => '%' + b.toString(16).toUpperCase()).join(''));
// '%E4%B8%AD' — 일치함

이러한 팽창은 URL 길이 제한에서 중요한 의미를 갖습니다. 한글 20자가 들어간 URL은 퍼센트 인코딩된 텍스트로 180자를 더하게 됩니다.

encodeURI vs encodeURIComponent — 올바른 함수 고르기

이 두 JavaScript 함수는 끊임없이 혼동됩니다. 생긴 건 비슷해 보여도 인코딩 대상 문자 집합이 전혀 다릅니다.

encodeURI()encodeURIComponent()
목적완전한 URL 전체를 인코딩단일 구성 요소(파라미터 키나 값) 인코딩
보존: / ? # & = @ + $ ,위 문자 모두 인코딩
인코딩공백, 비-ASCII, 일부 문장부호A-Z a-z 0-9 - _ . ~ ! ' ( ) * 외 전부
사용 시점경로에 공백이나 유니코드가 포함된 완전한 URL을 다룰 때사용자 입력으로 쿼리 파라미터를 구성할 때

프로덕션에 자주 배포되는 버그입니다.

// ❌ 버그: encodeURI는 &를 인코딩하지 않음
const search = 'Tom & Jerry';
const bad = `https://api.example.com/search?q=${encodeURI(search)}`;
// 결과: https://api.example.com/search?q=Tom%20&%20Jerry
// &가 쿼리 스트링을 분리 — 서버는 q=Tom%20 와 별도 파라미터 %20Jerry를 봄

// ✅ 수정: encodeURIComponent는 &를 %26으로 인코딩
const good = `https://api.example.com/search?q=${encodeURIComponent(search)}`;
// 결과: https://api.example.com/search?q=Tom%20%26%20Jerry

헷갈릴 때는 encodeURIComponent()를 사용하세요. 실무 URL 구성의 95%는 이쪽이 정답입니다.

URL 인코더 도구에서 두 모드를 나란히 비교해 보세요 →

언어별 URL 인코딩

JavaScript (브라우저 & Node.js)

// 파라미터 값 인코딩
const value = encodeURIComponent('price >= 100 & currency = €');
// 'price%20%3E%3D%20100%20%26%20currency%20%3D%20%E2%82%AC'

// 디코딩
const original = decodeURIComponent(value);
// 'price >= 100 & currency = €'

// 최신 방식: URLSearchParams가 인코딩을 자동 처리
const params = new URLSearchParams({ q: 'hello world', lang: '中文' });
console.log(params.toString());
// 'q=hello+world&lang=%E4%B8%AD%E6%96%87'
// 참고: URLSearchParams는 공백에 +를 사용 (폼 인코딩)

Python

from urllib.parse import quote, unquote, urlencode

# 경로 세그먼트 인코딩
quote('hello world/file name.txt', safe='/')
# 'hello%20world/file%20name.txt'

# 쿼리 파라미터 인코딩
urlencode({'q': '你好', 'page': '1'})
# 'q=%E4%BD%A0%E5%A5%BD&page=1'

# quote_plus는 공백에 +를 사용 (폼 인코딩)
from urllib.parse import quote_plus
quote_plus('hello world')  # 'hello+world'
quote('hello world')       # 'hello%20world'

Go

import "net/url"

// 쿼리 값 인코딩 (공백은 +)
url.QueryEscape("hello world & more")
// "hello+world+%26+more"

// 경로 세그먼트 인코딩 (공백은 %20)
url.PathEscape("hello world & more")
// "hello%20world%20&%20more"

// url.Values로 URL 안전하게 구성
params := url.Values{}
params.Set("q", "你好世界")
params.Set("page", "1")
fmt.Println(params.Encode())
// "page=1&q=%E4%BD%A0%E5%A5%BD%E4%B8%96%E7%95%8C"

Java

import java.net.URLEncoder;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;

// 인코딩 (공백은 + — Java는 폼 인코딩을 따름)
String encoded = URLEncoder.encode("hello world & more", StandardCharsets.UTF_8);
// "hello+world+%26+more"

// RFC 3986 준수가 필요하면 +를 %20으로 치환
String rfc3986 = encoded.replace("+", "%20");
// "hello%20world%20%26%20more"

// 디코딩
String decoded = URLDecoder.decode(encoded, StandardCharsets.UTF_8);
// "hello world & more"

Go와 Java는 기본적으로 폼 인코딩(공백을 +로)을 사용합니다. RFC 3986에 맞는 출력이 필요하면 결과를 후처리하여 +%20으로 치환하세요.

프로덕션을 망가뜨리는 다섯 가지 URL 인코딩 버그

1. 이중 인코딩 (%20 대신 %2520)

문자열을 인코딩했는데, 프레임워크가 한 번 더 인코딩합니다. %20%%25로 바뀌고, 서버는 공백이 아닌 리터럴 %20 텍스트를 보게 됩니다.

증상: URL에 %2520, %253D 또는 다른 %25xx 패턴이 포함됨.

진단: URL에서 %25% 문자가 인코딩되었다는 뜻이고, 보통 이중 인코딩을 가리킵니다.

수정: 먼저 디코딩한 뒤 한 번만 인코딩하세요. 인코딩하기 전에 입력이 이미 인코딩된 상태인지 확인하세요.

// 이중 인코딩 감지
function isDoubleEncoded(str) {
  return /%25[0-9A-Fa-f]{2}/.test(str);
}

// 안전한 인코딩: 먼저 디코딩, 그다음 인코딩
function safeEncode(str) {
  try { str = decodeURIComponent(str); } catch (e) { /* 인코딩 안 됨, 문제 없음 */ }
  return encodeURIComponent(str);
}

2. 경로 세그먼트 안의 +

공백을 +로 출력하는 라이브러리를 써서 파일명을 URL 인코딩한다고 해 봅시다. my report.pdf 파일이 my+report.pdf가 됩니다. 서버는 +를 리터럴 플러스 기호로 취급해 404를 돌려줍니다.

규칙: +가 공백을 뜻하는 곳은 쿼리 스트링(? 뒤)뿐입니다. 경로 세그먼트에서 +는 그냥 +일 뿐입니다. 경로의 공백에는 항상 %20을 사용하세요.

3. 깨진 OAuth 리다이렉트 URI

인증 URL이 다음과 같은 경우를 생각해 봅시다.

https://auth.provider.com/authorize?redirect_uri=https://myapp.com/callback?code=abc&state=xyz

OAuth 서버는 redirect_uri=https://myapp.com/callback?code=abc로 읽고, state=xyz를 별도의 최상위 파라미터로 취급합니다. 인증이 실패합니다.

수정: 리다이렉트 URI 값 전체를 인코딩하세요.

const redirectUri = 'https://myapp.com/callback?code=abc&state=xyz';
const authUrl = `https://auth.provider.com/authorize?redirect_uri=${encodeURIComponent(redirectUri)}`;
// redirect_uri=https%3A%2F%2Fmyapp.com%2Fcallback%3Fcode%3Dabc%26state%3Dxyz

4. 로그 속 깨진 비-ASCII 텍스트

서버 로그에 읽을 수 있는 한자가 아니라 %E4%BD%A0%E5%A5%BD 같은 문자열이 보입니다. URL은 올바르게 인코딩되어 있을 뿐, 로그 뷰어가 퍼센트 인코딩된 시퀀스를 디코딩하지 않는 것입니다.

수정: 로그를 디코더에 통과시키거나, URL을 URL 디코더에 붙여넣어 원본 텍스트를 확인하세요.

5. API 서명 실패

OAuth 1.0과 AWS Signature V4는 엄격한 RFC 3986 인코딩을 요구합니다. JavaScript의 encodeURIComponent()!, ', (, ), *를 인코딩하지 않습니다. 이 문자들이 서명 입력에 포함되어 있다면 서명이 일치하지 않습니다.

수정: 출력을 후처리하세요.

function rfc3986Encode(str) {
  return encodeURIComponent(str).replace(/[!'()*]/g, c =>
    '%' + c.charCodeAt(0).toString(16).toUpperCase()
  );
}

%20 vs + — 공백 인코딩의 딜레마

두 가지 표준이 한 문자를 두고 서로 다른 의견을 냅니다.

표준공백의 표현적용 범위
RFC 3986 (URI 문법)%20URL 어디에서나
application/x-www-form-urlencoded+HTML 폼 전송의 쿼리 스트링

+ 관례는 초기 웹 브라우저에서 내려온 유산입니다. <form>method="GET"으로 전송될 때 브라우저는 쿼리 스트링의 공백을 +로 인코딩합니다. HTML 스펙이 이 동작을 성문화합니다.

문제는 +가 ‘공백’을 의미하는 곳이 쿼리 스트링뿐이라는 점입니다. 경로 세그먼트에서 +는 리터럴 플러스 기호입니다. 그래서 https://example.com/my+file.pdfmy file.pdf가 아니라 my+file.pdf라는 이름의 파일을 반환하는 셈입니다.

실용 지침:

  • URL을 직접 구성하거나 경로 세그먼트를 인코딩할 때는 %20을 사용하세요. 어디에서나 동작합니다.
  • 폼 전송에서 온 쿼리 스트링을 파싱할 때 +를 받아들이세요. 프레임워크가 이미 처리해 주는 경우가 많습니다.
  • 둘을 섞어 쓰지 마세요. 구성 요소별로 하나의 관례를 정하고 일관되게 유지하세요.

URL 인코딩과 보안

URL 인코딩은 암호화가 아닙니다

퍼센트 인코딩은 완전히 가역적이고 결정론적인 변환이며, 암호학적 성질은 전혀 없습니다. 누구나 밀리초 안에 %48%65%6C%6C%6FHello로 디코딩할 수 있습니다.

민감한 데이터를 숨기기 위해 URL 인코딩을 사용하지 마세요. 요청 전체를 암호화하려면 HTTPS를 사용하세요. URL은 서버 로그, 브라우저 기록, Referer 헤더에 남으므로, 민감한 정보는 URL이 아니라 요청 본문에 담아야 합니다.

오픈 리다이렉트 공격

공격자는 단순한 유효성 검사를 우회하기 위해 인코딩된 URL을 사용합니다. %2F%2Fevil.com을 포함한 리다이렉트 파라미터는 //evil.com으로 디코딩되는데, 브라우저는 이를 공격자의 도메인을 가리키는 프로토콜 상대 URL로 해석합니다.

방어: 인코딩된 형태가 아니라 디코딩된 URL을 검증하세요. 리다이렉트 도메인에는 허용 목록을 사용하세요.

이중 인코딩 익스플로잇

WAF가 들어오는 URL에 <script> 태그가 있는지 확인합니다. 공격자가 %253Cscript%253E를 보냅니다. WAF는 퍼센트 인코딩된 텍스트를 보고 통과시킵니다. 애플리케이션이 한 번 디코딩하면 %3Cscript%3E가 되고, 두 번째 디코딩이 <script>를 만들어 필터를 우회합니다.

방어: 보안 검사를 적용하기 전에 모든 입력을 정규화(완전히 디코딩)하세요. 단일 디코딩 패스에 의존하지 마세요.

인증, 입력 유효성 검사, 보안 헤더 같은 웹 보안 기본기는 별도의 가이드에서 추가로 다뤄야 할 주제입니다.

URL 길이 제한과 인코딩이 비싸지는 순간

HTTP 스펙은 최대 URL 길이를 정하지 않지만, 스택의 각 계층이 실무적 제한을 부과합니다.

계층제한
일반적인 권장치2,000자
Chrome, Firefox약 2MB (단, 서버가 훨씬 먼저 거부)
Apache (기본값)8,190바이트
Nginx (기본값)8,192바이트
IIS16,384바이트 (쿼리 스트링)
CDN, 프록시상이 — 4,096~8,192바이트가 흔함

퍼센트 인코딩은 URL을 길게 만듭니다. 한자 한 글자는 1자에서 9자(%E4%B8%AD)로 늘어납니다. 이모지는 12자로 팽창합니다. 쿼리 스트링에 한자 200자만 있어도 퍼센트 인코딩된 텍스트로 1,800자가 됩니다.

한계에 부딪혔을 때: 데이터를 쿼리 파라미터에서 POST 요청 본문으로 옮기세요. 검색 인터페이스라면 JSON을 받는 POST 엔드포인트가 잘 맞습니다.

자주 묻는 질문

URL 인코딩이란 무엇이며 개발자에게 왜 필요한가요?

URL 인코딩(퍼센트 인코딩)은 URL에서 허용되지 않는 문자를 %XX 형식의 16진수 시퀀스로 변환합니다. URL은 66개의 비예약 ASCII 문자만 지원합니다. 공백, 앰퍼샌드, 유니코드 텍스트, 대부분의 문장부호는 인코딩하지 않으면 URL 구조를 깨뜨립니다.

encodeURI와 encodeURIComponent의 차이는 무엇인가요?

encodeURI()://, /, ?, & 같은 구조적 문자를 보존하면서 전체 URL을 인코딩합니다. encodeURIComponent()A-Z a-z 0-9 - _ . ~ ! ' ( ) *를 제외한 모든 것을 인코딩합니다. 쿼리 파라미터 값에는 encodeURIComponent()를 사용하세요. encodeURI()는 구조를 깨뜨리지 않고 공백이나 비-ASCII 문자만 고치고 싶은 완전한 URL을 다룰 때만 사용하세요.

URL에서 %20이 때때로 +로 나타나는 이유는 무엇인가요?

둘 다 공백을 나타내지만, 서로 다른 표준에서 나왔습니다. %20은 RFC 3986을 따르며 URL 어디에서나 동작합니다. +는 HTML 폼 인코딩 스펙을 따르며 쿼리 스트링에서만 동작합니다. 경로 세그먼트의 +는 리터럴 플러스 기호입니다. 헷갈릴 때는 %20을 사용하세요.

Python, JavaScript, Go, Java에서 텍스트를 URL 인코딩하려면 어떻게 하나요?

JavaScript: encodeURIComponent('hello world')hello%20world. Python: urllib.parse.quote('hello world')hello%20world. Go: url.QueryEscape("hello world")hello+world. Java: URLEncoder.encode("hello world", UTF_8)hello+world. Go와 Java는 기본적으로 폼 인코딩(공백을 +로)을 사용하므로, RFC 3986 출력이 필요하면 +%20으로 치환하세요.

URL 인코딩을 보안이나 암호화 용도로 사용할 수 있나요?

아닙니다. URL 인코딩은 키 없이 완전히 가역적입니다. 기밀성 기능이 전혀 없습니다. 민감한 데이터는 퍼센트 인코딩이 아니라 HTTPS로 보호하세요. URL은 서버 로그, 브라우저 기록, Referer 헤더에 남으므로, 민감한 데이터는 요청 본문에 담아야 합니다.

이중 인코딩은 무엇이며 어떻게 고치나요?

이중 인코딩은 이미 인코딩된 문자열이 다시 한번 인코딩될 때 발생합니다. %20%%25로 인코딩되어 %2520이 됩니다. 서버는 공백 대신 리터럴 %20 텍스트를 봅니다. 입력을 먼저 디코딩한 뒤 한 번만 인코딩하여 해결하세요. 두 자리 16진수가 뒤따르는 %25 패턴이 결정적인 단서입니다.

URL의 최대 길이는 얼마인가요?

HTTP 스펙에는 공식 최대값이 없습니다. 폭넓은 호환성을 위한 안전 상한은 2,000자입니다. Apache 기본값은 8,190바이트, Nginx는 8,192바이트입니다. 비-ASCII 문자는 퍼센트 인코딩 시 3~12배로 팽창하므로, 국제화된 URL이 제한에 더 빨리 걸립니다. 큰 페이로드에는 POST로 전환하세요.