안녕하세요, 천상나타입니다.

    지난 글(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좌표, 오른쪽에 있는 '완료'가 진짜 확정 버튼
    

    더 큰 함정은 그 다음이었습니다. 저장 버튼이 활성화되고 클릭도 잘 됐는데, 새로고침하면 전부 원래대로 돌아있는 거예요.

    클릭은 됐지만 서버는 400 — 프리뷰는 결과를 말해주지 않는다

    원인은 네트워크 로그에서야 보였습니다 — 저장 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 순으로

    삽질하면서 배운 게 제일 오래 남더라고요. 다음엔 이걸로 만든 자동화 결과물을 구조별로 뜯어보는 글을 써볼까 합니다.

    728x90
    Posted by 천상나타.