Notice
Recent Posts
Recent Comments
반응형
관리 메뉴

HM소프트

파이썬 Selenium 크롤러와 스케줄러 — 데이터 수집 자동화 (1) 본문

외주 개발일지

파이썬 Selenium 크롤러와 스케줄러 — 데이터 수집 자동화 (1)

HM소프트 2026. 6. 13. 05:45
반응형

공개 API가 없는 외부 화면을 Selenium으로 긁는 크롤러 설계. navigator.webdriver 숨기기·UA 무작위화·무작위 지연으로 차단을 피하고, 중첩 iframe 전환과 셀렉터 다단계 폴백으로 화면 개편을 견딘다. 식별자 우선 upsert로 멱등성을, 다층 예외 격리와 정직한 partial 로그로 새벽 수집을 지킨다.

시리즈를 시작하며 — 출처가 외부에 있는 서비스

지역에 흩어진 시설 정보를 한곳에 모아 검색하고, 즐겨찾기하고, 이용 후기를 모아 보여 주는 서비스를 만들었다. 백엔드(Python)가 데이터를 수집·정제·제공하고, 크로스플랫폼 앱이 그걸 소비하는 구조다. 그런데 이 서비스에는 보통의 CRUD 백엔드와 결정적으로 다른 점이 하나 있었다. 데이터의 출처가 우리 손 밖에 있다는 점이다.

사용자가 직접 입력하는 데이터라면 폼 검증으로 품질을 통제할 수 있다. 하지만 이 서비스의 핵심 가치는 "여기저기 흩어진 정보를 한 화면에 통합"하는 것이고, 그 원천 데이터는 외부 지도/검색 서비스의 화면 위에 HTML로만 존재한다. API가 공개돼 있지도 않다. 그러니 "어떻게 긁어 와서, 어떻게 깨끗하게 유지하느냐"가 프로젝트 전체의 성패를 갈랐다. 이 글은 그 첫 단추 — 브라우저를 자동으로 몰아 데이터를 수집하는 크롤러와, 그걸 주기적으로 반복 실행하는 스케줄러에 대한 기록이다.

전체 시리즈는 네 편으로 나눠 정리한다.

  1. (이 글) 데이터 크롤러와 스케줄러 — 화면을 긁어 DB에 적재하기까지
  2. API·검색·인증 — 그리고 빈 값(None)과의 싸움
  3. 배포 — 매니지드 호스팅과 매니지드 DB
  4. 크로스플랫폼 앱 — 모은 데이터를 손에 쥐여 주기

특정 서비스명·기관명·실주소는 전부 일반화하고, 구현 이야기만 다룬다.

왜 API가 아니라 브라우저였나

데이터를 외부에서 가져오는 방법은 보통 셋이다. (1) 공개 API를 호출한다, (2) 서버가 내려보내는 내부 JSON 엔드포인트를 직접 때린다, (3) 사람이 보는 화면을 그대로 자동 조작해 긁는다. 위에서 아래로 내려갈수록 깨지기 쉽고 느리다. 그러니 가능하면 위쪽을 택해야 한다.

문제는 대상이 그걸 허용하지 않았다는 점이다. 공식 API는 우리가 필요로 하는 항목(좌표·운영 시간·보유 장비·후기 같은 상세 정보)을 충분히 주지 않았고, 내부 JSON 엔드포인트는 토큰과 서명으로 막혀 있어 재현이 불안정했다. 무엇보다 핵심 정보가 여러 단계의 화면 전환을 거쳐야 드러나는 구조였다. 목록에서 항목을 누르면 상세 화면이 뜨고, 거기서 또 탭을 눌러야 후기가 나오는 식이다.

그래서 가장 무겁지만 가장 확실한 (3)을 골랐다. Selenium으로 실제 크롬을 띄워 사람처럼 클릭하면서 화면에 그려진 것을 그대로 읽는다. 느리고 깨지기 쉽다는 단점은 분명하지만, "사람이 볼 수 있는 건 무엇이든 긁을 수 있다"는 단순한 강점이 그 모든 단점을 덮었다. 어차피 우리는 실시간이 필요한 게 아니라 하루 한 번 갱신이면 충분했으니까.

