AI 자동화/AI 자동화 블로그

화면이 바뀌어도 버티는 자동화 만들기 — 셀렉터, 실패 감지, 로컬 백업

끄적인다 2026. 9. 16. 13:12
반응형

지난 글인 티스토리 로그인을 매번 다시 안 하려면 — 크롬 프로필 재사용에서 우리는 매번 카카오 계정 로그인과 2차 인증을 거치지 않고, 기존 세션을 그대로 유지한 채 브라우저를 제어하는 물리적인 기반을 마련했습니다. 로그인 장벽을 우회하여 에디터 진입까지 성공했다면, 이제 마주하게 되는 진짜 문제는 '자동화의 지속 가능성' 입니다.

웹 서비스는 살아있는 생물과 같아서 개발사의 의도에 따라 UI가 수시로 변경되며, 로컬 네트워크의 일시적인 지연으로 요소를 제때 화면에 그리지 못하는 일이 빈번하게 발생합니다. 어제까지 잘 돌던 코드가 오늘 아침 갑자기 에러를 뿜으며 멈추는 현상은 웹 자동화를 구축한 개발자라면 누구나 겪는 고질적인 문제입니다.

이번 글에서는 이 문제를 정면으로 해결하고자 합니다. "티스토리 에디터의 마크업 구조가 미세하게 개편되거나 네트워크가 흔들릴 때, 자동화 프로그램이 예외를 감지하고 스스로 복구하여 작업을 완수하게 만드는 구체적인 방법은 무엇인가?" 이 질문에 대한 답을 설계와 코드 수준에서 상세히 정리해 보겠습니다.


유연한 셀렉터 설계 — 절대 경로 XPath 탈피와 상대적 탐색

웹 페이지의 특정 요소를 클릭하거나 텍스트를 입력하기 위해 흔히 사용하는 방식이 CSS Selector나 XPath입니다. 하지만 크롬 개발자 도구에서 단순히 우클릭하여 복사한 절대 경로 XPath(/html/body/div[1]/div[2]/div/section/form/div[1]/button)는 아주 작은 레이아웃 변경에도 쉽게 깨집니다.

특히 티스토리와 같이 대형 플랫폼의 에디터는 성능 개선이나 기능 추가를 위해 내부 div 감싸기 구조를 수시로 변경합니다. 또한 현대적인 웹 프레임워크는 빌드 시점에 .btn_g.btn_save와 같은 클래스명을 난독화하거나 동적으로 생성하기도 하므로, 정적 클래스명에만 의존하는 셀렉터 역시 위험합니다.

이러한 문제를 극복하기 위해 본 시스템에서는 세 가지 셀렉터 설계 원칙을 수립하여 적용했습니다.

  1. 텍스트 기반 상대 XPath 사용: 버튼의 클래스명이나 계층 구조 대신, 화면에 보이는 텍스트 자체를 기준으로 요소를 탐색합니다. 예를 들어 '발행' 버튼을 찾을 때 고정된 계층 대신 //button[contains(text(), '발행')] 또는 부모 요소를 포함한 //div[contains(@class, 'wrap_btn')]//button[contains(., '발행')] 구조를 사용합니다.
  2. 안정적인 고유 ID 및 속성 결합: 변하지 않는 최상위 컨테이너의 id 속성을 기준점으로 삼고, 하위 요소를 탐색해 들어가는 방식을 취합니다.
  3. 다중 후보 셀렉터(Fallback) 지정: 주 셀렉터가 실패할 경우를 대비하여 차선책 셀렉터 목록을 배열로 관리하고, 순차적으로 대입하며 요소를 찾습니다.

아래 표는 본 자동화 시스템을 구축하며 정립한 셀렉터 작성 전략별 장단점 비교입니다.

셀렉터 작성 방식 변화 대응력 구현 및 탐색 속도 실제 적용 예시 권장 사용처
절대 경로 XPath 매우 낮음 빠름 (도구 복사 가능) /html/body/div[1]/div/button 사용을 절대 금지함
단순 CSS 셀렉터 보통 매우 빠름 .layer_post .btn_g 고유하고 변하지 않는 클래스명을 가진 UI 요소
텍스트 기반 상대 XPath 매우 높음 보통 (DOM 전체 탐색 가능성) //button[contains(., '발행')] 텍스트가 명확히 고정된 버튼 및 링크 요소
속성 및 부모 결합 XPath 높음 보통 //*[@id='mArticle']//input[@type='text'] 입력 폼, 에디터 본문 영역 등 구조적 탐색이 필요한 곳

명시적 대기(Explicit Wait)와 예외 복구 루틴

