Playwright 심화 — 세션 저장, 파일 업로드, SPA 대기 (실전 삽질기)
안녕하세요, 천상나타입니다.
지난 글(API가 없는 사이트를 자동화하는 법 — Playwright 입문)에서는 브라우저 자동화의 기본기를 정리했는데요, 이번엔 실제로 만들면서 삽질한 지점들을 공유합니다. 세션 저장, 파일 업로드, SPA 대기 — 문서만 보면 쉬워 보이는데 실전에선 다 함정이 있었어요.
1. 로그인 세션 저장 — 프로필 폴더가 곧 로그인 상태
입문편에서 잠깐 나온 persistent context는 자동화의 생명줄입니다. 브라우저 프로필 폴더를 통째로 지정해서, 쿠키·로컬스토리지·세션이 디스크에 저장됩니다.
const ctx = await chromium.launchPersistentContext('profiles/mysite', {
channel: 'chrome', // 진짜 크롬 사용 — 봇 탐지 완화에 도움
headless: false, // 최초 로그인은 사람이 직접
args: ['--disable-blink-features=AutomationControlled'],
ignoreDefaultArgs: ['--enable-automation'],
});

실전에서 만난 문제 세 가지:
① SingletonLock 에러. 같은 프로필 폴더를 두 개의 프로세스가 동시에 열면 SingletonLock: File exists로 죽습니다. 스크립트가 비정상 종료되면 락 파일만 남는 경우도 있어요. 해결은 간단 — 살아있는 인스턴스를 찾아 종료하거나, 락 파일을 지우면 됩니다. 저는 자동화 스크립트에 "이미 실행 중인지" 체크를 넣어뒀어요.
② 세션 만료 감지. 쿠키는 영원하지 않습니다. 구글 콘솔은 몇 주면 세션이 풀려서 로그인 페이지로 리다이렉트돼요. 자동화에서는 이렇게 방어합니다.
await page.goto(consoleUrl);
await page.waitForTimeout(3000);
if (page.url().includes('accounts.google.com') || page.url().includes('signin')) {
throw new Error('세션 만료 — 수동 재로그인 필요');
}
URL에 로그인 도메인이 찍혔는지 보는 게 제일 확실합니다. 화면 문구로 판별하면 언어·A/B 테스트에 흔들려요.
③ 프로필 폴더는 민감정보. 로그인 세션 전체가 들어있으니 .gitignore에 꼭 넣으세요. 실수로 커밋하면 세션 하이재킹 그대로 노출됩니다.
2. 파일 업로드 — "클릭 성공"과 "저장 성공"은 다르다
파일 업로드 자체는 한 줄입니다.
await page.locator('input[type=file]').setInputFiles('image.png');
숨겨진 input이라도 setInputFiles는 바로 먹습니다. filechooser 이벤트를 기다리는 방식도 있지만, DOM에 input이 있는 게 확인되면 직접 주입하는 쪽이 훨씬 안정적이에요.
문제는 업로드 후입니다. 제가 블로그 대표 이미지를 자동화로 교체할 때 겪은 일인데요,

이미지를 넣으면 크롭 편집기가 뜨고 '완료' 버튼을 눌러야 하는데, 같은 클래스의 완료 버튼이 DOM에 두 개 있었어요. 첫 번째를 누르면 편집이 확정되지 않은 채 넘어갔습니다. 결국 좌표로 구분했어요 — '취소' 버튼과 같은 줄에 있는 완료가 진짜입니다.
const cancel = page.locator('button:visible').filter({ hasText: /^취소$/ }).last();
const cbox = await cancel.boundingBox();
// 취소와 같은 y좌표, 오른쪽에 있는 '완료'가 진짜 확정 버튼
더 큰 함정은 그 다음이었습니다. 저장 버튼이 활성화되고 클릭도 잘 됐는데, 새로고침하면 전부 원래대로 돌아있는 거예요.

