Node.js는 스크래핑과 자동화 작업 대부분에서 기본 런타임으로 쓰인다. 수천 개의 동시 요청을 가볍게 처리하는 이벤트 루프, 방대한 패키지 생태계, 앞뒤로 동일한 언어 덕분이다. 레지덴셜 프록시를 여기에 연결하는 것은 코드 몇 줄이면 되지만, 세부 사항에서 Node에 특유한 함정에 걸리기 쉽다. 가장 널리 쓰이는 HTTP 클라이언트인 Axios의 proxy 옵션이 HTTPS에서 기대한 대로 동작하지 않기 때문이다. agent만 제대로 설정하면 나머지는 쉽다.
이 글은 Python에서의 레지덴셜 프록시, Playwright에서의 레지덴셜 프록시, Go에서의 레지덴셜 프록시와 같은 시리즈의 Node.js 편으로, Axios, Got, 네이티브 fetch에서 동작하는 코드와 Node 생태계에 고유한 함정들을 다룬다.
아래 내용은 모두 Shifter의 레지덴셜 게이트웨이를 사용한다. 엔드포인트는 p.shifter.io:443 하나이며, 모든 타겟팅 정보는 사용자 이름에 인코딩된다. 다른 프로바이더를 쓰려면 호스트와 인증 정보만 바꾸면 되고 구조는 동일하다.
게이트웨이 모델을 한 문단으로
프록시 사용자 이름에는 인증 정보 그리고 타겟팅 정보가 함께 담긴다. 국가나 세션을 바꾸기 위해 엔드포인트를 바꾸는 것이 아니라 사용자 이름 문자열을 바꾼다.
customer-USERNAME-country-us-sid-abc123-ttl-600country-us는 미국을 타겟팅하고, sid는 고정 세션을 지정하며, ttl은 해당 IP를 N초 동안 유지한다. sid/ttl을 생략하면 매 연결마다 로테이션된다. 비밀번호는 그대로 유지된다. Node에서는 이 전체 문자열이 프록시 agent에 전달하는 프록시 URL로 들어간다.
Axios: 내장 proxy 옵션이 아니라 agent를 사용하라
이 글에서 가장 중요한 내용이 여기 있다. Axios에는 proxy 옵션이 있지만, 인증이 필요한 HTTPS 대상에 대해서는 신뢰할 수 없다. 제대로 된 CONNECT 터널을 열지 못하고 조용히 실패하거나 실제 IP를 노출한다. 생태계에서 정착된 해결책은 https-proxy-agent로 만든 httpsAgent를 Axios에 넘기고, Axios가 프록시를 직접 처리하지 않도록 proxy: false를 설정하는 것이다.
import axios from 'axios';import { HttpsProxyAgent } from 'https-proxy-agent';
const user = `${process.env.SHIFTER_USER}-country-us`;const pass = process.env.SHIFTER_PASS;const proxyUrl = `http://${user}:${pass}@p.shifter.io:443`;
const agent = new HttpsProxyAgent(proxyUrl);
const client = axios.create({ httpsAgent: agent, proxy: false, // critical: let the agent handle it, not Axios timeout: 30000,});
const res = await client.get('https://api.ipify.org');console.log(res.data); // a US residential IP여기서 두 가지를 기억해야 한다. user 문자열에는 타겟팅 플래그(-country-us)가 포함되는데, 지역 정보가 거기에 담기기 때문이다. 그리고 proxy: false는 선택 사항이 아니다. 이것이 없으면 Axios 자체의 프록시 로직이 agent와 충돌하여 정확히 피하려던 그 깨진 동작이 발생한다. 이 한 줄이 Node 프록시에서 가장 흔한 버그다.
Got: agent는 agent.https 슬롯에 넣는다
Got은 프로토콜별로 키가 지정된 agent 옵션을 통해 프록시 agent를 받는다. 내부적으로 동일한 https-proxy-agent를 쓰지만, Got에는 맞서 싸울 내장 프록시 처리 로직이 없으므로 proxy: false 같은 처리가 필요 없다.
import got from 'got';import { HttpsProxyAgent } from 'https-proxy-agent';
const proxyUrl = `http://${user}:${pass}@p.shifter.io:443`;
const res = await got('https://api.ipify.org', { agent: { https: new HttpsProxyAgent(proxyUrl) }, timeout: { request: 30000 },});console.log(res.body); // a US residential IP패키징 관련 참고 사항 하나: Got은 v12부터 순수 ESM이므로 import got from 'got'는 ESM 프로젝트(package.json의 "type": "module")나 동적 import()가 필요하다. CommonJS에 묶여 있다면 Got v11을 유지하거나 Axios/undici로 전환하라. 이 ESM 대 CommonJS 분리는 Node 특유의 걸림돌이지 프록시 문제는 아니지만, 처음 설정하는 사람들이 흔히 걸려 넘어지는 부분이다.
네이티브 fetch: undici의 ProxyAgent를 dispatcher로
Node 18 이상은 undici 기반의 전역 fetch를 제공하며, undici는 https-proxy-agent를 전혀 쓰지 않는 자체 프록시 지원을 갖고 있다. 요청의 dispatcher로 ProxyAgent를 넘긴다.
import { ProxyAgent } from 'undici';
const dispatcher = new ProxyAgent(`http://${user}:${pass}@p.shifter.io:443`);
const res = await fetch('https://api.ipify.org', { dispatcher });console.log(await res.text()); // a US residential IP프로세스 내 모든 fetch를 프록시로 라우팅하려면 대신 전역으로 설정한다.
import { setGlobalDispatcher, ProxyAgent } from 'undici';setGlobalDispatcher(new ProxyAgent(proxyUrl));최신 Node를 사용 중이고 HTTP 클라이언트 의존성을 전혀 두고 싶지 않다면 이 방법이 가장 깔끔하다.
함정 1: agent를 재사용하고, 요청마다 새로 만들지 마라
어떤 클라이언트를 선택하든 프록시 agent가 연결 풀을 소유한다. 매 요청마다 새로운 HttpsProxyAgent나 ProxyAgent를 생성하면 keep-alive가 버려지고, 매번 프록시를 통한 전체 TCP + TLS 핸드셰이크 비용을 지불하게 된다. 이는 지연 시간 가이드가 제거하려는 바로 그 오버헤드다. 특정 identity에 대해 agent를 한 번만 만들고 요청 간에 재사용하라. Axios/Got 클라이언트(또는 undici dispatcher)를 시작 시점에 생성해서 보관하라.
함정 2: 동시성을 제한하라, 이벤트 루프는 알아서 해주지 않는다
Node의 이벤트 루프는 천 개의 요청을 한 번에 발사하는 것을 너무 쉽게 만들며, 아무것도 이를 막지 않는다. 큰 배열에 대한 await Promise.all(urls.map(fetchOne))은 모든 연결을 동시에 열어버려서 자기 쪽 소켓을 고갈시키고 대상에게는 공격처럼 보인다. 작은 동시성 제한기(p-limit이 일반적인 선택)나 간단한 큐로 진행 중인 요청 수를 제한하라.
import pLimit from 'p-limit';
const limit = pLimit(8); // at most 8 requests in flightconst results = await Promise.all( urls.map(url => limit(() => client.get(url))));전역이 아니라 타겟 호스트별로 동시성을 제한해서, 취약한 사이트 하나가 두들겨 맞는 동안 관대한 사이트가 굶주리지 않도록 하라. 대상이 견딜 수 있는 수준을 넘어선 병렬성은 처리량이 아니라 차단을 산다(차단 회피 방법). 각 호스트가 견디는 수준에 맞춰 한도를 설정하라.
함정 3: 비동기 오류는 명시적으로 처리해야 한다
프록시나 연결 실패는 거부된 프로미스로 나타나며, 처리되지 않은 거부는 프로세스를 크래시시키거나, 더 나쁘게는 배치 안에서 작업 하나를 조용히 누락시킬 수 있다. 각 요청을 감싸서 전송 실패 시 실행을 중단시키는 대신 새로운 identity로 재시도하게 하라.
async function fetchWithRetry(client, url, attempts = 3) { for (let i = 0; i < attempts; i++) { try { return await client.get(url); } catch (err) { if (i === attempts - 1) throw err; // transient (ECONNRESET, timeout, proxy 5xx): back off and retry await new Promise(r => setTimeout(r, 500 * 2 ** i)); } }}재시도할 가치가 있는 깨진 연결과 의도적인 속도 저하를 구분하라. 타임아웃은 깨진 시도이지만, 429는 서버가 여유를 요청하는 것이므로 두들기지 말고 물러서야 한다.
지역과 세션 로테이션
타겟팅 정보가 사용자 이름에 담기기 때문에, 다른 identity는 다른 프록시 URL을 의미하며, 이는 곧 다른 agent를 의미한다. 효율적인 패턴은 identity별로 agent 하나를 캐싱해서 연결 풀을 세션별로 재구축하지 않고 유지하는 것이다.
const agents = new Map();
function agentFor(country, sid) { const key = `${country}:${sid ?? 'rotate'}`; if (!agents.has(key)) { const u = `${process.env.SHIFTER_USER}-country-${country}` + (sid ? `-sid-${sid}-ttl-600` : ''); const url = `http://${u}:${process.env.SHIFTER_PASS}@p.shifter.io:443`; agents.set(key, new HttpsProxyAgent(url)); } return agents.get(key);}
// per request:await axios.get(targetUrl, { httpsAgent: agentFor('de', 'job-42'), proxy: false });각 논리적 작업 단위마다 고유한 sid를 부여하고 스트림 도중이 아니라 단위 사이에서 로테이션하라(고정 프록시와 로테이팅 프록시에서 이 구분을 다룬다). 작업을 identity에 매핑하는 방식은 로드 밸런싱 글에서 설명한 대로 하라.
실제로 프록시를 타고 있는지 확인하라
벤치마크나 다른 디버깅을 하기 전에 나가는 IP를 확인하라.
const res = await client.get('http://ip-api.com/json');console.log(res.data); // expect a residential IP in the targeted country자기 IP가 나온다면 agent가 적용되지 않은 것이다(Axios의 경우 거의 항상 proxy: false 누락이다). 멈춘다면 로컬 아웃바운드가 차단된 것이다. 둘 다 타임아웃 진단 가이드에서 다룬다.
FAQ
왜 Axios의 proxy 옵션이 내 HTTPS 프록시에서 작동하지 않는가?
Axios의 내장 proxy 옵션은 인증이 포함된 HTTPS 터널링을 안정적으로 처리하지 못한다. https-proxy-agent로 만든 httpsAgent를 사용하고, Axios가 직접 처리하려는 시도를 멈추도록 proxy: false를 설정하라. 이 조합이 신뢰할 수 있는 방법이며 “실제 IP가 반환된다”는 증상을 해결한다.
네이티브 fetch를 사용할 때 https-proxy-agent가 필요한가?
아니다. Node 18 이상의 fetch는 undici 기반이며, dispatcher로 넘기는(또는 setGlobalDispatcher로 전역 설정하는) 자체 ProxyAgent를 가지고 있다. https-proxy-agent는 Axios, Got, 그리고 내장 http/https 모듈용이다.
왜 내 프로젝트에서 import got from 'got'가 오류를 던지는가?
Got은 v12부터 순수 ESM이므로 ESM 프로젝트("type": "module")나 동적 import()가 필요하다. CommonJS에서는 Got v11을 유지하거나 대신 Axios/undici를 사용하라. 이는 프록시 문제가 아니라 모듈 시스템 문제다.
Node.js에서 요청마다 IP를 로테이션하려면 어떻게 하는가?
프록시 사용자 이름을 바꾸면 되는데, 이는 다른 프록시 URL과 다른 agent를 의미한다. Map에 identity별로 agent 하나를 캐싱해서 각 세션이 자신만의 연결 풀을 유지하게 하고, 요청마다 agent를 선택하라. 사용자 이름에서 sid를 생략하면 매 연결마다 로테이션된다.
스크래핑에는 Axios, Got, fetch 중 무엇이 좋은가?
셋 다 잘 작동한다. 네이티브 fetch + undici는 최신 Node에서 추가 의존성이 전혀 없다. Got은 편리한 재시도와 스트림을 제공한다. Axios는 어디에나 있고 익숙하지만 proxy: false 수정이 필요하다. 인체공학성과 기존 의존성을 기준으로 선택하라. 위의 프록시 설정과 함정들은 셋 모두에 적용된다.
결론
Node.js와 레지덴셜 프록시의 조합은 한 가지 명확하지 않은 규칙만 알면 빠르게 설정할 수 있다. Axios에서는 https-proxy-agent를 쓰고 proxy: false를 설정하며 내장 proxy 옵션은 절대 쓰지 말 것, Got에서는 agent를 agent.https에 넣을 것, 네이티브 fetch에서는 undici ProxyAgent를 dispatcher로 넘길 것. 그다음 연결을 따뜻하게 유지하기 위해 agent를 재사용하고, 이벤트 루프가 알아서 해주지 않으므로 동시성을 제한하고, 잘못된 요청이 크래시 대신 재시도하도록 비동기 오류를 처리하고, 지역이나 세션을 바꾸려면 프록시 사용자 이름을 변경하라.
이것만 제대로 하면 Node는 동시 수집을 그 무엇 못지않게 잘 처리한다. 레지덴셜 게이트웨이를 활용하고, 풀의 품질이 애초에 재시도 빈도를 결정한다는 점을 기억하라(IP 평판). 가격 페이지에는 자신의 대상에 맞춰 테스트해볼 수 있는 GB당 요금제가 있다.