Node.js + Cheerio 프록시 웹 스크래핑 완전 가이드

axios와 Cheerio로 가벼운 서버 사이드 스크래핑을 구축하고, 로테이팅 레지덴셜 프록시 풀을 axios 인터셉터로 통합하는 실전 가이드입니다. 대규모 동시 스크래핑과 에러 핸들링까지 완벽 커버.

Node.js + Cheerio 프록시 웹 스크래핑 완전 가이드

Node.js 스크래핑, 왜 Cheerio와 프록시인가?

웹 스크래핑을 시작하는 Node.js 개발자들이 가장 먼저 마주하는 문제는 차단입니다. 몇 번의 요청만으로도 403, 429 응답이 돌아오고, IP가 블랙리스트에 올라갑니다. Cheerio는 브라우저 없이 HTML을 파싱하는 가장 가벼운 도구이고, 로테이팅 레지덴셜 프록시는 IP 차단을 우회하는 가장 효과적인 수단입니다. 이 둘을 결합하면 대규모 스크래핑 파이프라인을 최소 리소스로 구축할 수 있습니다.

이 글에서는 Cheerio proxy 설정부터, axios proxy rotation 인터셉터 구현, 동시성 제어, 그리고 1만 개 URL을 순회하는 실전 예제까지 단계별로 다룹니다.

axios + Cheerio: 서버 사이드 HTML 파싱 기본

Cheerio는 jQuery와 거의 동일한 셀렉터 API를 제공하면서도 Node.js에서 네이티브로 동작합니다. 브라우저를 띄우지 않으므로 메모리 사용량이 1/10 수준이고, 초당 수백 건의 요청이 가능합니다.

import axios from 'axios';
import * as cheerio from 'cheerio';

async function scrapeProduct(url) {
  const { data: html } = await axios.get(url, {
    headers: {
      'User-Agent':
        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
        'Chrome/125.0.0.0 Safari/537.36',
      Accept:
        'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
    },
  });

  const $ = cheerio.load(html);

  return {
    title: $('h1.product-title').text().trim(),
    price: $('span.price').text().trim(),
    availability: $('span.stock').text().trim(),
    images: $('img.product-image')
      .map((_, el) => $(el).attr('src'))
      .get(),
  };
}

const product = await scrapeProduct('https://example-store.com/item/12345');
console.log(product);

이 패턴은 정적 HTML이 SSR로 렌더링되는 사이트에 완벽하게 들어맞습니다. 서버가 완성된 HTML을 반환하므로, JavaScript 실행 없이도 데이터를 추출할 수 있습니다.

프록시 통합: axios proxy 설정과 https-proxy-agent

단일 IP로 수십 건 이상의 요청을 보내면 즉시 차단됩니다. Node.js scraping Cheerio proxies를 설정하는 방법은 두 가지입니다.

방법 1: axios proxy 옵션

axios는 내장 프록시 설정을 지원합니다. 간단한 시나리오에서는 이것만으로 충분합니다.

import axios from 'axios';

const response = await axios.get('https://example.com/data', {
  proxy: {
    host: 'gate.proxyhat.com',
    port: 8080,
    auth: {
      username: 'user-country-US',
      password: 'YOUR_PASSWORD',
    },
  },
});

console.log(response.data);

방법 2: https-proxy-agent (HTTPS 대상용)

HTTPS 사이트에 프록시를 적용하려면 https-proxy-agent를 사용해 httpsAgent를 설정해야 합니다. 이 방식은 socks-proxy-agent로 SOCKS5 전환도 쉽습니다.

import axios from 'axios';
import { HttpsProxyAgent } from 'https-proxy-agent';

const agent = new HttpsProxyAgent(
  'http://user-country-US:YOUR_PASSWORD@gate.proxyhat.com:8080'
);

const response = await axios.get('https://example.com/data', {
  httpsAgent: agent,
});

console.log(response.data);

