Playwright 클릭이 안될 때 완벽 해결 가이드 — 원인 분석부터 실전 코드까지
자동화 테스트를 작성하다 보면 분명히 화면에 버튼이 보이는데 클릭이 되지 않아 테스트가 실패하는 상황을 마주하게 됩니다. 로컬에서는 잘 동작하던 테스트가 CI 환경에서 갑자기 멈추거나, TimeoutError가 쏟아지기 시작하면 무엇이 문제인지 막막하기 마련입니다. 이 글에서는 Playwright에서 클릭이 안 되는 다양한 원인과 상황별 해결 방법을 단계적으로 정리했습니다.
목차
- Playwright 클릭의 작동 원리 — Actionability Checks
- 클릭이 실패하는 주요 원인 5가지
- 해결 방법 1 — 올바른 Locator 선택
- 해결 방법 2 — 대기(Wait) 전략 활용
- 해결 방법 3 — force 옵션과 dispatchEvent
- 해결 방법 4 — iframe 및 오버레이 처리
- 해결 방법 5 — CI 환경에서의 클릭 실패 대응
- 상황별 빠른 체크리스트
1. Playwright 클릭의 작동 원리 — Actionability Checks
Playwright는 단순히 DOM 요소를 찾아서 이벤트를 발생시키는 도구가 아닙니다. 클릭 동작을 수행하기 전에 요소가 정말로 클릭 가능한 상태인지 일련의 검증을 자동으로 수행합니다. 이를 Actionability Checks라고 부릅니다.
locator.click()이 실행될 때 Playwright는 내부적으로 다음 조건을 모두 확인합니다.
| 검사 항목 | 설명 |
|---|---|
| Attached | 요소가 DOM에 존재하는가 |
| Visible | 요소가 화면에 보이는가 (opacity, display, visibility 등) |
| Stable | 요소가 애니메이션 없이 정지해 있는가 |
| Enabled | 요소가 disabled 상태가 아닌가 |
| Receives Events | 다른 요소에 가려져 있지 않은가 |
| In Viewport | 스크롤이 필요한 경우 자동 스크롤 후 클릭 |
이 조건 중 하나라도 충족되지 않으면 Playwright는 기본 타임아웃(30초) 동안 반복 재시도한 뒤 TimeoutError를 발생시킵니다. 에러 메시지만 봐서는 어느 조건이 문제인지 알기 어렵기 때문에, 각 원인을 하나씩 점검하는 것이 중요합니다.

