nodriver는 ultrafunkamsterdam이 만든 Python 브라우저 자동화 라이브러리로, undetected-chromedriver의 작성자가 개발한 프로젝트입니다. 이 도구는 사실상 그 프로젝트의 후속작이라고 소개합니다. 구조적으로 가장 크게 갈라지는 지점은 Selenium과 chromedriver입니다. nodriver는 WebDriver 바이너리 없이 Chrome DevTools Protocol(CDP)을 통해 Chromium과 직접 통신하며, API는 비동기 방식입니다. Playwright와 Puppeteer는 비교 대상이 조금 다릅니다. 이들도 WebDriver 계열이 아니라 프로토콜 기반의 브라우저 제어 도구이므로, 실제 차이는 API 설계, 패키징 방식, 브라우저 제공 방식, 호환성 정책에서 더 크게 드러납니다.
이번 리뷰는 실시간 차단 방어를 상대로 시험한 것이 아니라 nodriver 0.50.3 자체를 점검한 결과입니다. 설치된 패키지, import 동작, API 표면, 디스크 사용량, 라이선스를 살펴본 뒤, 브라우저는 127.0.0.1에서 제공한 페이지에만 접속시켰습니다. 실제 타깃, 안티봇 서비스, CAPTCHA는 전혀 사용하지 않았습니다. 따라서 아래 결과는 패키지의 동작과 기본 브라우저 노출 정보를 설명할 뿐, 탐지 회피 효과를 입증하는 것은 아닙니다.
이 범위 안에서도 눈에 띄는 점이 세 가지였습니다. 첫째, Python 3.14에서는 라이브러리가 아예 import되지 않습니다. 아주 작은 한 바이트 때문에 패키지 전체가 실행도 못 하고 멈춥니다. 둘째, 이런 계열의 드라이버치고는 놀랄 만큼 가볍습니다. 명시된 직접 의존성은 세 개뿐이고, 그에 따라 풀리는 전이 의존성 하나를 더해도 측정 환경에서 약 17MB 수준이었습니다. 셋째, 제가 만든 페이지에서 nodriver가 stock Playwright와 stock Puppeteer와 달라 보이는 유일한 속성은 불리언 하나뿐이었고, headless user-agent 문자열은 세 도구 모두 여전히 HeadlessChrome을 말하고 있었습니다. 나머지 놀라운 부분은 라이선스 쪽에 있습니다.
Python 3.14에서 import가 깨지는 문제
가장 먼저 부딪히게 되는 발견부터 보겠습니다. 코드가 실행되기도 전에 실패하기 때문입니다. Python 3.14에서 단순히 import nodriver를 하면 바로 에러가 납니다.
File ".../nodriver/cdp/network.py", line 1345
#: JSON (±Inf).
^
SyntaxError: Non-UTF-8 code starting with '\xb1' on line 1345, but no encoding declared; see PEP 263
문제가 되는 파일은 자동 생성된 cdp/network.py입니다. 헤더에도 # DO NOT EDIT THIS FILE!라고 적혀 있습니다. 주석 #: JSON (±Inf). 안의 ± 때문에 비 UTF-8 바이트 0xb1이 들어 있고, 소스 인코딩 선언도 없습니다. 모듈은 nodriver/__init__ → cdp/__init__ → network 순으로 로드되므로, 파싱 실패가 import 전체를 중단시킵니다. 패키지를 훑어본 결과, 비 UTF-8 소스 파일은 이 하나뿐이었습니다.
버전 경계는 조심해서 설명해야 합니다. Python 3.14.2에서는 이 파일이 거부되지만, Python 3.12.13에서는 수정 없이 import됩니다. 해당 환경의 network.py는 바이트 단위로 완전히 동일합니다(SHA-256 ef755f41800d4efb593736f8b55b331bba68eec373ed62ece433e66e4b491cd6). 따라서 결과 차이를 설명할 별도 소스 아티팩트는 없습니다. 이 리뷰는 CPython tokenizer의 정확히 어떤 변경이 원인인지까지는 분리하지 않았고, Python 3.13도 테스트하지 않았습니다. 여기서는 측정된 두 인터프리터 결과만 말하며, 그보다 앞선 Python 전체가 3.12처럼 동작한다고 단정하지 않습니다.
이 두 시점만으로 Python 3.13에 대한 결론은 내릴 수 없습니다.
이건 새로 찾아낸 문제라기보다 재현한 결과입니다. 같은 Python 3.14 traceback은 nodriver issue #35에 기록되어 있고, 수정 제안은 pull request #36에 있습니다. 버전 0.50.3에도 그 바이트는 그대로 남아 있습니다. PyPI 메타데이터는 Python 3.13까지만 classifier를 나열하며 3.14 지원은 주장하지 않습니다.
해결책은 버그만큼 작습니다. 검증된 인터프리터 버전을 쓰거나, pull request #36이 제안하듯 해당 파일 하나를 UTF-8로 다시 인코딩하면 됩니다. 다시 인코딩한 뒤에는 3.14에서도 패키지가 import되고 introspection도 문제없이 됩니다. 아래의 깨끗한 3.12.13 실행은 아직 패치되지 않은 경로 하나를 검증한 것이며, Python 3.13은 이번 리뷰에서 테스트하지 않았습니다.
만약 최신 인터프리터를 기본으로 쓰는 팀이라면 — 실제로 새 Python으로 빨리 넘어가는 조직이 많습니다 — 이건 충분히 현실적인 벽입니다. 직접 작성하지도 않은 파일의 SyntaxError 때문에 오후를 날리지 않도록, 이런 문제가 있다는 사실은 미리 알아두는 편이 좋습니다.
nodriver가 내부적으로 실제로 하는 일
한 줄 설명인 “CDP 네이티브, WebDriver 없음”은 wheel 안에 무엇이 들어 있는지 보면 홍보 문구가 아님을 알 수 있습니다. nodriver는 DevTools Protocol 바인딩 전체를 자체 포함하고 있습니다. nodriver.cdp 패키지에는 57개의 프로토콜 도메인 모듈이 들어 있으며, accessibility, dom, network, page, fetch, runtime, target, storage, input, emulation 등 여러 도메인을 포괄합니다. 이 모듈 수가 바로 “직접 CDP, Selenium 없음”이라는 주장의 실체입니다. WebDriver를 말하는 chromedriver 실행 파일을 외부 호출하는 대신, nodriver는 CDP 도메인용 Python 객체를 생성하고 WebSocket으로 프로토콜 자체를 직접 주고받습니다. 위에서 문제가 된 cdp/network.py도 그 57개 자동 생성 모듈 중 하나이기 때문에, 사람이 손으로 고치지 않는 생성 코드 안에서 이 문제가 발생한 것입니다.
이 프로토콜 계층 위에는 더 다루기 쉬운 객체 모델이 얹혀 있습니다. Tab 객체는 62개의 공개 메서드를 노출하며, 탐색 기능도 많은 드라이버보다 넓습니다. find()와 find_all()로 텍스트 매칭, select()와 select_all()로 CSS 선택, 그리고 xpath()라는 직접 진입점까지 제공합니다. 하나의 객체에서 XPath, CSS, 텍스트 검색을 모두 쓸 수 있다는 점은 실제로 꽤 편리합니다. 다른 라이브러리에서는 XPath를 쓰려면 evaluate()로 내려가야 하는 경우도 있습니다. Config 생성자는 user_data_dir, headless, browser_executable_path, browser_args, sandbox, 기본값이 'en-US'인 lang, host, port, expert, **kwargs를 노출합니다. 이 API 점검 중 Config(headless=True)를 생성했을 때 16개의 Chromium 실행 플래그가 잡혔고, 그중에는 --no-first-run, --no-default-browser-check, --remote-allow-origins=*, --homepage=about:blank가 포함되어 있었습니다. 이 단계에서는 start()를 호출하지 않았습니다. 아래의 브라우저 테스트는 별도의 실행입니다.

