무인 자동화를 짜다 보면 항상 같은 벽에 부딪힌다. 로그인이다. 검색엔진 관리 콘솔 같은 페이지는 아이디·비밀번호에 2차 인증, 캡차, “새 기기에서 로그인했어요” 알림까지 겹쳐서, 스크립트가 매번 새 브라우저를 띄우는 순간 첫 화면부터 막혀버린다. 내가 오래 쓴 우회는 조금 다르다. 스크립트가 브라우저를 새로 띄우지 않고, 내가 이미 로그인해 둔 크롬 창에 그대로 붙는다. 이게 CDP(Chrome DevTools Protocol) 어태치 방식이다.

이 글은 그 패턴의 원리와 셋업, 그리고 겪었던 함정을 정리한 회고다. 코드와 수치는 전부 합성(더미) 예시이고, 특정 회사의 실제 계정·콘솔·데이터와는 무관하다.

새 브라우저를 띄우는 방식은 왜 자꾸 막히나?

우리가 흔히 쓰는 playwright.chromium.launch()깨끗한 새 브라우저를 하나 만든다. 쿠키도 없고, 로그인 세션도 없고, 확장도 없는 무균실 같은 상태다. 테스트에는 좋지만, 로그인 뒤에서만 보이는 콘솔을 다루려면 매 실행마다 로그인 관문을 통과해야 한다.

flowchart TB
  subgraph L[새로 띄우는 방식 launch]
    L1["빈 브라우저 생성"] --> L2["로그인 폼"]
    L2 --> L3{"2차 인증<br/>캡차<br/>기기 알림"}
    L3 -->|실패| LX["여기서 자주 멈춤"]
    L3 -->|통과| L4["콘솔 진입"]
  end
  subgraph C[붙는 방식 connect_over_cdp]
    C1["내가 로그인해 둔<br/>크롬 창"] --> C2["스크립트가<br/>그 창에 붙음"]
    C2 --> C3["이미 로그인 상태<br/>바로 콘솔 진입"]
  end
  classDef red fill:#ffe3e3,stroke:#e03131,color:#a01818;
  classDef grn fill:#d3f9d8,stroke:#2f9e44,color:#1d6b2c;
  class L1,L2,L3,LX,L4 red;
  class C1,C2,C3 grn;

핵심 차이는 한 줄로 요약된다. launch는 세션을 새로 만들고, connect는 세션을 빌려 쓴다. 로그인이라는 사람 손이 꼭 필요한 단계를 사람이 한 번 해두고, 반복 작업만 스크립트에 맡기는 구조다.

CDP로 “붙는다”는 게 정확히 무슨 뜻인가?

크롬은 디버깅 포트를 열어둘 수 있다. --remote-debugging-port=9222 옵션으로 크롬을 켜면, 그 크롬은 127.0.0.1:9222에서 자기 내부를 조종할 수 있는 창구를 하나 연다. 이게 CDP다. 원래는 개발자 도구가 브라우저와 대화하려고 쓰는 통로인데, Playwright도 같은 통로로 들어갈 수 있다.

sequenceDiagram
  participant Me as 사람
  participant Chrome as 크롬 (포트 9222)
  participant Script as 파이썬 스크립트
  Me->>Chrome: 디버깅 포트 켜고 실행
  Me->>Chrome: 콘솔에 로그인 (2차 인증까지)
  Note over Chrome: 로그인 세션이 이 창에 살아있음
  Script->>Chrome: connect_over_cdp("127.0.0.1:9222")
  Chrome-->>Script: 기존 컨텍스트·탭 핸들 전달
  Script->>Chrome: 이미 열린 탭 조작 (클릭·입력)
  Note over Script,Chrome: 새 로그인 없이 바로 작업

포인트는 connect_over_cdp새 브라우저를 만들지 않는다는 점이다. 이미 돌아가는 크롬 프로세스에 손잡이를 거는 것뿐이다. 그래서 그 크롬이 들고 있던 로그인 세션, 쿠키, 열린 탭이 스크립트 쪽에서 그대로 보인다.

실제 셋업은 어떻게 하나?

두 단계다. ① 디버깅 포트를 연 크롬을 실행하고 ② 스크립트로 붙는다. 윈도우에서 크롬을 전용 프로파일로 띄우는 예시는 이렇다. 실제 경로·포트는 환경마다 달라지니 값은 예시로 본다.

