chromedp는 MIT 라이선스의 순수 Go 라이브러리로, Chrome DevTools Protocol을 통해 실제 Chrome 브라우저를 제어합니다. 별도의 WebDriver나 Node 런타임 없이, Go 프로그램 안에서 페이지의 JavaScript 실행이 끝난 뒤 DOM을 읽어옵니다. Go 모듈은 애플리케이션에 함께 빌드되지만, 실제 실행에는 외부 Chrome 실행 파일이 필요하며, 그 수명은 Go context로 관리됩니다.
설치는 go get 한 번과 제가 직접 제공한 Chrome만 있으면 됐습니다. allocator를 만들고, context를 파생한 다음, Run에 작업 목록을 넘기는 방식입니다. macOS arm64 환경에서, 디스크에 따뜻하게 올라온 headless shell을 사용해 새 프로세스로 첫 스크립트 결과를 읽기까지의 중앙값은 102ms였습니다. 다만 이것은 로컬 기준치일 뿐, 시작 시간이 절대 병목이 아니라는 뜻은 아닙니다. 로드 800밀리초 뒤 링크를 주입하는 테스트 픽스처에서는 네 가지 읽기 전략 중 두 개가 링크가 생기기 전에 끝났습니다.
브라우저는 진짜였고, 콘텐츠도 있었지만, 코드는 그걸 기다리지 않았습니다. chromedp를 쓰면서 제가 가장 유용하게 배운 점이 바로 이것입니다. 이건 버그가 아니라, "페이지를 렌더링했다"와 "정말 필요한 시점까지 기다렸다"의 차이입니다. 헤드리스 브라우저에 대한 흔한 인식은 이 구분을 흐려버리곤 하지만, 실제로 데이터가 나오는지 여부는 브라우저가 아니라 대기 전략이 결정합니다. 여기서 사용한 모든 수치는 제가 직접 통제하는 로컬 픽스처에서 나온 것이며, 어떤 실행보다 먼저 정답이 등록되어 있었고, 원본 요약은 chromedp 폴더의 벤치마크 저장소에 있습니다.
chromedp가 실제로 무엇인가
chromedp 스택은 의도적으로 아주 단순합니다. Selenium 서버도 없고, WebDriver 호환 계층도 없고, 뒤에서 몰래 돌아가는 Node 런타임도 없습니다. 여러분의 Go 프로그램은 Chrome 인스턴스에 WebSocket으로 연결해 CDP를 직접 사용합니다. 방식은 Puppeteer가 쓰는 전송 계층과 거의 같지만, JavaScript 층이 없다는 점이 다릅니다.
제가 2026년 7월 27일 확인했을 때 이 저장소는 MIT 라이선스 아래 13,212개 스타와 178개의 열린 이슈를 가지고 있었습니다. 제가 테스트한 버전은 v0.16.0이며, 저장소에서 가장 최신 태그입니다. 헷갈리지 않게 짚어두자면, GitHub Releases 페이지는 여전히 v0.15.1(2026-04-01 배포)을 최신 릴리스 객체로 보여주지만, go get github.com/chromedp/chromedp@latest는 v0.16.0으로 해석됩니다. Go modules와 GitHub release 객체의 최신 정보가 서로 어긋나 있는 상태입니다. 고장난 건 아니지만, 지금 무엇을 쓰는지 확인하려고 할 때 꽤 거슬립니다.
개념 모델은 끝까지 Go context입니다. Chrome을 시작할 수 있는 allocator context를 만들고, 거기서 browser context를 파생한 뒤, chromedp.Run(ctx, actions...)에 작업 목록을 넘깁니다. browser context의 자식 context는 새 탭입니다. context를 취소하면 그 context가 가리키는 대상도 사라집니다. Go 동시성 작업을 해본 적이 있다면 바로 익숙할 것이고, 처음이라면 Go로 웹 스크래핑을 시작하는 쉬운 단계별 가이드가 chromedp의 godoc보다 훨씬 부드러운 입문 경로입니다.
초기에 하나 정해둘 경계가 있습니다. chromedp는 렌더링된 DOM을 건네줄 뿐, 구조화된 데이터를 직접 주지는 않습니다. DOM에서 필드, 표, 가격 등을 꺼내는 작업은 여러분이 작성하고 유지해야 하는 코드입니다. 이건 드라이버이지, 스크래퍼 프레임워크가 아닙니다.
내부 메커니즘
chromedp의 모든 것은 Action입니다. Run은 그 시퀀스를 순서대로 대상에 대해 실행합니다. Navigate, Click, Evaluate, OuterHTML, WaitVisible—모두 같은 인터페이스를 따르고, 서로 조합 가능하며, 결국은 Go 타입을 입은 CDP 명령일 뿐입니다. 이 일관성은 이 라이브러리의 가장 좋은 설계 결정입니다. 편의 기능과 원시 프로토콜이 같은 레벨에 있기 때문입니다.
이게 중요한 이유는, 편의 계층이 의도적으로 얇기 때문입니다. chromedp는 cdproto 위에 구축되어 있는데, 이는 DevTools Protocol 전체를 커버하는 생성형 Go 바인딩이며, chromedp godoc은 두 계층을 나란히 설명합니다. 편의 액션이 없으면 같은 Run 안에서 도메인 호출로 내려가면 됩니다. 예를 들면 network.Enable(), page.CaptureScreenshot(), runtime.Evaluate() 같은 것들입니다. "예쁜 API"와 "진짜 API" 사이에 벽이 없다는 점은 모든 브라우저 드라이버에 해당하는 이야기가 아닙니다.
실무에서 판단이 갈리는 부분은 대기 액션이고, 사람들이 생각하는 것보다 종류도 훨씬 많습니다:
| 대기 액션 | 차단하는 대상 |
|---|---|
WaitReady(sel) | 노드가 DOM에 붙을 때까지 |
WaitVisible(sel) | 노드가 실제로 보일 때까지 |
WaitNotPresent(sel) / WaitNotVisible(sel) | 그 반대 조건, 로딩 스피너에 유용 |
Poll(js, res) | JavaScript 조건을 주기적으로 평가해 참이 될 때까지 |
프로세스 관리도 알아둘 만한 핵심 메커니즘입니다. 프로그램이 브라우저를 남겨둔 채 종료되는지 여부를 결정하기 때문입니다. chromedp는 Go의 exec.CommandContext로 Chrome을 띄웁니다. context를 취소하면 프로세스가 종료됩니다. 이 한 가지 구현 세부사항이 테스트에서 제가 경험한 좋은 동작과 날카로운 함정을 모두 설명해 줍니다.
설치는 Go 바이너리 하나와, 직접 제공해야 하는 Chrome 하나면 된다
go get github.com/chromedp/chromedp는 아무 문제 없이 v0.16.0으로 해결됐고, 의존성 트리에는 cgo import가 없습니다. 그래서 "순수 Go, 외부 의존성 없음"이라는 말은 Go 모듈에 대해서는 사실입니다.
하지만 런타임까지 사실인 건 아닙니다. chromedp는 외부 Chrome을 제어하며, 시스템에 Chrome이 없으면 실행은 즉시 실패합니다. 제가 측정한 모든 경우에는 chromedp.ExecPath를 통해 정확한 실행 파일을 지정했고, Chrome for Testing 151.0.7922.10 headless shell을 사용했습니다. 이것은 비판이 아니라 현실입니다. 브라우저를 제어하려면 브라우저가 필요하니까요. 다만 "외부 의존성이 없다"는 말과 "155MB짜리 Chrome을 바이너리와 함께 배포해야 한다"는 이야기는 완전히 다른 배포 시나리오입니다. README에는 그중 하나만 드러나 있습니다.
두 번째 설치 함정은 시간을 꽤 잡아먹었고, 코드를 쓰기 전에 알아두면 좋습니다. chromedp 이슈 #1591은 Go 1.25+ go test 러너가 NewExecAllocator 시작 도중 취소해 버리는 문제를 보고합니다. 같은 코드는 컴파일된 바이너리로 실행하면 정상입니다. 저는 go build로 만든 프로브 바이너리를 사용해 모든 측정을 수행했고, go test를 통해 어떤 것도 실행하지 않았습니다. Go 버전은 1.26.5였고, 환경은 macOS arm64였습니다. 처음 chromedp를 써봤는데 테스트 파일이 Chrome 시작 중에 죽는다면, 먼저 이 이슈를 읽어보는 게 좋습니다. 자신이 잘못했다고 생각하기 전에요.
실전: 같은 페이지를 읽는 네 가지 방법, 그중 두 개는 빈 결과

