REST API 업무 자동화 fetch 인증 페이지네이션 기준

··설명 약 9분·작성: 해비·블로그 소개

Node.js fetch로 업무 API를 호출할 때 응답과 페이지네이션은 어떻게 확인하나요?

fetch가 응답을 받았다고 업무 데이터까지 정상인 것은 아닙니다. HTTP 상태, JSON 해석, 필요한 필드를 차례로 확인하고 다음 페이지가 없을 때 반복을 끝냅니다.

적용 조건·범위: Node.js 24의 Response를 사용해 API 응답을 메모리에서 만듭니다. 네트워크나 인증 토큰 없이 HTTP 실패·다음 cursor·반복 cursor를 연습합니다. 실제 제공자의 인증 성공이나 호출 한도를 검증하는 예제는 아닙니다.

본문 근거: HTTP Semantics

응답을 주입해 페이지 종료와 HTTP 오류 확인

Node.js 24의 Response를 사용해 API 응답을 메모리에서 만듭니다. 네트워크나 인증 토큰 없이 HTTP 실패·다음 cursor·반복 cursor를 연습합니다. 실제 제공자의 인증 성공이나 호출 한도를 검증하는 예제는 아닙니다.

pagination-demo.mjs 전체 코드

async function readPages(fetchPage) {
  const seen = new Set();
  const items = [];
  let cursor = '';
  for (let page = 0; page < 5; page += 1) {
    if (seen.has(cursor)) throw new Error('반복 cursor');
    seen.add(cursor);
    const response = await fetchPage(cursor);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const data = await response.json();
    if (!Array.isArray(data.items)) throw new Error('items 배열 필요');
    items.push(...data.items);
    if (data.nextCursor === null) return items;
    if (typeof data.nextCursor !== 'string') throw new Error('cursor 형식 오류');
    cursor = data.nextCursor;
  }
  throw new Error('최대 페이지 초과');
}
const pages = {
  '': { items: ['a'], nextCursor: 'p2' },
  p2: { items: ['b'], nextCursor: null },
};
console.log((await readPages(async cursor => Response.json(pages[cursor]))).join(','));
for (const fetchPage of [
  async () => new Response('', { status: 401 }),
  async () => Response.json({ items: [], nextCursor: '' }),
]) {
  try { await readPages(fetchPage); }
  catch (error) { console.log(error.message); }
}

저장한 폴더에서 node pagination-demo.mjs로 실행합니다. 예상 출력은 다음과 같습니다.

a,b
HTTP 401
반복 cursor

직접 바꿔 보기: p2의 items에 c를 추가해 a,b,c가 나오는지 확인합니다. nextCursor를 숫자로 바꾸면 cursor 형식 오류여야 합니다. 실제 API에 적용할 때에는 해당 API의 cursor 필드·인증 헤더 계약을 사용해야 합니다.

핵심 원리

REST API 자동화는 HTTP 요청을 반복하는 작업입니다. URL·method·headers·body·status를 계약으로 보고 인증, 페이지네이션, 429/5xx 처리 범위를 분리합니다.

자동화는 한 번 성공하는 스크립트와 반복 운영 가능한 파이프라인이 다릅니다. 다음 회차로 갈수록 입력과 결과뿐 아니라 실패·재시도·중복 실행·감사 기록까지 관리합니다.

독립 실습: HTTP 성공과 업무 성공 구분

코드 직접 실행

본문 예제를 선택하고 실제 계산 결과를 확인합니다.

예제 크게 보기 (새 탭)

GET 응답이 왔다고 데이터까지 정상이라는 뜻은 아닙니다. HTTP 상태를 확인하고 JSON 해석을 거친 뒤 필드 검증을 해야 합니다. fetch는 HTTP 4xx·5xx 응답을 받았다는 이유만으로 항상 Promise를 거절하지 않으므로 response.ok 또는 status를 확인합니다.

관찰 기본 판단 다음 행동
2xx HTTP 응답 성공 JSON 파싱·업무 스키마 검증
401/403 인증·권한 문제 권한/만료/계정 확인, 무한 재시도 금지
404 경로 또는 자원 부재 base URL·ID 확인
429 요청 제한 공급자 정책·Retry-After 확인
일부 5xx·네트워크 오류 일시 실패 가능 8회차의 제한된 재시도 정책 검토

이 표는 API별 계약을 대신하지 않습니다. HTTP Semantics