# 디버깅 포트를 연 크롬을 별도 프로파일로 실행 (합성 예시)
Start-Process "chrome.exe" -ArgumentList @(
  "--remote-debugging-port=9222",
  "--user-data-dir=C:\cdp-profile\auto"
)

전용 --user-data-dir을 쓰는 이유가 있다. 평소 쓰는 크롬과 프로파일이 겹치면 디버깅 포트가 제대로 안 열리거나, 이미 실행 중인 크롬 때문에 새 옵션이 무시되는 일이 잦다. 자동화 전용 프로파일을 하나 파두고 거기서만 콘솔에 로그인해 두는 편이 훨씬 안정적이었다.

그다음 파이썬에서 붙는다.

from playwright.sync_api import sync_playwright
 
CDP_URL = "http://127.0.0.1:9222"
 
def open_console_page(target_hint="console"):
    """이미 로그인된 크롬에 붙어 원하는 탭 핸들을 돌려준다. (합성 예시)"""
    with sync_playwright() as p:
        browser = p.chromium.connect_over_cdp(CDP_URL)
 
        # 붙은 크롬에는 이미 컨텍스트가 있다. 새로 만들지 말 것.
        if not browser.contexts:
            raise RuntimeError("붙을 컨텍스트가 없음 — 크롬이 포트를 안 열었을 수 있음")
        context = browser.contexts[0]
 
        # 열린 탭 중에서 원하는 탭을 고르거나, 없으면 새 탭을 연다
        page = None
        for pg in context.pages:
            if target_hint in pg.url:
                page = pg
                break
        if page is None:
            page = context.new_page()
            page.goto("https://example.com/console")  # 합성 URL
 
        print("현재 URL:", page.url)
        return page
 
if __name__ == "__main__":
    open_console_page()

launch를 쓸 때와 결정적으로 다른 지점은 browser.contexts[0]이다. 붙는 방식에서는 컨텍스트를 내가 만드는 게 아니라 이미 있는 걸 꺼내 쓴다. 여기서 습관적으로 new_context()를 부르면 로그인 안 된 빈 컨텍스트가 생겨서, 붙은 의미가 사라진다.

launch와 connect, 언제 뭘 쓰나?

둘은 경쟁 관계가 아니라 용도가 다르다. 표로 정리하면 선택이 쉬워진다.

상황launch (새로 띄우기)connect_over_cdp (붙기)
로그인 불필요한 공개 페이지적합과함
2차 인증·캡차가 있는 콘솔매번 막힘적합
CI 서버에서 완전 무인 실행적합사람이 로그인해 둬야 함
세션 재활용이 핵심세션 없음세션 그대로
재현성·격리(테스트)깨끗해서 좋음상태가 섞일 수 있음

정리하면, 로그인 관문이 사람 손을 요구하는데 그 뒤 작업은 반복적일 때 붙는 방식이 빛난다. 반대로 완전 무인 서버에서 밤새 돌려야 한다면, 사람이 로그인 세션을 유지해 줄 수 없으니 서비스 계정이나 정식 API 같은 다른 길을 봐야 한다.

실제로 뭘 자동화했나 (합성 시나리오)

로그인 뒤 콘솔에서 하는 일은 대개 비슷하다. 목록을 훑고, 입력창에 값을 넣고, 버튼을 누르고, 결과를 읽는다. 아래는 가상의 “콘솔에서 항목 상태를 읽어 표로 남기는” 흐름이다. 데이터는 전부 더미다.

def collect_rows(page):
    """콘솔 표에서 행을 읽어 딕셔너리 리스트로 (합성 데이터)"""
    page.wait_for_selector("table.items tbody tr", timeout=15000)
    rows = []
    for tr in page.query_selector_all("table.items tbody tr"):
        cells = tr.query_selector_all("td")
        rows.append({
            "name": cells[0].inner_text().strip(),
            "status": cells[1].inner_text().strip(),
            "updated": cells[2].inner_text().strip(),
        })
    return rows
 
# 실행 결과 예시 (더미)
# [{'name': '항목-A', 'status': '정상',   'updated': '2026-07-14'},
#  {'name': '항목-B', 'status': '검토중', 'updated': '2026-07-13'},
#  {'name': '항목-C', 'status': '대기',   'updated': '2026-07-12'}]