셀렉터를 유연하게 작성했더라도, 페이지 로딩 속도가 봇의 실행 속도를 따라가지 못하면 NoSuchElementException이 발생합니다. 이를 해결하기 위해 단순 time.sleep(5)와 같은 암묵적이고 정적인 대기를 사용하는 것은 심각한 자원 낭비이자 불안정의 원인이 됩니다. 네트워크 상태가 좋을 때는 5초라는 아까운 시간을 멍하니 대기하고, 네트워크가 극도로 느려져 5.1초가 걸리는 순간 프로그램이 터져버리기 때문입니다.

이를 방지하기 위해 selenium.webdriver.support.ui.WebDriverWait와 expected_conditions를 활용한 명시적 대기(Explicit Wait)를 기본 인터랙션 단위로 삼아야 합니다. 지정한 요소가 화면에 나타나거나 클릭 가능해질 때까지만 대기하고, 나타나는 즉시 다음 동작을 수행하도록 설계하는 것입니다.

더 나아가, 일시적인 에러나 예기치 못한 모달 팝업으로 인해 클릭이 막히는 ElementClickInterceptedException 등을 방지하기 위해, 예외 발생 시 재시도(Retry) 및 화면 복구 루틴을 내장한 안전 인터랙션 함수를 구현하여 사용하고 있습니다.

# selenium_helper.py
# Selenium 브라우저 제어 시 발생할 수 있는 예외를 방지하고 안전한 인터랙션을 보장하는 헬퍼 모듈

import time
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import (
    TimeoutException,
    NoSuchElementException,
    ElementClickInterceptedException,
    StaleElementReferenceException
)

class SafeCommander:
    def __init__(self, driver, default_timeout=10):
        self.driver = driver
        self.default_timeout = default_timeout

    def safe_click(self, by, locator, retries=3, timeout=None):
        """
        지정한 요소를 대기 후 안전하게 클릭합니다.
        StaleElementReferenceException 이나 Click Intercepted 예외 발생 시 재시도합니다.
        """
        target_timeout = timeout if timeout is not None else self.default_timeout
        for attempt in range(1, retries + 1):
            try:
                # 요소가 화면에 존재하고 클릭 가능할 때까지 대기
                element = WebDriverWait(self.driver, target_timeout).until(
                    EC.element_to_be_clickable((by, locator))
                )
                element.click()
                return True
            except (TimeoutException, NoSuchElementException) as e:
                if attempt == retries:
                    print(f"[에러] 요소를 찾을 수 없거나 대기 시간을 초과했습니다: {locator}")
                    raise e
            except (ElementClickInterceptedException, StaleElementReferenceException) as e:
                if attempt == retries:
                    print(f"[에러] 요소 클릭이 차단되었거나 DOM이 변경되었습니다: {locator}")
                    raise e
                # 일시적 오류일 가능성이 높으므로 잠시 대기 후 재시도
                time.sleep(1.0)
        return False

    def safe_send_keys(self, by, locator, value, clear_first=True, timeout=None):
        """
        지정한 입력 필드에 안전하게 텍스트를 입력합니다.
        """
        target_timeout = timeout if timeout is not None else self.default_timeout
        try:
            element = WebDriverWait(self.driver, target_timeout).until(
                EC.presence_of_element_located((by, locator))
            )
            if clear_first:
                element.clear()
            element.send_keys(value)
            return True
        except TimeoutException as e:
            print(f"[에러] 입력 필드 대기 초과: {locator}")
            raise e

이러한 도우미 클래스를 만들어 두고, 티스토리 글쓰기 자동화 흐름 전반에서 driver.find_element().click() 대신 commander.safe_click(By.XPATH, "//button[...] ") 형태로 호출하면, 일시적인 렌더링 지연으로 인한 크래시 현상을 95% 이상 방지할 수 있습니다.


로컬 백업 시스템 — 네트워크 단절과 중단에 대응하는 데이터 보존

AI 자동화 블로그 시스템을 운영할 때 가장 뼈아픈 실패는 비용 낭비입니다. 본 시스템은 gemini-3.5-flash 모델과 claude-sonnet-4-6 모델을 하이브리드로 조합하여 텍스트를 생성합니다. 만약 수천 자에 달하는 완성도 높은 본문을 생성하는 데 성공했으나, 티스토리 에디터에 주입하고 발행 버튼을 누르는 물리적인 Selenium 제어 단계에서 오류가 발생해 프로그램이 강제 종료된다면 어떻게 될까요?

이미 사용한 API 토큰 비용은 그대로 청구되었는데, 정작 블로그에는 글이 올라가지 않고 생성된 데이터는 메모리 속에서 증발해 버립니다. 이는 1인 개발자 입장에서 치명적인 손실입니다.