페이지네이션은 응답의 다음 page/cursor가 없으면 끝내고, 같은 cursor가 반복되거나 최대 페이지 수를 넘으면 중단하도록 설계합니다. API마다 필드명이 다르므로 하나의 nextCursor를 공통 표준이라고 설명하지 않습니다. 토큰을 query string이나 로그에 넣지 않고, 실제 서비스 연결 시 해당 서비스의 인증 방식을 확인합니다.

이 실습의 연결 범위

아래 구현은 localhost 모의 서버와 비식별 응답을 사용합니다. 실제 외부 사이트의 GET·POST, 운영 인증정보, 실제 Task 생성은 수행하지 않습니다. 실제 API를 연결할 때는 별도 권한·계약·timeout·입출력 검증이 필요합니다.

자주 발생하는 오류와 해결

401/403은 자격정보·권한부터 확인하고, 같은 cursor 반복은 pagination 계약부터 점검합니다. 이 실습은 localhost만 허용하므로 외부 URL로 바꾸어 실행하지 않습니다. 네트워크 오류와 HTTP 응답 오류를 나누어 기록합니다.

학습 완료 체크리스트

  • HTTP 오류와 JSON·items 배열 검증 오류를 구분할 수 있습니다.
  • 페이지 종료·반복 cursor·최대 페이지 조건을 설명할 수 있습니다.
  • 상단 예제가 메모리 Response를 주입하며 네트워크·인증 검증을 하지 않음을 확인했습니다.
선택 실습: 같은 프로젝트로 연습하기

TaskFlow Ops Automation은 작업 처리 상태와 실패·재시도를 관리하는 자동화 예제입니다. 이 선택 실습에서는 처리 흐름을 이어서 만듭니다.

Before는 이전 회차까지만 구현된 상태입니다. 위 파일을 해당 경로에 추가·교체하고 README 순서로 실행한 뒤 기대 결과와 오류 처리를 확인하면 이번 회차의 After 상태가 됩니다.

선택: 연재 프로젝트와 전체 자료

연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.

TaskFlow Ops Automation 누적 적용

프로젝트에서 추가로 확인할 항목

  • localhost 모의 서버의 GET·POST와 잘못된 인증·외부 URL 거절을 전체 프로젝트에서 확인합니다.

이어서 시작하기: CSV JSON 파일 자동화 Node.js 읽기 검증 변환 저장의 완료 상태를 사용합니다. 중간 합류용 시작 ZIP과 이번 완료 ZIP은 아래 기존 다운로드 안내에서 구분해 선택합니다.

시작 체크포인트: automation-step-03-file-io. 이전 로컬 실습을 이어가거나 이번 회차 완성 ZIP을 새 폴더에 풀어 시작합니다. 이미 수정한 파일은 새 ZIP으로 덮어쓰지 말고 별도 폴더에서 비교합니다.

현재 구조는 아래 전체 코드의 경로 목록과 같습니다. 생성되는 output·logs는 실행 결과이며 input 원본은 유지합니다.

이번 추가 파일: src/automation/api-client.mjs, src/automation/demo-api.mjs, src/automation/local-http.mjs, src/automation/mock-api.mjs, tests/api.test.mjs.

이번 변경 파일: README.md.

api-client.mjs와 mock-api.mjs, demo-api.mjs를 추가해 로컬 HTTP 입력을 기존 run 함수로 연결합니다. 외부 URL과 redirect는 거부하고 실제 서비스 자격정보는 사용하지 않습니다.

Before는 이전 회차까지만 구현된 상태입니다. 위 파일을 해당 경로에 추가·교체하고 README 순서로 실행한 뒤 기대 결과와 오류 처리를 확인하면 이번 회차의 After 상태가 됩니다. 실제 외부 서비스·운영 설정은 변경하지 않습니다.

전체 파일·코드 보기

실습 프로젝트 전체 코드

이 체크포인트는 Node.js 24 이상의 로컬 비식별 실습입니다. 외부 전송과 실제 예약 실행은 포함하지 않습니다.

automation/input/task.json

{
  "sourceId": "sample-1",
  "title": "  주간 작업 점검  ",
  "status": "todo",
  "dueAt": null
}

automation/input/tasks.csv

sourceId,title,status,dueAt
sample-1, 주간 점검 ,todo,
sample-2,결과 검토,doing,2026-09-20T00:00:00Z
sample-3,,todo,
sample-1, 주간 점검 ,todo,

automation/input/tasks.json