HttpsProxyAgentCONNECT 터널링을 자동으로 처리하므로, HTTPS 대상에 대해 안정적으로 동작합니다. 대규모 스크래핑에서는 이 방식이 권장됩니다.

Cheerio가 충분한 때 vs 헤드리스 브라우저가 필요한 때

모든 사이트를 Cheerio로 스크래핑할 수 있는 것은 아닙니다. 다음 표를 보고 판단하세요.

기준 Cheerio (서버 사이드) Puppeteer / Playwright (헤드리스)
렌더링 방식 서버 사이드 렌더링 (SSR) 클라이언트 사이드 렌더링 (CSR)
메모리 사용량 ~50MB per instance ~300–500MB per instance
요청 속도 수백 req/s 5–20 req/s
JavaScript 실행 불가 가능
적합한 대상 전자상거래, 뉴스, 블로그, SERP SPA, React/Vue 앱, 동적 대시보드
프록시 통합 axios 레벨 — 간단 브라우저 컨텍스트 레벨 — 복잡
인프라 비용 낮음 (서버리스 가능) 높음 (컨테이너 클러스터 필요)

간단한 판단법: 브라우저에서 JavaScript를 비활성화한 상태로 페이지를 열었을 때 콘텐츠가 보이면 Cheerio로 충분합니다. 빈 화면이 나오면 헤드리스 브라우저가 필요합니다.

대부분의 전자상거래 플랫폼, 가격 비교 사이트, SERP 페이지는 SSR로 렌더링됩니다. Cheerio + 로테이팅 프록시 조합이 가장 비용 효율적인 선택입니다.

로테이팅 레지덴셜 프록시 풀: axios 인터셉터로 구현

이제 axios proxy rotation의 핵심인 재사용 가능한 인터셉터를 만듭니다. 이 인터셉터는 매 요청마다 새로운 레지덴셜 IP를 할당하고, 403/429 에러 시 자동으로 프록시를 교체하여 재시도합니다.

import axios from 'axios';
import { HttpsProxyAgent } from 'https-proxy-agent';

const PROXY_GATEWAY = 'gate.proxyhat.com';
const PROXY_PORT = 8080;
const PROXY_USER = 'YOUR_USERNAME';
const PROXY_PASS = 'YOUR_PASSWORD';

function createProxyAgent(country = 'US', sessionId) {
  let username = `${PROXY_USER}-country-${country}`;
  if (sessionId) {
    username += `-session-${sessionId}`;
  }
  return new HttpsProxyAgent(
    `http://${username}:${PROXY_PASS}@${PROXY_GATEWAY}:${PROXY_PORT}`
  );
}

function createScrapingClient({
  maxRetries = 3,
  baseDelay = 1000,
  defaultCountry = 'US',
} = {}) {
  const client = axios.create({ timeout: 15000 });

  // 요청 인터셉터: 매 요청에 새 프록시 에이전트 할당
  client.interceptors.request.use((config) => {
    const session = `s${Date.now()}${Math.random().toString(36).slice(2, 8)}`;
    const country = config.metadata?.country || defaultCountry;
    config.httpsAgent = createProxyAgent(country, session);
    config.httpAgent = createProxyAgent(country, session);
    config.metadata = { ...config.metadata, session, retryCount: config.metadata?.retryCount ?? 0 };
    return config;
  });

  // 응답 인터셉터: 403/429 시 프록시 교체 후 재시도
  client.interceptors.response.use(
    (response) => response,
    async (error) => {
      const config = error.config;
      const retryCount = config.metadata?.retryCount ?? 0;
      const status = error.response?.status;

      if ((status === 403 || status === 429) && retryCount < maxRetries) {
        const delay = baseDelay * Math.pow(2, retryCount);
        console.warn(`[RETRY] ${status} at ${config.url}, attempt ${retryCount + 1}/${maxRetries}, waiting ${delay}ms`);
        await new Promise((r) => setTimeout(r, delay));

        config.metadata = { ...config.metadata, retryCount: retryCount + 1 };
        return client(config);
      }
      return Promise.reject(error);
    }
  );

  return client;
}

