카테고리 없음

파이썬과 함께 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 디코딩 시점

  1. URI 생성: 구성 요소별로 "데이터 vs 구분자" 판단 후 인코딩.
  2. URI 해석: 스킴별 파싱을 먼저 끝낸 뒤 필요한 부분만 디코딩.
  3. 예외: 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 모듈