[
  { "sourceId": "sample-1", "title": " 주간 점검 ", "status": "todo", "dueAt": null },
  { "sourceId": "sample-2", "title": "결과 검토", "status": "doing", "dueAt": "2026-09-20T00:00:00Z" },
  { "sourceId": "sample-3", "title": "", "status": "todo", "dueAt": null },
  { "sourceId": "sample-1", "title": " 주간 점검 ", "status": "todo", "dueAt": null }
]

docs/automation-decision.md

# TaskFlow Ops Automation 도구 선택

입력은 개인정보가 없는 Task JSON입니다. sourceId로 원본을 연결하고 제목을 검증·정규화한 후 로컬 결과와 실행 기록을 저장합니다.

| 방식 | 선택 기준 | 운영 책임 |
| --- | --- | --- |
| Zapier / Make | 제공 커넥터와 시각적 연결이 업무에 맞음 | 권한·비용·실패 이력·재실행 확인 |
| n8n | 시각적 흐름과 코드 조합이 필요함 | 배포 방식별 업데이트·보안·상태 저장 책임 확인 |
| Node.js | 파일과 오류 처리를 직접 제어하며 학습함 | 테스트·배포·상태·복구를 직접 설계 |

이 과정은 Node.js 24 기본 모듈을 선택합니다. npm install은 필요하지 않습니다. 실제 외부 계정·토큰·개인정보를 사용하지 않습니다. API와 Webhook은 127.0.0.1 모의 서버, 예약은 수동 시뮬레이션만 사용합니다.

실패 시 입력 형식과 실행 결과를 먼저 확인합니다. 외부 전송·업로드·알림은 사람이 범위와 권한을 검토한 별도 작업입니다. 2편의 재실행은 별도 결과를 만드므로 멱등성 완성이 아닙니다. 9편에서 파일 상태와 결과를 확인하며 재개합니다.

package.json

{"name":"taskflow-ops-automation","private":true,"type":"module","engines":{"node":">=24"},"scripts":{"test":"node --test","start":"node src/automation/run.mjs"}}

README.md

# TaskFlow Ops Automation · 04 로컬 REST API

Node.js 24 이상, 설치할 패키지 없음. ZIP을 푼 폴더에서 실행합니다.

    node --test
    node src/automation/run.mjs
    node src/automation/run.mjs

정상 결과: 각 실행 successCount=1, failedCount=0. automation/output/실행ID/valid.json과 rejected.json, automation/logs/실행ID.json이 생성됩니다. 두 번 실행하면 서로 다른 실행ID 폴더가 생깁니다. 이 단계는 실행 내 sourceId 중복만 거르며 영속 멱등성은 9편에서 구현합니다.

입력: automation/input/task.json. sourceId는 1~80자 영문·숫자·밑줄·하이픈, title은 공백 제거 후 비어 있지 않은 200자 이하 문자열입니다. status는 todo/doing/done(기본 todo), dueAt은 null(기본) 또는 UTC ISO 날짜입니다. 예: 2026-09-21T00:00:00Z. 원본을 덮어쓰지 않습니다.

검증 실패는 rejected.json에 row/sourceId/field/reason/input으로 분리하며 CLI exit 1입니다. 읽기·파싱·저장 자체의 실패도 exit 1입니다. 로그는 집계만 남기며 실제 개인정보는 입력하지 않습니다. 테스트는 임시 폴더에 작은 교육 결과를 남깁니다.

## 이번 편 추가

    node src/automation/batch.mjs automation/input/tasks.json
    node src/automation/batch.mjs automation/input/tasks.csv

두 형식 모두 successCount=2, failedCount=1, skippedCount=1, partial_failure와 exit 1이 정상 관찰 결과입니다. 오류 행을 의도적으로 포함했습니다. rejected의 row는 헤더를 제외한 입력 레코드 번호(1부터)입니다. 출력 경로는 각 명령의 runId를 확인합니다.

CSV는 sourceId,title,status,dueAt의 단순 UTF-8 예제만 허용합니다. 따옴표가 있는 CSV, 필드 내부 쉼표·줄바꿈은 지원하지 않고 명시적으로 거부합니다. 실제 복잡한 CSV에는 별도 검증된 파서가 필요합니다. 파일 파싱 실패는 전체 중단, 레코드 검증 실패는 나머지 행 처리를 계속합니다.

## 04 로컬 API

    node src/automation/demo-api.mjs

