Devin.KR

파이썬 웹 API 호출 자동화 - urllib.request 로 JSON 받아 저장하고 재시도 (파이썬 자동화 6단원)

개발자 조회 1

이 단원에서 배우는 것

지금까지는 내 컴퓨터에 있는 파일만 다뤘다. 실무 자동화의 절반은 다른 시스템에서 데이터를 가져오는 일이다. 환율, 날씨, 택배 조회, 사내 시스템의 재고 수치는 대부분 웹 API로 제공된다. 웹 API는 브라우저 대신 프로그램이 주소를 요청하고, 사람이 읽을 화면 대신 JSON을 돌려받는 창구다.

  • 표준 라이브러리 urllib.request만으로 API를 호출하고 JSON을 읽는다.
  • 여러 페이지로 나뉜 응답을 끝까지 따라가며 모은다.
  • 시간 제한(timeout), 서버 오류 때의 재시도, 다시 시도해도 소용없는 오류의 구분을 익힌다.
  • 인터넷 없이 연습할 수 있도록 http.server로 내 컴퓨터에 연습용 API 서버를 띄운다.

문제 상황

서점이 도서 유통사와 계약하면서 재고 조회 API를 받았다. 매일 아침 전체 도서 목록과 재고를 받아 books-20260923.json처럼 날짜별 파일로 남겨 두고 싶다. 그래야 "지난주 화요일에는 재고가 몇 권이었나"를 나중에 확인할 수 있다. 품절 도서는 바로 알 수 있게 화면에도 출력한다.

유통사 API 문서에는 이렇게 적혀 있다.

  • GET /books?page=1 — 한 번에 2권씩 돌려준다. 응답의 next에 다음 쪽 번호가 있고, 마지막 쪽이면 null이다.
  • 서버가 바쁘면 503을 돌려줄 수 있다. 잠시 뒤 다시 요청하면 된다.

실제 유통사 서버로 연습할 수는 없으니, 같은 규칙으로 동작하는 연습 서버를 직접 띄운다. 연습 서버는 127.0.0.1(내 컴퓨터 자신)에서만 요청을 받으므로 인터넷에 노출되지 않는다.

연습 서버 준비

아래 코드를 practice_server.py로 저장한다. 이 서버 코드를 한 줄씩 이해할 필요는 없다. /books는 쪽 단위로 도서 목록을 주고, /flaky는 세 번에 두 번꼴로 503을 돌려주는 불안정한 주소라는 것만 알면 된다.

"""연습용 도서 API 서버. 인터넷 없이 내 컴퓨터(127.0.0.1)에서만 돈다."""
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlparse

BOOKS = [
    {"isbn": "9791100000011", "title": "파이썬 개론", "price": 28000, "stock": 3},
    {"isbn": "9791100000028", "title": "리팩터링", "price": 33000, "stock": 0},
    {"isbn": "9791100000035", "title": "SQL 첫걸음", "price": 22000, "stock": 12},
    {"isbn": "9791100000042", "title": "네트워크 기초", "price": 30000, "stock": 5},
    {"isbn": "9791100000059", "title": "리눅스 입문", "price": 26000, "stock": 1},
]
PAGE_SIZE = 2
calls = {"flaky": 0}


class Handler(BaseHTTPRequestHandler):
    def send_json(self, status, body):
        data = json.dumps(body, ensure_ascii=False).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(data)))
        self.end_headers()
        self.wfile.write(data)

    def do_GET(self):
        url = urlparse(self.path)
        query = parse_qs(url.query)
        if url.path == "/books":
            page = int(query.get("page", ["1"])[0])
            start = (page - 1) * PAGE_SIZE
            items = BOOKS[start:start + PAGE_SIZE]
            has_next = start + PAGE_SIZE < len(BOOKS)
            self.send_json(200, {"page": page, "items": items,
                                 "next": page + 1 if has_next else None})
        elif url.path == "/flaky":
            calls["flaky"] += 1
            if calls["flaky"] % 3 != 0:
                self.send_json(503, {"error": "잠시 후 다시 시도하세요"})
            else:
                self.send_json(200, {"page": 1, "items": BOOKS[:1], "next": None})
        else:
            self.send_json(404, {"error": "없는 주소"})

    def log_message(self, fmt, *args):
        pass


if __name__ == "__main__":
    print("연습 서버 시작: http://127.0.0.1:8765  (끝내려면 Ctrl+C)")
    HTTPServer(("127.0.0.1", 8765), Handler).serve_forever()