// 사용 예
const scraper = createScrapingClient({ defaultCountry: 'US' });
const { data } = await scraper.get('https://example-store.com/item/12345');

이 인터셉터 패턴의 장점은 비즈니스 로직과 프록시 로직이 완전히 분리된다는 것입니다. 스크래핑 코드에서는 scraper.get(url)만 호출하면 되고, IP 로테이션과 재시도는 인터셉터가 자동 처리합니다.

스티키 세션이 필요한 경우

로그인 이후 여러 페이지를 순회해야 한다면, 동일한 IP를 유지하는 스티키 세션을 사용하세요. ProxyHat의 session 플래그로 세션 ID를 고정하면 됩니다.

// 동일 세션 ID로 여러 요청 보내기
const sessionId = 'order-flow-abc123';

const agent = createProxyAgent('US', sessionId);
const stickyClient = axios.create({ httpsAgent: agent, httpAgent: agent });

// 이 세션의 모든 요청은 동일한 레지덴셜 IP로 전송
await stickyClient.post('https://example.com/login', loginPayload);
await stickyClient.get('https://example.com/account/orders');

동시성 제어: p-limit로 대량 URL 스크래핑

1만 개의 URL을 순차 처리하면 며칠이 걸립니다. 하지만 무제한 병렬 요청은 서버 차단과 프록시 풀 고갈을 유발합니다. p-limit로 동시성을 제어하면서 최대 처리량을 확보합니다.

import pLimit from 'p-limit';
import * as cheerio from 'cheerio';
import { createScrapingClient } from './scraper.js';

const CONCURRENCY = 50;
const limit = pLimit(CONCURRENCY);
const scraper = createScrapingClient({ defaultCountry: 'US' });

async function scrapeProductPage(url) {
  try {
    const { data: html } = await scraper.get(url);
    const $ = cheerio.load(html);

    return {
      url,
      title: $('h1.product-title').text().trim(),
      price: $('span.price').text().trim(),
      rating: $('span.rating-score').text().trim(),
      stock: $('span.stock').text().trim(),
      scrapedAt: new Date().toISOString(),
    };
  } catch (err) {
    return { url, error: true, status: err.response?.status, message: err.message };
  }
}

// 10,000개 URL 로드
const urls = await loadUrlList('product-urls.txt'); // 한 줄에 하나의 URL
console.log(`총 ${urls.length}개 URL 스크래핑 시작, 동시성: ${CONCURRENCY}`);

const results = await Promise.all(
  urls.map((url) => limit(() => scrapeProductPage(url)))
);

const succeeded = results.filter((r) => !r.error);
const failed = results.filter((r) => r.error);
console.log(`성공: ${succeeded.length}, 실패: ${failed.length}`);

// 실패한 URL 재시도 (최대 1회)
if (failed.length > 0) {
  console.log(`${failed.length}개 URL 재시도 중...`);
  const retryResults = await Promise.all(
    failed.map((f) => limit(() => scrapeProductPage(f.url)))
  );
  // 재시도 결과를 결과 배열에 병합
  retryResults.forEach((r, i) => {
    if (!r.error) results[results.indexOf(failed[i])] = r;
  });
}

// 결과 저장
import { writeFileSync } from 'fs';
writeFileSync('products.json', JSON.stringify(results, null, 2));

에러 핸들링: 403/429 대응과 서킷 브레이커

대규모 스크래핑에서는 에러 핸들링이 성패를 가릅니다. 단순 재시도만으로는 부족합니다. 서킷 브레이커 패턴을 적용하면 연속 실패 시 요청을 중단하고, 복구 후 자동으로 재개할 수 있습니다.

서킷 브레이커 구현