모의 서버를 127.0.0.1 임의 포트에 열고 GET 2페이지 3건을 같은 run()으로 저장한 뒤 로컬 POST를 확인하고 종료합니다. successCount=3, 로컬 POST 응답 sample-new가 예상 결과입니다. API_BASE_URL/API_TOKEN은 실제 서비스 연결 때 필요한 설정 경계이며 이 실습은 서버 주소를 자동 생성하고 공개 교육 문자열 local-training-only만 사용합니다. .env나 실제 키가 필요하지 않습니다.

API client는 외부 호스트와 redirect를 거부하고 HTTP 상태를 error.status에 보존합니다. 401/403은 권한, 404는 경로, 429는 제한, 5xx는 서버 오류로 구분합니다. 04에서는 자동 재시도하지 않습니다. 잘못된 페이지 계약, 반복 cursor, 100페이지 초과도 중단합니다. POST는 메모리의 모의 응답만 반환하며 실제 저장 서비스와 연결하지 않습니다.

src/automation/api-client.mjs

export async function request(url, { token, method = 'GET', body, timeoutMs = 2000 } = {}) {
  const target = new URL(url);
  if (target.protocol !== 'http:' || target.hostname !== '127.0.0.1' || target.username || target.password) throw new Error('교육용 API는 http://127.0.0.1 주소만 허용합니다.');
  const response = await fetch(target, {
    method, redirect: 'error', signal: AbortSignal.timeout(timeoutMs),
    headers: { authorization: `Bearer ${token ?? ''}`, 'content-type': 'application/json' },
    ...(body === undefined ? {} : { body: JSON.stringify(body) }),
  });
  if (!response.ok) throw Object.assign(new Error(`HTTP ${response.status}`), { status: response.status, retryAfter: response.headers.get('retry-after') });
  return response.json();
}
export async function listTasks(baseURL, token, { get = request } = {}) {
  const items = [], seen = new Set();
  let cursor = null;
  for (let page = 0; page < 100; page += 1) {
    const url = new URL('/tasks', baseURL);
    if (cursor !== null) url.searchParams.set('cursor', cursor);
    const data = await get(url, { token });
    if (!data || !Array.isArray(data.items) || !(data.nextCursor === null || typeof data.nextCursor === 'string')) throw new Error('페이지 응답 계약 오류');
    items.push(...data.items);
    if (data.nextCursor === null) return items;
    if (!data.nextCursor || seen.has(data.nextCursor)) throw new Error('반복 cursor 중단');
    seen.add(data.nextCursor);
    cursor = data.nextCursor;
  }
  throw new Error('최대 페이지 수 초과');
}

src/automation/batch.mjs

import { extname } from 'node:path';
import { readJSON } from './read-json.mjs';
import { readCSV } from './read-csv.mjs';
import { run } from './run.mjs';
import { isMain } from './paths.mjs';
export async function batch(file, options) {
  const ext = extname(String(file)).toLowerCase();
  if (!['.json', '.csv'].includes(ext)) throw new Error('JSON 또는 CSV 경로가 필요합니다.');
  return run(await (ext === '.csv' ? readCSV(file) : readJSON(file)), options);
}
if (isMain(import.meta.url)) {
  try {
    const result = await batch(process.argv[2] ?? new URL('../../automation/input/tasks.json', import.meta.url));
    console.log(JSON.stringify(result, null, 2));
    if (result.failedCount) process.exitCode = 1;
  } catch { console.error('batch 읽기·파싱·저장 실패'); process.exitCode = 1; }
}

src/automation/demo-api.mjs

import { startMock } from './mock-api.mjs';
import { listTasks, request } from './api-client.mjs';
import { run } from './run.mjs';
const mock = await startMock();
try {
  // 실제 credential이 아닌 로컬 모의 서버의 공개 교육 문자열입니다.
  const token = 'local-training-only';
  const tasks = await listTasks(mock.url, token);
  console.log(JSON.stringify(await run(tasks, { trigger: 'mock-api' }), null, 2));
  const created = await request(mock.url + '/tasks', { token, method: 'POST', body: { sourceId: 'sample-new', title: '모의 POST' } });
  console.log('로컬 POST 응답:', created.sourceId);
} catch { console.error('모의 API 실습 실패'); process.exitCode = 1; }
finally { await mock.close(); }

src/automation/local-http.mjs

