WSL2에서 Chrome의 Claude가 작동하지 않을 때의 해결 방법: 로그인된 Chrome 하나와 두 개의 드라이버를 이용한 저렴한 토큰
요약
WSL2 환경에서 Claude Code의 Chrome 확장 프로그램이 작동하지 않는 구조적 문제를 해결하기 위해, CDP(Chrome DevTools Protocol)를 활용하여 저렴하고 효율적으로 브라우저를 제어하는 방법을 제안합니다. 로그인된 Chrome 인스턴스 하나에 두 개의 드라이버를 연결하여 토큰 비용을 절감하고 인증 문제를 우회하는 구성 방식을 다룹니다.
핵심 포인트
- WSL2와 Windows Chrome 간의 네이티브 메시징 호스트 구조적 불일치 문제 설명
- 스크린샷 대신 접근성 트리(Accessibility Tree)를 활용한 토큰 비용 절감 전략
- 로그인 문제를 해결하기 위해 이미 로그인된 Chrome 인스턴스 활용
- CDP를 이용해 하나의 Chrome에 두 개의 클라이언트를 연결하는 구성법
- WSL2 네트워크 설정을 위한 .wslconfig의 networkingMode=Mirrored 필요성
Claude Code는 Claude in Chrome 확장을 통해 Chrome을 네이티브하게 제어할 수 있지만, WSL2 환경에서는 이를 수행하지 못합니다. 그 이유는 특정 플래그가 누락되어서가 아니라 구조적인 문제입니다. 확장은 Windows Chrome 내부에서 실행되며 Windows 레지스트리를 통해 네이티브 메시징 호스트 (Native Messaging Hosts)를 탐색하는 반면, WSL2에서 실행되는 Claude Code는 자신의 호스트 매니페스트 (Host Manifest)를 Linux 파일 시스템의 ~/.config/google-chrome/NativeMessagingHosts/에 설치하고 Linux 바이너리를 가리킵니다. 양쪽 모두 서로를 볼 수 없는 구조입니다. 제 컴퓨터의 경우 Linux 매니페스트는 그곳에 있지만 Windows 레지스트리 키가 존재하지 않기 때문에, /chrome은 "확장 프로그램이 감지되지 않음 (Extension not detected)"이라고 보고하며 재설치를 아무리 해도 바뀌지 않습니다. 공식 문서에는 WSL이 지원되지 않는다고 명시되어 있습니다. 결국 해결될 것임을 시사하는 오픈 이슈들이 있고, 그런 종류의 작업을 즐긴다면 비공식적인 Windows 측 브릿지 (Bridge)도 존재하지만, 저는 지금 당장 작동하는 무언가를 원했습니다.
따라서 작동 가능한 Chrome DevTools Protocol (CDP)로 회귀하게 되는데, 제가 찾은 모든 가이드는 여기서 멈춥니다. 즉, 이제 스크린샷을 찍을 수 있다는 것입니다. 스크린샷은 비용이 많이 드는 부분입니다. 단일 페이지를 이미지로 처리하는 데 수천 개의 토큰이 소모되며, 10단계의 양식을 채우는 에이전트 (Agent)는 매 단계마다 스크린샷을 찍습니다. 에이전트에게 실제로 필요한 정보, 즉 어떤 요소가 존재하고 어떤 요소를 클릭할 수 있는지에 대한 정보는 대신 접근성 트리 (Accessibility Tree)를 요청하면 수백 개의 토큰 정도로 해결됩니다.
문제의 나머지 절반은 로그인입니다. 테스트용으로 새로 실행된 Chrome (Chrome for Testing)은 사용자의 앱 로그인 화면을 본 적이 없으므로, 만약 개발 중인 앱이 모든 기능을 인증 (Auth) 뒤에 숨겨두었다면, 해당 브라우저를 제어하는 에이전트는 평생 로그인 양식만 바라보게 됩니다. 여러분은 이미 로그인된 브라우저를 원하며, 이미지보다 더 저렴한 방식으로 브라우저를 제어하기를 원합니다. 그것이 바로 아래의 설정입니다: 9222 포트에서 실행되는 하나의 Chrome, 여기에 연결된 두 개의 드라이버, 그리고 언제 어떤 드라이버를 사용할지에 대한 규칙입니다.
구성 방식
Windows
chrome.exe
--remote-debugging-port=9222 CDP 서버, HTTP + WebSocket
...
하나의 브라우저에 두 개의 클라이언트가 접속하는 방식입니다. CDP (Chrome DevTools Protocol)는 단순한 HTTP 및 WebSocket 서버이며 여러 클라이언트의 동시 접속을 허용하므로, 이는 누군가 대단한 기술이라고 자랑할 만한 트릭은 아닙니다. 단 하나의 규칙은 각 클라이언트가 자신만의 탭을 가져야 한다는 것입니다. 두 클라이언트가 동일한 활성 페이지를 제어하려고 하면 충돌이 발생하기 때문입니다.
전제 조건
Windows의 .wslconfig 파일에 networkingMode=Mirrored 설정이 필요합니다. 이 설정은 localhost:9222가 WSL 경계 양쪽 모두에서 동일한 주소를 의미하게 만들어 줍니다. 또한, 격리된 프로필(isolated profile)을 사용하여 9222 포트에서 대기 중인 Chrome이 필요합니다. 아직 이 설정이 되어 있지 않다면, Nebrass Lamouchi의 포스트에서 적절한 설정 방법을 다루고 있으므로 여기서 다시 설명하지는 않겠습니다. 한 가지 주의할 점은, 미러 모드(mirrored mode)가 Docker Desktop과 충돌할 수 있다는 것입니다. WSL2에서 Docker Engine을 네이티브로 실행하는 것이 이 둘을 공존시키려 노력하는 것보다 더 쉬웠습니다.
Windows에서 실행하거나 WSL에서 /mnt/c를 통해 실행하는 최소한의 런처(launcher) 명령입니다:
"/mnt/c/Program Files/Google/Chrome/Application/chrome.exe" \
--remote-debugging-port=9222 \
--user-data-dir="C:\Users\youruser\AppData\Local\ChromeDebugProfiles\myapp" \
...
--user-data-dir은 보기보다 더 많은 역할을 수행합니다. 이 옵션은 일상적으로 사용하는 Chrome과 분리된 프로필을 제공하므로, 디버그 브라우저가 실제 쿠키나 확장 프로그램에 전혀 영향을 주지 않습니다. 또한 실행 간에도 데이터가 유지되므로, 브라우저 내부에서 앱에 한 번 로그인해 두면 내일도 세션이 그대로 유지됩니다. 이러한 지속성(persistence)이야말로 이 설정을 구축할 가치가 있는 가장 큰 이유입니다.
무엇인가를 연결하기 전에 서버가 살아있는지 확인하세요:
curl -s http://127.0.0.1:9222/json/version | python3 -m json.tool
정상적인 응답에는 `
vercel-labs/agent-browser는 Vercel Labs에서 제작한 AI 에이전트(AI agents)를 대상으로 하는 브라우저 자동화 CLI(Command Line Interface)이며, Apache-2.0 라이선스를 따릅니다. 저는 이 패키지가 네이티브 바이너리(native binary)를 포함하고 있기 때문에 설치 전에 출처(provenance)를 확인했습니다. 해당 npm 패키지는 GitHub 저장소와 매핑되어 있으며, OIDC 및 SLSA 출처(provenance)를 통해 GitHub Actions로 배포되고, 런타임 의존성(runtime dependencies)이 없으며 깨끗한 감사(audit) 결과를 가지고 있습니다. postinstall 단계에서 체크섬(checksum) 검증 없이 HTTPS를 통해 GitHub releases로부터 사전 빌드된 Rust 바이너리를 가져오는데, 이 부분이 제가 가장 꺼려지는 부분입니다. 이는 네이티브 바이너리를 포함하는 npm 패키지들의 일반적인 관행이며, 저는 어쨌든 설치를 진행했으니 참고만 하시기 바랍니다.
npm install -g agent-browser
문서에서는 agent-browser install을 안내하며 Chrome for Testing을 다운로드하도록 유도할 것입니다. 하지만 이는 건너뛰십시오. 그것이 바로 이 포스트 전체에서 피하려고 하는 '로그아웃된 브라우저'입니다.
소켓 수정 (The socket fix)
먼저 WSL2 환경에서 실행하면 다음과 같은 오류가 발생합니다.
✗ Failed to create socket directory: Permission denied
agent-browser는 $XDG_RUNTIME_DIR 아래의 유닉스 소켓(Unix socket)을 통해 세션 데몬(session daemon)을 실행하는데, WSL2에서는 /run/user/1000이 보통 존재하지 않습니다. 이를 쓰기 가능한 위치로 지정해 주어야 합니다.
export XDG_RUNTIME_DIR=/tmp/abr-runtime
mkdir -p "$XDG_RUNTIME_DIR" && chmod 700 "$XDG_RUNTIME_DIR"
export 명령은 별도의 셸 호출(shell invocations) 사이에서 유지되지 않는데, 이는 에이전트가 한 번에 하나의 명령씩 CLI를 호출하고 각 호출마다 새로운 셸이 생성될 때 중요하게 작용합니다. 이를 ~/.bashrc에 넣고 mkdir 명령과 함께 구성하면 이 문제를 해결할 수 있습니다.
실행하지 말고 연결하라 (Attach, never launch)
agent-browser connect 9222
connect 9222는 CDP(Chrome DevTools Protocol)를 통해 이미 실행 중이며 로그인이 완료된 Chrome에 연결(attach)합니다. 만약 agent-browser가 자체 브라우저를 시작하게 두면, 로그아웃된 상태로 디스플레이나 헤드리스 모드(headless mode)를 요구하는 새로운 Linux용 Chrome for Testing이 실행되는데, 이는 여기서 얻을 수 있는 유일한 장점을 버리는 꼴이 됩니다. 또한 실행 중인 Chrome을 자동으로 찾아주는 --auto-connect 옵션도 있지만, 저는 자동 탐지에 의존하기보다 포트 번호를 직접 지정하는 것을 선호합니다.
스냅샷을 찍고, 참조(refs)를 기반으로 동작하기
페이지에 무엇이 있는지 묻고, 참조(refs)가 포함된 요소 트리(tree of elements)를 받은 다음, 해당 참조를 기반으로 동작합니다.
agent-browser snapshot
# textbox "code" [ref=e7]
# textbox "message" [ref=e17]
...
get text는 거의 비용을 들이지 않고 결과를 일반 텍스트(plain text)로 다시 읽어옵니다. 스크린샷(Screenshot)은 클릭이 제대로 되었는지 확인하는 용도가 아니라, 시각적 회귀(visual regression)나 레이아웃 버그처럼 실제로 픽셀(pixels) 정보가 필요할 때만 사용하세요.
두 가지 사항이 저를 당황하게 했습니다. 첫째, 참조(refs)는 DOM이 변경되는 즉시 만료(stale)되므로, 30초 전의 e17을 재사용하는 대신 무언가 리렌더링(re-render)된 후에는 항상 새로운 스냅샷을 찍으세요. 둘째, 싱글 페이지 애플리케이션(SPA)의 경우, 사용해야 할 탐색(navigation) 명령은 open이 아니라 pushstate <url>입니다. 저는 이 문제로 저녁 시간의 상당 부분을 허비했습니다. open은 SPA 라우트 변경 시 절대 발생하지 않는 로드 이벤트(load event)를 기다리기 때문에, 실제로는 페이지가 로드되어 바로 눈앞에 있음에도 타임아웃(timeout)을 보고합니다. pushstate는 Next.js를 포함한 라우터(router)를 감지하고 이를 통해 동작합니다.
나머지 기능들은 대략 예상하시는 범위 내에 있습니다. 요소를 찾기 위한 find role|text|label|placeholder|testid <value> <action>, 상태 확인을 위한 get url|title|text|html|value 및 is visible|enabled|checked, 빠른 확인을 위한 console / errors / network requests / vitals, 탭 관리를 위한 tab new|list|close 등이 있습니다. 혼란을 방지하기 위해 알아두어야 할 한 가지 까다로운 점은, type은 찾기(find) 동작이 아니라는 것입니다. 따라서 최상위 수준에서는 type <selector> <text> 형식을 유지합니다.
토큰 계산법, 그리고 더 많이 지불해야 할 때
일반적인 앱 페이지의 스크린샷을 찍을 때마다, 이미지로서 수천 개의 토큰이 소모됩니다. 반면 동일한 페이지의 접근성 스냅샷(accessibility snapshot)은 수백 개의 텍스트 토큰에 불과합니다. 제가 이를 제대로 벤치마킹(benchmark)한 것은 아니므로, 측정된 수치라기보다는 제 컨텍스트(context)가 차오르는 것을 관찰한 규모(order of magnitude)로 이해하시기 바랍니다. 하지만 이 차이는 결코 미미하지 않으며, 10단계의 흐름(flow)을 거치면 청구서에 그 차이가 나타납니다.
저는 2차 효과(second order effect)에 더 관심이 있습니다. 스냅샷이 작고 구조화되어 있기 때문에, Sonnet은 이 작업을 충분히 잘 처리합니다. 브라우저 드라이빙(Browser driving)은 추론(reasoning)이라기보다는 반복적인 도구 호출(tool calling)에 가깝기 때문에, 버튼을 클릭하는 데 Opus의 요율을 지불하는 것은 어리석은 일입니다. /model sonnet 명령어로 세션 전체를 전환하거나, Opus를 드라이버로 유지하면서 브라우저 작업은 Sonnet 서브 에이전트(subagent)에게 맡길 수 있습니다. 이 서브 에이전트는 추가적인 연결 작업 없이도 동일한 세션 연결형 MCP 도구들에 접근할 수 있습니다.
그렇다고 해서 chrome-devtools-mcp를 버려야 한다는 뜻은 아닙니다. 이 도구는 agent-browser가 보지 못하는 것들을 봅니다: 전체 네트워크 요청 본문(network request bodies), 구조화된 콘솔 출력(structured console output), Lighthouse 감사(audits), 성능 추적(performance traces), 힙 스냅샷(heap snapshots) 등입니다. 요청이 500 에러를 내거나 페이지가 왜 느리게 느껴지는지 원인을 파악할 때, 제가 원하는 도구는 바로 이것입니다. 따라서 제가 정착한 방식은 동일한 브라우저에서 agent-browser로 드라이빙하고 chrome-devtools-mcp로 조사하는 것입니다. 이렇게 하면 작업 중간에 전환하더라도 비용이 들지 않습니다. 이것이 단순히 제가 튜닝을 멈춘 지점인지, 아니면 올바른 요소 분리(factoring)인지 확신할 수는 없지만, 몇 달간 매일 사용하면서 잘 버텨주었습니다.
MCP 서버를 동일한 Chrome을 가리키도록 설정하는 것은 설정 한 줄이면 됩니다:
{
"mcpServers": {
"chrome-devtools": {
...
이것은 Claude Code의 ~/.claude.json 파일입니다. Gemini CLI는 ~/.gemini/settings.json에서 동일한 형태를 취하며, Copilot CLI는 ~/.copilot/mcp-config.json에서 동일한 형태를 가집니다.
Claude Code의 경우, 모든 디렉토리의 모든 세션에서 이를 인식할 수 있도록 사용자 범위(user-scoped)로 추가해야 하며, 파일을 직접 편집하기보다는 CLI를 통해 추가해야 합니다. Claude Code는 실행 중에 ~/.claude.json을 다시 작성하므로, 수동으로 편집한 내용을 기꺼이 덮어써 버릴 것입니다:
claude mcp add --scope user chrome-devtools -- \
npx -y chrome-devtools-mcp@latest -u http://127.0.0.1:9222
제 시간을 실제로 낭비하게 만든 주의사항들
9222 포트를 점유하고 있는 포트 프록시(portproxy) 유령
이것은 제가 겪은 세 가지 문제 중 가장 인상 깊은 것인데, 부분적으로는 어디에서도 문서화된 것을 찾을 수 없었기 때문이고, 또 다른 이유는 본능적으로 시도하는 모든 재설정(reset) 과정에서도 살아남기 때문입니다.
증상: Chrome이 9222 포트에서 시작되지 않으며, 사용자가 아는 어떤 프로세스도 리스닝(listening) 중이지 않고, CDP 엔드포인트에 대한 curl 요청은 아무것도 반환하지 않지만, netstat은 해당 포트가 이미 사용 중이라고 주장합니다. wsl --shutdown을 실행해도 해결되지 않으며 재부팅조차 효과가 없는데, 바로 이 점이 상황을 매우 당혹스럽게 만듭니다.
원인은 WSL 포트 브리징(port bridging)의 이전 Pre-Mirrored 시대에서 남겨진 netsh interface portproxy 규칙 때문입니다:
0.0.0.0:9222 -> 127.0.0.1:9222
172.24.160.1:9222 -> 127.0.0.1:9222
이 규칙들은 svchost.exe 내부에서 실행되는 Windows IP Helper 서비스인 iphlpsvc에 의해 호스팅되므로, netstat이 svchost를 범인으로 지목하는 것입니다. 이것은 WSL의 부산물이 아니라 지속적인 Windows 설정이기 때문에, 재부팅을 해도 해결되지 않았던 것입니다. 또한 0.0.0.0 리슨(listen) 주소도 주목할 만한데, 이 규칙이 언제부터인지 모르게 9222 포트를 전체 LAN에 조용히 노출하고 있었기 때문입니다.
찾는 방법:
/mnt/c/Windows/System32/netstat.exe -ano | grep ':9222' | grep -i LISTENING
/mnt/c/Windows/System32/tasklist.exe /svc /fi "pid eq <PID>" # iphlpsvc를 보여줌
powershell.exe -Command "netsh interface portproxy show all"
portproxy는 보호되어 있으므로, 관리자 권한의 PowerShell에서 삭제해야 합니다:
netsh interface portproxy delete v4tov4 listenport=9222 listenaddress=0.0.0.0
netsh interface portproxy delete v4tov4 listenport=9222 listenaddress=172.24.160.1
netsh interface portproxy show all
재부팅이나 wsl --shutdown 없이도 소켓이 즉시 해제됩니다.
도구 목록이 비어 있음, 또는 "Chrome DevTools가 실행 중이 아닙니다"
만약 사용자의 설정이 MCP 서버를 CDP를 기다리는 무언가로 감싸고 있고, 그것이 충돌하는 대신 아무 동작도 하지 않는(noop) 서버로 저하(degrade)된다면, 잘못된 포트 설정은 설치가 깨진 것과 동일하게 보입니다. 저는 한 세션은 브라우저를 완벽하게 제어하는 반면 다른 세션은 아무것도 실행되지 않았다고 주장하는 상황에서 한참을 헤맸는데, 원인은 각 설정이 서로 다른 포트를 가리키고 있었고 오직 하나만이 제가 실제로 실행한 Chrome과 일치했기 때문이었습니다.
도구들이 사라진다면, 다른 무엇을 의심하기 전에 포트를 먼저 의심하십시오:
grep -n "browser-url\|--browser" ~/.claude.json
curl -s http://127.0.0.1:9222/json/version
동일한 함정이 무료 포트를 자동으로 선택하는 리포지토리별 런처 (per-repo launcher)에서도 발생합니다. 리포지토리 A가 9222 포트를 점유하고, 리포지토리 B가 9223 포트를 점유하면, 이제 두 개의 Chrome이 실행되지만 그중 하나에만 로그인 정보가 남아 있게 됩니다. 단일 공유 브라우저를 사용하고 싶다면 포트를 명시적으로 고정(Pin)하십시오.
Chrome보다 먼저 시작된 MCP
MCP 서버는 시작 시점에 연결됩니다. Chrome이 실행되기 전에 MCP를 시작하면 데드 세션 (dead session)을 유지하게 되며, 이후 모든 호출은 Protocol error (Target.setDiscoverTargets): Target closed 오류와 함께 실패합니다. 이때 9222 포트에 대한 curl 명령은 완벽하게 정상적으로 보일 것입니다. Chrome을 먼저 실행한 다음, /mcp를 실행하고 다시 연결하십시오. 사소한 문제처럼 보이지만, 오류 메시지가 실제 원인과 전혀 상관없는 곳을 가리키기 때문에 새벽 1시에 이 문제를 마주하기 전에 미리 숙지해 둘 가치가 있습니다.
보안 (Security), 간단히
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기