카테고리 없음
파이썬과 함께 URL 인코딩과 퍼센트 인코딩 제대로 이해하기
거위는 배고프다
2025. 5. 10. 19:37
TL;DR
URL은 본질적으로 US‑ASCII 문자만 허용한다. 이 한계 때문에 등장한 것이 퍼센트 인코딩(percent‑encoding)이고, 이를 사용해 "안전하지 않은" 문자를 %XX 형태로 표현한다. 그러나 대소문자를 언제 무시하고 언제 구분해야 하는지는 계층마다 다르다—이 글은 바로 그 경계를 깔끔하게 정리한다.
1. 왜 URL 인코딩이 필요한가?
문자 집합 제한 | URL은 원칙적으로 US‑ASCII(7‑bit)만 허용 | https://example.com/테스트 → 깨짐 |
예약 문자 | :/?#[]@ 등은 문법 구분자이므로, 데이터로 쓰면 충돌 | name=Tom&Jerry → &가 파라미터 분리자로 해석 |
공백·한글·이모지 | 파싱 오류·깨짐 예방 | Hello World → Hello%20World 또는 Hello+World |
해결책 → 퍼센트 인코딩
문자 → 바이트(UTF‑8 등) → 16진수 → "%HH" 패턴으로 안전하게 표현한다.
2. 퍼센트 인코딩 기초
pct-encoded = "%" HEXDIG HEXDIG ; RFC 3986 §2.1
- %20 → 공백(ASCII 0x20)
- 대문자 A–F = 소문자 a–f (값만 중요)
- 일관성을 위해 대문자 16진수를 쓰는 것이 관례
2.1 인코딩 흐름
flowchart LR
문자 -->|UTF-8| 바이트 -->|16진수| 퍼센트("%HH") --> URL
2.2 디코딩 시점
- URI 생성: 구성 요소별로 "데이터 vs 구분자" 판단 후 인코딩.
- URI 해석: 스킴별 파싱을 먼저 끝낸 뒤 필요한 부분만 디코딩.
- 예외: Unreserved 문자의 %HH는 언제 디코딩해도 안전.
3. Reserved vs Unreserved
Reserved | :/?#[]@ ! $ & ' ( ) * + , ; = | URI 문법 구분자. 데이터로 쓰려면 인코딩 필수 |
Unreserved | A‑Z a‑z 0‑9 -._~ | 퍼센트 인코딩해도 의미는 동일. 정규화 시 디코딩 권장 |
범주 문자 특징
교훈: "슬래시(/)는 reserved, 틸드(~)는 unreserved"—%2F(슬래시)를 너무 일찍 디코딩하면 URL 경로 구조가 깨져 경로 탈출(path traversal)·오픈 리다이렉트·캐시 우회 같은 보안 문제가 생길 수 있다. 반면 %7E(틸드)는 비예약 문자이므로 언제 디코딩해도 의미가 변하지 않는다.
4. 대소문자 & 동등성 3계층
① 퍼센트 인코딩 내부 | %2F vs %2f | 무시 | 16진수는 값 표현이므로 동일(0x2F) |
② 스킴·호스트 | HTTP, Example.com | 무시 (파서가 소문자화) | RFC 3986 및 DNS 규칙 |
③ 경로·쿼리 등 데이터 | /A vs /a | 스킴/서버 의존 | Linux는 구분, Windows IIS는 미구분 등 |
from urllib.parse import urlparse
u = urlparse("<HTTP://Example.COM/%2F>")
print(u.scheme) # 'http'
print(u.hostname) # 'example.com'
print(u.netloc) # 'Example.COM' (원본 보존)
netloc vs hostname
netloc | ParseResult.netloc | 스킴 뒤 // 이후 원본 문자열 전체. 사용자 정보(user:pass@), 호스트명, 포트, 대소문자 그대로 보존. |
hostname | ParseResult.hostname | netloc에서 사용자 정보·포트·대괄호(IPv6)를 제거하고 소문자화한 호스트 이름만 반환. DNS 조회·쿠키 도메인 비교 등에서 사용. |
port | ParseResult.port | netloc에 명시된 포트를 정수로 반환 (None이면 기본 포트). |
TIP: 보안 로그나 HSTS 정책처럼 원본 표기가 필요하면 netloc. 실제 연결·도메인 비교 로직엔 hostname를 쓰자.
5. Python urllib.parse 실전
5.1 quote / quote_plus
quote() | 경로(Path) 인코딩 | %20 | 그대로 유지 | /images/바다.jpg → /images/%EB%B0%94%EB%8B%A4.jpg |
quote_plus() | 쿼리(Query) 인코딩 | + | %2F 로 인코딩 | q=a b/c → q=a+b%2Fc |
- 왜 분리할까?
- 경로에서는 /가 디렉터리 구분자라 인코딩하면 안 됨.
- 쿼리에서는 /도 데이터일 뿐이므로 인코딩해야 파싱이 안전.
- 공백을 +로 변환하는 것은 application/x-www-form-urlencoded 규칙(HTML 폼)과 호환되기 위함.
from urllib.parse import quote, quote_plus
print(quote("/흰 고양이.png")) # '/%ED%9D%B0%20%EA%B3%A0%EC%96%91%EC%9D%B4.png'
print(quote_plus("흰 고양이.png")) # '%ED%9D%B0+%EA%B3%A0%EC%96%91%EC%9D%B4.png'
5.2 urlencode urlencode
from urllib.parse import urlencode
params = {"name": "Tom&Jerry", "q": "가나다"}
print(urlencode(params))
# name=Tom%26Jerry&q=%EA%B0%80%EB%82%98%EB%8B%A4
5.3 unquote / 안전 디코딩
from urllib.parse import unquote
print(unquote("%EA%B0%80")) # '가'
print(unquote("John%2520Doe")) # 'John%20Doe' (한 번만 디코딩)
6. 흔한 실수 & 방지 팁
이미 인코딩된 문자열 재인코딩 | %25 → %2525 오류 | 중복 인코딩 방지 로직 |
urlparse("example.com") | netloc 누락 | 항상 // 붙이기 or 스킴 기본값 설정 |
urljoin(base, user_input) 직접 사용 | 오픈 리다이렉트 취약점 | urlsplit → 검증 → urlunsplit |
quote_plus로 경로 인코딩 | +가 공백으로 바뀜 | 경로는 quote 사용 |
7. Cheat Sheet
✔️ 경로 인코딩: quote(path, safe="/")
✔️ 쿼리 인코딩: urlencode(dict, doseq=True)
✔️ 퍼센트 디코딩: unquote(s), unquote_plus(s)
✔️ "~" 는 언제 디코딩해도 안전(비예약)
❌ "%" → 데이터로 쓰려면 "%25" 로 인코딩
8. 참고 문헌
- RFC 3986 – Uniform Resource Identifier (URI): Generic Syntax
- Python docs – urllib.parse 모듈