export function send(res, status, value, headers = {}) {
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', ...headers });
  res.end(JSON.stringify(value));
}
export async function readBody(req) {
  let size = 0;
  const parts = [];
  for await (const part of req) {
    size += part.length;
    if (size > 65536) throw new Error('body too large');
    parts.push(part);
  }
  return JSON.parse(Buffer.concat(parts).toString('utf8'));
}
export async function listenLocal(server) {
  await new Promise((resolve, reject) => {
    server.once('error', reject);
    server.listen(0, '127.0.0.1', resolve);
  });
  return {
    url: `http://127.0.0.1:${server.address().port}`,
    close: () => new Promise((resolve, reject) => {
      server.close((error) => error ? reject(error) : resolve());
      server.closeAllConnections();
    }),
  };
}

src/automation/mock-api.mjs

import { createServer } from 'node:http';
import { setTimeout as sleep } from 'node:timers/promises';
import { send, readBody, listenLocal } from './local-http.mjs';
import { normalizeTask } from './normalize-task.mjs';
export async function startMock({ repeatCursor = false, delay = 0, statuses = [] } = {}) {
  let calls = 0;
  const tasks = [1, 2, 3].map((n) => ({ sourceId: `sample-${n}`, title: `모의 작업 ${n}`, status: 'todo', dueAt: '2026-09-20T00:00:00Z' }));
  const server = createServer(async (req, res) => {
    try {
      if (delay) await sleep(delay);
      if (req.headers.authorization !== 'Bearer local-training-only') return send(res, 401, { error: 'unauthorized' });
      const status = statuses[calls++] ?? 200;
      if (status !== 200) return send(res, status, { error: 'simulated' }, { 'retry-after': '0' });
      const url = new URL(req.url, 'http://127.0.0.1');
      if (url.pathname !== '/tasks') return send(res, 404, { error: 'not_found' });
      if (req.method === 'GET') {
        const cursor = url.searchParams.get('cursor');
        if (cursor !== null && cursor !== 'page-2') return send(res, 400, { error: 'cursor' });
        return send(res, 200, { items: cursor ? tasks.slice(2) : tasks.slice(0, 2), nextCursor: repeatCursor || !cursor ? 'page-2' : null });
      }
      if (req.method === 'POST') return send(res, 201, normalizeTask(await readBody(req)));
      return send(res, 405, { error: 'method' });
    } catch { if (!res.headersSent) send(res, 400, { error: 'invalid_payload' }); }
  });
  return { ...await listenLocal(server), calls: () => calls };
}

src/automation/normalize-task.mjs

export function invalid(field, reason) {
  return Object.assign(new Error(reason), { field });
}

export function normalizeTask(input) {
  if (!input || typeof input !== 'object' || Array.isArray(input)) throw invalid('record', '객체가 필요합니다.');
  const { sourceId, title, status = 'todo', dueAt = null } = input;
  if (typeof sourceId !== 'string' || !/^[a-zA-Z0-9_-]{1,80}$/.test(sourceId)) throw invalid('sourceId', '영문·숫자·밑줄·하이픈 식별자가 필요합니다.');
  if (typeof title !== 'string' || !title.trim() || title.length > 200) throw invalid('title', '1~200자 제목이 필요합니다.');
  if (!['todo', 'doing', 'done'].includes(status)) throw invalid('status', 'todo, doing, done 중 하나여야 합니다.');
  if (dueAt !== null && (typeof dueAt !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(dueAt) || !Number.isFinite(Date.parse(dueAt)) || new Date(dueAt).toISOString() !== dueAt.replace(/Z$/, dueAt.includes('.') ? 'Z' : '.000Z'))) {
    throw invalid('dueAt', '유효한 UTC ISO 날짜 또는 null이 필요합니다.');
  }
  return { sourceId, title: title.trim(), status, dueAt };
}

src/automation/paths.mjs

import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
export const automationRoot = fileURLToPath(new URL('../../automation/', import.meta.url));
export function isMain(url) {
  return Boolean(process.argv[1]) && resolve(process.argv[1]) === fileURLToPath(url);
}

src/automation/read-csv.mjs