여기서 얻는 이점은 “사람이 매일 손으로 콘솔을 열어 표를 눈으로 확인하던 일”을 스크립트가 대신한다는 것이다. 그런데 로그인이 걸림돌이었을 뿐인데, 그 걸림돌만 CDP 어태치로 걷어내니 나머지는 평범한 Playwright 조작으로 풀린다.

어떤 함정이 있었나?

편한 만큼 조용히 깨지는 지점이 여럿 있었다. 원인과 대처를 상태 흐름으로 정리했다.

stateDiagram-v2
  [*] --> 포트열림확인
  포트열림확인 --> 연결실패: 9222 응답 없음
  연결실패 --> 프로파일점검: user-data-dir 충돌 의심
  프로파일점검 --> 포트열림확인: 전용 프로파일로 재실행
  포트열림확인 --> 붙기성공: 응답 정상
  붙기성공 --> 세션만료: 시간이 지나 로그아웃됨
  세션만료 --> 재로그인: 사람이 다시 로그인
  재로그인 --> 붙기성공
  붙기성공 --> 탭혼선: 여러 탭 중 엉뚱한 탭 조작
  탭혼선 --> URL필터: url로 정확히 골라 잡기
  URL필터 --> 완료
  붙기성공 --> 완료
  완료 --> [*]

특히 자주 만난 세 가지다.

  • 포트가 안 열림. 이미 평소 크롬이 떠 있으면 디버깅 옵션이 무시된다. 전용 프로파일로 별도 실행해야 포트가 실제로 열린다.
  • 세션 만료. 붙는 방식의 본질적 약점이다. 사람이 로그인해 둔 세션이 만료되면 스크립트도 같이 죽는다. 무인 장시간 실행에는 근본적으로 안 맞는다.
  • 탭 혼선. 붙은 크롬에 탭이 여러 개면 엉뚱한 탭을 조작한다. url in pg.url로 대상 탭을 정확히 골라 잡는 습관이 필요했다.

이 방식은 안전하고 정당한가?

기술이 되는 것과 해도 되는 것은 다르다. CDP 어태치는 어디까지나 내 계정, 내 브라우저, 내가 권한을 가진 콘솔에 대해서만 써야 한다. 남의 세션에 붙거나, 서비스 약관이 자동화를 금지한 화면을 우회하는 용도로 쓰면 곤란하다.

flowchart LR
  Q{"자동화 대상이<br/>내 권한 범위인가?"} -->|예| A["CDP 어태치 OK<br/>단, 약관 확인"]
  Q -->|아니오| B["멈춤<br/>정식 API·권한 요청"]
  A --> C{"공식 API가<br/>있는가?"}
  C -->|있음| D["API 우선<br/>UI 자동화는 보조"]
  C -->|없음| E["UI 자동화 허용<br/>깨지기 쉬움 감수"]
  classDef g fill:#d3f9d8,stroke:#2f9e44,color:#1d6b2c;
  classDef y fill:#fff3bf,stroke:#e67700,color:#8a5a00;
  classDef r fill:#ffe3e3,stroke:#e03131,color:#a01818;
  class A,D g;
  class E,C y;
  class B r;

그리고 이 방식은 UI에 기댄 자동화라 본질적으로 깨지기 쉽다. 콘솔 화면 구조가 바뀌면 셀렉터가 다 어긋난다. 공식 API가 있다면 그쪽이 훨씬 견고하다. CDP 어태치는 “API가 없거나 부족한 화면을, 내 권한 안에서, 한시적으로 자동화”할 때 꺼내는 카드로 보는 게 맞다.

한 줄 정리

flowchart LR
  A["사람: 전용 프로파일<br/>크롬에 로그인"] --> B["스크립트:<br/>connect_over_cdp로 붙기"]
  B --> C["contexts[0] 재활용<br/>반복 작업 자동화"]
  classDef b fill:#e7f5ff,stroke:#1c7ed6,color:#10548f;
  class A,B,C b;
  • 로그인은 사람이 한 번, 반복은 스크립트가 매번 — 그 경계선을 CDP 어태치가 그어준다.
  • launch는 세션을 새로 만들고 connect_over_cdp는 있는 세션을 빌린다. contexts[0]을 재활용하는 게 핵심이다.
  • 편하지만 세션 만료·UI 변경에 약하다. 내 권한 안에서, 약관을 확인하고, 공식 API가 없을 때 보조 수단으로만 쓰자.