Playwright에 레지덴셜 프록시를 연결하는 것은 한 번 해보면 몇 줄이면 끝나는 일이다. 문제가 되는 부분은 launch 옵션이 아니라, 아무도 문서에 적어두지 않는 세부사항들이다. 자격 증명이 어떻게 타겟팅을 인코딩하는지, 컨텍스트별 프록시를 깨뜨리는 Chromium의 함정, 로테이션이 브라우저 컨텍스트에 어떻게 매핑되는지, 그리고 브라우저가 로드하는 모든 이미지가 곧 비용을 지불하는 대역폭이라는 사실이다.
이 글은 Python으로 레지덴셜 프록시 사용하기의 브라우저 편에 해당한다. 실제로 실행되는 복사-붙여넣기 예제와, 동작하는 스니펫을 프로덕션에서도 버티는 자동화로 바꿔주는 부분들을 함께 다룬다.
아래 내용은 모두 Shifter의 레지덴셜 게이트웨이를 사용한다. 엔드포인트는 p.shifter.io:443 하나이고, 모든 타겟팅은 사용자명(username)에 인코딩된다. 다른 제공업체를 사용한다면 형태는 동일하며, 호스트와 자격 증명 형식만 바꾸면 된다. 예제는 Node/JavaScript이며, Python Playwright 스니펫은 글 마지막 부분에 있다.
가장 먼저 이해해야 할 한 가지
레지덴셜 게이트웨이에서는 프록시 사용자명이 인증과 타겟팅을 동시에 담는다. 국가나 세션을 바꾸기 위해 엔드포인트를 바꾸는 것이 아니라 사용자명 문자열을 바꾼다. 사용자명은 다음과 같은 형태다:
customer-USERNAME-country-us-sid-abc123-ttl-600왼쪽부터 순서대로 읽으면 된다: 계정 id, 그다음 플래그들이다. country-us는 미국을 타겟팅한다. sid-abc123은 스티키 세션을 고정한다. ttl-600은 해당 IP를 600초 동안 유지한다. sid/ttl을 빼면 매 연결마다 새 IP로 로테이션된다. 비밀번호는 고정값이다. 이것이 전체 개념 모델이며, 나머지는 이를 Playwright의 프록시 슬롯에 끼워 넣는 작업일 뿐이다.
자격 증명은 하드코딩하지 말고 항상 환경 변수에 보관한다:
const USER = process.env.SHIFTER_USER; // 계정 사용자명const PASS = process.env.SHIFTER_PASS;const GATEWAY = "http://p.shifter.io:443";Playwright 특유의 주의사항 하나를 먼저 짚어두자: 사용자명과 비밀번호를 프록시 URL에 인라인으로 넣지 말 것. Chromium은 인라인 프록시 자격 증명을 무시하므로 http://user:pass@host 형태는 조용히 인증에 실패한다. Playwright에는 프록시 객체에 전용 username, password 필드가 있으니 이를 사용해야 한다.
10줄짜리 버전
브라우저 전체에 단일 프록시를 적용하려면 launch()에 proxy를 설정한다:
const { chromium } = require("playwright");
const USER = process.env.SHIFTER_USER;const PASS = process.env.SHIFTER_PASS;
(async () => { const browser = await chromium.launch({ proxy: { server: "http://p.shifter.io:443", username: `${USER}-country-us`, // 타겟팅이 여기 담긴다 password: PASS, }, }); const page = await browser.newPage(); await page.goto("https://api.ipify.org?format=json"); console.log(await page.textContent("body")); // {"ip":"<미국 레지덴셜 IP>"} await browser.close();})();username 필드가 게이트웨이 모델 전체가 담기는 곳이다. ${USER}-country-us는 미국을 타겟팅하고, 스티키 IP를 원하면 sid/ttl을 추가하고, 더 좁히려면 city/asn을 추가한다. 비밀번호는 절대 바뀌지 않는다.
Chromium의 함정: 컨텍스트별 프록시
단일 프록시 버전은 하나의 아이덴티티에는 문제가 없다. 하지만 Playwright를 쓰는 진짜 이유는 여러 아이덴티티를 운용하는 것이며, 이를 위해서는 브라우저 컨텍스트마다 다른 프록시를 두고 싶을 것이다(각 컨텍스트는 독립된 쿠키/스토리지, 즉 독립된 “사용자”를 가진다).
여기 함정이 있다: Chromium에서는 브라우저를 이미 프록시가 설정된 상태로 실행해야만 컨텍스트별 프록시가 동작한다. 프록시 없이 실행하면 이후의 모든 newContext({ proxy })는 조용히 무시된다. 해결책은 launch 시점에 자리표시자(placeholder) 서버를 넣는 것이다:
const browser = await chromium.launch({ proxy: { server: "per-context" }, // 자리표시자, 컨텍스트별 프록시를 활성화한다});
// 이제 각 컨텍스트가 자신만의 실제 프록시 + 타겟팅을 가질 수 있다const ctxUS = await browser.newContext({ proxy: { server: "http://p.shifter.io:443", username: `${USER}-country-us-sid-a1-ttl-600`, password: PASS, },});const ctxDE = await browser.newContext({ proxy: { server: "http://p.shifter.io:443", username: `${USER}-country-de-sid-b2-ttl-600`, password: PASS, },});ctxUS는 미국 IP로, ctxDE는 독일 IP로 동시에, 하나의 브라우저 안에서 브라우징한다. 이 패턴이 앞으로 확장해나갈 기반이 된다. (Firefox와 WebKit은 이 자리표시자가 엄밀히 필요하지는 않지만, 넣어도 해가 되지 않고 브라우저 간 코드 호환성을 유지해준다.)
로테이팅 vs 스티키, Playwright 방식으로
순수 HTTP 스크레이퍼에서는 요청마다 로테이션하는 경우가 많다. 하지만 브라우저에서는 대개 이것이 잘못된 방식이다. 실제 사용자는 세션 중간에 IP를 바꾸지 않으므로, 쿠키는 그대로인데 페이지 로드 사이에 IP만 바뀌는 것은 탐지 신호가 된다. 관용적인 매핑은 다음과 같다:
- 하나의 컨텍스트 = 하나의 스티키 아이덴티티. 각 컨텍스트에 고유한
sid와 세션 전체를 커버하는ttl을 부여해서, 그 컨텍스트의 모든 페이지 로드가 실제 사용자처럼 하나의 IP를 공유하게 한다. - 새 컨텍스트를 만들어 로테이션한다. 새
sid(또는sid없이)를 가진 새 컨텍스트는 새 IP를 받는다. 다 쓴 이전 컨텍스트는 닫는다.
async function contextForSession(browser, sessionId, country = "us") { return browser.newContext({ proxy: { server: "http://p.shifter.io:443", username: `${USER}-country-${country}-sid-${sessionId}-ttl-600`, password: PASS, }, });}
// 50개의 타겟을 처리하되, 각각 IP가 고정된 자신만의 아이덴티티를 가진다for (let i = 0; i < 50; i++) { const ctx = await contextForSession(browser, `job-${i}`, "us"); const page = await ctx.newPage(); await page.goto("https://example.com/item/" + i); // ... 스크레이핑 ... await ctx.close(); // 이 아이덴티티는 끝났다}sid는 직접 정하면 된다. 논리적 세션마다 고유한 문자열이면 무엇이든 상관없다. 같은 문자열은 TTL이 만료될 때까지 같은 IP를 반환한다. 컨텍스트는 50개를 한꺼번에 실행하지 말고 작은 풀 단위로 동시 실행하라. 브라우저를 더 많이 띄운다고 속도가 빨라지지 않으며, 한 타겟에 IP가 몰려서 쏟아지면 행동 기반 탐지에 여전히 걸린다. 만약 간헐적이 아니라 지속적으로 차단당한다면, 문제는 로테이션이 아니라 IP 품질이나 행동 패턴이다. 스크레이핑 중 차단을 피하는 방법을 참고하라.
지역 타겟팅, 그리고 브라우저를 IP에 맞추기
타겟팅이 사용자명에 담겨 있으므로 지역 설정은 단순히 플래그 하나다: country-de, 더 좁히려면 city-berlin이나 asn-3320을 추가한다. 하지만 실제 브라우저에서는 사람들이 놓치는 또 다른 절반이 있다. 브라우저 핑거프린트가 IP와 일치해야 한다는 점이다. 독일 레지덴셜 IP에 en-US 로케일과 뉴욕 타임존이 같이 붙어 있으면 안티봇 시스템이 즉시 감지하는 모순이 된다. (스크레이퍼가 차단당하는 이유 참고.)
Playwright에서는 이 모든 것을 컨텍스트 단위로 맞출 수 있다:
const ctx = await browser.newContext({ proxy: { server: "http://p.shifter.io:443", username: `${USER}-country-de-city-berlin-sid-de1-ttl-600`, password: PASS, }, locale: "de-DE", timezoneId: "Europe/Berlin", geolocation: { latitude: 52.52, longitude: 13.405 }, permissions: ["geolocation"],});도시 이름은 게이트웨이 형식(소문자)을 따르며, 플래그는 자유롭게 조합할 수 있고 순서는 게이트웨이 입장에서 중요하지 않다. locale/timezoneId/geolocation을 프록시 국가에 맞추는 것은 진짜 현지 사용자처럼 보이게 하는 데 있어 가장 효과가 큰 작업 중 하나다.
대역폭 줄이기(GB당 요금이 부과된다)
이 항목은 브라우저에 특화된 것이며 중요한데, 레지덴셜 프록시는 기가바이트 단위로 과금되기 때문이다. 헤드리스 브라우저는 그냥 두면 페이지 안의 모든 이미지, 폰트, 동영상, 추적 스크립트를 다운로드하는데, 대부분은 스크레이핑에 필요하지 않다. 이를 중단시키면 대역폭(및 비용)을 절반 이상 줄일 수 있고, 실행 속도도 빨라진다.
context.route()를 사용해서 무거운 리소스 타입이 프록시에 도달하기 전에 차단한다:
await ctx.route("**/*", (route) => { const type = route.request().resourceType(); if (["image", "media", "font"].includes(type)) { return route.abort(); // 다운로드되지 않으므로 과금되지 않는다 } return route.continue();});document, script, xhr, fetch(실제 데이터와 이를 렌더링하는 JS)는 유지하고, 나머지는 버린다. 사이트가 이미지 뒤에 콘텐츠를 지연 로드하는 방식이라면 스크레이핑이 여전히 동작하는지 테스트하되, 대부분의 작업에서는 이것이 공짜로 얻는 이득이다. GB당 과금되는 레지덴셜 프록시에서 다루는 GB당 과금 모델과 잘 맞는다.
실제로 동작하는지 확인하기
설정을 신뢰하기 전에 두 가지를 확인하라. IP가 올바른 국가에 있는지(지역), 그리고 컨텍스트 사이에서 IP가 바뀌는지(로테이션). 빠른 확인 방법:
const page = await ctx.newPage();await page.goto("http://ip-api.com/json");const data = JSON.parse(await page.textContent("body"));console.log(data.query, data.countryCode); // 독일 IP를 기대한다서로 다른 sid를 가진 컨텍스트 두 개를 띄우면 서로 다른 IP 두 개가 나와야 하며, 둘 다 올바른 국가여야 한다. 국가가 틀렸다면 플래그 철자를 확인하라. 컨텍스트 간에 IP가 전혀 바뀌지 않는다면, launch 시점의 자리표시자를 빠뜨렸고 Chromium이 컨텍스트별 프록시를 무시하고 있을 가능성이 크다.
실제로 발생하는 오류들
Playwright는 프록시 문제를 HTTP 상태 코드가 아니라 내비게이션 오류로 드러낸다. 마주치게 될 세 가지는 다음과 같다:
net::ERR_TUNNEL_CONNECTION_FAILED프록시 인증 실패, 혹은 인식되지 않는 플래그(country나asn의 오타, 또는 Chromium이 무시한 인라인 자격 증명). username/password 필드를 수정하라. 재시도해도 소용없다.net::ERR_PROXY_CONNECTION_FAILED게이트웨이에 연결하지 못했거나, 너무 좁게 지정된 필터(예: country + city + asn)에 맞는 IP가 없는 경우다. 연결 상태를 확인한 뒤 플래그를 완화하라.- 페이지는 로드되지만 차단/CAPTCHA가 표시되는 경우 프록시가 아니라 타겟 사이트가 거부하는 것이다. 새 컨텍스트/IP로 로테이션하고, 속도를 늦추고, 위에서 설명한 대로 핑거프린트를 맞춰라.
내비게이션을 실패 시 새 컨텍스트를 만드는 작은 재시도 로직으로 감싸라:
async function gotoWithRetry(browser, url, country = "us", tries = 3) { for (let attempt = 0; attempt < tries; attempt++) { const ctx = await contextForSession(browser, `r-${Date.now()}-${attempt}`, country); const page = await ctx.newPage(); try { await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 }); return { ctx, page }; // 호출자가 다 쓰면 ctx를 닫는다 } catch (e) { await ctx.close(); if (attempt === tries - 1) throw e; await new Promise((r) => setTimeout(r, 2 ** attempt * 1000)); // 1초, 2초 } }}새 컨텍스트는 곧 새 IP를 의미하므로, 재시도는 실패했던 것과 같은 경로가 아니라 진짜 다른 경로를 시도하게 된다.
SOCKS5에 관한 참고사항
게이트웨이는 SOCKS5도 지원하지만, 알아둘 만한 Playwright의 제약이 있다. Playwright/Chromium은 인증이 필요한 SOCKS 프록시를 지원하지 않는다. 이 게이트웨이는 사용자명으로 인증하고(그리고 그 안에 타겟팅이 담겨 있으므로), 여기서는 SOCKS5가 실용적이지 않다. 위에서 소개한 HTTP 프록시를 사용하라. 어차피 브라우저 스크레이핑에서는 HTTP가 올바른 기본 선택이다. SOCKS의 장단점은 자동화를 위한 SOCKS5 프록시에 정리되어 있다.
Python에서도 동일하게
Playwright의 Python API는 JavaScript API를 그대로 반영하며, 프록시 객체도 동일하다:
from playwright.sync_api import sync_playwrightimport os
USER, PASS = os.environ["SHIFTER_USER"], os.environ["SHIFTER_PASS"]
with sync_playwright() as p: browser = p.chromium.launch(proxy={"server": "per-context"}) ctx = browser.new_context(proxy={ "server": "http://p.shifter.io:443", "username": f"{USER}-country-us-sid-a1-ttl-600", "password": PASS, }) page = ctx.new_page() page.goto("https://api.ipify.org") print(page.text_content("body")) browser.close()launch 시점 자리표시자 규칙도, 사용자명에 타겟팅을 인코딩하는 방식도 동일하다. 브라우저를 쓰지 않는 HTTP 작업에 대해서는 Python 가이드에서 requests, httpx, Scrapy를 다룬다.
FAQ
왜 Playwright에서 http://user:pass@host가 동작하지 않는가?
Chromium은 인라인 프록시 자격 증명을 무시한다. 대신 proxy 객체의 전용 username/password 필드에 사용자명과 비밀번호를 넣어라. Playwright에서 “프록시가 안 먹힌다”는 가장 흔한 원인이 바로 이것이다.
왜 컨텍스트별 프록시가 무시되는가?
프록시 없이 Chromium을 실행했기 때문이다. 컨텍스트별 프록시는 브라우저가 프록시를 설정한 채로 실행되었을 때만 활성화된다. launch()에 자리표시자 proxy: { server: "per-context" }를 넘긴 뒤, 각 컨텍스트에 실제 프록시를 설정하라.
브라우저에서 요청마다 IP를 로테이션해야 하는가?
아니다. 브라우저 세션은 실제 사용자처럼 하나의 IP를 유지해야 한다. 컨텍스트마다 스티키 sid를 사용하고, 세션 중간에 IP를 바꾸는 것이 아니라 새 컨텍스트를 만들어서 로테이션하라.
여러 국가를 동시에 실행하려면 어떻게 하는가?
하나의 브라우저에 여러 컨텍스트를 두고, 각 컨텍스트의 프록시 사용자명에 서로 다른 country-<cc>를 넣으면 된다. 동시에 독립적으로 실행된다.
이것이 Puppeteer나 Selenium에도 적용되는가?
게이트웨이는 그렇다. Puppeteer는 launch 시 --proxy-server를 받고 page.authenticate()로 인증하며, Selenium은 프록시 capability나 확장 프로그램으로 인증한다. 사용자명에 타겟팅을 인코딩하는 방식은 동일하며, 연결 방식만 다르다. 멀티 아이덴티티 작업에는 Playwright의 컨텍스트별 모델이 가장 깔끔하다.
실행 중 대역폭이 빠르게 소모된다. 어떻게 해야 하는가?
위에서 보여준 것처럼 context.route()로 이미지, 미디어, 폰트를 차단하라. 브라우저는 기본적으로 모든 것을 다운로드하며, GB당 요금제에서는 이것이 실제 비용이다. 무거운 리소스를 중단시키면 사용량을 절반 이상 줄이는 경우가 많다.
마무리
패턴은 다음과 같다: per-context 자리표시자로 Chromium을 실행하고, 각 컨텍스트에 country/sid/ttl을 인코딩한 사용자명을 가진 프록시를 부여하고, locale/timezone/geolocation을 국가에 맞추고, 이미지와 폰트를 중단시켜 대역폭을 아낀다. IP를 바꾸는 방식이 아니라 새 컨텍스트를 만드는 방식으로 로테이션한다. 오류는 net:: 내비게이션 실패로 나타나며, 새 컨텍스트를 만드는 재시도가 진짜 새 IP를 가져다준다.
위 스니펫들을 시작점으로 삼아 레지덴셜 게이트웨이를 연결하면, 오후 한나절 만에 프로덕션 수준의 브라우저 자동화를 갖출 수 있다. 요금제와 GB당 가격은 요금 페이지에서, 전체 플래그 레퍼런스는 게이트웨이 문서에서 확인할 수 있다.