import { readFile } from 'node:fs/promises';
export function parseCSV(raw) {
  if (raw.includes('"')) throw new Error('이 예제는 따옴표·quoted comma·줄바꿈 필드를 지원하지 않습니다.');
  const lines = raw.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').trimEnd().split('\n');
  const headers = lines.shift().split(',').map((v) => v.trim());
  const allowed = ['sourceId', 'title', 'status', 'dueAt'];
  if (!headers.includes('sourceId') || !headers.includes('title') || new Set(headers).size !== headers.length || headers.some((h) => !allowed.includes(h))) throw new Error('CSV 헤더 오류');
  return lines.map((line, index) => {
    const values = line.split(',');
    if (values.length !== headers.length) throw new Error(`CSV ${index + 2}행 필드 수 오류`);
    return Object.fromEntries(headers.map((key, i) => [key, key === 'dueAt' && !values[i] ? null : values[i]]));
  });
}
export async function readCSV(file) { return parseCSV(await readFile(file, 'utf8')); }

src/automation/read-json.mjs

import { readFile } from 'node:fs/promises';
export async function readJSON(file) {
  let value;
  try { value = JSON.parse((await readFile(file, 'utf8')).replace(/^\uFEFF/, '')); }
  catch { throw new Error('JSON 읽기 또는 파싱 실패'); }
  if (!Array.isArray(value)) throw new Error('JSON 배열이 필요합니다.');
  return value;
}

src/automation/ready.mjs

const major = Number(process.versions.node.split('.')[0]);
if (major < 24) {
  console.error('Node.js 24 이상이 필요합니다.');
  process.exitCode = 1;
} else {
  console.log('TaskFlow Ops Automation 준비 완료: 로컬 비식별 실습만 실행합니다.');
}

src/automation/run.mjs

import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { join } from 'node:path';
import { normalizeTask } from './normalize-task.mjs';
import { automationRoot, isMain } from './paths.mjs';

export async function run(inputs, { root = automationRoot, trigger = 'manual' } = {}) {
  if (!Array.isArray(inputs)) throw new Error('입력 배열이 필요합니다.');
  const runId = randomUUID();
  const startedAt = new Date().toISOString();
  const valid = [], rejected = [], seen = new Set();
  let skippedCount = 0;
  inputs.forEach((input, index) => {
    try {
      const task = normalizeTask(input);
      if (seen.has(task.sourceId)) { skippedCount += 1; return; }
      seen.add(task.sourceId);
      valid.push(task);
    } catch (error) {
      // 원본은 비식별 교육 fixture만 저장합니다. 운영 개인정보는 별도 보호가 필요합니다.
      rejected.push({ row: index + 1, sourceId: input?.sourceId ?? null, field: error.field ?? 'record', reason: error.message, input });
    }
  });
  const output = join(root, 'output', runId);
  await mkdir(output, { recursive: true });
  await mkdir(join(root, 'logs'), { recursive: true });
  const json = (value) => JSON.stringify(value, null, 2) + '\n';
  await writeFile(join(output, 'valid.json'), json(valid), { flag: 'wx' });
  await writeFile(join(output, 'rejected.json'), json(rejected), { flag: 'wx' });
  const log = { runId, trigger, startedAt, finishedAt: new Date().toISOString(), inputCount: inputs.length, successCount: valid.length, failedCount: rejected.length, skippedCount, status: rejected.length ? (valid.length ? 'partial_failure' : 'failed') : 'completed', output };
  await writeFile(join(root, 'logs', runId + '.json'), json(log), { flag: 'wx' });
  return log;
}

if (isMain(import.meta.url)) {
  try {
    const file = process.argv[2] ?? join(automationRoot, 'input', 'task.json');
    const value = JSON.parse(await readFile(file, 'utf8'));
    const result = await run(Array.isArray(value) ? value : [value]);
    console.log(JSON.stringify(result, null, 2));
    if (result.failedCount) process.exitCode = 1;
  } catch { console.error('입력 읽기·파싱 또는 결과 저장 실패. 경로와 형식을 확인하세요.'); process.exitCode = 1; }
}

tests/api.test.mjs

import test from 'node:test';
import assert from 'node:assert/strict';
import { startMock } from '../src/automation/mock-api.mjs';
import { request, listTasks } from '../src/automation/api-client.mjs';