크롤러의 뼈대 — 추상 베이스로 출처를 갈아끼우게

처음부터 출처가 하나라고 단정하지 않았다. 지금은 한 검색 서비스의 화면만 긁지만, 나중에 다른 출처를 붙일 수도 있다. 그래서 크롤러를 추상 베이스 클래스 하나와 출처별 구현 클래스로 나눴다. 브라우저를 띄우고, 지연을 주고, 요소를 안전하게 찾는 공통 동작은 베이스에 모으고, "어느 화면에서 무엇을 어떻게 읽는지"만 자식 클래스가 책임진다.

여기서 가장 중요한 건 safe_find_elements다. 평범한 find_elements는 요소가 없으면 빈 리스트를, 한 개를 찾는 find_element는 아예 예외를 던진다. 그런데 외부 화면은 요소가 있을 수도, 없을 수도, 늦게 나타날 수도 있다. 그래서 모든 요소 탐색을 "기다려 보되, 끝내 없으면 조용히 빈 리스트로 떨어지는" 래퍼 한 겹으로 감쌌다. 크롤러 코드 전체에서 if not elems:만 확인하면 되도록 만든 것이다. 이 한 겹 덕분에 상위 로직이 try/except로 뒤덮이지 않는다.

봇으로 보이지 않기 — 차단을 피하는 작은 장치들

자동화된 브라우저는 여러 흔적을 남긴다. 대상 화면은 그 흔적을 감지해 차단하거나, 빈 결과를 내려보낸다. 그래서 베이스 크롤러의 드라이버 초기화에는 봇 티를 지우는 설정이 꽤 들어갔다.

핵심은 세 가지다. 첫째, User-Agent를 몇 개 중에서 무작위로 골라 쓴다. 매번 똑같은 UA로 접속하면 패턴이 잡힌다. 둘째, disable-blink-features=AutomationControlledexcludeSwitches로 크롬이 스스로 광고하는 "나 자동화됐어요" 신호를 끈다. 셋째, 그래도 자바스크립트로 navigator.webdriver를 확인하면 봇임이 드러나므로, 페이지가 로드되기 전에 CDP로 그 속성을 undefined로 덮어쓰는 스크립트를 심는다.

그리고 가장 단순하지만 효과가 큰 게 무작위 지연이다. 기계는 0.0초 간격으로 다음 동작을 하지만 사람은 그러지 못한다. 요청 기본 간격을 8초로 크게 잡고, 거기에 무작위 폭을 더했다. 느려지지만, 차단당해 0건을 긁는 것보다는 천천히라도 긁는 게 낫다.

한 가지 역설적인 결정도 있었다. 보통 서버 크롤러는 화면을 안 그리는 헤드리스 모드로 돌려 자원을 아끼는데, 이 대상은 오히려 헤드리스를 더 잘 잡아냈다. 그래서 실제 수집은 창을 띄우는 모드로 돌렸다. "자원 절약"보다 "차단 회피"가 우선이었다.

중첩 iframe 속으로 — 화면 구조를 따라 들어가기

대상 화면의 구조가 까다로웠다. 검색 결과 목록과 상세 정보가 각각 별도의 iframe 안에 들어 있었다. 바깥 페이지에서 곧장 요소를 찾으면 아무것도 안 잡힌다. 먼저 검색 결과용 iframe으로 프레임을 전환하고, 거기서 목록을 읽은 뒤, 항목을 클릭하면 다시 바깥으로 나와 상세용 iframe으로 전환해야 했다.