따라서 자동화 워크플로우를 설계할 때는 블로그 자동화 프로그램, 어디까지 자동으로 할지 경계 정하기에서 다룬 상태 관리 개념을 한 단계 더 확장해야 합니다. 즉, AI로부터 본문 텍스트를 수신하는 즉시 로컬 디렉터리에 JSON 및 마크다운 파일로 물리 백업을 수행하는 단계를 필수로 삽입해야 합니다.

# backup_manager.py
# 생성된 포스팅 데이터를 티스토리 전송 전에 로컬에 백업하고 상태를 기록하는 모듈

import os
import json
from datetime import datetime

class LocalBackupManager:
    def __init__(self, backup_dir="backups"):
        self.backup_dir = backup_dir
        if not os.path.exists(self.backup_dir):
            os.makedirs(self.backup_dir)

    def save_draft(self, topic_id, title, content, tags):
        """
        생성 완료된 글 데이터를 로컬 JSON 파일로 저장합니다.
        """
        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        filename = f"draft_{topic_id}_{timestamp}.json"
        filepath = os.path.join(self.backup_dir, filename)

        backup_data = {
            "topic_id": topic_id,
            "title": title,
            "content": content,
            "tags": tags,
            "saved_at": datetime.now().isoformat(),
            "status": "GENERATED"
        }

        with open(filepath, "w", encoding="utf-8") as f:
            json.dump(backup_data, f, ensure_ascii=False, indent=4)

        print(f"[백업] 임시 초안이 로컬에 안전하게 저장되었습니다: {filepath}")
        return filepath

    def update_status(self, filepath, status):
        """
        발행 성공 여부에 따라 백업 파일의 상태를 업데이트합니다. (예: GENERATED -> PUBLISHED)
        """
        if not os.path.exists(filepath):
            return False

        with open(filepath, "r", encoding="utf-8") as f:
            data = json.load(f)

        data["status"] = status
        data["updated_at"] = datetime.now().isoformat()

        with open(filepath, "w", encoding="utf-8") as f:
            json.dump(data, f, ensure_ascii=False, indent=4)
        return True

이 백업 시스템이 갖춰지면 전체 프로세스는 다음과 같은 안전한 트랜잭션 구조를 띠게 됩니다.

  1. 글 생성 단계: Gemini 또는 Claude API를 호출하여 고품질 본문 텍스트를 생성합니다.
  2. 로컬 백업 단계: 생성 완료 즉시 backups/draft_[ID]_[시간].json 파일로 디스크에 기록합니다.
  3. 발행 시도 단계: Selenium을 구동하여 티스토리 에디터에 주입하고 발행을 시도합니다.
  4. 결과 처리 단계:
  5. 성공 시: 로컬 백업 파일 상태를 PUBLISHED로 변경하고, 주제 목록 파일의 상태 역시 완료로 업데이트합니다.
  6. 실패 시: 백업 파일 상태가 GENERATED로 유지됩니다. 개발자는 API를 재호출할 필요 없이, 로컬에 저장된 백업 파일을 읽어 들여 '발행 단계만 단독 재시도'할 수 있습니다.

실제로 구현하며 겪은 장벽과 해결책 — 티스토리 임시저장 팝업 우회