터미널을 하나 더 열어 python3 practice_server.py로 실행해 두고, 원래 터미널에서 아래 스크립트를 실행한다. 다 끝나면 서버 터미널에서 Ctrl+C로 끈다.

완성 스크립트

fetch_books.py로 저장한다. 사용법은 python3 fetch_books.py API주소 저장폴더다.

"""도서 API 의 모든 페이지를 받아 날짜가 붙은 JSON 파일로 저장한다."""
import json
import sys
import time
import urllib.error
import urllib.request
from datetime import date
from pathlib import Path

TIMEOUT = 5
RETRIES = 3


def get_json(url):
    request = urllib.request.Request(url, headers={"User-Agent": "bookstore-bot/1.0"})
    for attempt in range(1, RETRIES + 1):
        try:
            with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
                return json.loads(response.read().decode("utf-8"))
        except urllib.error.HTTPError as e:
            if e.code < 500 or attempt == RETRIES:
                raise
            print(f"  서버 오류 {e.code}, {attempt}번째 실패. 다시 시도합니다.")
        except (urllib.error.URLError, TimeoutError) as e:
            if attempt == RETRIES:
                raise
            reason = getattr(e, "reason", e)
            print(f"  연결 실패({reason}), {attempt}번째 실패. 다시 시도합니다.")
        time.sleep(attempt)


def fetch_all(base_url):
    books = []
    page = 1
    while page is not None:
        data = get_json(f"{base_url}?page={page}")
        print(f"{page}쪽: {len(data['items'])}권")
        books.extend(data["items"])
        page = data["next"]
    return books


def save(books, out_dir):
    out_dir.mkdir(parents=True, exist_ok=True)
    path = out_dir / f"books-{date.today():%Y%m%d}.json"
    tmp = path.with_suffix(".json.tmp")
    tmp.write_text(json.dumps(books, ensure_ascii=False, indent=2), encoding="utf-8")
    tmp.replace(path)
    return path


def main():
    if len(sys.argv) != 3:
        print("사용법: python3 fetch_books.py API주소 저장폴더")
        sys.exit(2)
    base_url, out_dir = sys.argv[1], Path(sys.argv[2])
    try:
        books = fetch_all(base_url)
    except urllib.error.HTTPError as e:
        print(f"실패: HTTP {e.code} {e.reason} ({e.url})")
        sys.exit(1)
    except (urllib.error.URLError, TimeoutError) as e:
        print(f"실패: 서버에 연결할 수 없습니다 ({getattr(e, 'reason', e)})")
        sys.exit(1)
    path = save(books, out_dir)
    sold_out = [b["title"] for b in books if b["stock"] == 0]
    print(f"{len(books)}권 저장 -> {path}")
    print("품절:", ", ".join(sold_out) if sold_out else "없음")


if __name__ == "__main__":
    main()

한 줄씩 해설

get_json — 요청 한 번과 재시도

request = urllib.request.Request(url, headers={"User-Agent": "bookstore-bot/1.0"})

요청에 User-Agent 헤더로 "누가 부르는지"를 밝힌다. 헤더를 주지 않으면 파이썬은 Python-urllib/3.x라고 자신을 소개하는데, 적지 않은 서비스가 이 이름을 자동 수집 프로그램으로 보고 막는다. 우리 서비스 이름을 적어 두면 상대 쪽 운영자가 로그를 보고 연락할 수도 있다.

with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
    return json.loads(response.read().decode("utf-8"))

urlopen이 실제로 요청을 보낸다. timeout=5는 5초 안에 응답이 없으면 포기하라는 뜻이다. 이 값을 주지 않으면 기본값이 무제한이라서, 상대 서버가 연결만 받고 응답을 안 주면 스크립트가 영원히 멈춰 있다. 예약 실행으로 돌리는 스크립트가 멈춰 있으면 다음 날 실행과 겹치고, 아무도 모르는 채로 데이터가 빠진다. 네트워크 호출에는 반드시 시간 제한을 준다.

response.read()bytes를 준다. 6단원에서 본 대로 .decode("utf-8")로 문자열로 바꾸고, 7단원의 json.loads로 딕셔너리로 바꾼다.

그다음 두 개의 except가 오류를 두 부류로 나눈다.

  • HTTPError — 서버에 닿았고 서버가 오류 번호로 답했다. 500번대(서버 쪽 문제, 503 = 바쁨)는 잠시 뒤 다시 시도할 가치가 있다. 400번대(404 = 없는 주소, 401 = 인증 실패)는 요청 자체가 틀렸으므로 백 번 다시 보내도 결과가 같다. 그래서 e.code < 500이면 바로 다시 던진다.
  • URLError, TimeoutError — 서버에 닿지도 못했다. 연결 거부, 주소 해석 실패, 시간 초과다. 일시적인 네트워크 문제일 수 있어 다시 시도한다.

