3줄 요약
- http://127.0.0.1:9401/json/version은 즉시 200을 반환하는데 Playwright의 connect_over_cdp만 100초 타임아웃으로 멈췄다.
- "Chrome이 죽었다"는 오진으로 재기동을 보고했지만 틀렸다. 진짜 원인은 열려 있던 탭 하나의 렌더러가 hang된 것이었다.
- Playwright는 연결 시 모든 페이지에 자동으로 붙기(auto-attach) 때문에, 죽은 탭 하나가 연결 전체를 막는다. 해법은 그 탭만 닫는 것이고 브라우저 재기동은 필요 없다.
| 질문 | 답 |
|---|---|
| 증상은 무엇이었나 | HTTP 엔드포인트는 정상인데 connect_over_cdp만 100초 타임아웃 |
| 처음에 무엇으로 오진했나 | "Chrome이 죽었다 → 재기동해야 한다" — 실제로 이렇게 보고했다가 틀렸다 |
| 무엇으로 갈랐나 | 브라우저 타깃과 페이지 타깃을 따로 찔렀다. 브라우저 레벨은 응답, 특정 페이지만 무응답 |
| 진짜 원인과 해법은 | 특정 탭의 렌더러 hang. Target.closeTarget으로 그 탭만 닫으면 해결된다 |
| 재기동은 왜 피하나 | 같은 프로필을 쓰는 다른 작업의 로그인 세션과 진행 상태를 함께 날린다 |
이 글대로 하면, 자동화가 붙지 않을 때 브라우저를 죽이고 다시 띄우는 대신 어느 탭이 범인인지 지목하고 그 탭만 닫아 로그인 세션을 살린 채 작업을 이어갈 수 있다.
connect_over_cdp가 100초간 멈추는 증상은 무엇인가?
CDP(Chrome DevTools Protocol)로 이미 떠 있는 Chrome에 Playwright를 붙이려는데 connect_over_cdp 호출이 100초 동안 응답 없이 멈췄다. 그런데 같은 시점에 HTTP 레벨의 CDP 엔드포인트는 즉시 200을 반환하고 있었다. 아래 한 줄이 그 확인이다.
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:9401/json/version
이 값이 200인데 connect_over_cdp만 멎는다면, 그 시점에 "브라우저가 죽었다"는 가설은 이미 배제된 것이다.
브라우저가 죽었다는 오진은 왜 틀렸는가?
정황을 원인으로 승격시켰기 때문이다. 아래 표의 왼쪽이 그때 실제로 내린 판단이고, 오른쪽이 그 판단이 무너진 지점이다.
| 단계 | 그때 한 것 | 왜 틀렸나 |
|---|---|---|
| 1. 증상 | connect_over_cdp가 100초 타임아웃 |
여기까지는 사실 |
| 2. 오진 | "Chrome 프로세스가 죽었다" | /json/version이 200을 주고 있었다. 죽은 프로세스는 HTTP에 응답하지 못한다 |
| 3. 처방 | "재기동해야 한다"고 보고 | 원인을 안 짚었으므로 재기동해도 같은 상태가 재현될 수 있다 |
| 4. 계측 | 브라우저 타깃과 페이지 타깃을 분리해 찌름 | 여기서 결론이 뒤집혔다 |
HTTP 엔드포인트가 살아 있다는 것은 브라우저 프로세스와 CDP 서버는 정상이라는 뜻이고, 문제는 그 안쪽인 개별 페이지 쪽에 있어야 했다.
진짜 원인은 무엇인가?
열려 있던 탭 하나의 렌더러만 hang 상태였다. 브라우저 타깃의 웹소켓은 Target.getTargets 요청에 정상 응답했지만, naver.com을 띄우고 있던 탭에 Runtime.evaluate를 보내면 응답이 오지 않았다. 그런데 Playwright는 connect_over_cdp로 연결할 때 브라우저에 열려 있는 모든 페이지에 자동으로 붙는다(auto-attach). 그 과정에서 hang된 탭에 붙으려는 시도가 막히면서 연결 전체가 100초 타임아웃까지 끌려갔다. 탭 하나의 고장이 연결 전체의 고장으로 번지는 구조다.
어느 쪽이 고장인지 무엇으로 가르는가?
세 관측의 조합으로 판정한다. 아래가 판별식이다.
/json/version |
브라우저 타깃 Target.getTargets |
개별 페이지 Runtime.evaluate |
판정 |
|---|---|---|---|
| 무응답 | 확인 불가 | 확인 불가 | 브라우저 프로세스가 죽었다. 재기동이 맞다 |
| 200 | 정상 응답 | 특정 탭만 무응답 | 그 탭의 렌더러 hang. 그 탭만 닫는다 |
| 200 | 정상 응답 | 전부 정상 | 브라우저가 아니라 스크립트 쪽을 의심한다 |
| 200 | 무응답 | 확인 불가 | CDP 서버 쪽 문제 — 우리 환경에서는 관측하지 못한 조합이다 |
핵심은 두 번째 줄이다. 브라우저 레벨은 살아 있는데 페이지 레벨 하나만 죽어 있으면, 그것은 브라우저 고장이 아니라 탭 고장이다.
재현과 진단은 어떤 순서로 하는가?
번호대로 따라가면 범인 탭이 지목된다. 각 단계는 앞 단계가 통과했을 때만 의미가 있다.
- 위의
curl한 줄로/json/version이 200인지 본다. 200이면 프로세스는 살아 있다. - Playwright로 붙지 말고 브라우저 타깃 웹소켓에 직접 붙어
Target.getTargets를 보낸다. 응답이 오면 브라우저 레벨은 정상이다. - 받은 타깃 목록에서
type이page인 것만 추린다. 이 목록이 용의자 명단이다. - 각 페이지 타깃 웹소켓에 하나씩 붙어
Runtime.evaluate로 가벼운 식(예:1+1)을 보낸다. 타임아웃은 3~5초로 짧게 잡는다. - 응답이 없는 타깃의
targetId를 적는다. 그 탭이 범인이다. - 브라우저 타깃 웹소켓으로
Target.closeTarget에{"targetId": "<적어둔 값>"}를 보내 그 탭만 닫는다. - 다시
connect_over_cdp를 호출한다.
2번부터 6번까지를 Playwright로 하지 않는 이유가 중요하다. connect_over_cdp 자체가 막혀 있으므로 진단과 복구는 Playwright를 거치지 않는 경로여야 한다. 우리 저장소에서는 진단 스크립트 scripts/image-gen/_cdp_probe_9401.py가 브라우저 타깃 웹소켓과 각 페이지 타깃 웹소켓을 따로 찔러 hang 지점을 갈랐다. 이 분리 없이는 오진에서 벗어나기 어렵다.
연결이 이미 성립한 뒤라면 CDP 메서드를 보내는 것은 간단하다. 아래는 자동화용 Chrome을 정상 종료하는 스크립트 scripts/note/restart_chrome_notranslate.py에서 CDP 세션을 여는 부분이다.
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
b = p.chromium.connect_over_cdp(CDP)
ctx = b.contexts[0]
pg = ctx.pages[0] if ctx.pages else ctx.new_page()
s = ctx.new_cdp_session(pg)
s.send("Browser.close")
s.send("Browser.close") 자리에 s.send("Target.closeTarget", {"targetId": tid})를 넣으면 브라우저가 아니라 탭 하나만 닫힌다. 연결 자체가 막힌 상황에서는 이 경로를 쓸 수 없으므로 앞의 웹소켓 직접 경로가 필요하다.
왜 Chrome을 재기동하면 안 되는가?
브라우저 프로세스를 죽여도 원인이 사라지지 않으면서 잃는 것만 크기 때문이다. 이런 CDP 프로필은 보통 하나의 계정·하나의 로그인 세션을 여러 작업이 함께 쓰는 구조라서, 재기동은 그 순간 붙어 있던 다른 작업의 로그인 세션과 진행 상태를 통째로 날린다. 로그인된 브라우저를 반복적으로 강제 종료하고 재실행하는 패턴은 서비스 쪽에서 봇 신호로 읽힐 여지도 있다.
종료가 불가피하다면 프로세스를 이름 단위로 죽이는 대신 CDP Browser.close로 정상 종료하는 편이 낫다. 같은 이름의 다른 프로세스까지 함께 죽는 사고를 막기 위해서다.
이 진단이 통하지 않는 경우는 무엇인가?
이 절차는 "브라우저 프로세스는 살아 있는데 연결만 안 되는" 상황에 한정된다. 1번 단계에서 /json/version이 아예 응답하지 않으면 이 글의 처방은 적용되지 않고, 그때는 재기동이 맞다.
| 실패 조건 | 그때 무엇을 하는가 |
|---|---|
| 페이지 타깃이 전부 정상 응답한다 | 브라우저가 아니라 스크립트 문제다. 다른 자동화가 이미 붙어 있지 않은지 본다 |
Target.closeTarget 후에도 멎는다 |
hang된 탭이 하나가 아닐 수 있다. 4번 단계를 남은 탭 전체에 다시 돌린다 |
| 닫아야 할 탭이 마지막 탭이다 | CDP 프로필에서 마지막 탭을 닫으면 Chrome이 종료된다. 빈 탭을 먼저 만들고 닫는다 |
| 그 탭이 진행 중 작업을 담고 있다 | 닫기 전에 결과를 파일로 내려받는다 |
한계도 분명하다. 여기 적은 포트 번호와 프로필 구성은 우리 환경의 값이고, auto-attach 동작과 100초라는 대기 시간은 Playwright 버전에 따라 달라질 수 있다. 재현할 때는 자기 환경의 타임아웃 값을 먼저 확인하는 편이 좋다. 렌더러가 hang된 원인 자체는 이 절차로 알 수 없다. 이 글이 다루는 것은 원인 제거가 아니라 막힌 연결을 최소 피해로 되살리는 방법이다.
붙기 전에 무엇을 확인해야 하는가?
자동화를 시작하기 전에 아래를 확인하면 이 사고의 대부분을 만나기 전에 걸러낸다. 그대로 복사해 쓰는 체크리스트다.
- [ ]
/json/version이 200인가 (아니면 프로세스부터 살린다) - [ ] 자동화가 쓰지 않는 탭, 특히 외부 사이트 탭이 남아 있지 않은가
- [ ] 같은 브라우저에 다른 Playwright 세션이 이미 붙어 있지 않은가
- [ ]
connect_over_cdp타임아웃을 짧게 잡아 두었는가 (기본값을 기다리면 진단이 늦어진다) - [ ] 새 탭을 여는 대신 기존 탭(
ctx.pages[0])을 재사용하는가 - [ ] 종료를
pg.close()가 아니라pg.goto('about:blank')로 하는가
마지막 두 항목은 이 사고와 별개로 붙어 있는 규율이다. 새 탭을 열면 그 탭이 활성화되면서 사용자의 화면을 뺏고, 마지막 탭을 닫으면 Chrome이 함께 종료되기 때문이다. 증상만 보고 원인을 짐작하면 엉뚱한 해법에 시간을 쓴다는 점은 서버 장애 화면의 정체가 크롬 자동번역이었던 사례와 같고, 같은 원칙을 조판 자동화에 적용한 사례는 md를 HTML로 바꿀 때 LLM에게 시키지 않은 이유에 있다. 오진을 그대로 적는 방식은 자체 콘텐츠 감사에서 확정한 규칙을 따른 것이다.
자주 묻는 질문
Q. connect_over_cdp가 100초 타임아웃으로 멎는 원인은 무엇인가?
A. 열려 있는 탭 중 하나의 렌더러가 hang 상태이고, Playwright가 연결 시 모든 페이지에 자동으로 붙으려 하면서 그 탭에서 막히기 때문이다.
Q. HTTP 엔드포인트(/json/version)가 정상인데도 브라우저가 죽었다고 볼 수 있는가?
A. 아니다. 엔드포인트가 정상 응답한다면 브라우저 프로세스와 CDP 서버는 살아 있다는 뜻이다. 문제는 개별 페이지 쪽에 있을 가능성이 높다.
Q. 원인을 어떻게 좁히는가?
A. 브라우저 타깃 웹소켓과 각 페이지 타깃 웹소켓을 따로 찔러본다. 브라우저 레벨(Target.getTargets)은 정상이고 특정 페이지(Runtime.evaluate)만 응답이 없다면 그 탭이 범인이다. 연결 자체가 막혀 있으므로 진단은 Playwright를 거치지 않는 경로로 한다.
Q. 해법은 브라우저 재기동인가?
A. 아니다. hang된 탭의 targetId를 찾아 Target.closeTarget으로 그 탭만 닫으면 해결된다. 다만 그것이 마지막 탭이면 Chrome이 함께 종료되므로 빈 탭을 먼저 하나 만들어 둔다.
Q. 왜 재기동을 피해야 하는가?
A. 하나의 프로필을 여러 작업이 함께 쓰는 경우, 재기동은 그 순간 붙어 있는 다른 작업의 로그인 세션과 진행 상태를 함께 날린다. 반복적인 강제 재기동은 봇 탐지 신호로 읽힐 여지도 있다.
Comments
Comments (0)
Leave a Comment