매번 전부 다시 긁지 않으려면: ETag와 304로 크롤러 재방문 주기 정하기

매번 전부 다시

새벽마다 돌려둔 크롤러가 어제와 똑같은 페이지를 처음부터 끝까지 다시 내려받고 있는 걸 로그에서 발견하면 마음이 무거워집니다. 매번 전부 다시 긁지 않으려면 서버가 보내주는 ETag와 Last-Modified 값을 저장해두고, 다음 요청 때 If-None-Match·If-Modified-Since 헤더에 같이 담아 보내는 조건부 요청 방식을 쓰면 됩니다. 서버가 내용이 바뀌지 않았다고 판단하면 본문 없이 304 Not Modified만 돌려주기 때문에, 대상 페이지 수가 많아질수록 네트워크·저장 비용이 눈에 띄게 줄어듭니다.

크롤러가 매번 전부 다시 긁게 되는 이유

requests.get(url)처럼 가장 기본적인 방식으로 작성한 크롤러는 요청을 보낼 때마다 서버에 “이 페이지를 통째로 보내달라”고만 말합니다. 서버 입장에서는 이전에 같은 클라이언트에게 같은 내용을 보낸 적이 있는지 알 방법이 없으니, 매번 200 OK와 전체 본문을 돌려줄 수밖에 없습니다.

문제는 이 구조가 페이지 1개일 때는 티가 안 나지만, 수백·수천 개 URL을 매일 도는 배치 작업에서는 그대로 비용으로 쌓인다는 점입니다. 바뀌지 않은 공지사항 페이지, 몇 달째 그대로인 상품 상세 페이지까지 전부 다시 내려받으면서 대역폭과 파싱 시간을 그대로 태우게 됩니다. HTTP 프로토콜 자체는 이 비효율을 줄이려고 캐시 검증용 헤더를 이미 1999년 HTTP/1.1 규격 때부터 제공하고 있는데, 직접 구현하지 않으면 쓸 일이 없습니다.

ETag와 Last-Modified가 실제로 하는 일

서버가 내려주는 ETag는 응답 본문의 지문 같은 값이고, 이 값을 이용해 변경 여부를 확인하는 조건부 요청 방식은 RFC 7232 문서에 규격으로 정리돼 있습니다. 클라이언트가 이전에 받아둔 ETag 값을 If-None-Match 헤더에 담아 다시 요청하면, 서버는 현재 본문의 ETag와 비교해서 같으면 304를, 다르면 새 본문과 200을 돌려줍니다.

Last-Modified는 이보다 단순한 방식으로, 리소스가 마지막으로 수정된 시각을 초 단위까지 담습니다. 클라이언트가 이 값을 If-Modified-Since 헤더로 보내면 서버가 그 시각 이후 변경이 있었는지만 확인합니다. 두 값 중 하나만 있어도 동작하지만, 둘 다 지원하는 서버라면 ETag가 더 정밀한 기준이 됩니다. 1초 안에 두 번 수정돼도 Last-Modified는 같은 값을 가리킬 수 있지만, ETag는 바로 달라지기 때문입니다.

Python requests로 조건부 GET 구현하기

실제 코드로 보면 구조가 훨씬 단순합니다. 첫 요청에서 받은 ETag를 저장해두고, 다음 요청에 그대로 되돌려 보내는 게 전부입니다.

import requests
import json
import os

CACHE_FILE = "etag_cache.json"

def load_cache():
    if os.path.exists(CACHE_FILE):
        with open(CACHE_FILE, "r") as f:
            return json.load(f)
    return {}

def save_cache(cache):
    with open(CACHE_FILE, "w") as f:
        json.dump(cache, f)

url = "https://example.com/notice"
cache = load_cache()
entry = cache.get(url, {})

headers = {}
if entry.get("etag"):
    headers["If-None-Match"] = entry["etag"]
if entry.get("last_modified"):
    headers["If-Modified-Since"] = entry["last_modified"]

resp = requests.get(url, headers=headers)

if resp.status_code == 304:
    print("변경 없음 - 저장된 본문 재사용")
