Puppeteer 탐지는 실제로 어떻게 작동하는가: 봇을 드러내는 12가지 신호
자동화 프레임워크는 실제 브라우저의 지문을 물려받지만, 수십 가지 관찰 가능한 방식으로 그것을 바꿔 놓는다. 방어자가 Puppeteer와 Playwright를 잡아내는 데 쓰는 12가지 JavaScript·네트워크·행동 신호에 대한 실전 가이드.
자동화 프레임워크는 실제 브라우저를 제어하며, 이는 브라우저의 지문을 물려받는다는 뜻이다. Puppeteer로 구동되는 Chrome은 사람이 쓰는 Chrome과 동일한 user agent, 동일한 Chrome 버전, 동일한 Chromium 빌드를 가진다. 겉보기에는 똑같아 보인다.
하지만 자동화 프레임워크는 수십 가지 미묘한 방식으로 브라우저를 바꿔 놓으며, 그 대부분은 JavaScript에서 관찰할 수 있다. 탐지는 마법이 아니다. 그것은 체크리스트다.
1. navigator.webdriver 플래그
가장 단순하고 가장 유명한 신호다. Chrome이 자동화 프레임워크 아래에서 실행되면 navigator.webdriver는 true를 반환한다. 이는 W3C WebDriver 명세의 일부이며 Chromium이 기본으로 구현한다.
진지한 자동화 프로젝트라면 모두 이것을 몇 초 안에 패치한다. Puppeteer-extra-stealth는 getter를 오버라이드한다. Playwright 사용자는 새 문서마다 Object.defineProperty 스크립트를 주입한다. 이 플래그는 숨기기가 아주 쉽다.
하지만 그렇게 쉽게 숨길 수 있다는 사실 자체가 하나의 신호다. 정상적인 Chrome은 아무것도 숨길 필요가 없다. 페이지가 Object.getOwnPropertyDescriptor(Navigator.prototype, 'webdriver')를 검사했을 때 그 descriptor가 네이티브 브라우저의 것과 다르게 보인다면, 플래그를 패치한 뒤에도 자동화는 잡힌다.
2. chrome 객체 이상 징후
정상적인 Chrome은 상당한 구조를 가진 전역 window.chrome 객체를 노출한다 — chrome.runtime, chrome.loadTimes, chrome.csi. Headless Chrome과 구형 Puppeteer 설정은 이 객체를 아예 생략하거나 축소된 버전만 노출한다.
Stealth 플러그인이 이 객체를 다시 만들어 내지만 그 재현은 불완전하다. chrome.runtime.PlatformOs 없이 chrome.runtime.onConnect만 존재할 수 있다. 함수 시그니처가 undefined 대신 객체를 반환할 수도 있다. 모든 불일치가 양성 신호다.
3. Permissions API 불일치
실제 브라우저는 Permissions API를 통해 질의할 때 일관된 결과를 반환한다. 사용자가 명시적으로 권한을 허용하거나 거부하지 않았다면 navigator.permissions.query({name: 'notifications'})는 'default'를 반환해야 한다.
Headless Chrome은 권한 프롬프트를 표시할 UI가 없기 때문에 기본으로 'denied'를 반환한다. 자동화 프레임워크는 이를 패치하지만 종종 잘못 패치한다. 흔한 단서: 'clipboard-read' 같은 흔치 않은 권한을 질의했을 때, 현재 버전의 실제 Chrome이 하는 것과 맞지 않는 결과가 나오는 것이다.
4. Plugin 및 MIME 타입 배열
실제 브라우저의 navigator.plugins는 PDF 뷰어나 Chromium PDF 플러그인 같은 항목이 담긴 PluginArray를 반환한다. Headless 모드에서는 이 배열이 비어 있다.
Stealth 플러그인이 가짜 항목을 추가하지만, 그 항목들은 흔히 잘못된 속성을 가진다 — length 누락, 잘못된 description 필드, 혹은 instanceof로 검사했을 때 Plugin 인스턴스처럼 동작하지 않는 plugin 객체 등이다.
5. 언어 및 로케일 불일치
navigator.language와 navigator.languages는 Accept-Language HTTP 헤더와 일치해야 한다. 또한 Intl.DateTimeFormat().resolvedOptions().timeZone이 보고하는 시간대와도 일관되어야 한다.
키이우(Europe/Kyiv 시간대)에 있다고 주장하면서 Accept-Language: en-US,en;q=0.9를 보내고 navigator.language === 'en-US'를 보고하는 봇은 가능하기는 하지만 통계적으로 흔치 않다. 여기에 프랑크푸르트 데이터센터의 IP까지 겹치면 그림이 분명해진다.
6. WebGL 렌더러 문자열
WebGL2RenderingContext.getParameter(WebGLDebugRendererInfo.UNMASKED_RENDERER_WEBGL)은 GPU 벤더와 모델을 반환한다. 실제 머신에서는 이런 값일 수 있다:
ANGLE (Intel, Intel(R) UHD Graphics 620 Direct3D11 vs_5_0 ps_5_0)
컨테이너에서 실행되는 Headless Chrome은 흔히 이런 값을 반환한다:
ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero)), SwiftShader driver)
SwiftShader나 Subzero를 포함하는 GPU 문자열은 무엇이든 강한 봇 신호다.
7. 화면 및 뷰포트 이상 징후
실제 브라우저 창은 브라우저 크롬 — 주소창, 탭, 북마크 바 — 을 반영하는 window.outerWidth와 window.outerHeight를 가진다. outer 치수와 inner 치수의 차이는 보통 세로로 80~140픽셀이다.
Headless 브라우저는 표시할 크롬이 없기 때문에 흔히 outerHeight === innerHeight다. headless: false로 실행하는 Puppeteer는 이를 해결하지만, 많은 자동화 설정은 여전히 동일한 치수로 실행된다.
8. 폰트 열거
실제 사용자는 OS, 언어, 설치된 애플리케이션에 따라 다양하게 수백 개의 폰트를 설치해 두고 있다. Office가 설치된 Windows 11은 500개 이상의 폰트를 가진다. Alpine Linux에서 실행되는 자동화 컨테이너는 30개다.
document.fonts.check()나 오프스크린 캔버스 렌더링을 통한 폰트 열거는 이를 즉시 드러낸다. 자신을 Windows 11 Chrome이라고 보고하면서 폰트를 30개만 노출하는 브라우저는 거의 확실히 자동화다.
9. 마우스 움직임 엔트로피
실제 사용자는 떨림을 동반하며 마우스를 움직인다. 곡선은 비선형적이다. 속도는 변한다. 두 번의 클릭 사이에는 보통 수백 개의 mousemove 이벤트가 있다.
자동화 프레임워크는 움직임을 합성한다 — 좌표까지의 직선이거나, 사람 손의 미세 떨림이 없는 프로그래밍된 곡선이다. 베지어 곡선 시뮬레이션조차 의심스러울 만큼 매끄러운 가속 프로파일을 가지는 경향이 있다.
봇 탐지 시스템은 움직임 궤적을 수집해 사람 움직임 모델에 대비해 점수를 매긴다. 지나치게 일정한 속도, 지나치게 깔끔한 곡선, 혹은 누락된 미세 이벤트를 가진 궤적은 표시(flag)된다.
10. 타이밍 시그니처
자동화 프레임워크는 사용자가 구동하는 브라우저와 다른 타이밍 특성으로 JavaScript를 실행한다. 이벤트 사이의, 특히 pointerdown과 pointerup 사이의 performance.now() 타이밍은 다른 분포를 따른다.
실제 클릭 홀드 시간은 편차가 큰 50~150ms다. Puppeteer의 기본 클릭 홀드는 고정값 — 흔히 30ms 또는 100ms — 이며 편차가 거의 없다. 홀드 시간이 동일한 클릭 100번은 명백한 자동화다.
11. TLS 지문 불일치
이 신호는 JavaScript를 완전히 우회한다. Puppeteer가 연결할 때는 실제 Chrome과 동일한 Chromium의 TLS 스택을 사용한다. 하지만 user agent 스푸핑으로 Chrome인 척하는 undici 기반 또는 axios 기반 스크래퍼는 Node.js의 TLS 스택을 사용하는데, 이는 뚜렷하게 다른 Client Hello 시그니처(JA4 해시)를 가진다.
User-Agent: Chrome/124.0.6367.60이라고 주장하면서 Node.js TLS 지문을 제시하는 요청은 비브라우저 클라이언트임을 즉시 확인해 준다.
12. CDP 프로토콜 누출
Puppeteer와 Playwright는 Chrome DevTools Protocol을 통해 Chrome과 통신한다. 일부 구성에서는 이 프로토콜이 탐지 가능한 흔적을 남긴다 — Runtime 객체의 추가 속성, 변형된 Error.stack 형식, 혹은 CDP 클라이언트가 연결되었을 때만 나타나는 특정 console.debug 출력 등이다.
특히 Runtime.enable 명령은 스택 트레이스가 렌더링되는 방식을 바꾼다. 오류를 절대 일으키지 않는 봇은 이 신호를 피하지만, 예외에 부딪히는 자동화 흐름(대부분 그렇다)은 지문을 남긴다.
이 신호들의 계층화
단일 신호만으로는 자동화의 증거가 되지 않는다. 흔치 않은 GPU 드라이버를 쓰는 실제 사용자가 이상한 WebGL 문자열을 반환할 수 있다. 프라이버시를 중시하는 사용자가 navigator.plugins를 수정해 두었을 수도 있다.
현대 탐지의 강도는 신호를 결합하는 데서 나온다 — 약한 양성 12개가 강한 양성 하나보다 강하다. 실제 사용자가 동시에 2~3개 넘는 이상 징후를 건드리는 일은 드물기 때문이다.
탐지 시스템은 각 신호에 가중치를 부여하고 점수를 계산한다. 임계값을 넘으면 방문자는 자동화로 취급된다. 정확한 임계값과 가중치를 제대로 잡는 것이 가장 어려운 부분이다 — 너무 엄격하면 정상 사용자가 차단되고, 너무 느슨하면 정교한 봇이 빠져나간다.
신호 자체는 쉬운 부분이다. 거의 모든 자동화 프레임워크가 12가지 신호를 전부 흘린다. 진짜 엔지니어링은 어떤 조합이 중요한지, 어떤 것이 오탐인지, 그리고 자동화 프레임워크가 적응함에 따라 모델을 어떻게 갱신할지 결정하는 데 있다.