class CircuitBreaker {
  constructor({ threshold = 5, resetTimeout = 60000 } = {}) {
    this.failures = 0;
    this.threshold = threshold;
    this.resetTimeout = resetTimeout;
    this.state = 'CLOSED'; // CLOSED | OPEN | HALF_OPEN
    this.nextAttempt = Date.now();
  }

  recordSuccess() {
    this.failures = 0;
    this.state = 'CLOSED';
  }

  recordFailure() {
    this.failures++;
    if (this.failures >= this.threshold) {
      this.state = 'OPEN';
      this.nextAttempt = Date.now() + this.resetTimeout;
      console.warn(`[CIRCUIT BREAKER] OPEN — ${this.failures}연속 실패, ${this.resetTimeout / 1000}초 대기`);
    }
  }

  async canProceed() {
    if (this.state === 'CLOSED') return true;
    if (this.state === 'OPEN') {
      if (Date.now() < this.nextAttempt) return false;
      this.state = 'HALF_OPEN';
      return true;
    }
    // HALF_OPEN: 1건만 허용
    return true;
  }
}

// 스크래핑 함수에 서킷 브레이커 통합
const breaker = new CircuitBreaker({ threshold: 5, resetTimeout: 60000 });

async function safeScrape(url) {
  if (!(await breaker.canProceed())) {
    return { url, error: true, message: 'Circuit breaker is OPEN' };
  }

  try {
    const result = await scrapeProductPage(url);
    if (result.error) {
      breaker.recordFailure();
    } else {
      breaker.recordSuccess();
    }
    return result;
  } catch (err) {
    breaker.recordFailure();
    return { url, error: true, message: err.message };
  }
}

에러 유형별 대응 전략

  • 403 Forbidden: IP가 차단된 것입니다. 프록시 로테이션으로 새 IP를 할당받아 재시도합니다. 인터셉터가 이미 처리합니다.
  • 429 Too Many Requests: 속도 제한에 도달했습니다. 지수 백오프로 대기 후 재시도합니다. 동시성을 낮추는 것도 고려하세요.
  • 407 Proxy Auth: 프록시 자격 증명 오류입니다. 사용자 이름과 비밀번호를 확인하세요.
  • Timeout: 대상 서버 응답이 느리거나 프록시 연결 문제입니다. 타임아웃을 15–30초로 설정하고, 재시도합니다.
  • 5xx Server Error: 대상 서버의 일시적 장애입니다. 지수 백오프로 재시도합니다.

스케일링 패턴: 컨테이너화와 헤드리스 플릿

단일 인스턴스로 처리량에 한계가 있을 때, 다음 전략으로 스케일아웃합니다.

1. 워커 스레드 활용

Node.js의 worker_threads를 사용하면 CPU 코어 수만큼 병렬 워커를 실행할 수 있습니다. 각 워커가 URL 청크를 할당받아 독립적으로 스크래핑합니다.

2. Docker 컨테이너 배포

스크래핑 워커를 Docker 이미지로 패키징하고, docker compose 또는 Kubernetes로 복제합니다. 각 컨테이너가 독립적인 프록시 세션을 사용하면 IP 충돌 없이 병렬 처리가 가능합니다.

3. 메시지 큐 기반 분산

RabbitMQ나 Redis 큐를 사용해 URL을 분배합니다. 워커는 큐에서 URL을 가져와 스크래핑하고, 결과를 데이터베이스에 저장합니다. 실패한 URL은 재시도 큐로 이동합니다.

프록시 풀이 충분하다면 동시성을 높이세요. ProxyHat의 레지덴셜 풀은 수백만 개의 IP를 보유하므로, 병목은 프록시가 아니라 대상 서버의 속도 제한입니다.

실전: 전자상거래 10K URL 스크래핱 파이프라인