이 라이브러리에는 안티탐지 지향 API도 들어 있습니다. 존재 여부는 확인했지만, 어떤 타깃에도 실제로 시험하지는 않았습니다. 그 효과를 실제 서비스에서 검증하는 건 제가 일부러 하지 않은 부분이므로, 그 점은 여기서만 언급하고 넘어가겠습니다. 중립적으로 말하자면 nodriver의 네이밍은 스텔스 계열 경쟁 제품들보다 훨씬 절제되어 있습니다. 이야기는 detect_and_bypass 같은 메서드 이름이 가득한 것이 아니라, CDP 네이티브와 실행마다 새 프로필이라는 구조적 특징에 가깝습니다. 이것은 API 설계에 대한 관찰일 뿐, 결과를 보장한다는 의미는 아닙니다.
이 수치는 패키지를 import한 뒤 Python의 기본 introspection 도구인 inspect, 모듈 순회, 속성 카운팅으로 얻었습니다. 어떤 사이트도 건드리지 않았습니다. 직접 다시 확인해 보려면, 수치는 패키지에서 바로 나오는 값입니다. 감이 아니라 실제 데이터입니다.
스스로를 어떻게 드러내는가

실제 방어 체계에 접근하지 않고도 답할 수 있는 질문이 있습니다. nodriver가 브라우저를 제어할 때, 그 브라우저는 접속한 페이지에 자신에 대해 무엇을 말할까요? 저는 navigator.webdriver, user-agent, platform, languages, plugin과 hardware 개수, window.chrome의 형태, Permissions API 응답, window와 screen의 geometry 같은 값들을 읽는 페이지를 만들었습니다. 그 페이지는 127.0.0.1에서 서빙했고, nodriver, Botasaurus, stock Playwright, stock Puppeteer를 각각 붙였습니다. 네 스택 모두 같은 Chrome 빌드(Chrome for Testing 151.0.7922.10)를 사용했기 때문에, 다른 값이 있다면 브라우저가 아니라 라이브러리 차이입니다. headless와 headed 모두 3회씩 실행했습니다. 아래 값들은 모두 세 번의 실행에서 동일하게 유지되었습니다.
| 스택 | 모드 | navigator.webdriver | User-agent 토큰 | navigator.languages |
|---|---|---|---|---|
| nodriver 0.50.3 | headless | false | HeadlessChrome/151.0.0.0 | ["en-US"] |
| nodriver 0.50.3 | headed | false | Chrome/151.0.0.0 | ["en-US"] |
| Botasaurus 4.0.92 | headless / headed | false | HeadlessChrome/151 / Chrome/151 | ["en-US"] |
| Playwright 1.56.0 | headless / headed | true | HeadlessChrome/151 / Chrome/151 | ["en-US","en"] |
| Puppeteer 24.16.0 | headless / headed | true | HeadlessChrome/151 / Chrome/151 | ["en-US"] |
가장 분명한 차이는 저 불리언 하나입니다. 기본 실행 설정에서는 nodriver가 navigator.webdriver를 false로 보고하는 반면, stock Playwright와 Puppeteer는 headless와 headed 모두에서 true를 보고합니다. 이 테스트는 설정 수준의 차이를 보여줄 뿐, WebDriver 바이너리가 없어서 그런 것이라고 단정하지는 않습니다.
네 스택 모두에서 해당 속성은 여전히 Navigator.prototype의 브라우저 네이티브 getter입니다. 즉, function get webdriver() { [native code] } 형태이며, 인스턴스의 own-property가 되거나 다른 함수로 바뀐 것이 아닙니다. 페이지 JavaScript가 로드 후 이 값을 덮어쓴 것도 아닙니다. 무엇이 그 차이를 만들었는지에 대해서는 실행 인자나 소스 경로까지 따로 파악하지 않았습니다.
두 번째로 주목할 점이 바로 마케팅 문구를 조정해 줍니다. headless 모드에서 nodriver의 user-agent는 여전히 HeadlessChrome/151.0.0.0를 알립니다. stock Puppeteer와 완전히 같고, stock Playwright와도 같습니다. headed로 실행하면 Chrome/151.0.0.0로 바뀌며, 이 역시 동일합니다. 안티탐지 라이브러리가 브라우저 자동화에서 가장 잘 알려진 자기 식별 문자열을 기본적으로 가려줄 것이라고 생각했다면, 그렇지 않습니다. 그건 직접 설정해야 합니다.
나머지는 거의 전부 네 스택에서 같았습니다. 이것도 분명히 말할 가치가 있습니다. 아래 속성들은 nodriver, Botasaurus, Playwright, Puppeteer 모두에서 동일하게 읽혔습니다.
| 속성 | 네 스택 모두에서 동일한 값 |
|---|---|
platform | MacIntel |
vendor | Google Inc. |
| Plugins | 5개 |
| MIME types | 2개 |
pdfViewerEnabled | true |
| 논리 코어 수 | 12개 |
| 보고된 디바이스 메모리 | 16 GB |
| 터치 포인트 | 0 |
window.chrome | 존재함, app/csi/loadTimes 포함, runtime 없음 |
| WebGL renderer 문자열 | 네 스택 모두 동일 |
예전부터 많이 언급되던 Permissions API와 Notification.permission의 모순도 어느 곳에서도 나타나지 않았습니다. 네 스택 모두 default와 prompt로 일치했습니다. 또한 document와 window에서 오래된 WebDriver 스택의 흔적이던 cdc_ 스타일 잔재도 찾아봤지만, 모두 비어 있었습니다.
nodriver가 제어 대상으로 쓰이는 브라우저보다 더 “날것의 자동화 브라우저”처럼 보이는 유일한 지점은 창 크기입니다. headless nodriver는 800×600 화면에서 outerWidth/outerHeight가 0×0으로 보고되지만, headless Playwright는 viewport를 직접 설정하기 때문에 1280×720으로 보고합니다. Puppeteer는 nodriver처럼 0×0입니다. 이것은 기능 차이가 아니라 기본 설정 차이이며, 원하면 바꿀 수 있습니다.
배포 전에 꼭 알아둘 마지막 한 가지가 있습니다. nodriver는 브라우저를 함께 제공하지 않으므로, 기본적으로는 머신에 이미 설치된 Chrome을 사용합니다. 제 환경에서는 /Applications/Google Chrome.app를 자동으로 찾았고, Chrome 150.0.7871.187이 사용되었습니다. 노출된 user-agent도 고정된 버전이 아니라 그 버전을 반영했습니다. 즉, 당신의 환경에서 브라우저 버전이 무엇으로 보일지는 결국 당신의 설치 상태에 달려 있습니다.
이것이 무엇인지, 그리고 무엇이 아닌지를 분명히 합시다. 누군가 숨기라고 요청하지 않았을 때 자동화 스택이 무엇을 노출하는지에 대한 기록입니다. 방어자 입장에서는 유용하고, 자신의 도구가 무엇을 방송하는지 알고 싶은 경우에도 유용합니다. 하지만 이것이 특정 서비스에 대해 어떤 의미를 갖는지는 보여주지 않습니다. 저는 그걸 테스트하지 않았고, 위의 어떤 행도 결과를 암시하는 것으로 읽으면 안 됩니다.
실제로 페이지에서 콘텐츠를 가져올 수 있나?
자신을 어떻게 드러내는지는 한 가지이고, 올바른 HTML을 돌려주는지는 또 다른 일입니다. 저는 이 벤치마크 저장소의 나머지 도구들과 맞춰, 같은 세 가지 콘텐츠 클래스가 있는 테스트 페이지에 nodriver를 돌렸습니다. 그래서 수치도 여기에 있는 다른 도구들과 직접 비교됩니다. 페이지에는 세 가지가 있습니다. A는 서빙되는 바이트 안에 literal로 들어 있는 정적 링크, B는 파싱 중 inline script가 만들어낸 노드로, 마커와 URL이 조각에서 조립되어 JavaScript를 실제로 실행해야만 보입니다. C는 load 이벤트가 끝난 뒤 800ms 후에 주입되는 노드로, 같은 방식으로 조립됩니다. C가 가장 까다로운 대상입니다. load 시점에서 읽으면 이 노드는 보이지 않습니다.
| 스택 | 기본 읽기 | 명시적 대기 포함 |
|---|---|---|
| nodriver 0.50.3 | 3개 중 2개 (A + B, C 누락) | 3개 중 3개 |
| Botasaurus 4.0.92 | 3개 중 2개 | 3개 중 3개 |
| Playwright 1.56.0 | 3개 중 2개 | 3개 중 3개 |
| Puppeteer 24.16.0 | 3개 중 2개 | 3개 중 3개 |
nodriver는 heavyweights와 정확히 같은 지점에 도달했습니다. browser.get() 뒤에 곧바로 tab.get_content()를 호출하면 load 시점의 스냅샷을 읽는 셈입니다. JavaScript는 제대로 실행합니다. class B가 서버에 내려간 바이트 어디에도 없는데도 나타나는 것을 보면 알 수 있습니다. 하지만 load 이후에 주입된 것은 놓칩니다. 여기에 tab.select("#delayed-injected")를 추가하면 세 가지 모두 잡힙니다. Playwright와 Puppeteer에서 보던 그 발목 잡는 습관과 동일하고, 해결책도 같습니다. 세 번의 반복과 전체 수트의 세 번의 별도 실행에서 모두 안정적이었고, flaky한 결과는 없었습니다.
주입 지연 시간을 바꿔 보며 기본 읽기가 언제 포기하는지 확인했습니다. nodriver는 주입이 load 이후 100ms 이상 늦어지면 class C를 더 이상 보지 못했습니다. stock control 두 개와 같은 경계입니다. (여기서는 Botasaurus가 예외인데, 두 안티탐지 라이브러리 사이에서 정말 흥미로운 차이입니다. Botasaurus의 get()은 기본적으로 전체 페이지 로드를 기다리므로, 기본 읽기만으로도 300ms 주입까지 잡아냅니다. 대신 네비게이션 하나당 약 250ms를 더 지불합니다.)
대기 동작 자체에도 예산을 잡아야 할 특성이 있습니다. 이번 sweep에서 nodriver의 tab.select()는 지연 시간을 촘촘히 추적하기보다, 거친 폴링 구간으로 움직였습니다.
| class C 지연 | 0 ms | 100 ms | 400 ms | 800 ms | 1500 ms |
|---|---|---|---|---|---|
nodriver select() | 124–152 ms | 1132–1141 | 1128–1129 | 2132–2177 | 2138–2150 |
Puppeteer waitForSelector | 113–129 ms | 203–216 | 512–516 | 911–919 | 1608–1611 |
이 테스트에서는 100ms 주입이 select()에서 약 1.1초로 이어졌습니다. 설치된 루프는 miss 후 await self와 이어지는 await self.sleep(0.5)를 수행하며, 여기서 측정된 결합 사이클은 약 1초 근처였습니다. 다만 await self가 보편적으로 고정 길이 sleep이라고 단정되지는 않습니다. 테스트한 지연 노드는 모두 찾았습니다. Puppeteer의 waiter는 이 지연을 더 정밀하게 따라갔습니다. 여러 개의 연속 대기에서는 차이가 누적될 수 있지만, 이번 리뷰는 selector 30개짜리 실서비스 페이지까지는 벤치마크하지 않았습니다.
비동기이면서 가볍다는 프레이밍이 현실과 만나는 또 다른 지점은 실행 시작입니다. 브라우저를 띄우는 속도는 nodriver가 Botasaurus와 Puppeteer와 비슷한 구간에 있었고, Playwright보다는 훨씬 느렸습니다.
| 스택 | 브라우저 실행 시간, 여러 실행 기준 |
|---|---|
| nodriver 0.50.3 | 910–1583 ms |
| Botasaurus 4.0.92 | 986–1151 ms |
| Puppeteer 24.16.0 | 969–1008 ms |
| Playwright 1.56.0 | 282–365 ms |
실행 후에는 nodriver의 navigate-and-read가 네 도구 중 가장 빠른 편으로 119–129ms였습니다. 시동은 평범하고, 운행은 가볍습니다.
설치와 크기: 진짜로 좋은 부분
여기서 nodriver는 “집중형”이라는 평가를 받을 만합니다. 이것은 스크래핑 기능에 대한 칭찬이 아니라, 검증 가능한 설치 사실입니다. 깔끔한 pip install nodriver는 다음과 같이 풀립니다.
| 설치 사실 | nodriver 0.50.3 |
|---|---|
| 명시된 직접 런타임 의존성 | 3개 — websockets, mss, deprecated |
| 풀리는 전이 의존성 | deprecated를 통해 wrapt |
측정된 site-packages 총량 | 약 17.2 MB, 6개의 dist-info 디렉터리 포함, 환경의 pip 포함 |
| 그중 nodriver 자체 비중 | 3.7 MB |
| numpy / lxml | 없음 |
| 설치 시 브라우저 바이너리 다운로드 | 없음 |
렌더링 스택을 통째로 끌고 오는 이 카테고리에서는 상당히 가벼운 편입니다.
직접 의존성과 전이 패키지를 구분하는 것은 유지보수에서 중요합니다. nodriver의 메타데이터는 mss, websockets, deprecated를 요구하고, wrapt는 deprecated가 필요해서 따라옵니다. 17.2MB 환경에서 여섯 번째 dist-info 디렉터리는 이미 환경에 들어 있던 pip입니다. 따라서 “6개 분포에 걸친 17.2MB”는 측정된 환경을 설명하고, “추가된 런타임 패키지 4개”는 설치가 풀어낸 결과를 설명합니다. 같은 말이 아닙니다.
이 숫자의 의미를 잘 보여주는 비교가 있습니다. 같은 머신에서 형제 프레임워크 Botasaurus는 44개 패키지, 122.3MB를 차지했습니다. 디스크 사용량이 대략 7배 차이입니다. 이것이 집중형 CDP 드라이버와 올인원 프레임워크의 차이이며, 장단점은 양쪽에 있습니다. nodriver는 감사 가능한 얇은 의존성 트리를 제공하고, Botasaurus는 기본 제공 기능이 더 많지만 그만큼 디스크와 의존성 표면을 요구합니다. 어느 쪽이 절대적으로 “더 좋다”기보다, 드라이버가 필요한지 프레임워크가 필요한지에 따라 다릅니다. 다만 작고 살펴보기 쉬운 설치를 중시한다면, nodriver는 이 역할에 비해 유난히 깔끔합니다.
작은 디스크 사용량이 곧 작은 import를 뜻하는 것은 아닙니다. 바이트 수정된 Python 3.14 복사본에서는 import nodriver가 약 158ms 걸렸습니다(새 subprocess import에서의 중앙값, 대략 151–199ms). 패치되지 않은 3.14 import는 실패하므로 그 경우 타이밍은 보고되지 않았습니다. 57개의 CDP 도메인 모듈이 한꺼번에 eager load되고, import 후 상주 메모리는 Chrome 프로세스가 뜨기 전 기준으로 31.5–31.9MB였습니다. 실제로 브라우저를 띄우면 훨씬 더 늘어나지만, 이 메모리 측정에는 포함하지 않았습니다.
이 수치들에는 두 가지 단서가 붙습니다. 하나는 이 결과가 macOS arm64 한 대에서 나왔다는 점, 다른 하나는 디스크 크기와 import 타이밍이 3.14의 바이트 패치 복사본에서 측정되었다는 점입니다. 패치되지 않은 패키지는 그 인터프리터에서 아예 import되지 않기 때문입니다. 지원되는 Python에서는 패치가 필요 없습니다. 실제로 3.12.13에서는 깨끗한 unpatched import를 확인했고, 모든 브라우저 측정치는 여기서 나왔습니다. 그리고 런타임에는 여전히 실제 Chrome, Chromium, Edge, Brave 바이너리가 필요합니다. nodriver는 기존 브라우저를 조종할 뿐 브라우저를 함께 배포하지 않으므로, 설치 비용은 이 17MB 바깥에 있고, 앞서 본 노출 섹션처럼 여러분의 머신에 설치된 Chrome 버전이 그대로 트래픽이 드러내는 버전입니다.
라이선스가 사실상 진짜 채택 결정이다