여기서 ID 추출 방식이 재밌다. 목록 항목의 링크에 식별자가 들어 있으면 그걸 바로 쓰지만, 없는 경우엔 직접 클릭해 URL이 바뀌는 걸 보고 거기서 식별자를 떼어 냈다. URL이 .../place/1234567?... 꼴이라, /place/ 뒤를 잘라 내면 된다. 클릭으로 바깥 페이지로 튕겨 나갔다가, ID를 챙긴 뒤 다시 검색 iframe으로 돌아와 다음 항목으로 넘어간다.

def _get_external_id_by_click(self, item, idx: int) -> Optional[str]:
    try:
        item.find_element(By.CSS_SELECTOR, "a").click()
        self.random_delay(2, 4)

        # 바깥 페이지로 복귀해 URL에서 식별자 추출
        self.driver.switch_to.default_content()
        current_url = self.driver.current_url
        external_id = None
        if "/place/" in current_url:
            external_id = current_url.split("/place/")[1].split("?")[0].split("/")[0]

        # 다음 항목을 위해 검색 iframe으로 재전환
        WebDriverWait(self.driver, 10).until(
            EC.presence_of_element_located((By.CSS_SELECTOR, "#searchIframe"))
        )
        self.driver.switch_to.frame(
            self.driver.find_element(By.CSS_SELECTOR, "#searchIframe")
        )
        self.random_delay(1, 2)
        return external_id
    except Exception as e:
        logger.warning(f"클릭으로 식별자 추출 실패: {e}")
        # 실패해도 최소한 프레임 상태는 복구해 둔다
        try:
            self.driver.switch_to.default_content()
            self.driver.switch_to.frame(
                self.driver.find_element(By.CSS_SELECTOR, "#searchIframe")
            )
        except Exception:
            pass
        return None

이 함수에서 가장 신경 쓴 건 except 절이다. 클릭 도중 예외가 나면 드라이버의 "현재 프레임"이 어딘지 모를 상태가 된다. 그러면 다음 항목 파싱이 줄줄이 깨진다. 그래서 실패하더라도 반드시 검색 iframe으로 프레임 상태를 되돌려 놓고 빈 ID를 반환한다. 항목 하나의 실패가 목록 전체를 무너뜨리지 않게 만드는, 작지만 결정적인 방어다.

셀렉터가 자꾸 바뀐다 — 다단계 폴백 전략

크롤링에서 가장 자주, 가장 조용히 깨지는 부분이 CSS 셀렉터다. 대상 화면은 클래스 이름이 q2LdB, DWs4Q, zPfVt 같은 난독화된 해시 꼴이라, 화면이 한 번 개편되면 이 이름들이 통째로 바뀐다. 셀렉터 하나에 의존하면, 어느 날 갑자기 모든 수집이 0건이 된다. 그것도 에러 없이 조용히.

그래서 모든 필드 추출을 셀렉터 후보 목록을 순서대로 시도하는 구조로 짰다. 새 셀렉터를 먼저 시도하고, 안 되면 옛 셀렉터로, 그것도 안 되면 텍스트 패턴 기반 휴리스틱으로 떨어진다.

이중 안전망의 발상은 이렇다. 클래스 이름은 자주 바뀌지만, 데이터의 의미적 형태는 잘 안 바뀐다. 이름에는 업종을 뜻하는 접미사가 붙고, 주소에는 "동/로/구" 같은 행정 단위가 들어간다. 셀렉터가 통째로 갈려도 이 패턴 폴백이 살아 있으면 절반은 건진다. 그리고 이름이 끝내 안 잡히면 그 항목은 아예 버린다. 이름 없는 레코드를 DB에 넣으면 검색에서 영원히 안 잡히는 유령 데이터가 되기 때문이다. "불완전하게 저장하느니 안 저장한다"가 원칙이었다.

평점이나 후기 수도 마찬가지로 방어적으로 긁었다. 평점 텍스트에서 숫자와 점만 추려 내고, 후기 수는 "후기"라는 단어가 든 텍스트나 괄호로 감싼 숫자에서 숫자만 뽑는다. float("".join(c for c in text if c.isdigit() or c == '.')) 같은 식이다. 화면에 "별점 4.5"로 나오든 "4.5점"으로 나오든, 숫자만 긁으면 둘 다 살아남는다.