지금까지의 모든 요소를 결합한 완전한 파이프라인입니다. URL 로딩, 동시성 제어, 프록시 로테이션, 서킷 브레이커, 결과 저장을 하나의 흐름으로 연결합니다.

import pLimit from 'p-limit';
import * as cheerio from 'cheerio';
import { createScrapingClient } from './scraper.js';
import { CircuitBreaker } from './circuit-breaker.js';
import { readFileSync, writeFileSync } from 'fs';

// 설정
const CONCURRENCY = 50;
const MAX_RETRIES = 2;
const limit = pLimit(CONCURRENCY);
const scraper = createScrapingClient({ defaultCountry: 'US', maxRetries: 3 });
const breaker = new CircuitBreaker({ threshold: 10, resetTimeout: 30000 });

// URL 목록 로드
const urls = readFileSync('product-urls.txt', 'utf-8')
  .split('\n')
  .map((u) => u.trim())
  .filter(Boolean);

console.log(`총 ${urls.length}개 URL, 동시성 ${CONCURRENCY}`);

// 스크래핑 함수
async function processUrl(url) {
  if (!(await breaker.canProceed())) {
    return { url, error: true, message: 'Circuit breaker OPEN' };
  }

  try {
    const { data: html } = await scraper.get(url);
    const $ = cheerio.load(html);

    const result = {
      url,
      title: $('h1').text().trim(),
      price: $('[data-price]').text().trim(),
      availability: $('[data-stock]').text().trim(),
      scrapedAt: new Date().toISOString(),
    };

    breaker.recordSuccess();
    return result;
  } catch (err) {
    breaker.recordFailure();
    return {
      url,
      error: true,
      status: err.response?.status,
      message: err.message,
    };
  }
}

// 실행
const results = await Promise.all(urls.map((url) => limit(() => processUrl(url))));

const succeeded = results.filter((r) => !r.error);
const failed = results.filter((r) => r.error);

console.log(`완료 — 성공: ${succeeded.length}, 실패: ${failed.length}`);

writeFileSync('products.json', JSON.stringify(results, null, 2));

// 실패 URL 저장 (나중에 재시도)
if (failed.length > 0) {
  writeFileSync('failed-urls.txt', failed.map((f) => f.url).join('\n'));
  console.log(`실패 URL 목록: failed-urls.txt`);
}

핵심 요약

  • Cheerio는 SSR 사이트에 최적입니다. JavaScript 실행이 필요한 CSR 사이트에는 Puppeteer나 Playwright를 사용하세요.
  • axios 인터셉터로 프록시 로테이션과 재시도 로직을 비즈니스 코드에서 분리하세요. 유지보수성이 크게 향상됩니다.
  • https-proxy-agent를 사용해 HTTPS 대상에 대한 프록시 터널링을 확실히 처리하세요.
  • p-limit로 동시성을 30–100 사이로 설정하고, 대상 서버의 응답 속도에 맞게 조정하세요.
  • 서킷 브레이커를 구현해 연속 실패 시 전체 작업을 보호하세요. 5–10회 연속 실패 후 30–60초 대기가 적절합니다.
  • 403/429 응답은 프록시 로테이션으로 해결합니다. ProxyHat의 session 플래그로 스티키 세션을, country 플래그로 지역 타겟팅을 제어하세요.
  • 결과 검증을 자동화하세요. 빈 응답이나 캡차 페이지를 감지해 실패로 처리하는 로직을 포함하세요.

ProxyHat의 레지덴셜 프록시 풀은 200개 이상의 국가를 지원하며, 요청 단위 IP 로테이션과 스티키 세션을 모두 제공합니다. 요금제를 확인하고 바로 시작하세요. 추가 가이드는 ProxyHat 블로그에서 확인할 수 있습니다.

시작할 준비가 되셨나요?

AI 필터링으로 148개국 이상에서 5천만 개 이상의 레지덴셜 IP에 액세스하세요.

가격 보기레지덴셜 프록시
← 블로그로 돌아가기