원인은 네트워크 로그에서야 보였습니다 — 저장 API가 400 {message: "사용할 수 없는 닉네임입니다"}를 반환하고 있었어요. 닉네임 검증 실패 하나가 섹션 전체 저장을 막고 있었던 겁니다. UI는 아무 에러도 안 보여줬는데 말이죠.
교훈은 이겁니다: 저장 계열 액션은 응답 코드를 직접 봐야 합니다.
const respP = page.waitForResponse(
r => r.url().includes('info.json'), { timeout: 10_000 }
);
await page.click('button.btn_save');
const res = await respP;
if (res.status() !== 200) throw new Error('저장 실패: ' + res.status());
버튼 클릭 후 "에러 없이 끝났다"가 아니라, 서버가 200을 줬는지로 판단해야 조용한 실패를 잡을 수 있습니다.
3. SPA 대기 — sleep은 최후의 수단
SPA(React/Angular 앱)는 요소가 언제 뜰지 몰라서 대기 전략이 핵심입니다. 우선순위가 있습니다.
① 응답 대기가 제일 정확. 데이터가 온 뒤 렌더되는 화면은 waitForResponse가 정답입니다.
await Promise.all([
page.waitForResponse(r => r.url().includes('/api/list')),
page.click('#search'),
]);
② 요소 대기가 기본. locator.waitFor()는 요소가 실제로 보일 때까지 기다려주는데, SPA에선 "DOM엔 있지만 아직 비어있는" 경우가 함정입니다. 목록의 첫 행을 기다리는 식으로 한 단계 더 깊게 봐야 해요.
③ networkidle은 함정 투성이. 요즘 사이트는 분석 스크립트·롱폴링 때문에 진짜 유휴 상태가 안 와서 타임아웃 나는 경우가 많습니다. 저는 domcontentloaded까지 기다린 뒤, 필요한 요소를 개별 대기하는 쪽으로 바꿨어요.
// 나쁜 예 — SPA에선 타임아웃 나기 쉬움
await page.goto(url, { waitUntil: 'networkidle' });
// 나은 예 — 필요한 것만 정확히
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.locator('.result-item').first().waitFor({ timeout: 15_000 });
④ 그래도 sleep이 필요한 순간. 디바운스된 자동완성, 애니메이션 완료, 프레임워크의 렌더 큐 등 이벤트로 못 잡는 대기는 존재합니다. 이럴 땐 waitForTimeout을 쓰되, 최소한으로. 제 기준은 "이벤트로 기다릴 방법이 문서에 없는가"를 먼저 찾고, 없을 때만 sleep입니다.
정리
- 세션: 프로필 폴더 재사용 + 만료는 URL 리다이렉트로 감지 + SingletonLock 주의
- 업로드:
setInputFiles로 주입 → 편집기 확정 버튼을 위치로 특정 → 저장 API 응답 코드 확인 - 대기: waitForResponse > locator.waitFor > networkidle > sleep 순으로
삽질하면서 배운 게 제일 오래 남더라고요. 다음엔 이걸로 만든 자동화 결과물을 구조별로 뜯어보는 글을 써볼까 합니다.
'IT > Tool' 카테고리의 다른 글
| dmg가 조용히 사라졌다 — electron-builder YAML 들여쓰기 삽질기 (0) | 2026.11.14 |
|---|---|
| API가 없는 사이트를 자동화하는 법 — Playwright 입문 (0) | 2026.11.14 |
| [연금 저축 공제 환급 계산기] 연소득, 연금 저축 입금액을 입력하시면 연말 정산시 세액 공제액을 계산해드립니다. - 바로 변환해드려요. (0) | 2020.05.11 |
| 로또번호를 입력하시면 해당번호로 당첨될 뻔한 내역을 알려 드립니다. - 바로 변환해드립니다. (0) | 2020.04.21 |
| [LOTTO] 로또 회차번호를 선택하시면 1등 당첨내역 정보를 알려드려요. (0) | 2020.04.18 |