로컬 백업과 명시적 대기 구조를 모두 갖추고 실제 자동 포스팅 테스트를 돌려보던 중, 예상치 못한 장벽에 부딪혔습니다. 티스토리 에디터 페이지(https://www.tistory.com/onboarding/write/ 혹은 각 블로그의 글쓰기 주소)에 진입할 때, 간헐적으로 화면 전체가 어두워지며 다음과 같은 모달 경고창이 뜨는 현상이었습니다.

"임시저장된 글이 있습니다. 불러오시겠습니까? [취소] [확인]"

이 팝업창이 나타나면 에디터의 본문 영역이나 제목 입력창이 모두 레이어에 가려져 클릭할 수 없는 상태(ElementClickInterceptedException)가 됩니다. 더 큰 문제는 이 팝업이 항상 뜨는 것이 아니라, 이전에 글을 쓰다가 비정상 종료된 세션이 있을 때만 간헐적으로 발생한다는 점이었습니다.

단순히 무조건 대기하도록 코드를 짜면 팝업이 뜨지 않는 정상적인 상황에서 대기 시간 초과로 에러가 발생하고, 팝업 처리를 생략하면 팝업이 떴을 때 뒤의 입력 로직이 전부 먹통이 되었습니다.

이 문제를 해결하기 위해 본 시스템에서는 '비차단형 조건부 팝업 클리어 루틴'을 도입했습니다. 에디터 진입 직후 아주 짧은 타임아웃(1.5초)을 가진 명시적 대기를 가동하여 임시저장 팝업의 '취소' 버튼이 존재하는지 확인하고, 존재한다면 클릭하여 닫은 뒤 진행하며, 존재하지 않아 타임아웃이 발생하면 팝업이 없는 것으로 판단하고 즉시 예외를 무시한 채 본문 입력 단계로 넘어가는 설계입니다.

# tistory_publisher.py
# 티스토리 에디터 진입 시 발생하는 임시저장 팝업을 안전하게 처리하는 실전 코드 예시

from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException

def dismiss_temporary_save_popup(driver):
    """
    티스토리 에디터 진입 시 간헐적으로 나타나는 '임시저장 글 불러오기' 팝업을 감지하여 닫습니다.
    팝업이 나타나지 않는 정상 상황에서는 에러 없이 빠르게 통과합니다.
    """
    print("[프로세스] 임시저장 팝업 존재 여부를 확인합니다...")

    # 임시저장 팝업의 '취소' 버튼을 타겟팅하는 상대 XPath
    # 티스토리 에디터 마크업 기준, 모달 창 하단의 취소 버튼 클래스 및 텍스트 매칭
    cancel_button_xpath = "//div[contains(@class, 'layer_post')]//button[contains(text(), '취소')]"

    try:
        # 팝업 감지를 위해 타임아웃을 1.5초로 매우 짧게 설정합니다.
        cancel_btn = WebDriverWait(driver, 1.5).until(
            EC.element_to_be_clickable((By.XPATH, cancel_button_xpath))
        )
        cancel_btn.click()
        print("[정보] 임시저장 팝업을 감지하여 안전하게 닫았습니다.")
    except TimeoutException:
        # 1.5초 이내에 버튼이 나타나지 않으면 팝업이 없는 것으로 간주하고 조용히 넘어갑니다.
        print("[정보] 임시저장 팝업이 발견되지 않았습니다. 계속 진행합니다.")
    except Exception as e:
        print(f"[경고] 팝업 처리 중 예외 발생 (무시하고 진행): {str(e)}")

이 1.5초짜리 방어벽 덕분에 이전 작업의 실패 흔적으로 인해 에디터가 어떤 상태에 놓여 있든 상관없이, 자동화 스크립트는 일관되게 깨끗한 도화지 상태의 에디터를 확보할 수 있게 되었습니다.


견고한 자동화 설계의 장단점 비교

이러한 예외 처리와 로컬 백업 중심의 방어적 설계는 단순한 자동화 스크립트 작성에 비해 확실한 안정성을 제공하지만, 시스템 복잡도를 증가시키는 트레이드오프가 존재합니다.

장점

  • 무중단 운영 가능: 일시적인 네트워크 순단이나 티스토리 서버의 미세한 지연이 발생하더라도 스스로 재시도하여 작업을 완수하므로, 사람이 모니터를 지켜보고 서 있을 필요가 없습니다.
  • 운영 비용의 극적인 절감: API 호출로 생성한 고가의 본문 콘텐츠가 브라우저 오류로 유실되는 일이 원천 차단됩니다. 실패하더라도 로컬 백업본을 통해 언제든 무비용으로 재시도가 가능합니다.
  • 디버깅 편의성: 예외가 발생한 시점의 상태와 데이터가 로컬 JSON에 고스란히 남기 때문에, 어떤 단계에서 셀렉터가 깨졌는지 추적하기가 매우 용이합니다.

단점

  • 코드 베이스 비대화: 단순히 요소를 찾아 클릭하는 코드에 비해, 예외 처리 블록(try-except), 재시도 카운터, 명시적 대기 헬퍼 등이 추가되면서 코드의 전체 양이 3배 이상 늘어납니다.
  • 초기 설계 공수 증가: 상태 머신을 설계하고 로컬 파일 시스템의 입출력(I/O)을 관리하는 로직을 초기에 꼼꼼하게 다져놓아야 하므로 첫 작동까지 걸리는 개발 시간이 길어집니다.

마치며

이번 글에서는 웹 환경의 변화와 네트워크 불안정 속에서도 굴하지 않고 작동하는 견고한 자동화 시스템의 뼈대를 살펴보았습니다. 유연한 상대 XPath 설계, 명시적 대기를 활용한 안전 인터랙션, 그리고 API 비용을 지키기 위한 로컬 백업 전략은 장기적으로 무인 자동 블로그를 운영하기 위한 필수 조건입니다.

이렇게 튼튼하게 구축한 자동화 파이프라인 위에 이제 진짜 '콘텐츠의 영혼'을 불어넣을 차례입니다. 다음 글에서는 AI가 이전에 작성했던 글들의 맥락과 스타일을 기억하여, 매번 뜬금없는 글을 쓰는 대신 하나의 일관된 연재물처럼 자연스럽게 이어 쓰는 기술을 다루겠습니다.

다음 주제: AI가 앞 글을 기억하게 만들기 — 연재 맥락 주입과 발행 이력 자동 기록

반응형