장비 정보는 키워드 매칭으로

상세 화면에서 욕심을 좀 냈다. 단순히 이름·주소·평점만이 아니라, 보유 장비편의 시설(주차·휠체어 접근·야간 운영 여부) 같은 부가 정보까지 긁고 싶었다. 이런 정보는 정해진 필드에 깔끔하게 들어 있지 않고, 페이지 곳곳의 텍스트에 흩어져 있다. 그래서 정공법 대신 키워드 사전으로 본문을 훑는 방식을 택했다.

미리 관심 있는 장비 키워드 목록을 만들어 두고, 상세 페이지 전체 텍스트에서 그 단어가 등장하는지를 확인하는 식이다. 시설 여부도 같은 발상으로, 본문에 "주차"가 있으면 주차 가능, "휠체어"가 있으면 접근 가능으로 본다.

# 관심 장비 키워드 사전 (예시 — 실제로는 더 길다)
EQUIPMENT_KEYWORDS = ["MRI", "CT", "초음파", "내시경", "엑스레이", "골밀도"]

def _extract_features(self, page_text: str, facilities: list) -> dict:
    detail = {}

    # 본문 + 수집한 시설 텍스트를 한 덩어리로
    all_text = " ".join(facilities) + " " + page_text

    # 시설 여부: 단어 존재 = 보유로 간주
    detail["has_parking"] = "주차" in all_text
    detail["wheelchair_accessible"] = "휠체어" in all_text
    detail["has_night_care"] = any(kw in all_text for kw in ["야간", "심야", "24시"])
    detail["has_weekend_care"] = any(kw in all_text for kw in ["주말", "토요일", "일요일"])

    # 장비: 키워드 사전과 교차, 중복 제거
    equipment = []
    for keyword in EQUIPMENT_KEYWORDS:
        if keyword.lower() in page_text.lower() and keyword not in equipment:
            equipment.append(keyword)
    detail["medical_equipment"] = list(set(equipment))
    return detail

이 방식은 정밀하진 않다. "주차 불가"라고 적혀 있어도 "주차"라는 단어만 보고 가능으로 판단할 수 있다. 하지만 외부 화면을 긁는 단계에서 완벽한 정밀도를 노리는 건 비용 대비 무모하다. 일단 넉넉하게 긁어 두고, 정밀한 판정은 나중에 정제 단계나 사용자 신고로 보정하는 쪽이 현실적이었다. 좌표도 비슷하게, 상세 페이지 URL에 lat=, lng= 파라미터가 박혀 있으면 정규식으로 떼어 냈다. 정공법이 막히면 URL이든 본문이든 닥치는 대로 신호를 줍는 게 크롤링의 생리다.

DB 적재 — upsert와 중복 방지

긁은 데이터를 DB에 넣을 때 가장 경계한 건 중복이다. 크롤러는 매일 같은 지역을 다시 긁으니, 같은 항목이 어제도 오늘도 들어온다. 그대로 INSERT만 하면 같은 시설이 수십 개로 불어난다. 그래서 적재는 전부 upsert(있으면 수정, 없으면 삽입)로 처리했다.

동일 항목 판정은 두 단계다. 먼저 출처+외부 식별자가 일치하는 행을 찾고, 그게 없으면 이름+주소 일부로 한 번 더 찾는다. 식별자가 가장 믿을 만하지만, 클릭 추출이 실패해 식별자가 비는 경우를 대비한 폴백이다.

수정할 때도 욕심 부리지 않았다. 값이 들어온 필드만 갱신한다. 오늘 수집에서 어떤 항목의 평점을 못 긁었다면, DB에 이미 있는 어제 평점을 0으로 덮어쓰면 안 된다. 그래서 if data.get(field) is not None:로 필터링해, 새로 긁힌 값이 있을 때만 그 컬럼을 UPDATE 문에 포함시켰다. "수집 실패가 기존 데이터를 훼손하지 않는다"는 원칙이다. 이미지 URL 목록이나 장비 목록처럼 배열인 값은 json.dumps로 직렬화해 텍스트 컬럼에 넣었다. 스키마를 단순하게 유지하면서도 구조화된 값을 담는 절충이다.