이 픽스처는 127.0.0.1에서 동작하는 로컬 서버로, DOM에 들어오는 시점만 다른 세 종류의 콘텐츠를 제공합니다. 하나는 서빙된 바이트 안에 바로 있는 정적 <a>, 하나는 초기 파싱 중 inline <script>가 생성하는 <a>, 그리고 하나는 load 이벤트 뒤 configurable한 밀리초 뒤에 setTimeout으로 만들어지는 `입니다. 스크립트로 생성되는 두 링크의 마커와 href는 JavaScript의 문자열 조각에서 조합되므로, 서빙된 바이트 어디에도 연속된 리터럴은 존재하지 않습니다. 따라서 "찾았다"는 결과는 Chrome이 JavaScript를 실행했다는 증거이지, 단순히 HTML을 읽었다는 뜻이 아닙니다.
recall은 Go 프로브 안이 아니라 사전에 등록된 정답 마커와 비교해 Python에서 계산합니다. 그래서 프로브가 답을 미리 알고 속일 수 없습니다. 각 전략은 세 번씩 실행했으며, 찾은 집합은 세 번 모두 동일했습니다.
| 읽기 전략 | 정적 HTML 링크 | 파싱 시 주입 | 로드 800ms 후 주입 | 소요 시간 |
|---|---|---|---|---|
Navigate 후 읽기, 대기 없음 | found | found | missed | 317 ms |
WaitReady("body") | found | found | missed | 107 ms |
WaitVisible("#delayed-injected") | found | found | found | 912 ms |
| 마커가 나타날 때까지 Poll | found | found | found | 972 ms |
두 행은 세 개 중 두 링크만 가져옵니다. 단순 읽기 방식은 Navigate가 load 이벤트에서 반환되고 세 번째 링크가 아직 존재하지 않기 때문에 놓칩니다. WaitReady("body")는 실무에서 더 위험한 이유로 놓칩니다. body는 load 시점에 이미 붙어 있으므로, 기다림이 즉시 해제되고, 마치 책임 있게 처리한 것 같은 느낌만 줍니다. 실제로는 107ms 만에 반환했고, 대기하지 않은 경로보다도 빨랐지만 결과는 똑같이 불완전한 페이지였습니다.
메커니즘을 추측이 아니라 확인하기 위해, 주입 지연을 바꿔가며 양 극단을 다시 돌렸습니다(recall-summary.json):
| load 이후 주입 지연 | 대기 없이 읽기에서 보임 | WaitVisible에서 보임 | WaitVisible 소요 시간 |
|---|---|---|---|
| 0 ms | yes (race) | yes | 109 ms |
| 100 ms | no | yes | 208 ms |
| 400 ms | no | yes | 519 ms |
| 800 ms | no | yes | 911 ms |
| 1500 ms | no | yes | 1625 ms |
이 픽스처에서 WaitVisible의 소요 시간은 주입 지연을 그대로 따라갑니다. 100→208, 400→519, 800→911, 1500→1625처럼 말이죠. 이는 노드가 나타날 때까지 실제로 차단됐다는 뜻이지, 너무 일찍 읽은 게 아니라는 증거입니다. 0ms 행은 경계선입니다. setTimeout(…, 0)는 즉시 읽기보다 먼저 발동할 수 있으므로, 대기 없는 경로가 그걸 잡아낼 수 있습니다. 하지만 이 스윕의 100ms 이상에서는 대기 없는 경로가 매번 놓쳤습니다.
실서비스에서는 같은 타이밍 실수가 유효한 HTML을 반환하면서 추출 행은 0개인데도 종료 코드는 0이 되는 결과를 낳을 수 있습니다. 이는 여기서 직접 측정한 사고가 아니라, 픽스처 동작에 근거한 충분히 가능한 실패 모드입니다. 렌더링은 절반일 뿐이고, 읽기는 원하는 데이터와 연결된 애플리케이션 수준 조건을 기다려야 합니다.
WaitReady와 WaitVisible은 우열 관계가 아니라 서로 다른 질문에 답한다
흔히 WaitVisible이 WaitReady보다 "더 안정적"이라고 말합니다. 하지만 그 표현은 너무 부정확해서 오히려 해롭습니다. DOM에는 붙어 있지만 display: none으로 숨겨진 노드가 있는 페이지에서는 두 액션의 차이가 명확합니다(waitsem-summary.json, 동일한 세 번의 실행):
| 대상 노드 | 액션 | 결과 | 시간 |
|---|---|---|---|
attached, display:none | WaitReady | 반환됨 | ~6 ms |
attached, display:none | WaitVisible | 타임아웃, context deadline exceeded | 4000 ms |
| 보이는 노드 | WaitVisible, 기본 query | 반환됨 | 4–12 ms |
| 보이는 노드 | WaitVisible, ByID | 반환됨 | 1–2 ms |
| 보이는 노드 | WaitVisible, ByQuery | 반환됨 | 1 ms |
WaitReady는 붙어 있는지를 봅니다. WaitVisible은 실제로 보이는지를 봅니다. 잘못 고르면, 아직 렌더링되지 않은 콘텐츠를 지나쳐버리거나, 애초에 보일 가능성이 없던 노드 때문에 전체 timeout을 소모하게 됩니다. deadline 자체의 동작은 깔끔합니다. 4초 정확히 context deadline exceeded가 나고, 멈춤도 없고, 좀비 상태도 없습니다. 이 정도면 일부 드라이버보다 낫습니다.
한 가지 보고된 함정은 재현되지 않았습니다. 이슈 #440은 기본 query에서 WaitVisible("#id")가 멈춘다고 보고하지만, v0.16.0에서는 재현되지 않았습니다. 기본 query, ByID, ByQuery 모두 매 실행마다 보이는 노드에서 정상 반환했습니다. 재현되지 않았다는 것이 수정되었다는 뜻은 아닙니다. 이것은 한 페이지의 한 selector 형태에 대한 결과일 뿐이며, 이슈 자체를 무효화하지는 못합니다.
defer cancel()을 빼먹으면 지붕이 무너진다
return 값이 아니라 프로세스 수를 봤습니다. 모든 lifecycle 테스트는 고유한 --user-data-dir를 사용했고, renderer 자식 프로세스를 제외한 실제 Chrome browser 프로세스를 pgrep로 셌습니다. 각 경로는 세 번씩 실행했습니다(lifecycle-summary.json).
| 종료 경로 (macOS, 각 3회) | 생성된 chrome-headless-shell의 상태 | 시간 |
|---|---|---|
| context와 allocator를 취소함 | 사라짐 | 13, 13, 12밀리초 |
| 취소하지 않고 Go 프로세스 종료 | 프로그램보다 브라우저가 오래 감 — 실행 전 0개였던 browser 프로세스가 종료 후 1개로 남음; 3회 모두 orphan 발생 | — |
취소는 깔끔하고 빠르며, exec.CommandContext가 약속하는 그대로입니다. (모든 고아 프로세스는 이후 harness가 강제로 종료했고, 호스트는 깨끗하게 정리됐습니다.)
이건 이미 알려져 있고 문서화된, 플랫폼 범위가 정해진 동작입니다. 측정한 건 제 것이지만, 발견 자체는 새롭지 않습니다. chromedp 이슈 트래커는 여러 각도에서 이 현상을 다뤄왔습니다. #774는 FreeBSD에서 같은 종료되지 않음을 설명하고, #752는 macOS에서 Chromium 프로세스가 멈춰 남는 문제를 보고합니다. #562와 #1566은 그 메커니즘을 명확히 설명합니다. 제가 추가한 것은 프로세스 수와 양쪽 경계의 시간이며, 이런 정성적 보고에는 없는 정보입니다.
메커니즘 자체는 빌드 태그 이야기로 이해할 수 있습니다. v0.16.0 소스에서 allocate_linux.go는 자식 프로세스에 Pdeathsig = SIGKILL을 설정하므로 Linux에서는 커널 수준의 부모 사망 신호가 작동합니다. 반면 macOS에서 컴파일되는 allocate_other.go는 그 호출을 no-op으로 만듭니다. darwin에는 같은 신호가 없으므로, 프로그램이 종료될 때 Chrome을 죽일 방법이 없습니다. 한편 godoc 문구는 일반적인 약속처럼 읽힙니다. 기본 명령이 "Go 프로그램이 종료될 때 열려 있는 브라우저에 SIGKILL을 보낸다"고 설명하지만, Linux 범위 제한은 빌드 태그가 붙은 소스까지 읽어야만 보입니다. 문서가 과장됐다고 말하는 건 타당하지만, chromedp의 버그라고 말하는 건 아닙니다.
실무적인 결과는 어느 쪽이든 같습니다. macOS에서는 defer cancel()이 핵심입니다. 이걸 빼면 매 실행마다 브라우저 프로세스가 새어 나갑니다. 저는 Linux를 테스트하지 않았으므로, 그곳까지 같은 결과라고 일반화하지 않습니다. 소스상 Linux는 다르게 동작할 것으로 보이지만, 추정은 측정이 아닙니다.
콜드 스타트, 동시성, 그리고 배포를 결정하는 지루한 요소들

새 프로세스 시작, allocator, context, localhost 이동, 첫 Evaluate까지의 중앙값은 5개 프로세스 기준 102ms였고, 범위는 98~111ms였습니다(coldstart-summary.json). 이 macOS arm64 픽스처에서 따뜻하게 올라온 디스크 기반 headless shell을 사용했을 때, 시작 시간은 지연 콘텐츠 대기보다 훨씬 작았습니다. 컨테이너, 차가운 파일시스템, CI, 서버리스 환경, 실제 운영 환경의 네비게이션은 측정하지 않았습니다.
동시성 측면에서 chromedp는 두 가지 형태를 제공합니다. 하나의 브라우저에 여러 child context(탭)를 붙이거나, 여러 개의 독립 브라우저를 띄우는 방식입니다. 네 번의 네비게이션을 각 세 번씩 실행했습니다(concurrency-summary.json):
| 모드 | 전체 시간(p50) | 범위 | 최대 Chrome browser 프로세스 수 |
|---|---|---|---|
| 공유 브라우저, child context 4개 | 214 ms | 209–219 | 1 |
| 별도 브라우저 4개 | 264 ms | 261–278 | 4 |
이번 측정에서 확인된 포인트는 프로세스 수입니다. 아주 단순한 로컬 네비게이션 4개를 돌릴 때, 하나의 Chrome 브라우저 프로세스를 쓰느냐 4개를 쓰느냐의 차이였습니다. 전체 시간 범위는 겹치지 않았지만, 여전히 처리량 벤치마크라기보다 방향성 있는 결과입니다. RSS와 PSS는 측정하지 않았으므로, 이 테스트는 메모리 절감까지 입증하지는 못합니다.
에러 경로에 대한 작은 프로브는 여기서 강건성 주장까지 할 만큼 상세하지 않습니다. 초안에는 HTTP 상태, 네비게이션 오류, 이벤트, harness 로직 중 무엇이 각각의 조건을 드러냈는지 적혀 있지 않습니다. 정확한 API 결과와 원본 산출물이 공개되기 전까지는 500/dead-link 처리를 미보고 상태로 취급하는 게 맞습니다.
측정 범위 밖, 즉 이 숫자들로 다루지 않은 것들: Linux에서의 lifecycle 동작, N=4를 넘는 동시성이나 실제 페이지 작업이 들어가는 경우, 메모리 차이(RSS가 아니라 프로세스만 셌습니다), 네트워크 인터셉트와 요청 캡처, 그리고 #168과 #1593에 있는 WaitReady 타임아웃 보고입니다. 그것들은 간헐적 timeout을 설명하지만, 제가 측정한 것은 대기 의미론으로, 완전히 다른 질문입니다. 장비 하나, Chrome 빌드 하나뿐입니다.
chromedp는 이 벤치에서 유일한 Go CDP 드라이버가 아닙니다. rod도 같은 픽스처, 같은 harness, 같은 호스트, 같은 Chrome 빌드로 동일한 세션에서 테스트했고, 별도의 리뷰가 있습니다.
장점과 단점
장점:
- 진짜 CDP 접근 — 편의 액션과 원시
cdproto도메인 호출을 같은Run안에서 조합할 수 있어 API 한계에 막히지 않습니다. - 테스트한 macOS 픽스처에서 로컬 cold-cycle 기준 p50 102ms, 범위 98–111ms.
cancel()시 Chrome이 약 13ms 안에 정리되며, 매번 동일하게 동작합니다.- child context는 N개의 탭을 하나의 브라우저 프로세스로 공유합니다(별도 브라우저 4개 대비 프로세스 1개).
- 테스트에서 결정적이었습니다. recall 집합, 대기 의미론, lifecycle 결과가 각 3회 반복에서 모두 동일했습니다.
- 깔끔한 deadline 처리: 도달 불가능한 조건의
WaitVisible은 멈추지 않고 정확히 4초에context deadline exceeded를 반환했습니다. - 순수 Go 모듈(cgo 없음), MIT 라이선스. 다만 런타임에는 여전히 외부 Chrome 실행 파일이 필요합니다.
단점:
- 런타임에 외부 Chrome이 필요합니다. "의존성 없음"이라는 평판은 Go 모듈에만 해당합니다.
WaitReady("body")는 그럴듯해 보이지만 load 이후 콘텐츠를 조용히 놓치는 함정입니다. 불완전한 페이지로 107ms 만에 반환했습니다.- 단순한
Navigate+ read 경로는 load 후 약 100ms 이상 뒤에 주입된 모든 것을 결정적으로 놓치며, 오류도 내지 않습니다. - macOS에서는
cancel()없이 종료하면 브라우저가 orphan 됩니다(3/3회). 알려져 있고 플랫폼 범위도 명확하지만, 실수하기 쉽습니다. - godoc의 SIGKILL-on-exit 문구는 전역 약속처럼 읽히지만, 실제 메커니즘은 Linux 전용 빌드 태그 소스에 있습니다.
- Go 1.25+의
go test는 allocator 시작을 취소할 수 있습니다(#1591); 대신 바이너리를 빌드하세요. - 구조화된 데이터가 아니라 DOM을 돌려주므로, 원하는 모든 필드는 직접 작성하고 테스트하고 고쳐야 합니다.
- 최신 태그(v0.16.0)가 최신 GitHub Release 객체(v0.15.1)보다 앞서 있어 버전 확인이 잠깐 혼란스럽습니다.
chromedp는 누구에게 맞고, 누구는 피해야 하는가
서비스가 이미 Go 기반이고 그 안에 실제 브라우저가 필요하다면, chromedp는 거의 정답에 가깝습니다. 관리해야 할 Node 프로세스도 없고, 살려둘 WebDriver 서버도 없고, 컴파일된 바이너리 하나와 여러분이 배포하거나 설치한 Chrome만 있으면 됩니다. context 모델은 Go의 동시성 원리와 너무나 자연스럽게 맞아떨어져서, 브라우저의 수명도 코드베이스의 다른 부분과 똑같이 defer 규칙으로 관리됩니다. 또 편의 API가 커버하지 않는 것—CDP 네트워크 이벤트, 정밀한 페이지 생명주기 훅, 프로토콜 수준의 트릭—이 필요하면 라이브러리를 벗어나지 않고 cdproto로 내려가면 됩니다.
대기 조건을 명시적으로 제어하고 싶을 때도 잘 맞습니다. 여기서의 wait 액션은 휴리스틱이 아니라 프리미티브입니다. 말 그대로 정확히 그 일만 합니다. 올바르게 고르는 책임이 이제 사용자에게 있다는 점만 받아들이면, 이건 장점입니다.
팀이 Go를 쓰지 않는다면 피하세요. 언어 자체에 대한 약속이 진짜 비용이니까요. 클릭 후 자동으로 알아서 잘 기다리는 UX를 기대한다면 역시 피하세요. chromedp는 추측하지 않고, 요청한 것을 정확히 수행한 뒤 그 시점의 페이지 상태를 반환할 뿐입니다. 또는 구조화된 레코드가 진짜 필요한 경우에도 피하거나, 그 목적에 맞는 예산을 충분히 잡아야 합니다. 모든 필드는 여러분이 작성하고, 테스트하고, 사이트가 바뀔 때마다 수리해야 하는 selector이기 때문입니다. 그리고 요구사항이 단순히 "이 500개 URL에서 데이터만 가져와 줘"라면, Go에서 브라우저 오케스트레이션을 세우는 건 과한 장치일 수 있습니다. 브라우저 자동화 가이드에서는 언제 이런 장치가 가치가 있는지, 언제 아닌지 설명합니다.
대안과, 우리 스택이 들어가는 자리
브라우저 드라이버 범주 안에서 rod 역시 Go 기반 CDP 드라이버이고, Playwright와 Puppeteer는 Node 계열 옵션입니다. 둘의 대기 계약은 서로 대체 불가입니다. Playwright는 많은 액션 전에 actionability를 자동으로 기다리지만, 그조차도 액션 이후 애플리케이션 데이터가 언제 다 들어왔는지는 알려주지 않습니다. chromedp는 더 낮은 수준의 wait primitive를 제공하고, actionability와 애플리케이션 수준 readiness 조건은 호출자에게 맡깁니다. 더 넓은 영역을 살피고 있다면, 테스트한 오픈소스 스크래퍼 총정리에서 이 선의 반대편에 있는 정적 크롤러와 추출 라이브러리도 볼 수 있습니다.
관련 리뷰: Browserless 리뷰.
관리형 추출 서비스는 또 다른 범주입니다. 브라우저와 selector 제어권을 포기하는 대신, 렌더링과 스키마 구성을 외부에 맡깁니다. 구조화된 레코드가 결과물이고 DOM 자체는 필요 없을 때 유용할 수 있지만, 브라우저를 Go 서비스가 직접 통제해야 할 때는 chromedp가 더 잘 맞습니다. 우리는 이런 서비스 중 하나인 Thunderbit을 만들고 있지만, 이 픽스처에 대해 직접 실행하지는 않았습니다. 따라서 이 테스트만으로 chromedp와의 동등성, 지연시간, 추출 품질, 비용 비교를 주장할 수는 없습니다.
결론
chromedp를 써야 할까요? Go를 쓰고 있고 실제 브라우저를 직접 통제하고 싶다면, 대답은 예입니다. 이번 로컬 픽스처에서는 첫 스크립트 결과를 p50 102ms에 도달했고, 취소 후 Chrome을 약 13ms 안에 정리했으며, 동시에 4개 탭을 돌릴 때 브라우저 프로세스는 하나만 사용했습니다. 이런 값들은 경계가 있는 관찰 결과일 뿐, 보편적 성능 약속은 아닙니다. 진짜 매력은 편의 계층이 한계에 도달했을 때 Go에서 직접 CDP에 접근할 수 있다는 점입니다.
다만 주장 범위는 정확히 잡아야 합니다. 평판이 두 가지를 과장하기 때문입니다. "순수 Go, 의존성 없음"은 모듈을 말하는 것이고, 런타임에는 Chrome 바이너리를 직접 배포하고 관리해야 합니다. 또 "헤드리스 브라우저를 쓰면 동적 콘텐츠가 다 나온다"는 말은, 기다림이 원하는 노드에 맞춰져 있을 때만 사실입니다. 단순 읽기와 WaitReady("body") 모두 로드 800ms 후 주입된 콘텐츠가 빠진 페이지를 조용히 돌려줬습니다. macOS에서는 defer cancel()이 스타일 취향이 아닙니다. 이걸 빼면 실행마다 브라우저가 새어 나가며, 이는 이미 알려진 플랫폼 동작이지만 결국 당신이 처리해야 할 문제입니다. 이 세 가지를 제대로 맞추면 chromedp는 제가 측정해 본 브라우저 드라이버 중 가장 예측 가능한 축에 속합니다. 틀리면 조용히 실패하는데, 스크래퍼에게 그건 최악의 실패 방식입니다.
웹 데이터 추출용 Thunderbit 사용해보기 Get Started Free
FAQ
chromedp는 JavaScript로 렌더링된 콘텐츠를 실제로 읽을 수 있나요?
네. 하지만 원하는 노드에 맞는 wait가 있어야 합니다. 링크가 load 이벤트 800ms 뒤에 주입되는 픽스처에서 Navigate 후 읽기와 WaitReady("body")는 모두 실패했지만, 해당 노드에 대한 WaitVisible과 JavaScript poll은 각각 3번 실행 모두에서 성공했습니다. 지연 시간을 바꿔가며 보면, 대기 없는 읽기는 100ms 이상 모든 설정에서 노드를 놓쳤습니다. 렌더링은 필요조건이고, 올바르게 기다리는 것이 충분조건입니다.
WaitReady와 WaitVisible의 차이는 무엇인가요?
WaitReady는 노드가 DOM에 붙을 때까지 차단합니다. WaitVisible은 실제로 보일 때까지 차단합니다. DOM에 붙어 있지만 display: none인 노드에서는 WaitReady가 약 6ms 만에 반환한 반면, WaitVisible은 4초 context deadline까지 기다린 뒤 깨끗한 context deadline exceeded를 반환했습니다. 어느 쪽이 "더 안정적"인 것은 아닙니다. 서로 다른 질문에 답할 뿐이고, 잘못 고르는 것이 진짜 함정입니다.
chromedp에서는 정말 defer cancel()이 필요한가요?
macOS에서는 그렇습니다. context와 allocator를 취소하면 생성된 Chrome이 매번 12~13ms 안에 정리됐지만, 취소하지 않고 Go 프로세스를 종료하면 세 번 모두 브라우저 프로세스가 남았습니다. 이건 이미 알려진 플랫폼 범위의 동작입니다. chromedp의 트래커는 다른 비-Linux 시스템에서도 같은 비종료 패턴을 기록했고, 이를 해결하는 부모 사망 kill은 Linux 전용 빌드 태그 소스에 있습니다. 저는 Linux를 테스트하지 않았으므로, orphan 결과는 macOS 범위로 보세요.
chromedp를 쓰려면 Chrome을 따로 설치해야 하나요?
네. Go 모듈 자체는 cgo 없는 순수 Go지만, 외부 브라우저를 제어하므로 브라우저가 없으면 즉시 실패합니다. 저는 chromedp.ExecPath로 Chrome for Testing 151.0.7922.10 headless shell을 명시적으로 제공했습니다. 장점은 시작 비용이 작다는 점입니다. 첫 스크립트 결과까지의 완전한 cold cycle 중앙값은 5개 새 프로세스 기준 102ms였고, 범위는 98~111ms였습니다.
탭을 공유해서 하나의 브라우저를 쓰는 게 좋나요, 아니면 브라우저를 따로 띄워야 하나요? 브라우저 프로세스 수를 줄이는 것이 목표라면 child context를 선호하세요. localhost 네비게이션 4개를 하나의 브라우저로 돌리면 Chrome browser 프로세스는 1개였고, 브라우저를 따로 4개 띄우면 4개였습니다. 전체 시간도 공유 구성 쪽이 더 좋았습니다(중앙값 214ms 대 264ms). 다만 아주 단순한 로컬 페이지 4개는 처리량 벤치마크가 아닙니다. 메모리는 측정하지 않았습니다. 더 강한 세션 분리, 다른 프록시, 더 작은 충돌 범위가 필요하다면 별도 브라우저가 여전히 맞을 수 있으며, 그런 트레이드오프는 이번 테스트 범위 밖입니다.


