네이버·티스토리에 글을 자동으로 올리는 Playwright 스크립트를 돌리다 보면 꼭 한 번씩 만나는 벽이 있다. 로컬에서 headless=False(브라우저 창을 띄우고 보는 모드)로 볼 땐 멀쩡히 발행되던 게, 야간 배치로 headless=True(창 없이 백그라운드)로 돌리면 발행 버튼 근처에서 스크립트가 그냥 멈춰버린다. 로그를 봐도 에러가 아니라 그냥 타임아웃. 처음엔 셀렉터(요소를 찾는 주소)가 틀린 줄 알고 며칠을 헤맸다. 범인은 발행 버튼을 누르는 순간 튀어나오는 confirm(“정말 발행하시겠습니까?“) 다이얼로그였다.

Playwright는 이 브라우저 네이티브 다이얼로그를 누가 처리하겠다고 미리 손을 들지 않으면 자동으로 취소(dismiss) 해버린다. 그래서 확인 버튼이 눌리지 않고, 다음 액션이 뜨지 않는 화면을 기다리다 타임아웃이 나는 것이다. 오늘은 이 다이얼로그를 이벤트 핸들러로 한 번에 다스리는 패턴을 정리한다.

왜 발행 버튼만 누르면 스크립트가 멈췄나?

먼저 전체 그림부터. 문제가 어디서 갈라지는지 한눈에 보면 이렇다.

flowchart TD
    A[발행 버튼 click] --> B{다이얼로그 발생}
    B --> C{핸들러 등록됨?}
    C -->|아니오| D[Playwright가 자동 dismiss]
    D --> E[확인 안눌림 → 다음화면 안뜸]
    E --> F[wait_for_selector 타임아웃]
    C -->|예| G[handler가 accept 처리]
    G --> H[발행 완료 화면 진입]
    classDef bad fill:#ffe3e3,stroke:#e03131,color:#a81111
    classDef good fill:#e7f5ff,stroke:#1c7ed6,color:#10548f
    class D,E,F bad
    class G,H good

핵심은 이거다. alert·confirm·beforeunload(페이지 이탈 경고) 같은 다이얼로그는 DOM 요소가 아니라 브라우저가 직접 그리는 창이다. 그래서 평소 쓰던 page.click("확인")으로는 잡을 수 없다. Playwright는 이걸 dialog라는 별도 이벤트로 흘려보낸다. 그 이벤트를 안 받으면 기본값으로 취소해버린다.

click 직전에 잡으려는 순서 문제는 왜 생기나?

두 번째로 삽질한 지점. “그럼 버튼 누르고 나서 다이얼로그를 잡으면 되겠네”라고 생각했는데 아니었다. 다이얼로그는 click이 반환되기 전에 뜬다. 즉 click 자체가 다이얼로그가 닫힐 때까지 블록될 수 있다. 시간순으로 보면 이렇다.

sequenceDiagram
    participant S as 스크립트
    participant P as Playwright
    participant B as 브라우저
    S->>P: page.on("dialog", handler) 먼저 등록
    S->>P: 발행버튼 click
    P->>B: 클릭 이벤트 전달
    B-->>P: confirm 다이얼로그 발생
    P->>S: dialog 이벤트 방출
    S->>P: dialog.accept()
    P->>B: 확인 처리
    B-->>P: click 반환

그래서 핸들러 등록이 반드시 click보다 앞서야 한다. 순서만 지키면 나머지는 단순하다.

handle_dialog 패턴은 실제로 어떻게 쓰나?

동기 API 기준 합성 예시다. 실제 서비스명·계정은 다 더미로 바꿨다.

from playwright.sync_api import sync_playwright
 
def publish_post(page, title, body):
    # ① 핸들러를 click보다 먼저 등록한다
    def on_dialog(dialog):
        print(f"[dialog] type={dialog.type} msg={dialog.message}")
        # confirm/alert는 accept, 이탈경고(beforeunload)만 dismiss
        if dialog.type == "beforeunload":
            dialog.dismiss()
        else:
            dialog.accept()  # confirm의 "확인" 누르기
 
    page.on("dialog", on_dialog)
 
    page.fill("#title", title)
    page.fill("#editor", body)
 
    # ② 이제 눌러도 다이얼로그가 자동 처리된다
    page.click("button.publish")
    page.wait_for_selector("text=발행 완료", timeout=15000)

여기서 page.on은 페이지가 살아있는 동안 계속 듣는 상시 핸들러다. 한 페이지에서 여러 번 발행하거나 예상 못한 alert가 중간에 껴도 다 받아낸다. 반대로 딱 한 번만 잡고 싶으면 page.once("dialog", ...)를 쓴다.

한 가지 주의. dialog.accept()핸들러 안에서 동기적으로 불러야 한다. 핸들러 밖으로 dialog 객체를 빼돌려서 나중에 처리하려 하면 이미 자동 dismiss된 뒤라 accept()가 먹지 않는다. 이게 두 번째로 오래 잡아먹은 함정이었다.

prompt 입력값이나 특정 문구만 골라 처리하려면?

prompt 다이얼로그(입력창이 딸린 것)는 accept()에 값을 넣어준다. 그리고 다이얼로그 종류·문구에 따라 분기하면 상태를 이렇게 다룰 수 있다.

stateDiagram-v2
    [*] --> 대기
    대기 --> 확인처리 : confirm/alert
    대기 --> 값입력 : prompt
    대기 --> 이탈유지 : beforeunload
    확인처리 --> [*] : accept()
    값입력 --> [*] : accept("입력값")
    이탈유지 --> [*] : dismiss()
def on_dialog(dialog):
    if dialog.type == "prompt":
        dialog.accept("자동입력값")      # 입력창에 값 채워 확인
    elif "임시저장" in dialog.message:
        dialog.dismiss()                # 임시저장 팝업은 무시
    else:
        dialog.accept()

메시지 문구로 걸러내는 게 특히 유용했다. 발행 확인은 accept, 페이지 나갈 때 “저장 안 된 변경사항” 경고는 dismiss처럼 성격이 다른 팝업을 한 핸들러에서 갈래로 나눠 처리할 수 있다.

그래서 뭐가 남았나?

정리하면 세 줄이다. 첫째, 브라우저 네이티브 다이얼로그는 DOM이 아니라 dialog 이벤트로 잡는다. 둘째, 핸들러는 반드시 click보다 먼저 등록한다. 셋째, accept/dismiss는 핸들러 안에서 즉시 부른다.

이 패턴을 넣고 나서 야간 배치가 조용해졌다. 예전엔 “왜 이 글만 안 올라갔지” 하고 아침마다 로그를 뒤졌는데 이제는 예상 못한 alert가 껴도 핸들러가 알아서 넘긴다. OSMU(한 번 쓴 글을 여러 채널에 뿌리는 것)로 네이버·티스토리 양쪽에 자동 발행할 때, 플랫폼마다 확인 팝업 문구가 다른 것도 메시지 분기 하나로 흡수된다.

다음엔 이 다이얼로그 핸들러를 파일 업로드 다이얼로그(이건 또 file_chooser 이벤트라 결이 다르다)와 묶어서, 이미지까지 자동으로 붙이는 발행 파이프라인으로 확장해볼 생각이다.