후기와 인력 정보도 별도 테이블에 같은 철학으로 저장했다. 후기는 같은 항목·같은 출처·같은 본문이면 중복으로 보고 건너뛰고, 인력 정보는 항목+이름이 같으면 갱신한다. 외부에서 긁은 데이터는 언제나 또 들어온다는 전제 위에서 모든 쓰기를 "두 번 들어와도 괜찮게" 짰다(멱등성).

스케줄러 — 복잡한 주기를 daily/weekly로 깎아 내다

데이터는 한 번 모으고 끝이 아니다. 시설 정보는 계속 바뀌니 주기적으로 다시 긁어야 한다. 처음에는 분류별·지역별로 제각각 다른 주기를 두는 정교한 스케줄을 떠올렸지만, 금세 후회했다. "지금 무엇이 도는 중이고, 다음엔 뭐가 도는지"를 사람이 머릿속으로 추적할 수 없게 되더라. 그래서 과감히 딱 두 가지로 깎아 냈다.

  • 매일 새벽: 가장 데이터가 많고 변화가 잦은 핵심 지역만 가볍게 갱신
  • 매주 한 번: 전국 전체를 무겁게 한 바퀴 갱신

이걸 무거운 작업 큐 프레임워크로 만들 수도 있었지만, "로컬 PC에서 켜 두면 도는" 단순한 스크립트면 충분했다. 그래서 파이썬의 가벼운 schedule 라이브러리로, 사람이 읽으면 그대로 이해되는 선언 두 줄을 썼다.

schedule.every().day.at("03:00")은 거의 영어 문장처럼 읽힌다. 스케줄을 단순하게 둔 건 게으름이 아니라 운영 가능성에 대한 판단이었다. 복잡한 주기는 처음엔 똑똑해 보여도, 몇 주 뒤 "어제 강남 데이터가 왜 안 갱신됐지?"를 추적할 때 지옥이 된다. 매일 핵심 / 매주 전체, 이 두 문장이면 누구라도 5초 만에 이해한다. 갱신이 새벽 시간대인 것도 의도적이다 — 대상 화면의 트래픽이 적어 차단 위험이 낮고, 우리 서비스 사용자도 잠든 시간이다.

한 번의 실패가 전부를 무너뜨리지 않게 — 다층 예외 격리

스케줄러의 실제 수집 루프는 지역 × 분류의 이중 반복이고, 각 항목마다 다시 상세·후기·인력 정보를 긁는 삼중 작업이 붙는다. 수백 번의 외부 요청 중 몇 번이 실패하는 건 정상이다. 문제는 그 한 번의 실패가 나머지 전부를 멈추게 하느냐다. 그래서 예외를 여러 겹으로 격리했다. 항목 하나가 실패해도 같은 지역의 다음 항목은 돌고, 상세 정보 긁기가 실패해도 그 항목의 기본 정보는 이미 저장돼 있다.

이 구조의 핵심은 실패의 격리 단위를 의식적으로 나눈 것이다. 가장 바깥(지역×분류)에서 한 검색이 통째로 실패해도 다음 검색으로 넘어가고, 항목 단위 실패는 그 항목만 버리고, 상세·후기 같은 부가 정보 실패는 그냥 무시하고 지나간다. 중요한 정보일수록 바깥에, 부가적인 정보일수록 안쪽에 둬서, 안쪽이 깨져도 바깥은 살아남게 했다. 기본 정보 저장(upsert)을 상세 정보 긁기보다 먼저 둔 것도 같은 이유다. 후기 수집이 실패해도 시설 자체는 이미 검색 가능한 상태로 DB에 들어가 있다.

무엇이 도는지 남기기 — 수집 로그

