자동화 slecs

공공 포털 크롤링 자동 적재 봇 초기 구조 설계

목차

자동 적재 봇 프로젝트의 첫 커밋을 올렸다. 파일 다섯 개, 그리고 .env.example.gitignore까지 — 전형적인 "0에서 시작하는 날"이었다.


왜 이걸 만들기로 했냐면

공공 정보 포털에서 데이터를 주기적으로 긁어와서 내부 시스템에 적재해야 하는 수요가 생겼다. 담당자가 수동으로 들어가서 다운로드하고 업로드하는 루틴이 반복되고 있었는데, 그걸 자동화하지 않은 채 운영하는 건 명백한 낭비였다. 팀 입장에서도 이런 반복 작업은 사람이 해야 하는 일이 아니다.

초기 커밋이지만 파일 구성을 보면 설계 방향이 어느 정도 잡혀 있다.

파일 역할
bizinfo_crawler.py 대상 포털 크롤링 진입점
classifier.py 수집 데이터 분류/파싱 로직
crawler_common.py 공통 유틸 (세션, 재시도, 로깅 등)
.env.example 환경변수 템플릿 (실 값 노출 방지)
.gitignore 민감 파일 커밋 제외
README.md 실행 방법, 구조 설명

처음부터 단일 파일로 때려 박는 대신 크롤링 / 분류 / 공통 레이어를 분리한 건 의식적인 결정이었다. 나중에 다른 포털이 추가되거나, 분류 로직만 교체해야 할 때 건드릴 범위를 최소화하기 위해서다.


초기 커밋에서 챙겨야 할 것들

크롤러 프로젝트는 첫 커밋 시점에 이미 몇 가지를 결정해둬야 한다. 나중에 고치려면 히스토리가 지저분해지거나 팀원이 실수할 여지가 생긴다.

  • .env.example 먼저: 실제 .env는 절대 커밋하지 않는다. 그 규칙을 첫 커밋부터 못 박아두는 것. .gitignore.env 추가하고 .env.example로 어떤 환경변수가 필요한지 명시해두면 온보딩 시 설명할 내용이 줄어든다.
  • 공통 모듈 분리: 크롤러는 재시도, 타임아웃, 헤더 관리, 로깅 패턴이 반복된다. crawler_common.py 하나에 몰아두면 각 크롤러 파일은 "어디서 뭘 가져오냐"에만 집중할 수 있다.
  • README.md 초안: 아무것도 없는 상태에서도 실행 방법 한 섹션은 써두는 게 맞다. 나 혼자 쓰는 프로젝트라도 두 달 뒤 내가 낯선 사람이 된다.
# crawler_common.py 에서 보통 이런 패턴으로 시작
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

def build_session(retries=3, backoff=0.5) -> requests.Session:
    session = requests.Session()
    retry = Retry(
        total=retries,
        backoff_factor=backoff,
        status_forcelist=[429, 500, 502, 503, 504],
    )
    adapter = HTTPAdapter(max_retries=retry)
    session.mount("https://", adapter)
    session.mount("http://", adapter)
    return session

공공 포털 크롤링은 응답이 불안정한 경우가 종종 있어서, 재시도 로직을 공통 레이어에 미리 박아두는 게 거의 필수다. 각 크롤러 파일에서 매번 구현하면 놓치는 케이스가 생긴다.


classifier.py를 분리한 이유

이 부분을 조금 더 얘기하고 싶다. 크롤러와 분류 로직을 붙여두면 테스트가 어려워진다. 크롤러는 네트워크가 필요하지만, 분류 로직은 그냥 문자열/HTML 파싱이라 mock 없이도 단위 테스트가 가능하다. 처음부터 분리해두면 CI에서 네트워크 없이 분류 로직만 검증하는 게 쉬워진다.

# classifier.py 역할 — 수집된 raw 데이터를 구조화된 dict 로 변환
def classify_entry(raw: dict) -> dict | None:
    # 필요한 키가 없으면 None 반환 (적재 스킵)
    required = ["title", "category", "date"]
    if not all(raw.get(k) for k in required):
        return None
    return {
        "title": raw["title"].strip(),
        "category": raw["category"],
        "date": raw["date"],
        # ... 나머지 필드 정제
    }

분류 함수가 None을 반환하면 적재 단계에서 스킵하는 방식. 예외를 던지는 것보다 None 리턴으로 처리하는 게 배치 작업에서는 더 자연스럽다. 한 건 실패로 전체 파이프라인이 죽는 건 원하지 않으니까.


팀에 이 봇을 공유할 때 제일 먼저 물어볼 것 같은 질문이 "이거 언제 어떻게 실행되냐"였다. README에 그걸 먼저 써두고 커밋한 게 결과적으로 맞는 순서였다. 코드보다 README가 먼저인 날도 있다.

끝.


🛒 이 글과 어울리는 추천 상품

*위 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.

댓글 0

첫 댓글 달아줘.