else:
    print(f"status={resp.status_code}, 새 본문 {len(resp.text)}자 수신")
    cache[url] = {
        "etag": resp.headers.get("ETag"),
        "last_modified": resp.headers.get("Last-Modified"),
    }
    save_cache(cache)

첫 실행에서는 캐시가 없으니 status=200, 새 본문 ...자 수신이 출력되고, ETag가 파일에 저장됩니다. 바로 다음에 같은 스크립트를 다시 돌리면 서버가 변경을 감지하지 못했을 경우 변경 없음 - 저장된 본문 재사용이 찍히면서 응답 본문 자체를 받지 않습니다. 304 응답은 본문이 없는 대신 헤더만 오기 때문에, resp.text를 그대로 쓰면 빈 문자열이 되니 이전에 저장해둔 본문을 따로 불러오는 로직이 반드시 필요합니다.

재방문 주기는 어떻게 정해야 할까요?

ETag 캐싱 자체는 변경 여부만 알려줄 뿐, 언제 다시 물어볼지는 직접 정해야 합니다. 가장 쉬운 기준은 지난 N번의 요청에서 304가 나온 비율을 보는 것인데, 최근 10번 중 9번이 304였다면 그 페이지는 바로 다음 주기를 2~3배 늘려도 큰 손실이 없습니다. 반대로 매번 200이 오는 페이지는 주기를 그대로 두거나 더 짧게 당기는 식으로 조정합니다.

사이트에 sitemap.xml이 있다면 각 URL의 lastmod 값을 먼저 비교해서, 그 값이 바뀐 URL만 조건부 요청 대상으로 추려내는 방법도 있습니다. 응답 헤더에 Cache-Control: max-age=3600 같은 값이 같이 온다면, 그 시간 안에는 재요청 자체를 보내지 않는 것도 서버 부하를 줄이는 방법입니다. 다음과 같은 표로 페이지 유형별 기준을 정리해두면 운영할 때 판단이 빨라집니다.

페이지 유형 최근 10회 중 304 비율 권장 재방문 주기
공지/약관 페이지 9~10회 24시간 이상
상품 상세 페이지 5~8회 3~6시간
실시간 가격/재고 페이지 0~2회 조건부 요청 생략, 매번 200 수신

ETag 캐싱이 안 통하는 경우와 도입 전에 확인할 것

모든 서버가 이 방식을 지원하지는 않습니다. 템플릿에 현재 시각이나 방문 횟수 같은 값을 그대로 렌더링하는 동적 페이지는 내용이 실질적으로 같아도 ETag가 매 요청마다 달라져서, 304를 받을 기회 자체가 사라집니다. 이런 경우 조건부 요청을 걸어도 효과가 없으니, 먼저 curl -I 같은 명령으로 같은 URL을 두 번 조회해서 ETag 값이 실제로 유지되는지 확인해보는 게 먼저입니다.

CDN이나 리버스 프록시를 거치는 사이트는 원본 서버가 보낸 ETag를 중간에서 바꾸거나 제거하는 경우도 있어서, 직접 눈으로 헤더를 찍어보기 전에는 지원 여부를 단정하기 어렵습니다. 또한 URL별로 ETag·Last-Modified를 저장해야 하므로, 대상이 수십만 건을 넘어가는 크롤러라면 이 캐시 저장소 자체의 용량과 조회 속도도 같이 고려해야 합니다. 작은 파일 기반 캐시는 수천 건 정도까지는 괜찮지만, 그 이상이라면 SQLite나 Redis처럼 키-값 조회가 빠른 저장소로 옮기는 편이 낫습니다.

결국 매번 전부 다시 긁지 않으려면 조건부 요청을 켜는 것만으로는 부족하고, 대상 페이지가 실제로 ETag를 안정적으로 유지하는지부터 확인한 뒤, 304 비율을 보면서 페이지별 재방문 주기를 조정하는 순서로 접근하는 게 안전합니다. 지금 운영 중인 크롤러가 있다면 가장 자주 도는 URL 하나만 골라 위 코드로 먼저 테스트해보고, 304가 실제로 돌아오는지부터 확인해보시길 권합니다.

GitHub Actions가 매번 5분씩 걸릴 때 점검할 캐시 키

참고: 매번 전부 다시 — 위키백과

Leave a Comment