개발 취재 노트

Playwright page.goto가 30초 만에 타임아웃난다면 — networkidle의 함정

AI 캐릭터들이 쇼츠 영상에서 티격태격하는 콘텐츠(personaverse)의 렌더링 파이프라인은 HTML 페이지를 헤드리스 크로미움으로 열어 Playwright로 화면을 녹화하는 방식이었다. 배경 클립을 1080x1620, 53초짜리 영상으로 바꿔 넣고 렌더를 돌리자, 109MB 클립에서 페이지를 여는 단계(page.goto)가 30초 만에 타임아웃나며 렌더가 죽었다.

결론

page.goto(url, waitUntil="networkidle")는 "네트워크 요청이 한동안 조용해졌다"는 조건을 로딩 완료로 본다. 그런데 페이지 안에서 큰 비디오를 계속 내려받는 구조에서는 이 조건이 실제 렌더 준비 여부와 어긋날 수 있다. 이 사례에서도 109MB 클립 때문에 networkidle이 전체 다운로드 대기를 30초 안에 조건을 채우지 못했다. Puppeteer의 networkidle0/networkidle2도 같은 방식(일정 시간 네트워크 연결이 0개 또는 2개 이하로 유지돼야 통과)으로 로딩 완료를 판단하므로, 같은 원인으로 같은 증상이 생긴다.

해결은 로딩 완료 판단을 네트워크 유휴가 아니라 domcontentloaded + 페이지가 스스로 세팅하는 명시적 "준비됨" 신호로 바꾸는 것이었다. 세션에서는 networkidle 대신 DOM 로드까지만 기다리고, 이후 __ready 폴링에 맡기도록 record.py를 고쳤다.

# Before — 큰 비디오를 받는 동안 networkidle 조건이 30초 안에 만족되지 않았다
page.goto(url, wait_until="networkidle")

# After — DOM 로드 뒤, 실제 렌더 준비는 페이지가 세팅하는 플래그로 판단
page.goto(url, wait_until="domcontentloaded")
page.wait_for_function("window.__ready === true")

이 패턴을 쓸 때 window.__ready는 아무 타이밍에나 세팅하면 안 된다. 배경 비디오가 실제로 재생 가능한 시점(video 엘리먼트의 canplay/loadeddata 이벤트)이나, 녹화 대상 DOM이 다 그려진 시점에 맞춰 페이지 쪽 스크립트가 직접 true로 바꿔줘야 한다. 이 조건을 빼먹으면 wait_for_function이 끝까지 통과하지 못해 결국 또 다른 타임아웃으로 막힌다.

상황

personaverse는 세대·가계·한국 현대사를 가진 AI 캐릭터들이 이슈를 두고 티격태격하는 숏폼 영상을 만드는 프로젝트다. 이 엔진은 렌더용 HTML을 헤드리스 크로미움으로 열고 Playwright로 화면을 녹화한다. 그래서 배경 영상을 별도 합성으로 얹기보다 HTML의 <video> 레이어로 깔 수 있는 구조였다.

이번에 만들던 편은 "장난처럼 부르는 줄 알았던 사람이 사실은 노래를 잘했다"는 반전 소재를 짜깁기 클립 위에 캐릭터 박도은이 말풍선으로 설명하는 구성이었다. 사용자는 편집본을 가져왔고, 초반에는 리브의 칭찬 멘트, 중간에는 장난스럽게 부르는 장면, 후반에는 진짜로 노래를 잘하는 장면이 이어진다고 설명했다 .

증상

클립은 1080x1620 해상도, 53초 길이, 오디오가 있는 세로 영상이었다. 타임라인을 분석하고 박도은 대사까지 장면 설정에 반영한 뒤 처음 렌더를 돌리자 바로 실패했다. 세션에서 확인된 진단은 "클립이 109MB로 커서 page.goto가 networkidle(전체 다운로드 대기)를 30초 안에 못 채우고 타임아웃났다"는 것이었다.

원인

record.py가 렌더용 페이지를 열 때 page.goto(url, waitUntil="networkidle")를 쓰고 있었다. 이 조건은 네트워크가 조용해지는 시점을 페이지 로딩 완료로 본다. 하지만 이 렌더 페이지의 핵심 자산은 페이지 안에서 재생되는 큰 배경 비디오였다. 109MB짜리 클립을 받는 동안 networkidle은 30초 안에 완료 조건을 채우지 못했고, 그 결과 page.goto가 타임아웃났다.

문제의 핵심은 클립이 "크다"는 사실 자체보다, 렌더 준비 완료를 "네트워크 유휴"로 판단했다는 데 있었다. 화면 녹화 파이프라인에서는 HTML 구조가 준비되고, 페이지가 실제로 그릴 준비가 됐는지를 보는 편이 더 직접적인 조건이다.

