크롤러 본문 이미지 절대경로 변환과 footer 찌꺼기 제거 후 DB
목차
크롤러 본문 정제 작업을 한 번에 묶어서 처리했다. 이미지 절대경로 변환, footer 쓰레기 자산 제거, 그리고 기존 DB 데이터 마이그레이션까지 세 가지를 하나의 fix로 밀어 넣은 작업이다.
왜 이 세 가지가 동시에 터졌나
크롤러가 수집한 본문 HTML을 그대로 저장하면 반드시 마주치는 문제가 있다. 원본 페이지의 <img src="/images/foo.png"> 같은 상대경로는 원본 도메인을 벗어나는 순간 죽어버린다. 우리 서비스에서 그 HTML을 렌더링하면 이미지가 전부 깨진다. 이건 크롤러를 처음 짤 때 "일단 저장부터 하고 보자" 식으로 넘어갔다가 나중에 꼭 터지는 클래식한 부채다.
footer 자산 문제는 조금 다른 결이다. 크롤러가 본문 영역을 선택자로 잘라낼 때, 페이지 하단의 footer 안에 있는 CSS/JS 링크나 로고 이미지가 섞여 들어오는 경우가 있다. 특히 공공기관이나 스타트업 지원 포털처럼 레거시 HTML 구조를 가진 사이트들은 semantic 한 영역 구분이 없어서 선택자가 의도보다 넓게 잡히기 쉽다. 결과적으로 DB에는 본문인 척 footer 찌꺼기가 섞인 HTML이 들어가 있는 상태였다.
두 문제가 터지는 시점이 겹쳤고, 어차피 기존 데이터도 다 오염돼 있으니 마이그레이션 스크립트까지 같이 만들자는 결론이 났다.
작업 파일별 역할
| 파일 | 역할 |
|---|---|
crawler_common.py |
절대경로 변환 / footer 제거 공통 유틸 함수 |
bizinfo_crawler.py |
비즈인포 크롤러에 공통 유틸 연결 |
kstartup_crawler.py |
케이스타트업 크롤러에 공통 유틸 연결 |
migrate_content_absolutize.py |
기존 DB 레코드 일괄 재정제 마이그레이션 |
공통 로직을 crawler_common.py에 몰아넣은 건 올바른 판단이었다. 각 크롤러마다 같은 정제 로직을 복붙하면 나중에 정책 하나 바꿀 때 파일 세 개를 동시에 고쳐야 하고, 그중 하나를 빠뜨리는 사고가 반드시 생긴다. 팀에서 크롤러 소스가 늘어날수록 이 공통 레이어의 가치는 더 커진다.
# crawler_common.py 에서 이런 패턴으로 처리
from urllib.parse import urljoin
from bs4 import BeautifulSoup
def absolutize_images(html: str, base_url: str) -> str:
soup = BeautifulSoup(html, "html.parser")
for img in soup.find_all("img"):
src = img.get("src", "")
if src and not src.startswith(("http://", "https://", "data:")):
img["src"] = urljoin(base_url, src)
return str(soup)
def strip_footer_assets(html: str) -> str:
soup = BeautifulSoup(html, "html.parser")
for tag in soup.find_all(["footer", "script", "link"]):
tag.decompose()
return str(soup)
실제 구현이 완전히 동일하진 않겠지만 방향은 이렇다. urljoin을 쓰면 /path, ../path, //cdn.example.com/path 같은 케이스를 한 번에 커버할 수 있어서 직접 문자열 치환보다 훨씬 안전하다. data: URI는 건드리지 말아야 하는데, 이걸 빠뜨리면 base64 인라인 이미지가 망가진다.
마이그레이션 스크립트를 따로 분리한 이유
migrate_content_absolutize.py가 별도 파일로 존재한다는 게 포인트다. 마이그레이션 로직을 크롤러 코드 안에 섞거나 서버 시작 시 자동 실행되게 짜는 팀들이 꽤 있는데, 그건 위험하다.
- 운영 DB에 대규모 UPDATE 가 서버 시작 중에 날아가면 롤백 타이밍을 잡기 어렵다
- 한 번만 돌아야 할 스크립트가 조건 실수로 두 번 돌 수 있다
- 진행 상황 로깅, 배치 사이즈 조절, dry-run 옵션 같은 걸 넣으려면 독립 파일이 훨씬 편하다
별도 파일로 빼두면 "이거 언제 돌렸어?" 를 파일 자체의 존재와 커밋 히스토리로 추적할 수 있다. 팀 협업 관점에서 마이그레이션 이력을 남기는 가장 단순한 방법이기도 하다.
# migrate_content_absolutize.py 패턴
def run_migration(batch_size=100, dry_run=False):
offset = 0
while True:
rows = fetch_batch(offset, batch_size)
if not rows:
break
for row in rows:
new_content = absolutize_images(row["content"], row["source_url"])
new_content = strip_footer_assets(new_content)
if not dry_run:
update_content(row["id"], new_content)
offset += batch_size
배치 단위로 처리하고 dry_run 플래그 하나 넣어두는 것만으로도 실수할 여지가 많이 줄어든다. 대량 마이그레이션에서 전체를 한 트랜잭션으로 묶으면 롤백 시 DB에 부담이 크기 때문에, 배치 커밋을 하되 실패 구간 로깅을 꼼꼼히 남기는 게 일반적인 패턴이다.
크롤러는 "데이터 수집"이라는 측면에서 백엔드 개발자가 상대적으로 품질을 느슨하게 보는 경향이 있다. 그런데 결국 그 데이터가 서비스 본문으로 나가기 때문에, 정제 레이어가 허술하면 사용자 화면에 직접 영향이 간다. 이번 작업처럼 공통 유틸 분리 + 마이그레이션 스크립트 분리를 습관으로 잡아두면, 나중에 세 번째 네 번째 크롤러 소스가 붙을 때 같은 부채를 반복하지 않아도 된다.
끝.
🛒 이 글과 어울리는 추천 상품
*위 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.
댓글 0
첫 댓글 달아줘.