test('localhost 인증·페이지 조회·POST를 검증한다', async () => {
  const mock = await startMock();
  try {
    const tasks = await listTasks(mock.url, 'local-training-only');
    assert.equal(tasks.length, 3);
    const created = await request(mock.url + '/tasks', { token: 'local-training-only', method: 'POST', body: { sourceId: 'sample-new', title: '모의 생성' } });
    assert.equal(created.sourceId, 'sample-new');
    await assert.rejects(request(mock.url + '/tasks', { token: 'wrong' }), (e) => e.status === 401);
    await assert.rejects(request(mock.url + '/missing', { token: 'local-training-only' }), (e) => e.status === 404);
  } finally { await mock.close(); }
});
test('외부 주소·반복 cursor·timeout을 차단한다', async () => {
  await assert.rejects(request('https://example.com/tasks', {}), /127\.0\.0\.1/);
  const mock = await startMock({ repeatCursor: true });
  try { await assert.rejects(listTasks(mock.url, 'local-training-only'), /cursor/); }
  finally { await mock.close(); }
  const slow = await startMock({ delay: 80 });
  try { await assert.rejects(request(slow.url + '/tasks', { token: 'local-training-only', timeoutMs: 5 })); }
  finally { await slow.close(); }
});

tests/core.test.mjs

import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { normalizeTask } from '../src/automation/normalize-task.mjs';
import { run } from '../src/automation/run.mjs';

test('sourceId와 제목을 보존하고 공백을 정규화한다', () => {
  assert.deepEqual(normalizeTask({ sourceId: 'sample-1', title: '  점검  ' }),
    { sourceId: 'sample-1', title: '점검', status: 'todo', dueAt: null });
});
test('빈 제목과 잘못된 식별자를 거부한다', () => {
  assert.throws(() => normalizeTask({ sourceId: 'sample-1', title: ' ' }));
  assert.throws(() => normalizeTask({ sourceId: '../unsafe', title: '점검' }));
});
test('한 건 처리와 동일 입력의 별도 실행 기록을 확인한다', async () => {
  const root = await mkdtemp(join(tmpdir(), 'taskflow-core-'));
  const input = { sourceId: 'sample-1', title: ' 점검 ' };
  const first = await run([input], { root });
  const second = await run([input], { root });
  assert.equal(first.successCount, 1);
  assert.notEqual(first.runId, second.runId);
  const output = JSON.parse(await readFile(join(root, 'output', first.runId, 'valid.json'), 'utf8'));
  assert.equal(output[0].sourceId, 'sample-1');
});
test('검증 실패는 rejected와 실패 상태로 남는다', async () => {
  const root = await mkdtemp(join(tmpdir(), 'taskflow-invalid-'));
  const result = await run([{ sourceId: 'sample-2', title: '' }], { root });
  assert.equal(result.status, 'failed');
  assert.equal(result.failedCount, 1);
  const rejected = JSON.parse(await readFile(join(root, 'output', result.runId, 'rejected.json'), 'utf8'));
  assert.equal(rejected[0].row, 1);
  assert.equal(rejected[0].field, 'title');
});

tests/files.test.mjs