HTTPErrorURLError의 자식 클래스다. 그래서 HTTPError먼저 적어야 한다. 순서를 바꾸면 모든 HTTP 오류가 URLError 쪽으로 가서 404도 재시도한다. 10단원에서 본 "구체적인 예외부터 잡는다" 규칙이다.

마지막 시도에서도 실패하면 raise로 예외를 그대로 올려 보낸다. 재시도 사이에는 time.sleep(attempt)로 1초, 2초씩 점점 길게 쉰다. 바쁜 서버에 쉬지 않고 연달아 요청하면 서버를 더 바쁘게 만들 뿐이다.

500번대 응답과 연결 실패는 최대 3번까지 시도하며 사이에 1초, 2초를 쉰다(시도 번호만큼, 지수 증가가 아니다). 404 같은 400번대는 다시 해도 같으므로 바로 실패한다.

그림 · get_json 의 재시도 타임라인 — 500번대 응답과 연결 실패는 최대 3번까지 시도하며 사이에 1초, 2초를 쉰다(시도 번호만큼, 지수 증가가 아니다). 404 같은 400번대는 다시 해도 같으므로 바로 실패한다.

fetch_allnext가 없을 때까지 쪽을 넘긴다

pageNone이 될 때까지 반복한다. API 응답의 next를 그대로 다음 쪽 번호로 쓰므로, 전체 쪽 수를 미리 알 필요가 없다. 4단원의 while을 "언제 끝날지 모르는 반복"에 쓰는 전형적인 모양이다. 받은 목록은 books.extend(...)로 이어 붙인다. append를 쓰면 리스트 안에 리스트가 들어간다.

save — 날짜별 파일, 그리고 임시 파일 교체

파일 이름에 date.today():%Y%m%d를 넣어 하루에 한 파일씩 쌓는다. 같은 날 다시 실행하면 그날 파일을 새 내용으로 바꾼다. 쓰는 방식은 자동화 5단원의 백업과 같은 원리다. 먼저 .json.tmp에 다 쓰고, tmp.replace(path)로 한 번에 바꿔 끼운다. replace는 대상이 있어도 윈도우에서까지 덮어쓰는 이름 바꾸기다. 쓰는 도중에 멈춰도 기존 파일은 멀쩡하다. JSON은 7단원에서 강조한 대로 ensure_ascii=False로 한글을 그대로 남긴다.

연습 서버는 한 쪽에 2권씩 주고 마지막 쪽의 next 를 null 로 보낸다. 세 쪽 5권을 모은 뒤 .json.tmp 에 쓰고 replace 로 날짜 파일과 바꿔 끼운다.

그림 · next 가 없을 때까지 쪽을 넘기고 임시 파일로 저장한다 — 연습 서버는 한 쪽에 2권씩 주고 마지막 쪽의 next 를 null 로 보낸다. 세 쪽 5권을 모은 뒤 .json.tmp 에 쓰고 replace 로 날짜 파일과 바꿔 끼운다.

main — 실패를 사람이 읽을 수 있는 한 줄로

예외를 잡지 않으면 수십 줄짜리 추적 정보가 출력된다. 개발 중에는 유용하지만 매일 아침 결과를 보는 사람에게는 소음이다. main에서 예상 가능한 실패만 잡아 한 줄로 요약하고, 종료 코드 1로 끝낸다. 예상하지 못한 오류(예를 들어 응답에 items가 없어서 나는 KeyError)는 일부러 잡지 않았다. 그런 오류는 추적 정보가 있어야 고칠 수 있다.

실행 결과

연습 서버를 켜 둔 상태에서 실행한다. 저장 파일 이름의 날짜는 실행한 날이다.

$ python3 fetch_books.py http://127.0.0.1:8765/books data
1쪽: 2권
2쪽: 2권
3쪽: 1권
5권 저장 -> data/books-20260923.json
품절: 리팩터링
$ cat data/books-20260923.json
[
  {
    "isbn": "9791100000011",
    "title": "파이썬 개론",
    "price": 28000,
    "stock": 3
  },
  {
    "isbn": "9791100000028",
    "title": "리팩터링",
    "price": 33000,
    "stock": 0
  },
  {
    "isbn": "9791100000035",
    "title": "SQL 첫걸음",
    "price": 22000,
    "stock": 12
  },
  {
    "isbn": "9791100000042",
    "title": "네트워크 기초",
    "price": 30000,
    "stock": 5
  },
  {
    "isbn": "9791100000059",
    "title": "리눅스 입문",
    "price": 26000,
    "stock": 1
  }
]