해결

page.goto의 waitUntil을 networkidle에서 domcontentloaded로 바꾸고, 실제 렌더 준비가 끝났는지는 네트워크 상태가 아니라 페이지가 스스로 세팅하는 커스텀 __ready 플래그를 폴링해서 판단하도록 바꿨다 . 수정 직후 같은 클립으로 다시 렌더를 돌리자 53.5초짜리 결과물이 60초 제한 안에 성공적으로 나왔다.

이후 세션 끝까지 이 결론이 뒤집히지는 않았다. 뒤쪽 작업은 진짜 노래 구간에서 말풍선을 지우고, 제목 카드와 오디오 싱크를 다듬는 쪽으로 이어졌다. 정리 메모에서도 이 변경은 "109MB 클립에서 로딩 대기 타임아웃나던 것(networkidle -> domcontentloaded)"으로 남았다 .

덤 — Playwright/Puppeteer를 쓰는 다른 작업에도 그대로 생긴다

이 문제는 특정 쇼츠 엔진에만 해당하지 않는다. Playwright나 Puppeteer로 헤드리스 스크린샷, PDF 생성, E2E 테스트, 화면 녹화를 자동화하다 보면 대상 페이지가 큰 비디오·이미지를 계속 내려받거나, 애널리틱스 비컨·웹소켓·롱폴링처럼 끊이지 않는 네트워크 활동을 갖고 있을 때 networkidle류 조건이 오래 만족되지 않을 수 있다. "로딩 끝"을 네트워크 유휴가 아니라 domcontentloaded나 페이지가 직접 보내는 준비 신호로 재는 쪽이 이런 페이지에서는 더 안전하다.

취재 후기 — 30초짜리 침묵이 영원히 안 끝난 이유

박도은
우리 쇼츠 엔진 있잖아. HTML 페이지를 헤드리스 브라우저로 열고, 그 화면을 그대로 녹화하는

방식. 거기 배경 클립을 바꿔 넣었더니 렌더가 시작도 못 하고 멈췄어.

정바다
배경 클립이 문제야? 크로미움이 비디오를 못 읽은 거 아니야?
정하늘
못 읽은 거면 재생 쪽 로그가 먼저 보였을 거야. 어디서 멈췄는데?
박도은
page.goto에서 30초 타임아웃. 렌더 페이지를 여는 단계에서 끝났어.
정바다
그럼 페이지가 너무 무거운 거네. 클립을 압축하면 해결되겠는데?
박도현
해결은 될 수 있어도 원인 설명은 아니지. page.goto가 뭘 기다렸는지 봐야 해.
정하늘
맞아. waitUntil 옵션 뭐였어? 혹시 networkidle?
박도은
응, networkidle. 그게 정확히 뭘 기다리는 건데?
정하늘
네트워크가 조용해질 때까지 기다리는 조건이야. 보통은 이미지나 스크립트가 다 내려오면

통과하지.

정바다
그런데 이번엔 배경이 그냥 이미지가 아니라 53초짜리 영상이잖아.
박도은
그리고 파일도 109MB였어. 세션에서는 그래서 networkidle이 전체 다운로드 대기를 30초 안에

못 채웠다고 봤어.

박도현
그럼 브라우저가 죽은 게 아니라, 기다리는 기준이 영상 다운로드랑 안 맞았던 거네.
정하늘
그렇지. 렌더 준비와 네트워크 유휴는 같은 말이 아니야.
정바다
그러면 네트워크가 조용해질 때까지 기다리지 말고, 화면이 준비됐다는 신호를 따로 봐야겠네.
정하늘
domcontentloaded로 페이지 구조만 먼저 열고, 그다음 페이지 안의 __ready 플래그를

폴링하면 돼. 실제로 그렇게 고쳤고.

박도은
근데 그 플래그는 아무 때나 true로 켜면 안 되지 않아? 뭘 보고 켜는 건데?
정하늘
비디오가 재생 가능해지는 시점, canplay 같은 이벤트에 맞춰 페이지 쪽에서 직접 켜주는

거야. 그게 없으면 wait_for_function도 영원히 안 끝나.

박도은
다시 돌렸더니 53.5초짜리 결과물이 60초 안에 나왔어.
박도현
큰 파일을 없애서 해결한 게 아니라, 기다리는 질문을 바꾼 거네.
정하늘
응. "네트워크가 조용한가?"가 아니라 "이 페이지가 녹화할 준비를 끝냈는가?"를 물어야 했던

문제였어.