import test from 'node:test';
import assert from 'node:assert/strict';
import { parseCSV } from '../src/automation/read-csv.mjs';
import { readJSON } from '../src/automation/read-json.mjs';
import { run } from '../src/automation/run.mjs';
import { mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

test('통제된 UTF-8 CSV를 읽는다', () => {
  assert.deepEqual(parseCSV('sourceId,title,status,dueAt\r\nsample-1,한글,todo,\r\n'), [{ sourceId: 'sample-1', title: '한글', status: 'todo', dueAt: null }]);
});
test('따옴표 CSV와 필드 수·중복 헤더 오류를 명시 거부한다', () => {
  for (const raw of ['sourceId,title\na,"b,c"', 'sourceId,title\na,b,c', 'sourceId,sourceId\na,b']) assert.throws(() => parseCSV(raw));
});
test('JSON과 CSV의 동일 모델, 잘못된 행과 중복 분리', async () => {
  const input = await readJSON(new URL('../automation/input/tasks.json', import.meta.url));
  const root = await mkdtemp(join(tmpdir(), 'taskflow-batch-'));
  const result = await run(input, { root });
  assert.equal(result.successCount, 2);
  assert.equal(result.failedCount, 1);
  assert.equal(result.skippedCount, 1);
  assert.equal(result.status, 'partial_failure');
});

독자 확인: HTTP 200과 업무 처리 성공은 같은 뜻일까요?

아래는 비식별 로컬 예제 또는 설계표를 직접 점검하기 위한 절차입니다. 이전에 기록된 로컬 테스트 결과와 구분해 읽으세요.

확인 항목 실습과 판단 기준
입력 4회차의 localhost 모의 API에서 두 페이지의 Task와 cursor를 받아오고, 비식별 신규 Task 한 건을 POST하는 예제를 사용합니다.
처리 GET 요청별 status, cursor, 다음 cursor, 누적 sourceId를 기록합니다. POST는 응답 ID까지 확인합니다. 이어 401·429·반복 cursor·timeout을 로컬 fixture로 구분합니다.
예상 결과 제공된 demo의 예상 GET 집계는 2페이지·3건, POST 응답 ID는 sample-new입니다. 2xx 이후에도 JSON 파싱·필드 검증이 실패하면 업무 성공으로 기록하지 않습니다.
같은 입력 재실행 동일 cursor가 반복되면 무한 요청 전에 중단합니다. POST 응답을 받지 못했을 때 바로 재전송하면 중복 생성될 수 있으므로 서버 결과 조회 또는 멱등성 지원 여부를 먼저 확인합니다.
실패와 복구 401·403은 자격 증명과 권한 확인 후 중단, 429는 서버의 제한 정보를 기록합니다. 비밀 토큰이나 Authorization 값을 로그에 남기지 않습니다.
다음 학습 5회차에서 외부 필드명을 내부 Task 스키마로 매핑·검증합니다.

이번 편집의 검증 범위: 문서와 공개 본문 반영을 확인합니다. 이 회차의 자동화·외부 서비스·예약 작업을 이번 편집에서 새로 실행하지 않았으므로 실행 결과는 미확인입니다. 위 수치는 기존 예제의 예상값이며 실제 환경에서 직접 확인해야 합니다.

실행과 검증 결과

프로젝트 루트에서 각각 실행합니다. 설치 준비는 1회차를 참고하며 새 의존성은 없습니다.

node --test
node src/automation/demo-api.mjs

누적 테스트 9개가 통과했습니다. 테스트는 localhost 페이지 조회와 HTTP 오류·반복 cursor·timeout·외부 주소 거절을 확인합니다. demo-api 명령의 기대 결과는 GET 2페이지 3건을 처리한 successCount=3과 로컬 POST 응답 sample-new입니다. 모의 서버는 실행 안에서 열고 닫습니다.

검증 결과는 Node.js 24와 비식별 로컬 모의 환경에 한정됩니다. 실제 외부 API·제공자 서명·운영 인증·예약 실행은 미검증입니다.

완료 체크포인트

automation-step-04-api-client

다음 학습

5회차 데이터 매핑 검증 자동화 입력 스키마와 실패 데이터 분리

실습 파일 다운로드

automation-step-04-api-client 전체 파일 ZIP

시작 체크포인트: 3회차 전체 파일 ZIP

합성 데이터와 로컬 파일·localhost 모의 서버로 실행합니다. 외부 서비스 쓰기·실제 알림·운영 예약 작업은 실행하지 않습니다.

글 소개·학습 준비·전체 목차
열쇠와 문서 묶음으로 표현한 안전한 API 데이터 연결 표지

업무·자동화 · 4 / 11

REST API 업무 자동화 fetch 인증 페이지네이션 기준

파일 기반 자동화를 HTTP API와 연결합니다. Node.js 내장 fetch를 기준으로 GET/POST 요청, 상태 코드 검사, JSON 응답 파싱, 인증정보 환경변수 관리, 페이지네이션 반복을 구현합니다.

상단 실습은 이전 TaskFlow 파일 없이 시작합니다. 아래 누적 프로젝트에서는 같은 원리를 파일 입력·API·복구 기록에 차례로 적용합니다.

이번 글에서 배우는 내용

  • REST API 요청/응답 구조
  • fetch의 HTTP 오류 처리: 네트워크 오류와 4xx/5xx 차이
  • Authorization header와 Secret 관리
  • query parameter와 pagination
  • 요청 timeout/cancel 개념은 소개하되 재시도는 8회차로 분리
  • API 응답을 바로 업무 데이터로 신뢰하지 않고 다음 회차 검증으로 넘기는 이유

선수 지식

async/await, JSON과 HTTP 요청·응답의 기본 용어가 필요합니다.

목차

  1. 핵심 원리
  2. 독립 실습: HTTP 성공과 업무 성공 구분
  3. 이 실습의 연결 범위
  4. 자주 발생하는 오류와 해결
  5. 학습 완료 체크리스트
  6. TaskFlow Ops Automation 누적 적용
  7. 실습 프로젝트 전체 코드
  8. 실행과 검증 결과
  9. 완료 체크포인트
  10. 다음 학습

이 글이 도움이 되었나요?

조회 중

분류별 과정 이동 (선택)

읽음 기록

읽음 — / 전체 11개 · 필수 11개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기