세 쪽을 따라가며 다섯 권을 모두 받았고, 한글이 그대로 저장됐다. 이번에는 불안정한 주소를 불러 본다.

$ python3 fetch_books.py http://127.0.0.1:8765/flaky data
  서버 오류 503, 1번째 실패. 다시 시도합니다.
  서버 오류 503, 2번째 실패. 다시 시도합니다.
1쪽: 1권
1권 저장 -> data/books-20260923.json
품절: 없음

503을 두 번 받고 세 번째에 성공했다. 사람이 새로 고침을 누르듯 스크립트가 알아서 기다렸다가 다시 시도했다. 없는 주소를 부르면 다시 시도하지 않고 바로 끝난다.

$ python3 fetch_books.py http://127.0.0.1:8765/bookz data
실패: HTTP 404 Not Found (http://127.0.0.1:8765/bookz?page=1)

마지막으로 서버를 끄고 실행한다.

$ python3 fetch_books.py http://127.0.0.1:8765/books data
  연결 실패([Errno 61] Connection refused), 1번째 실패. 다시 시도합니다.
  연결 실패([Errno 61] Connection refused), 2번째 실패. 다시 시도합니다.
실패: 서버에 연결할 수 없습니다 ([Errno 61] Connection refused)

연결 자체가 거부되자 두 번 더 시도한 뒤 한 줄로 실패를 알리고 종료 코드 1로 끝났다. 오류 번호는 운영체제마다 다르게 보일 수 있다(맥은 Errno 61, 리눅스는 Errno 111).

표 · 오류 종류별 처리와 이 단원의 실제 실행 결과

오류get_json 의 처리실제 출력종료 코드
HTTP 500번대 (/flaky 의 503)최대 3번 시도, 사이에 1초·2초 대기503 두 번 뒤 세 번째에 1쪽: 1권0
HTTP 400번대 (/bookz 의 404)다시 시도하지 않고 바로 raise실패: HTTP 404 Not Found (http://127.0.0.1:8765/bookz?page=1)1
연결 실패 (서버를 끈 뒤)최대 3번 시도, 사이에 1초·2초 대기실패: 서버에 연결할 수 없습니다 ([Errno 61] Connection refused)1
시간 초과 (5초)연결 실패와 같이 재시도실행하지 않음 (연습 서버는 늦게 답하지 않는다)-

실무에서 자주 틀리는 것

1. 주소에 한글이나 공백을 그대로 넣는다

검색어처럼 사용자가 입력한 값을 주소에 넣을 때 문자열을 그대로 이어 붙이면 안 된다.

from urllib.parse import quote, urlencode

base = "http://127.0.0.1:8765/books"
print("직접 이어 붙임:", base + "?q=파이썬 개론&page=1")
print("urlencode     :", base + "?" + urlencode({"q": "파이썬 개론", "page": 1}))
print("quote         :", quote("리팩터링 2판/개정"))
$ python3 demo_urlencode.py
직접 이어 붙임: http://127.0.0.1:8765/books?q=파이썬 개론&page=1
urlencode     : http://127.0.0.1:8765/books?q=%ED%8C%8C%EC%9D%B4%EC%8D%AC+%EA%B0%9C%EB%A1%A0&page=1
quote         : %EB%A6%AC%ED%8C%A9%ED%84%B0%EB%A7%81%202%ED%8C%90/%EA%B0%9C%EC%A0%95

URL에는 공백과 한글을 그대로 쓸 수 없다. 첫 줄 같은 주소를 urlopen에 넘기면 요청을 보내기도 전에 http.client.InvalidURL: URL can't contain control characters 오류가 난다. 값에 &=가 들어 있으면 오류 없이 다른 요청이 된다. 질의 문자열은 urlencode로, 경로 조각은 quote 만든다. 딕셔너리로 넘기면 순서와 구분자도 알아서 붙여 준다.

2. API 키를 코드에 적는다

실제 유통사 API는 대개 인증 키를 요구한다. 키를 fetch_books.py 안에 문자열로 적으면, 스크립트를 동료에게 보내거나 git에 올리는 순간 키가 새어 나간다. 키는 환경 변수로 넘기고(os.environ["BOOK_API_KEY"]), 없으면 바로 멈추게 한다. 키가 들어간 설정 파일은 .gitignore에 넣는다. 키가 새어 나갔다면 코드를 지우는 것으로는 부족하고, 키 자체를 새로 발급받아야 한다.

3. 너무 빨리, 너무 많이 요청한다

1000쪽짜리 목록을 쉬지 않고 받으면 상대 서버는 공격으로 볼 수 있다. API 문서의 호출 제한(예: 분당 60회)을 확인하고, 필요하면 쪽 사이에 time.sleep(1)을 넣는다. 429(Too Many Requests)를 받았다면 속도를 줄이라는 신호다. 공개 웹 페이지를 긁어 오는 경우라면 그 사이트의 이용약관과 robots.txt부터 확인한다.

4. requests가 없어서 못 한다고 생각한다

파이썬 웹 호출 예제의 대부분은 외부 패키지 requests를 쓴다. requests.get(url, timeout=5).json()처럼 더 짧게 쓸 수 있어서 많이 쓰이지만, 회사 PC나 서버에 패키지를 설치할 수 없는 환경도 흔하다. 이 단원의 스크립트는 표준 라이브러리만 쓰므로 파이썬만 있으면 어디서든 돈다. 설치가 가능하고 요청이 복잡해지면(쿠키, 세션, 파일 업로드) 14단원의 가상환경을 만들고 requests로 옮기는 것을 고려한다. 어느 쪽이든 timeout과 재시도 원칙은 똑같다.

표 · 표준 라이브러리 urllib 와 requests 대응 (requests 쪽은 비교용, 이 책에서 실행하지 않음)

할 일이 단원 (urllib)requests
요청과 시간 제한urlopen(request, timeout=5)requests.get(url, timeout=5)
요청 헤더Request(url, headers={...})headers= 인자
쿼리 문자열urllib.parse.urlencode(...)params= 인자
JSON 본문json.loads(response.read().decode("utf-8"))response.json()
HTTP 오류 번호HTTPError 예외의 e.coderaise_for_status() 또는 status_code
연결 실패·시간 초과URLError, TimeoutErrorrequests.ConnectionError, requests.Timeout

스스로 확인하기

  1. except 두 개의 순서를 바꿔 URLError를 먼저 적으면 /bookz(404) 호출 결과가 어떻게 달라지는가?
  2. 어제 파일(books-어제날짜.json)과 오늘 받은 목록을 비교해 재고가 바뀐 도서만 출력하려면 어떻게 하겠는가? ISBN을 어떻게 활용하는가?
  3. 유통사가 403(권한 없음)을 돌려주기 시작했다. 지금 코드는 재시도하는가? 재시도하는 것이 맞는가?

정답

  1. HTTPErrorURLError의 한 종류라서 먼저 적힌 URLError 쪽에 잡힌다. 그래서 404인데도 연결 실패(Not Found), 1번째 실패. 다시 시도합니다.라는 틀린 안내가 두 번 찍히고, 세 번 요청하며 3초를 쉰 뒤에야 예외가 올라간다. main의 순서는 그대로라서 마지막 줄은 실패: HTTP 404로 맞게 나온다. 결과는 같아 보여도 필요 없는 재시도로 상대 서버에 요청을 더 보내고, 중간 안내가 원인을 네트워크 문제로 잘못 가리킨다.
  2. 어제 파일을 json.load로 읽어 {b["isbn"]: b["stock"] for b in 어제목록}처럼 ISBN을 키로 하는 딕셔너리를 만들고(딕셔너리 컴프리헨션이 낯설면 for 문으로 만들어도 된다), 오늘 목록을 돌면서 old.get(b["isbn"])과 오늘 재고가 다르면 출력한다. 제목은 바뀔 수 있고 중복될 수도 있으므로, 변하지 않는 고유 번호인 ISBN으로 짝을 짓는다. 어제 없던 ISBN은 신간, 오늘 없는 ISBN은 절판 후보다.
  3. 재시도하지 않는다. 403은 400번대라서 e.code < 500 조건에 걸려 바로 올라간다. 권한 문제는 시간이 지나도 저절로 풀리지 않으니 이것이 맞다. 대신 사람이 알아채야 하므로, 자동화 7단원에서 볼 로그와 종료 코드로 실패를 확실히 남긴다.

마지막 단원에서는 지금까지 만든 스크립트를 도구로 다듬는다. argparse로 옵션과 도움말을 붙이고, 로그와 종료 코드를 남기고, 매일 정해진 시각에 저절로 실행되도록 예약한다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.