왜 순정 Puppeteer는 곧바로 탐지되는가
Node.js 생태계에서 헤드리스 크롬 자동화의 표준인 Puppeteer(그리고 Playwright)는 브라우저를 제어하는 강력한 도구지만, 안티봇 시스템 앞에서는 몇 초 만에 노출됩니다. 이는 설계상 어쩔 수 없는 아티팩트가 존재하기 때문입니다.
navigator.webdriver 플래그
W3C WebDriver 규격에 따라 자동화된 브라우저는 navigator.webdriver를 true로 설정합니다. Cloudflare, Datadome, PerimeterX 같은 안티봇 솔루션은 이 값을 1차 필터로 사용합니다. 순정 Puppeteer에서는 이 값을 false로 바꾸는 공식 API가 없습니다.
불일치하는 plugins·mimeTypes 배열
일반 Chrome은 navigator.plugins와 navigator.mimeTypes에 PDF 뷰어, 크롬 PDF 뷰어 등 여러 항목을 포함합니다. 반면 자동화 모드의 Chromium은 이 배열이 비어 있거나 항목 수가 다릅니다. 이 불일치는 지문 기반 탐지에서 매우 신뢰도 높은 신호입니다.
iframe chromedriver 아티팩트
CDP(Chrome DevTools Protocol) 세션이 활성화되면 document.$cdc_ 변수가 전역 스코프에 주입됩니다. 일부 안티봇 시스템은 페이지 내 iframe에서 이 변수의 존재를 검사합니다. 순정 Puppeteer는 이 변수를 제거하지 않습니다.
기타 탐지 시그널
navigator.languages가 Accept-Language 헤더와 불일치window.chrome객체 누락 (Chromium에서만 존재해야 함)- 헤드리스 모드에서
navigator.platform불일치 - WebGL 렌더러 문자열에 "SwiftShader" 표시
navigator.hardwareConcurrency가 1 또는 2로 설정 (실제 사용자는 보통 4~16)
이러한 시그널들이 조합되면 안티봇 시스템은 단일 신호가 아니라 시그널 누적 점수로 봇 여부를 판단합니다. 하나만 우회한다고 해결되지 않습니다.
puppeteer-extra와 스텔스 플러그인이 패치하는 시그널
puppeteer-extra는 Puppeteer 위에 미들웨어/플러그인 아키텍처를 추가하는 래퍼입니다. 그중 puppeteer-extra-plugin-stealth는 10개 이상의 개별 스텔스 규칙(Evasions)을 적용해 자동화 탐지 시그널을 패치합니다.
주요 Evasion 모듈
| Evasion | 패치 내용 |
|---|---|
| webdriver-override | navigator.webdriver를 false로 설정 |
| chrome-runtime | window.chrome 객체 (runtime, csi, loadTimes) 주입 |
| plugins-shim | navigator.plugins 배열에 실제 Chrome 플러그인 항목 추가 |
| mime-types | navigator.mimeTypes 일치시킴 |
| iframe-contentWindow | iframe에서 contentWindow 프로퍼티 정상화 |
| media-codecs | 헤드리스 모드에서 누락된 코덱 지원 시뮬레이션 |
| webgl-vendor | WebGL 렌더러에서 "SwiftShader" 제거 |
| hardware-concurrency | navigator.hardwareConcurrency를 합리적 값으로 오버라이드 |
| languages | navigator.languages를 Accept-Language와 일치시킴 |
| user-agent-override | 헤드리스 UA 문자열에서 "HeadlessChrome" 제거 |
기본 설정만으로도 대부분의 1세대 안티봇을 통과합니다. 하지만 2세대 이상(카나리 테스트, FingerprintJS 등)은 더 정밀한 핑거프린트 분석을 수행하므로, 스텔스 플러그인만으로는 한계가 있습니다.
const puppeteer = require('puppeteer-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
// 스텔스 플러그인 등록 — 미들웨어 패턴
puppeteer.use(StealthPlugin());
(async () => {
const browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
const page = await browser.newPage();
await page.goto('https://bot.sannysoft.com/');
// 스텔스 테스트 페이지에서 대부분 초록색 통과 확인
await page.screenshot({ path: 'stealth-test.png', fullPage: true });
await browser.close();
})();스텔스 + 레지덴셜 프록시: 가장 강력한 안티 디텍션 스택
스텔스 플러그인은 브라우저 레벨 시그널만 패치합니다. 하지만 안티봇 시스템은 네트워크 레벨에서도 IP 평판, ASN, 데이터센터 대역 여부를 검사합니다. 데이터센터 IP는 스텔스 플러그인이 아무리 완벽해도 차단됩니다.
레지덴셜 프록시는 실제 ISP 대역의 IP를 제공하므로, 안티봇의 IP 평판 검사를 통과합니다. 스텔스 플러그인과 결합하면 브라우저 지문과 네트워크 지문 모두 정상 사용자와 구분할 수 없게 됩니다.
const puppeteer = require('puppeteer-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
puppeteer.use(StealthPlugin());
(async () => {
// ProxyHat 레지덴셜 프록시 — 미국 IP로 지역 타겟팅
const proxyUrl = 'http://user-country-US:PASSWORD@gate.proxyhat.com:8080';
const browser = await puppeteer.launch({
headless: 'new',
args: [
`--proxy-server=${proxyUrl}`,
'--no-sandbox',
'--disable-setuid-sandbox'
]
});
const page = await browser.newPage();
// 스텔스 + 레지덴셜 IP 조합으로 안티봇 통과
await page.goto('https://www.example.com/protected-page');
const html = await page.content();
console.log('페이지 길이:', html.length);
await browser.close();
})();이 조합이 강력한 이유는 상호 보완입니다. 스텔스가 브라우저 지문을 정상화하고, 레지덴셜 프록시가 IP 평판을 정상화합니다. 둘 중 하나만 빠지면 고급 안티봇에 걸립니다.
핵심 인사이트: 데이터센터 프록시 + 스텔스 플러그인은 IP 평판 검사에서 실패합니다. 레지덴셜 프록시 + 스텔스 없음은 브라우저 지문 검사에서 실패합니다. 두 레이어를 모두 커버해야 프로덕션급 안티 디텍션이 가능합니다.
커스텀 이밸루에이터: Canvas/WebGL 핑거프린트 랜덤화
스텔스 플러그인은 기본값을 정상화하지만, 모든 스텔스 브라우저가 동일한 핑거프린트를 갖게 되는 문제가 있습니다. FingerprintJS 같은 고급 서비스는 동일한 핑거프린트가 여러 세션에서 반복되면 클러스터링으로 봇을 탐지합니다.
해결책은 세션별 핑거프린트 랜덤화입니다. page.evaluateOnNewDocument를 사용해 페이지 로드 전에 스크립트를 주입합니다.
const crypto = require('crypto');
function generateFingerprintNoise() {
// 세션별 고유 노이즈 시드
const seed = crypto.randomBytes(16).toString('hex');
const noise = parseInt(seed.slice(0, 8), 16) % 50 - 25; // -25 ~ +25
return `
// Canvas 핑거프린트 노이즈 주입
const origToDataURL = HTMLCanvasElement.prototype.toDataURL;
HTMLCanvasElement.prototype.toDataURL = function(type) {
const ctx = this.getContext('2d');
if (ctx) {
const imgData = ctx.getImageData(0, 0, this.width, this.height);
// 시드 기반 미세 노이즈 추가 — 시각적 차이는 없지만 해시가 달라짐
for (let i = 0; i < imgData.data.length; i += 4) {
imgData.data[i] = Math.max(0, Math.min(255, imgData.data[i] + ${noise}));
}
ctx.putImageData(imgData, 0, 0);
}
return origToDataURL.apply(this, arguments);
};
// WebGL 렌더러/벤더 랜덤화
const origGetParam = WebGLRenderingContext.prototype.getParameter;
WebGLRenderingContext.prototype.getParameter = function(param) {
if (param === 37445) return 'Intel Inc.'; // UNMASKED_VENDOR
if (param === 37446) return 'Intel Iris OpenGL Engine'; // UNMASKED_RENDERER
return origGetParam.apply(this, arguments);
};
`;
}
// 사용 — page 생성 시마다 새 노이즈 주입
page.evaluateOnNewDocument(generateFingerprintNoise());이 기법은 핑거프린트 해시를 세션마다 다르게 만들면서도, 시각적으로는 차이가 없도록 미세 노이즈만 추가합니다. 고급 핑거프린팅 서비스를 상대로 세션 클러스터링을 방지하는 핵심 기법입니다.
브라우저 컨텍스트별 프록시 로테이션
Puppeteer의 browser.createIncognitoBrowserContext()는 동일 브라우저 프로세스 내에서 격리된 컨텍스트를 만듭니다. 하지만 --proxy-server는 브라우저 레벨 인자이므로, 컨텍스트별로 다른 프록시를 설정하려면 다른 방식이 필요합니다.
프로덕션에서는 프록시 로테이션 미들웨어를 구현해 브라우저 인스턴스(또는 컨텍스트) 단위로 다른 프록시를 할당합니다.
const puppeteer = require('puppeteer-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
puppeteer.use(StealthPlugin());
class ProxyRotator {
constructor(credentials, countries) {
this.credentials = credentials;
this.countries = countries;
this.index = 0;
}
// 라운드 로빈 + 스티키 세션 조합
nextProxy(sessionId) {
const country = this.countries[this.index % this.countries.length];
this.index++;
const [user, pass] = this.credentials;
// 세션 ID가 있으면 스티키 세션, 없으면 회전
const userPart = sessionId
? `${user}-country-${country}-session-${sessionId}`
: `${user}-country-${country}`;
return {
url: `http://${userPart}:${pass}@gate.proxyhat.com:8080`,
country
};
}
}
(async () => {
const rotator = new ProxyRotator(
['myuser', 'mypassword'],
['US', 'DE', 'JP', 'GB']
);
const tasks = [
{ url: 'https://example.com/page1', session: 'sess-alpha' },
{ url: 'https://example.com/page2', session: 'sess-beta' },
{ url: 'https://example.com/page3', session: 'sess-gamma' },
];
// 각 태스크에 독립 브라우저 + 독립 프록시 할당
for (const task of tasks) {
const { url, country } = rotator.nextProxy(task.session);
const proxy = rotator.nextProxy(task.session);
const browser = await puppeteer.launch({
headless: 'new',
args: [
`--proxy-server=${proxy.url}`,
'--no-sandbox'
]
});
const page = await browser.newPage();
page.evaluateOnNewDocument(generateFingerprintNoise());
await page.goto(task.url, { waitUntil: 'networkidle2' });
console.log(`[${proxy.country}] ${task.url} 완료`);
await browser.close();
}
})();스티키 세션(-session-abc123)은 동일 IP를 유지해야 하는 로그인 플로우나 멀티스텝 폼에 필수적입니다. 반면 회전 모드는 대량 SERP 수집처럼 각 요청이 독립적인 경우에 적합합니다. ProxyHat의 사용자명 플래그로 두 모드를 간단히 전환할 수 있습니다.
스케일링: 컨테이너 플릿, 브라우저 풀, 리소스 관리
프로덕션 크롤러에서는 단일 브라우저 인스턴스로 충분하지 않습니다. 동시성, 안정성, 리소스 효율을 위해 브라우저 풀과 컨테이너화가 필요합니다.
브라우저 풀 패턴
const { GenericPool } = require('generic-pool');
const puppeteer = require('puppeteer-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
puppeteer.use(StealthPlugin());
const PROXYHAT_USER = 'myuser';
const PROXYHAT_PASS = 'mypassword';
function createProxyUrl(country, sessionId) {
const userPart = sessionId
? `${PROXYHAT_USER}-country-${country}-session-${sessionId}`
: `${PROXYHAT_USER}-country-${country}`;
return `http://${userPart}:${PROXYHAT_PASS}@gate.proxyhat.com:8080`;
}
// 브라우저 팩토리
const browserPool = GenericPool.createPool({
create: async () => {
const country = ['US', 'DE', 'GB', 'FR'][Math.floor(Math.random() * 4)];
const browser = await puppeteer.launch({
headless: 'new',
args: [
`--proxy-server=${createProxyUrl(country)}`,
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage', // 컨테이너 내 /dev/shm 제한 대응
'--disable-gpu'
]
});
browser._proxyCountry = country;
return browser;
},
destroy: async (browser) => {
await browser.close();
}
}, {
max: 10, // 최대 10개 브라우저 인스턴스
min: 2, // 최소 2개 유지
idleTimeoutMillis: 60000,
acquireTimeoutMillis: 30000
});
async function scrapeWithPool(url) {
const browser = await browserPool.acquire();
try {
const page = await browser.newPage();
page.evaluateOnNewDocument(generateFingerprintNoise());
await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
const data = await page.evaluate(() => document.title);
return data;
} finally {
await browserPool.release(browser);
}
}
// 동시 10개 페이지 크롤링
const urls = Array.from({ length: 10 }, (_, i) =>
`https://example.com/page-${i + 1}`
);
Promise.all(urls.map(scrapeWithPool)).then(console.log);컨테이너화 패턴
Docker 컨테이너당 1~3개의 브라우저 인스턴스를 실행하는 것이 리소스 관리에 가장 효율적입니다. 각 컨테이너에 --shm-size=2g를 설정해 Chrome의 공유 메모리 요구를 충족시켜야 합니다.
# Dockerfile
FROM node:20-slim
RUN apt-get update && apt-get install -y \
chromium \
fonts-ipafont-gothic \
--no-install-recommends && \
rm -rf /var/lib/apt/lists/*
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
CMD ["node", "crawler.js"]# docker-compose.yml — 5개 워커 스케일 아웃
version: '3.8'
services:
crawler:
build: .
shm_size: '2gb'
environment:
- PROXYHAT_USER=myuser
- PROXYHAT_PASS=mypassword
- CONCURRENCY=3
deploy:
replicas: 5
mem_limit: 2g
cpus: 1.0리소스 관리 체크리스트
- 메모리: Chrome 인스턴스당 200~500MB. 컨테이너당 2~3개 인스턴스로 제한
- /dev/shm: Docker 기본값 64MB는 부족.
--shm-size=2g필수 - 타임아웃: 페이지 로드 30초, 전체 태스크 120초 상한 설정
- 메모리 누수:
page.close()와browser.close()를 반드시 finally 블록에서 실행 - 좀비 프로세스: 크롬 프로세스 고아 방지를 위해
pid-cleanup또는docker --init사용
윤리적 고려사항: 스텔스는 합법적 스크래핑을 위한 도구
안티 디텍션 기술은 강력하지만, 그 자체로 도덕적 판단을 내포하지 않습니다. 스텔스 + 프록시 조합은 합법적 사용 사례에서만 사용해야 합니다.
- 공개 데이터 수집: robots.txt를 존중하고, 서버에 과부하를 주지 않는 속도로 수집
- 자사 데이터 접근: 자사 계정으로 자사 데이터에 접근하는 자동화
- 가격 모니터링: 공개적으로 접근 가능한 가격 정보의 합법적 수집
- SEO/SERP 모니터링: 검색 결과 순위 추적
절대 해서는 안 되는 것: 계정 탈취, 자격증명 스터핑, 결제 우회, 티켓/스니커즈 봇으로 수량 제한 회피, 경쟁사 서비스 공격. 이러한 행위는 법적 책임뿐 아니라 생태계 전체에 해를 끼쳐 합법적 스크래핑의 여지까지 줄입니다.
GDPR, CCPA 등 개인정보 보호 규정도 준수해야 합니다. 개인 데이터를 수집한다면 목적 제한, 최소 수집, 동의 원칙을 따르세요.
핵심 요약
Key Takeaways:
- 순정 Puppeteer는
navigator.webdriver, 빈 plugins 배열,$cdc_아티팩트 등으로 즉시 탐지됩니다.- puppeteer-extra-plugin-stealth는 10개 이상의 evasion 규칙으로 주요 탐지 시그널을 패치합니다.
- 스텔스 플러그인(브라우저 레벨) + 레지덴셜 프록시(네트워크 레벨) 조합이 프로덕션급 안티 디텍션의 최소 요구사항입니다.
- 세션별 Canvas/WebGL 노이즈 주입으로 핑거프린트 클러스터링을 방지하세요.
- ProxyHat 사용자명 플래그로 국가 타겟팅과 스티키 세션을 손쉽게 제어합니다.
- 브라우저 풀 + Docker 컨테이너 플릿으로 동시성과 안정성을 확보하세요.
- 스텔스 기술은 합법적 데이터 수집에만 사용하고, robots.txt와 개인정보 보호 규정을 준수하세요.
안티 디텍션 스택을 구축할 때 가장 중요한 것은 다층 방어입니다. 브라우저 지문, 네트워크 지문, 행동 패턴 세 가지 축을 모두 커버해야 프로덕션 환경에서 안정적인 크롤링이 가능합니다. ProxyHat의 레지덴셜 프록시와 puppeteer-extra 스텔스 플러그인의 조합은 그 기반을 단단하게 만들어 줍니다.
프록시 위치와 요금제는 프록시 로케이션과 요금 페이지에서 확인하세요. SERP 추적이나 이커머스 가격 모니터링에 대한 자세한 구현 가이드는 SERP 스크래핑 가이드와 웹 스크래핑 유스케이스를 참고하세요.






