
Playwright로 티스토리 글 관리 화면에서 글 하나를 삭제하려다 같은 오류를 여섯 번 만났다. <div class="group_head">… from <div id="kakaoHead"> subtree intercepts pointer events. 체크박스가 분명히 화면에 있는데 클릭이 닿지 않는다. 이 글은 이 오류가 무엇을 뜻하는지, 흔히 쓰는 force=True가 왜 이 경우엔 오히려 해로웠는지, 그리고 결국 무엇으로 해결했는지를 다룬다. 결론은 "가로채는 요소를 치우거나, 포인터를 쓰지 않거나" 둘 중 하나다.
오류 메시지는 클릭 지점에 다른 요소가 있다는 뜻이다
Playwright는 클릭 전에 몇 가지 조건을 검사한다. 문서가 "actionability checks"라 부르는 것으로, 요소가 보이는지, 움직임이 멈췄는지, 활성화됐는지, 그리고 포인터 이벤트를 받는지다. 마지막 항목의 정의는 이렇다. "클릭 지점 (10;10)에서 다른 요소(보통 오버레이)가 대신 클릭을 가로채는지 확인한다."
내 경우 가로챈 요소는 둘이었다. 화면 상단에 고정된 카카오 헤더(#kakaoHead)와, 체크박스를 감싼 .check_blog 래퍼다. 스크롤 위치에 따라 어느 쪽이 걸리는지가 달라졌다. 체크박스가 헤더 아래로 들어가면 헤더가, 헤더를 피하면 래퍼가 가로챘다. Playwright는 이 상태에서 기본 30초 동안 재시도하다가 TimeoutError를 낸다.
force=True는 클릭을 성공시켰지만 상태를 바꾸지 못했다
가장 먼저 손이 가는 해법은 click(force=True)다. 문서는 force가 "필수적이지 않은 actionability 검사를 끈다"고 설명한다. 포인터 이벤트 검사도 여기 들어간다. 실제로 오류는 사라졌고 DOM의 checked는 true가 됐다.
그런데 행의 텍스트는 여전히 "선택 안됨"이었다. 삭제 버튼이 들어 있는 드롭다운도 열리지 않았다. 이 화면은 React로 만들어져 있어서, 체크 상태는 DOM 속성이 아니라 React 상태로 관리된다. force=True는 검사를 건너뛰고 좌표에 클릭을 날리는데, 그 좌표에 있는 것은 오버레이다. 클릭은 오버레이가 받았고 체크박스의 React 핸들러는 실행되지 않았다. DOM은 바뀌고 앱은 모르는, 가장 헷갈리는 상태다.
force는 "검사가 틀렸다고 확신할 때" 쓰는 옵션이지, "검사가 맞는데 무시하고 싶을 때" 쓰는 옵션이 아니다. 이번엔 검사가 정확했다.
해결은 네 가지, 순서대로 시도할 것
시도한 순서대로 정리한다. 앞의 것이 될수록 더 정직한 해법이다.
1. 요소를 화면 가운데로 스크롤한다. 고정 헤더는 화면 위쪽만 덮는다. scrollIntoView({block: 'center'})로 요소를 가운데로 옮기면 헤더를 피한다. 한 번은 이걸로 통과했다. 다만 페이지 길이와 창 크기에 따라 결과가 달라져서 신뢰하기 어려웠다.
2. 가로채는 요소를 숨긴다. #kakaoHead에 display: none을 준다. 확실하지만 조심할 것이 있다. 처음에 관리 화면의 본문 컨테이너 #mFeature까지 숨겼더니, 삭제 버튼이 그 안에 있어서 "버튼이 보이지 않음" 오류로 바뀌었다. 무엇을 숨기는지 정확히 알아야 한다.
3. 포인터 대신 DOM 이벤트를 쓴다. page.evaluate("() => document.querySelector('label[for=inpCheck544]').click()"). 좌표가 아니라 요소에 직접 클릭 이벤트를 보내므로 오버레이가 끼어들 수 없다. React는 위임된 이벤트를 받아 정상적으로 상태를 갱신했다. 행이 "선택 됨"으로 바뀌었다. 이번 문제의 최종 해법이다.
4. 키보드를 쓴다. 요소에 포커스를 주고 Space를 누른다. 포인터를 아예 쓰지 않으므로 가로채기와 무관하다. 체크박스, 버튼, 링크에 통한다.
# 3번: DOM 이벤트로 선택 → 메뉴 열기 → 삭제
page.evaluate("() => document.querySelector('label[for=\"inpCheck544\"]').click()")
assert "선택 됨" in row_text(page) # React 상태가 바뀌었는지 확인
page.evaluate("() => [...document.querySelectorAll('button.btn_opt')]"
".find(b => /변경/.test(b.textContent)).click()")
page.evaluate("() => [...document.querySelectorAll('input[type=button]')]"
".find(e => e.value === '삭제').click()")
왜 하필 label 이었나
이 화면의 체크박스는 스타일된 체크박스다. <input type="checkbox">는 시각적으로 숨겨져 있고 사용자가 보는 것은 <label for="...">다. input에 직접 click()을 보냈을 때는 상태가 안 바뀌었고, label에 보내자 바뀌었다. 브라우저가 label 클릭을 연결된 input의 활성화로 변환하는 과정에서 React가 기대하는 이벤트 순서가 만들어진 것으로 보인다. 정확한 내부 동작은 React 소스를 보지 않았으므로 단정하지 않는다. 다만 "사용자가 실제로 누르는 요소에 이벤트를 보내라"는 원칙은 여기서도 맞았다.
반론 하나. DOM 이벤트로 클릭하는 3번은 Playwright가 굳이 만들어둔 안전장치를 우회하는 것 아닌가. 맞다. 그래서 그 뒤에 반드시 상태 확인을 붙였다. 행 텍스트가 "선택 됨"인지, 체크된 항목이 정확히 1개인지 확인하고 아니면 중단한다. 검사를 우회했으면 검사를 직접 해야 한다. 그게 force=True와의 차이다.
참고 자료
- Playwright Python — Auto-waiting / Actionability — "receives events" 검사의 정의와
force옵션의 의미 - Playwright Python — Keyboard — 포인터 대신 키 입력으로 조작하는 방법
- Trigger Input Updates with React Controlled Inputs — DOM 값 변경을 React가 감지하지 못하는 이유
'IT' 카테고리의 다른 글
| AI 반도체 거품 논쟁, 매출 1조 달러와 캡엑스 6천억 달러 사이 (0) | 2026.09.14 |
|---|---|
| Playwright 파일 업로드, input 없는 사이트 처리법 (0) | 2026.09.13 |
| 프론트엔드 프레임워크 2026, 시그널이 표준이 되고 논쟁은 끝났다 (0) | 2026.09.13 |
| 티스토리 이미지 업로드 URL 만료, 서명 주소를 저장하면 안 되는 이유 (0) | 2026.09.12 |
| 주니어 개발자 채용이 줄었다는 말의 진짜 의미 (0) | 2026.09.12 |