2. 클릭이 실패하는 주요 원인 5가지
원인 1 — 요소가 다른 요소에 가려진 경우
모달 다이얼로그, 토스트 알림, 고정(fixed) 헤더, 쿠키 동의 배너 등이 클릭하려는 버튼 위에 올라와 있으면 Playwright는 해당 요소가 클릭 이벤트를 받을 수 없다고 판단합니다. 육안으로는 버튼이 보여도 실제로는 투명한 오버레이가 덮고 있는 경우도 많습니다.
원인 2 — 페이지 하이드레이션 미완료
React, Next.js, Vue 등 SPA 프레임워크는 서버에서 HTML을 렌더링한 뒤 클라이언트에서 다시 JavaScript로 DOM을 활성화하는 하이드레이션(Hydration) 과정을 거칩니다. 이 과정이 완료되기 전에 클릭하면 이벤트 핸들러가 아직 연결되지 않아 클릭이 무시됩니다.
원인 3 — 잘못된 Selector 사용
CSS 선택자가 여러 요소를 매칭하거나, 동적으로 변하는 클래스명·ID를 사용하면 원하는 요소를 정확히 찾지 못합니다. 특히 스타일 라이브러리가 자동 생성하는 클래스명(예: sc-aXZVg)은 빌드마다 바뀌기 때문에 사용을 피해야 합니다.
원인 4 — 애니메이션·트랜지션 진행 중
버튼이 페이드인되거나 슬라이드되는 도중에는 Playwright의 Stable 검사를 통과하지 못합니다. CSS transition이나 JavaScript 애니메이션이 완전히 끝날 때까지 클릭이 대기됩니다.
원인 5 — iframe 내부 요소 클릭
결제 위젯, 지도 임베드, 광고 컨테이너처럼 <iframe> 안에 있는 요소는 일반 페이지 컨텍스트에서 접근할 수 없습니다. 반드시 iframe 컨텍스트로 전환 후 클릭해야 합니다.
3. 해결 방법 1 — 올바른 Locator 선택
Playwright 공식 문서는 가능하면 사용자 관점의 Locator를 우선 사용하도록 권장합니다. 테스트가 실제 사용자 경험과 가까울수록 유지보수도 쉬워집니다.
권장 Locator 우선순위
getByRole()— 접근성 역할 기반 (가장 견고)getByText()— 텍스트 기반getByLabel()— 폼 레이블 기반getByTestId()—data-testid속성 기반- CSS / XPath — 마지막 수단
// 권장: 역할 기반 Locator
await page.getByRole('button', { name: '로그인' }).click();
// 권장: 텍스트 기반
await page.getByText('다음 단계').click();
// 권장: 테스트 ID (HTML에 data-testid="submit-btn" 추가 필요)
await page.getByTestId('submit-btn').click();
// 비권장: 깨지기 쉬운 CSS 선택자
await page.click('.sc-aXZVg.kDmPzL');
선택자가 여러 요소를 매칭할 가능성이 있다면 strict 모드를 활성화하여 즉시 에러로 확인할 수 있습니다.
// strict 모드: 매칭되는 요소가 2개 이상이면 에러 발생
await page.locator('button.submit').click({ strict: true });
4. 해결 방법 2 — 대기(Wait) 전략 활용
Playwright의 자동 대기 기능이 강력하지만, 특정 상황에서는 명시적인 대기가 필요합니다. 무분별한 page.waitForTimeout(3000) 같은 하드코딩된 대기는 테스트를 느리게 만들고 불안정하게 만드는 주원인입니다. 대신 조건 기반 대기를 사용하세요.
// 나쁜 예: 무조건 3초 대기
await page.waitForTimeout(3000);
await page.click('#submit');
// 좋은 예: 요소가 활성화될 때까지 대기
await page.waitForSelector('#submit:not([disabled])');
await page.click('#submit');
// 좋은 예: 네트워크 요청이 완료될 때까지 대기 후 클릭
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: '저장' }).click();
// 좋은 예: 특정 응답을 기다린 후 클릭
await Promise.all([
page.waitForResponse(res => res.url().includes('/api/data')),
page.getByRole('button', { name: '불러오기' }).click()
]);
타임아웃을 늘리는 것도 임시 방편으로 사용할 수 있습니다. 특정 액션만 타임아웃을 늘리려면 옵션에서 직접 지정합니다.
// 해당 클릭만 60초 타임아웃 적용
await page.getByRole('button', { name: '처리' }).click({ timeout: 60000 });
5. 해결 방법 3 — force 옵션과 dispatchEvent
Actionability Checks를 통과하지 못하는 요소를 어떻게든 클릭해야 하는 경우가 있습니다. 예를 들어 커스텀 UI 라이브러리가 실제 <button> 대신 <div>를 사용하거나, 보이지 않는 파일 업로드 입력 요소를 클릭해야 할 때입니다. 이럴 때는 아래 두 가지 방법을 검토하세요.
방법 A — force: true 옵션
Actionability Checks를 건너뛰고 강제로 클릭 이벤트를 발생시킵니다. 요소가 실제로 이벤트를 수신할 수 있는지는 보장되지 않으므로 최후의 수단으로만 사용합니다.
await page.locator('#hidden-trigger').click({ force: true });
방법 B — dispatchEvent
요소의 가시성 여부와 관계없이 DOM 이벤트를 직접 발생시킵니다. force: true보다 더 낮은 수준에서 동작합니다.
await page.locator('#my-element').dispatchEvent('click');
주의: force와 dispatchEvent는 실제 사용자 동작과 다르게 동작할 수 있으므로 테스트의 신뢰성을 낮출 수 있습니다. 가능하면 근본 원인을 수정하는 방향으로 접근하고, 이 방법들은 서드파티 위젯처럼 코드 수정이 불가능한 경우에만 사용하세요.