무료 도구 리뷰에서는 보통 “오픈소스다”를 라이선스 논의의 끝으로 취급합니다. nodriver는 그게 시작입니다. 라이선스가 AGPL-3.0이기 때문입니다. wheel의 LICENSE.txt와 저장소의 spdx_id 모두에서 확인했습니다. 이것은 강한 네트워크 카피레프트 라이선스이며, 주변 경쟁 제품들이 요구하는 것과는 실질적으로 다릅니다.
대부분이 nodriver와 비교하게 되는 도구들은 허용적 라이선스를 쓰는 경우가 많아, 대비가 더 분명합니다.
| 도구 | 라이선스 | 수정한 복사본을 네트워크 서비스로 운영할 때 |
|---|---|---|
| nodriver | AGPL-3.0 | 13조에 따라 수정 버전의 대응 소스를 원격 사용자에게 제공해야 할 수 있음 |
| Playwright | Apache-2.0 | 이에 상응하는 네트워크 카피레프트 조항 없음 |
| Puppeteer | Apache-2.0 | 위와 같음 |
| Botasaurus | MIT | 위와 같음 |
적용 범위도 중요합니다. AGPL 13조는 원격 네트워크 상호작용에 사용되는 대상 프로그램의 수정 버전에 대해 다룹니다. 이 리뷰는 별도의 주변 서비스 코드가 해당 저작물의 일부인지, 내부 사용이나 회사 경계에서 생기는 예외를 어떻게 볼지까지는 판단하지 않습니다. 호스팅 제품에서 nodriver를 수정해 쓸 계획이라면, 라이선스 본문과 아키텍처를 법무와 함께 검토해야 합니다. 이것은 기술적 채택 신호이지, 법률 자문은 아닙니다.
AGPL이 좋은지 나쁜지에 대해 평을 하려는 것은 아닙니다. 카피레프트는 정당한 선택이고, 실제로 많은 진지한 프로젝트가 사용합니다. 다만 “nodriver는 무료 오픈소스다”라는 말이 참이면서도 불완전하다는 점을 짚는 것입니다. 의무는 실제로 존재하고, 이 생태계의 기본값인 허용적 라이선스와는 다르며, 결정을 내릴 때 분명 포함되어야 합니다. “무료”라는 한마디로 눌러버리면 안 됩니다. (참고로 PyPI는 라이선스 classifier를 아예 제공하지 않습니다. AGPL 텍스트는 wheel 안에 있고, SPDX id는 저장소에 있으니, 이 정보를 패키지 인덱스에만 의존하지 마세요.)
메타데이터, 날짜 포함
GitHub API와 PyPI에서 바로 얻은 시점별 저장소 및 패키지 수치입니다.
| 항목 | 2026년 7월 14일 기준 값 |
|---|---|
| Stars | 4,511 |
| Forks | 422 |
| Open issues | 14 |
| 생성 시점 | 2024년 2월 |
| 마지막 push | 2026년 5월 |
| 최신 PyPI 릴리스 | 0.50.3 |
| wheel | pure-Python py3-none-any |
requires-python | >=3.9 |
| Python classifiers | 3.7–3.13 |
관찰되는 유지보수 신호는 엇갈립니다. 저장소는 2026년 5월에 push되었지만, 최신 측정 패키지에는 여전히 Python 3.14 import 문제가 남아 있었고 제 연구 시점까지 수정은 배포되지 않았습니다. Stars와 open issue 수만으로 유지보수 속도가 충분한지 여부가 정해지지는 않습니다.
장점과 단점
장점:
- 작고 이해하기 쉬운 설치: 직접 의존성 세 개와 전이
wrapt만 포함하고, 측정 환경에서 약 17.2MB, dist-info 6개(그중 하나는pip)였으며, numpy/lxml도 없고 설치 시 브라우저 다운로드도 없음. - 진정한 CDP 네이티브: 57개 모듈짜리 DevTools Protocol 바인딩을 자체 포함하고, chromedriver/Selenium 바이너리 없이 프로토콜을 직접 사용함.
Tab의 요소 탐색 범위가 넓고(공개 메서드 62개), XPath, CSS, 텍스트 검색을 기본 지원해서 XPath를 위해 rawevaluate()로 내려갈 필요가 없음.- 설계상 비동기이며, 실행마다 새 프로필을 쓰고, headless, executable path, args, lang, ports 같은 주요 설정을 담는
Config가 깔끔함. - JavaScript로 만들어진 페이지에서도 heavyweights와 비슷하게 콘텐츠를 잘 가져옴: 기본 읽기에서 3개 중 2개, 명시적 대기 후 3개 중 3개. 같은 테스트 페이지에서 stock Playwright와 Puppeteer와 동일했고, 세 번의 실행에서 안정적이었음. navigate-and-read는 119–129ms로 네 도구 중 가장 빠름.
- 기본값으로
navigator.webdriver를false로 보고하며, stock control 두 개가true를 보고하는 것과 다름. 이때 속성을 패치한 것이 아니라 브라우저 고유의 native getter는 그대로 유지됨. - undetected-chromedriver의 CDP 네이티브 후속작이라는 명확한 구조적 정체성.
단점:
- Python 3.14에서는 기본 상태로 import되지 않음. 비 UTF-8 파일 하나(
cdp/network.py) 때문에 import 시SyntaxError가 발생함. 공개 이슈 #35로 재현되었고, 0.50.3 기준 아직 수정되지 않음. ≤3.13으로 고정하거나(3.12.13에서는 깨끗하게 확인됨), 파일을 다시 인코딩해야 함. - AGPL-3.0은 수정한 복사본을 네트워크 서비스로 운영하는 사람에게 실제 채택 고려 사항임. Apache/MIT 계열보다 더 엄격함.
- 작은 디스크 사용량이 작은 import를 뜻하지는 않음. eager load 되는 57개 CDP 모듈 때문에 cold start가 약 158ms이고, 브라우저가 뜨기 전 상주 메모리도 약 31.5MB임.
tab.select()는 반초 단위 폴링 백오프를 쓰기 때문에 짧은 대기가 반올림됨. 100ms 대기가 약 1.1초로 늘어남. 항상 맞기는 하지만, 작은 대기가 많으면 누적됨.- headless 실행에서도 user-agent에 기본적으로
HeadlessChrome가 그대로 드러남. stock control과 정확히 같으며, 기본 설정이 가장 눈에 띄는 자기 식별 문자열을 숨겨주지는 않음. - 런타임에는 여전히 실제 Chrome/Chromium/Edge/Brave 바이너리가 필요함. pip 설치가 가볍다고 해서 의존성 이야기가 끝나는 것이 아니며, 머신에 설치된 Chrome 버전이 곧 노출 버전임.
- 어떤 안티봇 시스템에 대해 효과가 있는지는 여기서 검증하지 않았음. 스텔스 전제 자체는 의도적으로 테스트하지 않았음.
내가 테스트하지 않았고, 따라서 말할 수 없는 것: 탭별 메모리, CDP 왕복 지연, 프로필 처리, 대규모 처리량, Linux나 Windows, Python 3.13 구체 버전, 그리고 가장 큰 항목인 실제 라이브 서비스에 대한 안티봇 효과입니다. 이번 리뷰의 브라우저는 모두 127.0.0.1의 테스트 페이지에만 접속했습니다. 모든 수치는 단일 머신(macOS arm64) 기준이며, 브라우저 제어 수치는 Python 3.12.13에서 패치 없이 측정했고, 더 오래된 크기와 import 타이밍 수치는 Python 3.14에서 바이트 패치 복사본으로 측정했습니다.
또한 브라우저 업그레이드 매트릭스도 돌리지 않았으므로, 미래 Chrome 릴리스와의 호환성은 이번 리뷰의 결과가 아니라 채택자의 운영 점검 항목으로 남아 있습니다.
누구에게 맞고, 누구는 피해야 하나
nodriver는 가벼운 async CDP 네이티브 드라이버를 원하고, 브라우저 업데이트와 런타임 운영을 스스로 책임질 수 있을 때 잘 맞습니다. 작은 의존성 트리는 감사하기 쉽고, 프로토콜 레벨 제어를 선호하는 경우에도 적합합니다. 컨테이너 적합성은 아직 검증되지 않았습니다. 이번 리뷰에서는 Linux 이미지, 브라우저 설치, 공유 라이브러리, sandbox, 프로세스 정리를 실험하지 않았습니다.
두 그룹은 다른 대안을 보는 편이 좋습니다. Python 3.14를 쓰고 있고, 인터프리터를 고정하거나 vendored 파일을 직접 패치할 생각이 없다면, 수정이 배포될 때까지 기다리세요. 지금은 import 자체가 멈춥니다. 그리고 AGPL-3.0이 서비스 배포 방식과 충돌할 수 있다면 — 예를 들어 비공개 수정이 있는 호스팅 서비스 — 그 라이선스 하나만으로도 이 도구 위에 올리기 전에 허용적 라이선스 대안을 검토할 충분한 이유가 됩니다. 이는 코드에 대한 비판이 아니라, 지금 알아두는 것이 나중에 컴플라이언스 검토 때 알아차리는 것보다 훨씬 낫다는 뜻입니다.
또, 진짜로 필요한 것이 브라우저를 직접 조종하는 것이 아니라 데이터라면 이 도구는 건너뛰는 편이 낫습니다. nodriver는 스크립트 가능한 Tab과 62개의 메서드를 제공하지만, 렌더링된 페이지를 깔끔하고 구조화된 레코드로 바꾸는 일은 여전히 직접 작성해야 합니다. 그건 다른 작업이고, 그 사이를 메워 주는 것이 관리형 API입니다.
대안과 Thunderbit의 위치
솔직한 프레이밍부터 하겠습니다. nodriver는 무료이고, AGPL이며, 셀프호스트 방식입니다. 브라우저를 직접 돌리고, 업데이트를 직접 관리하고, 런타임의 모든 실패를 직접 책임집니다. 정확히 그런 통제를 원하는 개발자에게는, 이미 갖고 있는 라이브러리보다 저렴한 관리형 서비스는 없습니다.
오픈소스 안에서 비교할 때는 로고가 아니라 작업 기준으로 보세요. 실제 브라우저 드라이버를 놓고 고민한다면, 저희 Playwright와 Puppeteer 비교에서 nodriver와 같은 형태의 두 대표 주자를 다뤘습니다. 스텔스 지향 축에서 Python 계열의 가장 가까운 이웃은 Scrapling 리뷰입니다. 원시 브라우저 제어보다 LLM 친화적 출력이 목적이라면 Crawl4AI 리뷰처럼 페이지를 렌더링해 Markdown으로 돌려주는 도구가 있고, 대규모 브라우저리스 크롤링에는 여전히 Scrapy 리뷰가 기준점입니다. 여러 도구를 함께 비교하려면 오픈소스 스크래퍼 총정리가 범주를 나란히 보여줍니다.
고지: 이 글은 Thunderbit에서 발행했습니다. Thunderbit는 관리형 추출 서비스이므로, 비교의 기준은 아키텍처가 아니라 작업입니다. nodriver는 직접 호스팅하고 프로그래밍하는 브라우저 제어 레이어를 제공하고, Thunderbit는 렌더링을 처리한 뒤 페이지 콘텐츠나 스키마 형태의 레코드를 서비스로 반환합니다. 프로토콜 수준의 브라우저 제어와 셀프호스팅이 필수라면 nodriver를 쓰세요. 브라우저 소유보다 결과 레코드와 운영 인계가 더 중요하다면 관리형 추출기를 고려하세요. 변동 가능한 endpoint, credit, batch limit 관련 내용은 라이브러리 벤치마크가 아니라 요금 페이지에 있어야 합니다.
트레이드오프는 일이 어디에 있느냐입니다. nodriver는 브라우저 관리와 런타임 유지보수를 사용자 쪽에 남기고, 요청당 서비스 요금은 받지 않습니다. 관리형 API는 그 계층을 떠안는 대신 호출 비용을 청구합니다.
웹 데이터 추출을 위해 Thunderbit 사용해 보기
결론
가볍고 비동기이며 CDP 네이티브인 Chromium 드라이버가 필요하고, 자신의 Python 버전에서 정상 동작을 확인했으며(이 리뷰에서는 3.12.13에서 깨끗하게 import됨), AGPL-3.0이 배포 방식에 미치는 영향을 검토했다면 nodriver를 쓰세요. 실행 시 풀리는 런타임 그래프는 4개 패키지를 추가했고, 측정 환경은 약 17MB였으며, 라이브러리는 57개의 CDP 도메인 모듈, 62개 메서드를 가진 Tab 표면, native XPath를 제공합니다. 이 테스트 페이지에서는 기본값으로 3개 중 2개 클래스를 가져왔고, 대기 후 3개 중 3개를 가져와 stock Playwright와 Puppeteer와 동일했습니다.
다만 단점도 솔직히 보아야 합니다. Python 3.14에서는 비 UTF-8 바이트 하나를 고치기 전까지 아예 import되지 않습니다. 이는 문서화된 미해결 이슈이지 미스터리는 아니지만, 실제로 걸리면 즉시 멈추는 벽입니다. 라이선스는 AGPL-3.0으로, 서비스로 수정 복사본을 운영하는 사람에게는 형식이 아니라 실제 결정 사항입니다. 작은 설치가 작은 import를 보장하지도 않습니다. CDP 모듈 전부가 초기에 로드되기 때문이고, select()의 반초 폴링 때문에 짧은 대기는 각각 약 1초씩 듭니다. 제가 실제로 답할 수 있었던 기본 노출 질문에 대해서도, 마케팅보다 그림은 더 좁았습니다. stock Puppeteer와 다른 것은 불리언 하나뿐이었고, headless user-agent는 여전히 HeadlessChrome라고 말했으며, 제가 측정한 나머지 모든 속성은 네 스택에서 동일했습니다. 그리고 많은 사람들이 nodriver를 찾게 되는 이유인 안티탐지 전제는 의도적으로 테스트하지 않았습니다. 라이브 방어 체계와 맞붙이지 않고, 제 머신에서 페이지 하나를 상대로 라이브러리를 점검했을 뿐입니다. 확실히 말할 수 없는 bypass 주장을 드리기보다, 그 사실을 분명히 말하는 편이 낫다고 생각합니다. 제가 답할 수 있었던 항목들만 놓고 보면, nodriver는 잘 만들어졌고 매우 가벼운 드라이버입니다. 다만 Python 버전 벽과 카피레프트 라이선스라는 두 개의 날카로운 모서리를 미리 보아야 합니다.
웹 데이터 추출을 위해 Thunderbit 사용해 보기 Get Started Free
자주 묻는 질문
왜 Python 3.14에서 import nodriver가 실패하나요?
cdp/network.py 안에 소스 인코딩 선언 없이 비 UTF-8 ± 바이트가 들어 있기 때문입니다. Python 3.14.2는 이 파일을 거부하고 전이 import를 중단합니다. Python 3.12.13에서는 바이트가 동일한 파일이 수정 없이 import됩니다. 이번 리뷰는 정확한 인터프리터 변경 지점은 분리하지 않았고 Python 3.13도 테스트하지 않았습니다. upstream 경로는 nodriver issue #35와 pull request #36입니다. 검증된 버전을 쓰거나 파일을 UTF-8로 다시 인코딩하세요.
“CDP 네이티브, webdriver 없음”은 무엇을 주고, 설치 비용은 얼마나 드나요?
nodriver는 57개의 DevTools Protocol 도메인 모듈을 번들로 포함하고, Selenium을 통해 chromedriver를 호출하는 대신 WebSocket으로 CDP를 직접 다룹니다. Tab은 native XPath를 포함해 62개 메서드를 제공합니다. 패키지 메타데이터상 런타임 요구사항은 세 개(websockets, mss, deprecated)이며, 이를 풀면 wrapt가 추가됩니다. 측정 환경은 pip를 포함해 6개의 dist-info 디렉터리에 걸쳐 약 17.2MB였습니다. numpy, lxml, 다운로드된 브라우저 바이너리는 없었습니다. 단, 두 가지는 주의해야 합니다. 패치된 복사본의 import는 57개 CDP 모듈이 eager load되기 때문에 약 158ms 걸렸고, Chrome 계열 브라우저는 별도로 여전히 필요합니다.
AGPL-3.0 라이선스가 제 프로젝트에 중요한가요? 배포 방식에 따라 다릅니다. AGPL-3.0은 네트워크 카피레프트 라이선스입니다. 수정한 nodriver를 서비스로 운영하고 다른 사람이 그 서비스를 쓴다면, 수정 버전의 소스를 제공해야 할 의무가 생길 수 있습니다. 개인 스크립트나 외부에 공개하지 않는 내부 도구라면 보통 큰 문제가 아닙니다. 하지만 패치한 nodriver를 기반으로 한 상용 호스팅 제품이라면 컴플라이언스 책임자와 반드시 검토해야 할 사안이며, Apache-2.0이나 MIT보다 엄격합니다.
nodriver는 자기 자신을 어떻게 드러내며, 그게 Cloudflare를 이긴다는 뜻인가요?
앞의 절반은 측정된 사실이고, 뒤의 절반은 아닙니다. 그리고 그 차이가 중요합니다. 127.0.0.1에서 제공한 페이지에서, control과 같은 Chrome 빌드를 돌렸을 때 navigator.webdriver는 false로 나왔고 stock Playwright와 stock Puppeteer는 둘 다 true였습니다. 이 값은 속성을 패치해서 만든 것이 아니라 브라우저 실행 시점에 설정된 것이며, descriptor는 여전히 Chrome의 native getter입니다. 그 불리언을 제외하면 거의 모든 값이 control과 똑같았습니다. platform 문자열, 5개 plugin, 12개 코어, 16GB device memory, window.chrome 구조, Permissions API의 모순 없음, document와 window의 cdc_ 잔재 없음까지 같았습니다. headless 실행에서는 user-agent에 여전히 HeadlessChrome/151.0.0.0가 보였고, 이것도 control과 같습니다. 즉, 기본 설정만으로는 이 문자열이 가려지지 않습니다. 하지만 이것이 실제 안티봇 서비스 앞에서 어떤 의미가 있는지는 전혀 알 수 없습니다. 저는 nodriver를 라이브 사이트에 붙이지 않았고, 안티봇 서비스도 호출하지 않았으며, CAPTCHA도 건드리지 않았습니다. 의도적으로 범위 밖이었습니다. 위의 disclosure table은 스택이 무엇을 말하는지만 보여줍니다. 누가 듣고 있는지, 그리고 그들이 무엇을 하는지는 말해주지 않습니다.
nodriver는 JavaScript로 렌더링된 콘텐츠를 제대로 처리하나요?
네, 다만 언제 읽느냐가 중요합니다. 세 가지 콘텐츠 클래스가 있는 테스트 페이지에서 기본 browser.get() + tab.get_content()는 3개 중 2개를 반환했습니다. JavaScript는 올바르게 실행합니다. sync-injected class는 서빙된 바이트 어디에도 없는데도 나타났기 때문입니다. 하지만 load 이후에 주입된 것은 놓쳤습니다. tab.select("#delayed-injected")를 추가하면 3개 중 3개가 반환됩니다. 같은 페이지에서 stock Playwright와 stock Puppeteer와 동일했습니다. 다만 한 가지 특이점은 예산에 넣어 두세요. select()는 반초 단위 폴링 백오프를 쓰므로, 100ms 대기가 약 1.1초가 됩니다.