마지막으로, 새벽에 혼자 도는 작업일수록 무슨 일이 있었는지 흔적이 남아야 한다. 그래서 수집이 끝날 때마다 결과를 전용 로그 테이블에 적었다. 몇 개를 추가하고 갱신했는지, 후기는 몇 개 들어왔는지, 에러는 몇 번 났는지, 그리고 언제 시작해 언제 끝났는지.

end_time = datetime.now()
db.save_crawl_log(
    source="search",
    # 에러가 하나도 없어야 success, 일부라도 있으면 정직하게 partial
    status="success" if results["errors"] == 0 else "partial",
    added=results["added"],
    updated=results["updated"],
    reviews=results["reviews"],
    error_message=f"{results['errors']} errors" if results["errors"] else None,
    started_at=start_time,
    finished_at=end_time,
)
duration = (end_time - start_time).total_seconds()
logger.info(f"완료 — 추가 {results['added']}, 갱신 {results['updated']}, "
            f"에러 {results['errors']}, 소요 {duration/60:.1f}분")

여기서 작지만 중요한 결정이 status다. 에러가 단 한 번이라도 났으면 success가 아니라 partial로 적는다. "부분 성공"을 "성공"으로 포장하지 않는 것 — 나중에 "어느 날부터 데이터가 이상하다"를 추적할 때, 정직한 로그가 유일한 단서가 된다. 셀렉터가 갈려 수집량이 뚝 떨어지는 사고도, 이 로그에서 added 수가 어느 날부터 0에 가까워지는 걸 보고 가장 먼저 알아챘다. 알림 채널(웹훅)로 결과를 쏘는 통로도 같이 두어, 새벽 작업이 조용히 망가지지 않게 했다.

정리하며 — 외부 데이터를 다루는 법

크롤러와 스케줄러를 만들며 얻은 교훈을 줄이면 이렇다.

  • 공개 API가 없으면 브라우저로. 느리고 깨지기 쉽지만 "사람이 보는 건 다 긁는다"는 확실함이 모든 단점을 덮는다. 실시간이 아니라 하루 한 번이면 충분한 도메인이라 가능한 선택이었다.
  • 봇으로 보이지 않는 게 절반. User-Agent 무작위화, 자동화 플래그 끄기, navigator.webdriver 숨기기, 그리고 무작위 지연. 차단당해 0건을 긁느니 천천히 긁는다.
  • 셀렉터는 반드시 갈린다. 새 셀렉터 → 옛 셀렉터 → 텍스트 패턴 휴리스틱으로 다단계 폴백을 깔아, 화면 개편이 조용한 0건 사고로 번지지 않게 했다.
  • 모든 쓰기를 멱등하게. 같은 데이터가 매일 또 들어온다는 전제로, 식별자 우선 + 이름·주소 폴백 upsert, 값이 있는 필드만 갱신, 중복 후기 차단. 수집 실패가 기존 데이터를 훼손하지 않게.
  • 스케줄은 사람이 읽히게. 정교한 주기보다 "매일 핵심 / 매주 전체" 두 문장이 운영을 가능하게 한다.
  • 실패를 격리하고, 정직하게 로깅한다. 중요한 정보는 바깥에, 부가 정보는 안쪽에 두어 부분 실패에도 본체가 살아남게 했고, 부분 성공을 성공으로 포장하지 않았다.

이렇게 긁어 온 데이터는, 솔직히 말하면 지저분하다. 어떤 항목은 좌표가 없고, 어떤 필드는 비어 있고, 평점이 엉뚱하게 박힌 경우도 있다. 외부 데이터를 다루는 서비스의 숙명이다 — 들어오는 값을 못 믿으니, 그다음 모든 단계에서 "빈 값일 수 있다"를 가정해야 한다. 다음 2편에서는 이 모은 데이터를 사용자에게 제공하는 API·검색·인증, 그리고 그 과정에서 끝없이 마주친 "빈 값(None)과의 싸움"을 다룬다.

반응형