[파이썬 100강] 96강. urllib로 HTTP 요청과 URL 처리를 표준 라이브러리로 해결하기
외부 API를 붙이거나 웹 리소스를 가져올 때 가장 먼저 떠오르는 건 보통 requests입니다. 하지만 모든 환경에서 외부 패키지를 쉽게 추가할 수 있는 건 아닙니다. 사내 배포 제약, 서버 이미지 최소화 정책, 에어갭(폐쇄망) 환경처럼 표준 라이브러리만 써야 하는 상황은 생각보다 자주 만납니다. 이때 urllib를 제대로 다룰 수 있으면 "패키지 설치가 안 돼서 막히는" 순간을 꽤 많이 줄일 수 있습니다.
이번 강의는 서론을 길게 끌지 않고 바로 실무 관점으로 갑니다. 목표는 단순합니다. urllib로 URL 조립, 쿼리 인코딩, GET/POST 요청, 타임아웃, 예외 처리, JSON 파싱까지 운영 가능한 최소 패턴을 손에 익히는 것입니다.
핵심 개념
urllib는 하나의 모듈이 아니라 역할이 나뉜 묶음입니다. 초보가 여기서 가장 많이 헷갈립니다. "왜 함수가 여기엔 없지?" 같은 상황이 반복되기 때문입니다. 핵심만 먼저 정리하면 아래 네 가지입니다.
urllib.parse: URL 파싱/조립/쿼리스트링 인코딩urllib.request: HTTP 요청 전송, 헤더/메서드/바디 설정urllib.error: 요청 실패 시 예외(HTTPError,URLError) 처리urllib.robotparser: robots.txt 해석(크롤링 정책 체크에 활용)
실무에서 중요한 포인트는 문법 그 자체보다 경계 조건입니다. 예를 들어 URL에 한글/공백이 섞이거나, 쿼리 파라미터에 리스트 값이 들어오거나, 서버가 4xx/5xx를 반환하거나, 네트워크가 순간적으로 끊기는 경우가 항상 발생합니다. urllib는 이런 상황을 자동으로 "마법처럼" 해결해 주지 않습니다. 대신 개발자가 흐름을 명시적으로 설계할 수 있게 해줍니다. 즉, 조금 더 장황해 보일 수는 있지만 그만큼 동작이 투명합니다.
또 한 가지, urllib.request.urlopen()의 반환값은 바이트 스트림입니다. 그래서 read() 결과를 바로 문자열처럼 쓰면 깨집니다. 바이트 → 디코딩(UTF-8 등) → 구조 파싱(JSON) 순서를 의식적으로 밟아야 합니다. 이 순서가 몸에 붙으면 HTTP 처리에서 발생하는 절반 이상의 혼란을 줄일 수 있습니다.
기본 사용
예제 1) URL 조립과 쿼리스트링 인코딩
>>> from urllib.parse import urlencode, urlunparse
>>> params = {
... "query": "파이썬 urllib",
... "page": 1,
... "tags": ["python", "stdlib"],
... }
>>> qs = urlencode(params, doseq=True)
>>> qs
'query=%ED%8C%8C%EC%9D%B4%EC%8D%AC+urllib&page=1&tags=python&tags=stdlib'
>>> url = urlunparse(("https", "api.example.com", "/search", "", qs, ""))
>>> url
'https://api.example.com/search?query=%ED%8C%8C%EC%9D%B4%EC%8D%AC+urllib&page=1&tags=python&tags=stdlib'
해설:
urlencode(..., doseq=True)를 주면 리스트가tags=a&tags=b형태로 풀립니다.- 문자열 붙이기로 URL을 만들면 인코딩 누락/
?·&오타가 자주 납니다. - 한글/공백은 반드시 인코딩된 형태로 전송되어야 서버가 안정적으로 해석합니다.
예제 2) GET 요청 + 타임아웃 + JSON 파싱
>>> import json
>>> from urllib.request import Request, urlopen
>>> req = Request(
... "https://httpbin.org/get?topic=urllib",
... headers={"User-Agent": "python100-lesson96/1.0"},
... method="GET",
... )
>>> with urlopen(req, timeout=5) as res:
... status = res.status
... body = res.read().decode("utf-8")
...
>>> status
200
>>> data = json.loads(body)
>>> data["args"]["topic"]
'urllib'
해설:
- 요청 객체(
Request)를 쓰면 URL/헤더/메서드를 명시적으로 관리할 수 있습니다. timeout을 습관처럼 넣으세요. 없으면 네트워크 지연 때 작업이 오래 멈출 수 있습니다.res.read()는 bytes라서decode("utf-8")후json.loads()로 파싱해야 합니다.
예제 3) POST 요청
>>> import json
>>> from urllib.request import Request, urlopen
>>> payload = {"name": "gunwoo", "role": "admin"}
>>> raw = json.dumps(payload).encode("utf-8")
>>> req = Request(
... "https://httpbin.org/post",
... data=raw,
... headers={"Content-Type": "application/json", "User-Agent": "python100-lesson96/1.0"},
... method="POST",
... )
>>> with urlopen(req, timeout=5) as res:
... out = json.loads(res.read().decode("utf-8"))
...
>>> out["json"]["name"]
'gunwoo'
해설:
data에 바이트를 넣으면 POST로 전송됩니다.- JSON 전송 시
Content-Type: application/json헤더를 함께 보내는 습관이 중요합니다. - API 서버마다 스키마 검증이 엄격하므로, 전송 전 payload 구조를 검증하는 것이 안전합니다.
예제 4) URL 파싱으로 입력 URL 검증
>>> from urllib.parse import urlparse
>>> u = urlparse("https://example.com:443/api/v1/users?id=7")
>>> (u.scheme, u.netloc, u.path, u.query)
('https', 'example.com:443', '/api/v1/users', 'id=7')
>>> u.scheme in {"http", "https"}
True
해설:
- 입력 URL이 신뢰되지 않는 경우
scheme,netloc를 먼저 검사하세요. - 잘못된 스킴(
file://,javascript:등)을 차단하면 보안 사고를 예방할 수 있습니다.
자주 하는 실수
실수 1) 쿼리스트링을 문자열로 수동 연결
>>> base = "https://api.example.com/search"
>>> keyword = "파이썬 urllib"
>>> bad_url = f"{base}?q={keyword}&page=1"
>>> bad_url
'https://api.example.com/search?q=파이썬 urllib&page=1'
원인:
- 공백/한글/특수문자를 인코딩하지 않아서 서버/프록시 구간에서 해석이 흔들립니다.
해결:
>>> from urllib.parse import urlencode
>>> good_url = f"{base}?" + urlencode({"q": keyword, "page": 1})
>>> good_url
'https://api.example.com/search?q=%ED%8C%8C%EC%9D%B4%EC%8D%AC+urllib&page=1'
실무 팁:
- URL은 "문자열"이 아니라 "구조화된 데이터"라고 생각하세요.
- 조립은
urlencode/urlunparse에 맡기고, 사람은 값 의미만 관리하는 편이 안전합니다.
실수 2) 타임아웃 없이 요청 보내기
>>> from urllib.request import urlopen
>>> # 안 좋은 예: timeout 생략
>>> # with urlopen("https://example.com") as res:
>>> # print(res.status)
원인:
- 네트워크 이슈가 생기면 작업 전체가 장시간 블로킹되어 배치/크론이 밀립니다.
해결:
>>> from urllib.request import Request, urlopen
>>> req = Request("https://httpbin.org/get", method="GET")
>>> with urlopen(req, timeout=5) as res:
... res.status
200
실무 팁:
- "평소엔 빠르다"는 이유로 타임아웃을 생략하면 장애 때 복구가 느려집니다.
- 서비스 성격에 맞춰 2~10초 내에서 기준값을 정하고 팀 규칙으로 고정하세요.
실수 3) 예외를 한 덩어리로 처리해서 원인 분리 실패
>>> from urllib.request import urlopen
>>> try:
... urlopen("https://httpbin.org/status/404", timeout=5)
... except Exception as e:
... print("실패", e)
...
실패 HTTP Error 404: Not Found
원인:
Exception하나로 잡으면 HTTP 상태코드 문제인지 DNS/네트워크 문제인지 구분이 어렵습니다.
해결:
>>> from urllib.request import urlopen
>>> from urllib.error import HTTPError, URLError
>>> try:
... urlopen("https://httpbin.org/status/404", timeout=5)
... except HTTPError as e:
... print("HTTP 실패", e.code)
... except URLError as e:
... print("네트워크 실패", e.reason)
...
HTTP 실패 404
실무 팁:
HTTPError는 서버가 응답은 했지만 상태가 실패인 경우입니다.URLError는 연결 자체 실패(DNS, 라우팅, TLS 등) 가능성이 큽니다.- 알림/재시도 정책을 다르게 가져가야 운영 효율이 올라갑니다.
실무 패턴
운영 코드에서는 "요청 한 번"보다 "요청 함수 하나"가 중요합니다. 프로젝트가 커질수록 호출 지점이 늘어나기 때문에, 공통 래퍼를 만들어 정책을 중앙에서 통제하는 편이 좋습니다.
>>> import json
>>> from urllib.request import Request, urlopen
>>> from urllib.error import HTTPError, URLError
>>>
>>> def http_json(method, url, *, query=None, payload=None, headers=None, timeout=5):
... from urllib.parse import urlencode
... if query:
... sep = '&' if '?' in url else '?'
... url = f"{url}{sep}{urlencode(query, doseq=True)}"
...
... req_headers = {"User-Agent": "python100-lesson96/1.0"}
... if headers:
... req_headers.update(headers)
...
... data = None
... if payload is not None:
... data = json.dumps(payload).encode("utf-8")
... req_headers.setdefault("Content-Type", "application/json")
...
... req = Request(url, data=data, headers=req_headers, method=method.upper())
... try:
... with urlopen(req, timeout=timeout) as res:
... text = res.read().decode("utf-8")
... return {"ok": True, "status": res.status, "data": json.loads(text)}
... except HTTPError as e:
... return {"ok": False, "kind": "http", "status": e.code, "reason": str(e)}
... except URLError as e:
... return {"ok": False, "kind": "network", "reason": str(e.reason)}
...
>>> result = http_json("GET", "https://httpbin.org/get", query={"q": "urllib"})
>>> result["ok"], result["status"]
(True, 200)
이 패턴의 장점은 명확합니다.
- 입력 검증/인코딩/헤더 규칙이 한곳에 모입니다.
- 예외 분류가 표준화되어 알림 로직과 대시보드 연결이 쉬워집니다.
- 호출부에서는 비즈니스 로직에 집중할 수 있습니다.
여기에 한 가지를 더하면 좋습니다. 바로 재시도 정책입니다. 다만 무조건 재시도하면 안 됩니다. 400, 401, 403 같은 클라이언트 오류는 보통 재시도로 해결되지 않습니다. 반대로 502, 503, 504처럼 일시적 장애 가능성이 있는 상태는 제한된 횟수 재시도가 유효합니다. 즉, 재시도는 "많이"가 아니라 "정확히"가 중요합니다.
오늘의 결론
한 줄 요약: urllib는 투박해 보여도, URL/HTTP 처리의 본질(인코딩·타임아웃·예외 분류)을 정확히 익히기에 가장 좋은 표준 도구입니다.
기억할 것:
- URL 조립은 문자열 붙이기 대신
urllib.parse로 구조적으로 처리합니다. - HTTP 요청에는 타임아웃과
User-Agent를 기본값처럼 넣습니다. - 실패는
HTTPError와URLError를 분리해 다뤄야 운영 판단이 빨라집니다.
연습문제
build_search_url(base, keyword, tags)함수를 작성해tags리스트를doseq=True로 인코딩해 보세요.fetch_json(url, timeout=3)함수를 작성해 성공 시 dict를, 실패 시{"ok": False, "kind": ...}형태를 반환해 보세요.- 동일 API를 5번 호출해 상태코드별 횟수를 집계하고, 실패율(%)을 계산해 출력해 보세요.
이전 강의 정답
- 트랜잭션으로 원자성 보장하기
>>> import sqlite3
>>> conn = sqlite3.connect(":memory:")
>>> cur = conn.cursor()
>>> cur.execute("CREATE TABLE account(id INTEGER PRIMARY KEY, balance INTEGER)")
<sqlite3.Cursor object at ...>
>>> cur.executemany("INSERT INTO account(balance) VALUES(?)", [(100,), (50,)])
<sqlite3.Cursor object at ...>
>>> conn.commit()
>>> with conn:
... conn.execute("UPDATE account SET balance = balance - 30 WHERE id = 1")
... conn.execute("UPDATE account SET balance = balance + 30 WHERE id = 2")
...
>>> list(conn.execute("SELECT id, balance FROM account ORDER BY id"))
[(1, 70), (2, 80)]
- 인덱스로 조회 성능 개선하기
>>> conn.execute("CREATE TABLE log(id INTEGER PRIMARY KEY, level TEXT, message TEXT)")
<sqlite3.Cursor object at ...>
>>> conn.executemany(
... "INSERT INTO log(level, message) VALUES(?, ?)",
... [("INFO", "a"), ("ERROR", "b"), ("INFO", "c"), ("ERROR", "d")],
... )
<sqlite3.Cursor object at ...>
>>> conn.execute("CREATE INDEX idx_log_level ON log(level)")
<sqlite3.Cursor object at ...>
>>> list(conn.execute("SELECT COUNT(*) FROM log WHERE level='ERROR'"))[0][0]
2
- 파라미터 바인딩으로 SQL 인젝션 방지하기
>>> user_input = "ERROR"
>>> list(conn.execute("SELECT id, message FROM log WHERE level = ?", (user_input,)))
[(2, 'b'), (4, 'd')]
실습 환경/재현 정보
- 실행 환경:
condaenvpython100(Python 3.11.14) - 가정한 OS: macOS/Linux 공통
- 네트워크: 예제 요청은
https://httpbin.org기준 - 재현 순서:
urllib.parse예제로 URL/쿼리 인코딩 확인- GET/POST 요청에서
decode -> json.loads순서 확인 - 404/네트워크 오류를 각각 발생시켜
HTTPError/URLError분기 확인
- 점검 포인트:
- 코드블록 출력이 문서 설명과 일치하는지
- 타임아웃 값이 모든 요청 경로에 누락 없이 들어갔는지
- 실패 리턴 구조(
ok/kind/status/reason)가 일관적인지