하나의 페이지를 웹 스크래핑 API로 가져오는 것은 한 줄의 코드로 끝난다. 하지만 매일 밤 5만 개의 페이지를 가져오면서 플랜의 한도를 넘기지 않고, 프로세스가 중단되어도 작업을 잃지 않으며, 실패를 조용히 누락시키지 않는 것은 엔지니어링의 문제다. Node.js는 이 작업에 적합하다. 작업의 대부분이 네트워크 대기 시간이며, 이는 Node의 이벤트 루프가 가장 잘 처리하는 영역이기 때문이다.
이 가이드는 Shifter의 Web Scraping API 위에 Node.js로 작은 규모의 프로덕션 수준 파이프라인을 구축한다: 제한된 동시성, 적절한 오류에 대한 재시도, 재개 가능한 출력, 그리고 별도의 오류 로그. 전체 코드는 의존성 없이 약 60줄이며, API의 모의(mock) 버전으로 테스트했다. 아직 첫 요청을 보내지 않았다면, 웹 스크래핑 API 초보자 가이드부터 시작하라.
파이프라인의 구조
스크래핑 파이프라인에는 네 가지 작업이 있다:
- 가져올 URL의 큐(queue).
- 큐에서 URL을 가져와 API를 호출하는 워커(worker), 한 번에 고정된 수만큼 동작한다.
- 나중에 성공할 수 있는 것은 재시도하고, 그럴 수 없는 것은 기록하는 오류 처리.
- 충돌에도 살아남고, 재실행 시 완료된 작업을 건너뛸 수 있게 하는 출력.
API는 프록시, 브라우저, 그리고 일부 재시도를 자체적으로 처리한다. 파이프라인의 역할은 적절한 속도로 API에 요청을 보내고 무슨 일이 일어났는지에 대한 신뢰할 수 있는 기록을 유지하는 것이다.
동시성: 모든 것에 Promise.all을 쓰지 말고 워커를 사용하라
가장 흔한 Node.js 실수는 모든 URL을 요청으로 매핑한 뒤 전체에 대해 await Promise.all(...)을 사용하는 것이다. URL이 5만 개라면, 이는 한 번에 5만 개의 요청을 발사하는 것과 같다. API는 그중 대부분에 429 Too Many Requests로 응답하는데, 모든 플랜에는 동시성 한도가 있기 때문이다 (Shifter의 경우 플랜에 따라 한 번에 20개에서 500개까지의 요청).
해결책은 하나의 큐를 공유하는 고정된 워커 풀이다. 각 워커는 다음 URL을 가져와 처리하고, 다시 돌아와 또 다른 URL을 가져온다. 이렇게 하면 언제든지 정확히 concurrency 개수만큼의 요청만 진행 중이게 된다. 별도의 라이브러리는 필요 없으며, 공유 배열과 루프만으로 충분하다.
재시도: 나중에 성공할 수 있는 오류에 한해서만
모든 오류가 재시도할 가치가 있는 것은 아니다. 타임아웃, 렌더링 실패, 429, 서버 오류는 다음 시도에서 성공할 수 있다. 잘못된 API 키나 형식이 잘못된 요청은 영원히 실패하며, 이를 재시도하는 것은 시간 낭비일 뿐이다. 이 파이프라인은 408, 422, 429, 500을 시도마다 점점 길어지는 대기 시간을 두고 재시도하며, 그 외의 모든 것은 즉시 실패로 기록한다. 웹 스크래퍼를 위한 재시도와 백오프 가이드에서 대기 시간이 왜 점점 늘어나야 하는지 설명한다.
출력: 추가 전용(append-only)이며 재개 가능
성공한 각 결과는 도착하는 즉시 줄바꿈으로 구분된 JSON(NDJSON) 파일에 추가된다. URL당 데이터와 타임스탬프를 담은 한 줄이다. 이 방식에는 세 가지 장점이 있다:
- 충돌이 발생해도 진행 중이던 요청만 손실된다. 이미 기록된 것은 모두 그대로 남는다.
- 재실행 비용이 낮다. 시작 시 파이프라인은 출력 파일을 읽어 이미 존재하는 URL을 건너뛰므로, 같은 페이지에 대해 두 번 비용을 지불하지 않고도 실패한 실행을 다시 시작할 수 있다.
- 실패는 별도로
.errors파일에 보관되며, 이를 검토하거나 다음 실행에 다시 투입할 수 있다.
재실행이 동일한 결과를 만들어내도록 설계하는 것이 멱등성 있는 스크래퍼의 핵심 개념이며, 이에 대해 더 자세히 다룬다.
코드
import { appendFile, readFile } from 'node:fs/promises';
const API = process.env.SCRAPER_API_URL ?? 'https://scrape.shifter.io/v1';
const RETRY = new Set([408, 422, 429, 500]);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Fetch one URL through the Web Scraping API, retrying only errors that can succeed later.
export async function fetchPage(url, { extractRules, attempts = 3, backoffMs = 2000 } = {}) {
const params = new URLSearchParams({ api_key: process.env.SHIFTER_SCRAPER_KEY, url });
if (extractRules) params.set('extract_rules', JSON.stringify(extractRules));
for (let attempt = 1; ; attempt++) {
const res = await fetch(`${API}?${params}`, { signal: AbortSignal.timeout(120_000) });
if (res.ok) return extractRules ? res.json() : res.text();
if (!RETRY.has(res.status) || attempt === attempts) {
throw new Error(`HTTP ${res.status} for ${url}`);
}
await sleep(backoffMs * attempt);
}
}
// URLs already written to the output file, so a restarted run skips them.
async function alreadyDone(outFile) {
try {
const lines = (await readFile(outFile, 'utf8')).split('\n').filter(Boolean);
return new Set(lines.map((line) => JSON.parse(line).url));
} catch {
return new Set();
}
}
// Run `concurrency` workers over the queue; each result becomes one NDJSON line.
export async function runPipeline(urls, { outFile, concurrency = 10, extractRules, backoffMs } = {}) {
const done = await alreadyDone(outFile);
const queue = urls.filter((u) => !done.has(u));
const stats = { skipped: urls.length - queue.length, ok: 0, failed: 0 };
async function worker() {
for (let url = queue.shift(); url !== undefined; url = queue.shift()) {
try {
const data = await fetchPage(url, { extractRules, backoffMs });
await appendFile(outFile, JSON.stringify({ url, data, at: new Date().toISOString() }) + '\n');
stats.ok++;
} catch (err) {
await appendFile(`${outFile}.errors`, JSON.stringify({ url, error: err.message }) + '\n');
stats.failed++;
}
}
}
await Promise.all(Array.from({ length: concurrency }, worker));
return stats;
}
이 코드는 Node의 내장 fetch, AbortSignal.timeout, fs/promises만 사용하므로, 아무것도 설치하지 않아도 현재의 모든 Node.js 릴리스에서 실행된다.
상품 페이지 목록에 대해 실행하면서, API에 HTML이 아닌 JSON을 요청하는 예시:
import { readFile } from 'node:fs/promises';
import { runPipeline } from './pipeline.mjs';
const urls = (await readFile('urls.txt', 'utf8')).split('\n').filter(Boolean);
const stats = await runPipeline(urls, {
outFile: 'products.ndjson',
concurrency: 20, // stay under your plan's concurrency limit
extractRules: {
name: { selector: 'h1', output: 'text' },
price: { selector: '.price', output: 'text' },
},
});
console.log(stats); // { skipped, ok, failed }
테스트 방법
22개의 URL을 제공하는 API의 로컬 스탠드인(stand-in)에 대해 파이프라인을 실행했다: 일반 페이지 20개, 성공하기 전에 두 번 429로 실패하는 페이지 1개, 항상 401을 반환하는 페이지 1개. 테스트는 다음을 확인했다:
- 21개 페이지가 성공했고 하나의 실패가 오류 파일에 기록되었다;
- 불안정한 페이지는 재시도되어 세 번째 시도에서 성공했으며,
401은 한 번만 시도되었다; concurrency: 5일 때 동시에 진행 중인 요청이 5개를 넘은 적이 없었다;- 두 번째 실행에서는 완료된 21개 페이지를 모두 건너뛰고 실패한 것만 재시도했다.
더 확장하기
위의 단일 프로세스 설계는 한 번의 실행으로 수만 개의 페이지를 충분히 처리한다. 그 이상으로 확장하려면:
- 큐를 메모리 밖으로 옮긴다. 데이터베이스 테이블이나 메시지 큐를 사용하면 여러 프로세스가 작업을 나눠 가지며 전체 머신의 재시작에도 살아남을 수 있다.
- 결과를 도착하는 대로 데이터베이스에 적재한다. NDJSON은 좋은 중간 형식이며, 웹 스크래핑 API 데이터를 SQL로 옮기기 가이드에서 적재 단계를 다룬다.
- 백프레셔(backpressure)를 적용한다. 가져오기 다음 단계가 가져오기보다 느리다면, 결과가 메모리에 쌓이게 두지 말고 워커의 속도를 늦춰라. 분산 크롤러의 백프레셔와 흐름 제어를 참고하라.
- 수치를 지켜본다. 실행마다 성공, 실패, 재시도 횟수를 추적하고 실패율이 변동할 때 알림을 받아라. 스크래핑 파이프라인 모니터링 가이드에 중요한 지표들이 정리되어 있다.
FAQ
Node.js는 웹 스크래핑 파이프라인에 적합한가?
API 위에 구축된 파이프라인이라면 그렇다. 작업의 대부분이 네트워크 응답을 기다리는 것이며, Node.js는 하나의 프로세스에서 적은 오버헤드로 많은 동시 요청을 처리한다. CPU를 많이 쓰는 파싱 작업은 API의 추출 규칙이나 별도의 단계에서 처리하는 것이 낫다.
Node.js 스크래퍼는 몇 개의 동시 요청을 보내야 하는가?
스크래핑 API 플랜이 허용하는 만큼만, 그 이상은 안 된다. 그 한도에 맞춘 고정된 워커 풀을 사용하고, 429 응답은 속도를 늦추라는 신호로 받아들여라.
스크래핑 작업을 재개 가능하게 만들려면 어떻게 해야 하는가?
각 결과를 도착하는 즉시 URL을 키로 하는 추가 전용 파일이나 테이블에 기록하고, 시작 시 이미 결과가 있는 URL은 건너뛰어라. 그러면 충돌이 발생해도 진행 중이던 요청만 손실된다.
큐 라이브러리가 필요한가?
단일 프로세스라면 필요 없다. 고정된 수의 비동기 워커와 공유 배열이면 충분하다. 여러 프로세스나 머신이 작업을 나눠야 할 때 데이터베이스나 메시지 큐로 옮겨라.
결론
Node.js에서 확장 가능한 스크래핑 파이프라인에는 네 가지가 필요하다: 플랜의 동시성에 맞춘 고정 워커 풀, 나중에 성공할 수 있는 오류에만 한정된 재시도, 재실행이 이어받을 수 있는 추가 전용 출력, 그리고 실패에 대한 별도 기록. 위의 코드는 이 네 가지를 약 60줄로 구현한다. API는 프록시와 브라우저를 처리하며, 파이프라인의 역할은 꾸준히 요청을 보내고 무슨 일이 일어났는지 절대 놓치지 않는 것이다.