6. 해결 방법 4 — iframe 내부 요소 클릭
결제창, 소셜 로그인 버튼, 지도 컴포넌트 등 <iframe> 안에 있는 요소를 클릭하려면 먼저 iframe 프레임 컨텍스트를 가져와야 합니다.
// URL 기반으로 iframe 찾기
const paymentFrame = page.frameLocator('iframe[src*="payment"]');
await paymentFrame.getByRole('button', { name: '결제하기' }).click();
// 이름(name 속성) 기반으로 iframe 찾기
const frame = page.frame({ name: 'login-frame' });
await frame.locator('#username').fill('testuser');
iframe이 로드되기 전에 접근하면 실패하므로, 필요하다면 iframe 내부 요소를 waitForSelector로 먼저 기다린 뒤 클릭하는 것이 안전합니다.
7. 해결 방법 5 — CI 환경에서의 클릭 실패 대응
로컬에서는 문제없이 작동하던 테스트가 GitHub Actions, GitLab CI, Jenkins 등 CI 환경에서 실패하는 경우가 자주 있습니다. CI 서버는 로컬보다 CPU와 메모리가 제한적이어서 페이지 렌더링과 JavaScript 실행이 더 느릴 수 있습니다.
CI 환경 대응 체크리스트
- headless 모드 확인: CI에서는 headless 브라우저로 실행되므로 headful 모드에서만 동작하는 코드가 있으면 수정이 필요합니다.
- 전역 타임아웃 늘리기:
playwright.config.ts에서timeout과actionTimeout을 환경에 맞게 설정합니다. - retries 설정:
retries: 2를 추가하면 일시적인 실패를 자동으로 재시도합니다. - slowMo 제거: 개발 편의를 위해 추가한
slowMo옵션이 CI 타임아웃과 충돌하지 않는지 확인합니다.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
timeout: 60000, // 테스트 전체 타임아웃 60초
retries: process.env.CI ? 2 : 0, // CI에서만 2회 재시도
use: {
actionTimeout: 15000, // 각 액션 타임아웃 15초
navigationTimeout: 30000,
},
});
8. 상황별 빠른 체크리스트
| 증상 | 우선 확인 사항 | 해결 방법 |
|---|---|---|
| TimeoutError: element not visible | 요소가 실제로 보이는가? | waitForSelector, CSS 확인 |
| TimeoutError: element is disabled | disabled 속성 또는 aria-disabled | 활성화 조건 완료 후 클릭 |
| 클릭은 되는데 아무 반응 없음 | 하이드레이션 완료 여부 | waitForLoadState('networkidle') |
| 다른 요소가 클릭됨 | 오버레이, 토스트, 배너 존재 | 오버레이 먼저 닫기 또는 scroll |
| 로컬 OK, CI 실패 | 타임아웃 설정, 서버 성능 | timeout 증가, retries 추가 |
| iframe 내부 요소 클릭 불가 | iframe 프레임 컨텍스트 | frameLocator() 사용 |
마무리
Playwright에서 클릭이 안 될 때는 무작정 타임아웃을 늘리거나 force: true를 남발하기보다 왜 클릭이 안 되는지 원인을 파악하는 것이 먼저입니다. Actionability Checks 중 어느 단계에서 막히는지 에러 메시지와 trace 파일을 통해 확인하고, 올바른 Locator 선택 → 조건 기반 대기 → 필요 시 force 옵션 순서로 접근하면 대부분의 문제를 해결할 수 있습니다.
- 클릭 전 Actionability Checks 6가지를 이해하고 어느 조건이 미충족인지 파악하기
getByRole,getByText,getByTestId등 안정적인 Locator 우선 사용하기- 하드코딩 대기 대신
waitForSelector,waitForLoadState,waitForResponse활용하기 force: true와dispatchEvent는 최후의 수단으로만 사용하기- CI 환경에는 타임아웃을 넉넉히 설정하고
retries를 추가하기
'개발' 카테고리의 다른 글
| FastAPI 비동기 처리, 왜 중요하고 어떻게 시작해야 할까? 초보자를 위한 성능 최적화 첫걸음 (0) | 2026.06.08 |
|---|---|
| Git rebase vs merge: 초보자를 위한 핵심 차이점과 선택 가이드 (0) | 2026.06.08 |
| n8n 자동화 워크플로우 만들기 (0) | 2026.05.31 |
| ChatGPT API 파이썬 연동 (0) | 2026.05.30 |
| SSD vs HDD 차이점 완벽 정리 — 어떤 저장장치를 선택해야 할까? (1) | 2026.05.29 |