티스토리 뷰
들어가며
우리 팀은 API를 제공하지 않는 외부 서비스(ERP, 외부 관리자 페이지 등)에서 매일 데이터를 수집하는데, 이 수집기들이 Playwright 위에서 돌아간다. Celery worker에 headless 브라우저를 올려서 매일 새벽마다 실행하는 구조다.
일주일 동안 이 수집기들이 연쇄적으로 터졌는데, 하나 고치면 다른 데서 터지고, 그걸 고치면 또 다른 데서 터지는 식이었다. 돌이켜보면 전부 "Playwright는 테스트 도구"라는 전제에서 오는 문제들이었다고 생각된다.
보이지 않는 로딩 모달이 클릭을 삼킨다
ERP 시스템에서 엑셀을 다운로드하는 수집기가 운영 환경에서만 간헐적으로 실패했다. "엑셀변환하기" 메뉴를 못 찾아서 30초 timeout.
코드 흐름은 이랬다:
# "조회펼침" 버튼 클릭 → 데이터 로딩 시작
await page.locator("div").filter(has_text=re.compile(r"^조회펼침$")).first.click()
await asyncio.sleep(1)
# 캔버스 영역 우클릭 → 컨텍스트 메뉴에서 "엑셀변환하기" 클릭
await page.mouse.click(canvas_x, canvas_y, button="right")
await page.get_by_text("엑셀변환하기").click() # ← 여기서 timeout
디버그용 스크린샷을 찍어봤더니 우클릭 시점에 "데이터를 조회하고 있습니다." 로딩 모달이 화면을 덮고 있었다. 모달 위에서 우클릭하면 당연히 컨텍스트 메뉴가 안 뜬다.
로컬에서는 데이터가 적어서 sleep(1) 안에 로딩이 끝나버린다. EC2에서 수만 행을 처리할 때만 터진다. 심지어 디버깅하려고 page.evaluate()로 DOM 덤프를 넣었더니 그 코드가 만든 수백ms 지연이 우연히 모달이 사라질 시간을 벌어줘서 문제가 안 재현되기도 했다. 디버깅 코드를 넣으면 버그가 사라지는 상황이었다.
결국 sleep 대신 모달이 실제로 사라질 때까지 대기하는 것으로 바꿨다:
try:
await page.get_by_text("데이터를 조회하고 있습니다.", exact=False) \
.wait_for(state="hidden", timeout=120000)
except PlaywrightTimeoutError:
raise CollectorDownloadError("데이터 조회가 시간 내에 완료되지 않음")
wait_for(state="hidden")은 해당 요소가 DOM에서 사라질 때까지 기다린다. sleep은 race condition을 늦추는 거지 해결하는 게 아니다. 운영 환경에서만 나타나는 타이밍 버그는 대부분 이런 식이라고 생각된다 — 로컬에서는 충분히 빨라서 우연히 통과한 케이스.
click()이 hang되는 경우
위의 fix를 배포하자마자 같은 수집기가 다른 위치에서 또 멈췄다. 이번에는 엑셀 다운로드 팝업의 "확인" 버튼.
async with page.expect_download(timeout=60000) as download_info:
await page.get_by_role("button", name="확인").click() # ← 30초간 hang
Playwright call log를 보면 element가 visible/enabled/stable로 잘 잡혔고, "performing click action"까지 진입한 다음에 멈췄다. actionability check는 통과했는데 click 프로토콜 자체가 hang된 거다.
직전에 체크박스 토글 코드가 있었는데, 이 ERP의 UI 컴포넌트가 리렌더링하면서 click 이벤트를 삼켜버리는 것으로 추정했다.
Playwright의 표준 click()이 거치는 단계가 꽤 많다:
element 찾기 → actionability check → scroll into view →
클릭 좌표 계산 → pointer 이벤트 시뮬레이션 → 후속 상태 확인
이 중 하나가 멈추면 전체가 멈춘다. dispatch_event("click")은 이 파이프라인을 전부 건너뛰고 DOM 이벤트만 직접 발생시킨다:
confirm_button = page.get_by_role("button", name="확인")
await confirm_button.wait_for(state="visible", timeout=10000)
await asyncio.sleep(1)
async with page.expect_download(timeout=60000) as download_info:
await confirm_button.dispatch_event("click")
다만 dispatch_event는 합성 이벤트라 isTrusted가 false다. 모든 경우에 되는 건 아니고, click 파이프라인 hang이 확인된 특정 케이스에 대한 우회책이다.
참고로 force=True도 있는데 이건 actionability check만 우회하는 거라 이 상황에서는 안 통했다. 증상이 다르면 써야 할 도구도 다르다.
세션 재사용으로 불필요한 로그인 생략하기
외부 관리자 페이지 수집기는 매번 수집할 때마다 로그인 → 페이지 이동 → 데이터 추출 순서로 돌아간다. 그런데 생각해보면 이전 수집에서 이미 로그인한 세션이 살아있다면 로그인 과정을 굳이 반복할 필요가 없다. 로그인 자체가 외부 서비스에 불필요한 요청을 보내는 것이기도 하고, 수집 속도도 느려진다.
그래서 세션 쿠키가 살아있으면 로그인을 생략하는 분기를 넣었다:
# 이전 세션 쿠키 복구 후 관리자 대시보드에 직접 접근
await page.goto(ADMIN_DASHBOARD_URL)
await asyncio.sleep(7)
# 관리자 경로에 머물러 있으면 세션이 살아있는 것
if "/admin/" in page.url:
return # 로그인 생략
# 리다이렉트 됐으면 세션 만료 → 정식 로그인 진행
await page.goto(LOGIN_URL)
세션 판별은 단순하다. 관리자 URL로 접근했을 때 URL 경로가 /admin/을 유지하면 세션이 살아있는 거고, 로그인 페이지로 리다이렉트되면 만료된 거다.
여기까지는 간단한데, 문제는 이 세션 쿠키가 브라우저 재시작 후에 사라진다는 거였다.
Firefox가 세션 쿠키를 버린다
세션 재사용을 구현했더니 로컬에서는 잘 됐다. 그런데 브라우저를 재시작하면 세션이 날아갔다.
알고보니 세 가지가 겹친 거였다:
- 대상 서비스의 인증 쿠키가 session-only (
Set-Cookie에Expires없음). 브라우저 닫으면 사라짐. - Firefox는 session cookie를
cookies.sqlite에 안 넣는다. 메모리에만 유지하고 원래는 session restore로 복구함. - Playwright가 session restore를 강제로 꺼버린다.
prefs.js에browser.sessionstore.resume_from_crash = false를 박음. juggler 패치 레벨이라user.js로 override도 안 됨.
결과적으로 브라우저를 닫으면 session cookie가 어디에도 남지 않는다.
근데 Firefox가 cookies.sqlite에는 안 넣지만 sessionstore-backups/ 디렉토리에는 저장한다. crash recovery용 파일인데 jsonlz4라는 Mozilla 독자 포맷으로 압축되어 있다. 이걸 직접 파싱하기로 했다:
import lz4.block, json
def _restore_cookies_from_sessionstore(user_data_dir: Path) -> list[dict]:
candidates = [
user_data_dir / "sessionstore.jsonlz4", # clean shutdown
user_data_dir / "sessionstore-backups" / "recovery.jsonlz4", # crash
user_data_dir / "sessionstore-backups" / "previous.jsonlz4", # 이전 세션
]
merged = {}
for path in candidates:
if not path.exists():
continue
raw = path.read_bytes()
if raw[:8] != b"mozLz40\0": # Mozilla LZ4 magic number
continue
obj = json.loads(lz4.block.decompress(raw[8:]))
for cookie in obj.get("cookies", []):
if TARGET_DOMAIN not in cookie.get("host", ""):
continue
key = (cookie["host"], cookie["name"], cookie["path"])
merged[key] = cookie
return [
{
"name": c["name"], "value": c["value"],
"domain": c["host"], "path": c.get("path", "/"),
"expires": int(time.time()) + 7 * 86400, # 7일 후 만료로 강제
"httpOnly": bool(c.get("httponly", False)),
"secure": bool(c.get("secure", False)),
"sameSite": "Lax",
}
for c in merged.values()
]
session cookie에는 원래 expires가 없으니까 7일 후 만료를 강제로 박아서 context.add_cookies()에 넣는다.
한 가지 더 삽질한 게 있는데, Firefox는 clean shutdown 시 recovery.jsonlz4를 프로필 루트의 sessionstore.jsonlz4로 이동시킨다. 처음에 sessionstore-backups/만 봤다가 정상 종료한 경우에 파일이 없어서 한참 헤맸다. 종료 방식에 관계없이 세 경로를 다 봐야 한다.
일단 Playwright가 테스트 도구로 설계되었기 때문에, 매번 깨끗한 상태에서 시작하는 게 "올바른 동작"이다. 운영 환경 크롤러로 쓸 때는 이런 테스트에 맞는 기본값들이 전부 장애물이 된다. 필요하면 브라우저 내부 파일을 직접 다루는 것도 선택지라고 생각된다.
부록: dialog handler 누적
위의 세션 재사용 로직은 retry loop 안에서 login()을 여러 번 호출한다. 여기서 한 가지 더 터졌다.
외부 관리자 페이지가 접근 시 JavaScript alert을 2회 띄우는데, 자동으로 닫기 위해 dialog handler를 등록했다:
async def login(page, config):
page.on("dialog", _on_dialog)
await _do_login(page, config)
문제는 login()이 3번 호출되면 handler도 3개 등록된다는 거다. dialog 하나에 handler 3개가 동시에 accept()를 치니까 첫 번째만 성공하고 나머지는 Cannot accept dialog which is already handled! 에러.
try/finally로 handler 생명주기를 격리했다:
async def login(page, config):
async def _on_dialog(dialog):
try:
await dialog.accept()
except Exception:
pass
page.on("dialog", _on_dialog)
try:
await _do_login(page, config)
finally:
page.remove_listener("dialog", _on_dialog)
Playwright 이벤트 리스너는 수동으로 제거해야 한다. page.once()도 있지만 alert이 2번 이상 뜨면 첫 번째만 처리하고 멈춘다. retry가 있는 코드에서는 등록/해제를 명시적으로 관리하는 게 맞는 것 같다.
마무리
돌이켜보면 전부 "Playwright는 테스트 도구"라는 데서 오는 문제들이었다. 테스트에서는 깨끗한 상태, 짧은 실행 시간, 예측 가능한 페이지가 전제되는데, 운영 환경 크롤러는 이 전제가 전부 깨진 환경에서 돌아간다.
사실 이 글에서 다루지 못한 시도와 실험들이 훨씬 많다. 외부 서비스를 자동화하다 보면 인증, 방어 메커니즘, 환경 차이 등 글로 풀기 어려운 문제들을 만나게 되는데, 결국 중요한 건 여러 방법들을 빠르게 시도해보고 안 되면 바로 다음 방법으로 넘어가는 것, 그리고 되는 방법을 찾았을 때 그걸 운영 환경에서 안정적으로 동작하게 만드는 것이라고 생각된다.
'연습' 카테고리의 다른 글
| RTB: 수집 파이프라인 구조 개선안 (0) | 2026.03.08 |
|---|---|
| 구독 시스템 구현기 (0) | 2026.01.23 |
| 구독 시스템 설계기 (0) | 2025.12.21 |
| GIL with dict (3) | 2025.12.09 |
| IDLE한 상태에서 배운 점 (1) | 2025.11.26 |
- Total
- Today
- Yesterday
- 불필요한 값 무시하기
- 위상정렬
- Til
- 회고
- 이것도모르면바보
- endl을절대쓰지마
- SQL
- 프로그래머스
- 최대한 간략화하기
- 백준
- kafka쓰고싶어요
- 그리디
- 코딩테스트
- PREFECT
- 우선순위큐
- 스택
- Javascript
- 파이썬
- docker-compose update
- 삽질
- Python
- vscode
- jwt
- django testcase
- cipher suite
- requests
- 힙
- BOJ
- Remote
- SSL
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | |||||
| 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| 10 | 11 | 12 | 13 | 14 | 15 | 16 |
| 17 | 18 | 19 | 20 | 21 | 22 | 23 |
| 24 | 25 | 26 | 27 | 28 | 29 | 30 |
| 31 |