Webhook 자동화 이벤트 수신과 중복 처리 기준

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

Webhook에서 eventId와 sourceId는 어떻게 다르게 사용하나요?

Webhook은 변경이 생겼을 때 제공자가 이벤트를 보내는 방식입니다. 전달 사건의 eventId와 업무 대상의 sourceId를 구분해야 재전송과 새 변경을 다르게 처리할 수 있습니다.

적용 조건·범위: 서명 검증은 제공자가 정한 header·algorithm·원본 body 규칙을 따라야 합니다. JSON을 파싱해 다시 문자열로 만든 body는 원본 바이트와 달라 서명이 맞지 않을 수 있습니다. 로컬 POST 테스트는 실제 provider 서명 검증을 통과했다는 증거가 아닙니다. GitHub Webhook 운영 원칙

본문 근거: GitHub Webhook 운영 원칙

eventId와 업무 sourceId를 구분하는 수신 모의

이 예제는 Node.js 24 한 프로세스에서 이미 검증된 이벤트를 처리하는 모의 실습입니다. 서버를 열거나 서명 검증을 구현하지 않습니다. 실제 수신기에서는 이 함수 앞에 제공자의 인증·서명 검증과 영속적인 수신 기록이 필요합니다.

event-demo.mjs 전체 코드

const seen = new Set();
function receive(event) {
  if (!event || typeof event.eventId !== 'string' || !event.eventId.trim() ||
      typeof event.sourceId !== 'string' || !event.sourceId.trim()) {
    return 'invalid';
  }
  if (seen.has(event.eventId)) return 'duplicate';
  seen.add(event.eventId);
  return `accepted ${event.eventId} / ${event.sourceId}`;
}
console.log(receive({ eventId: 'e1', sourceId: 'task-a' }));
console.log(receive({ eventId: 'e1', sourceId: 'task-a' }));
console.log(receive({ eventId: 'e2', sourceId: 'task-a' }));
console.log(receive({ sourceId: 'task-a' }));

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

accepted e1 / task-a
duplicate
accepted e2 / task-a
invalid

직접 바꿔 보기: e2는 같은 업무에 대한 새 변경일 수 있어 eventId 기준에서는 허용됩니다. sourceId만 Set에 넣으면 어떤 정상 변경을 놓칠지 설명하세요. 이 Set은 프로세스 종료 시 사라지며 업무 처리 성공이나 영속적 중복 방지를 보장하지 않습니다.

핵심 원리

Webhook은 상대 시스템이 이벤트를 밀어 넣는 구조입니다. 같은 이벤트가 여러 번 도착할 수 있고 순서가 바뀔 수도 있으므로 eventId와 처리 상태를 기준으로 중복을 막습니다.

이번 단계부터는 정상 경로만으로 자동화를 평가하지 않습니다. 중복 이벤트, 네트워크 실패, 늦은 응답, 부분 성공, 재실행이 실제 운영에서 더 중요한 조건이 됩니다.

독립 실습: 같은 이벤트가 다시 오는 경우

코드 직접 실행

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

예제 크게 보기 (새 탭)

Polling은 주기적으로 변경을 조회하고 Webhook은 제공자가 이벤트를 보내는 방식입니다. Webhook의 eventId는 전달 사건을, payload의 sourceId는 업무 대상을 식별합니다. 둘을 같은 ID라고 가정하지 않습니다.

전달 상황 판단
새 eventId와 정상 payload 검증 후 처리 후보
같은 eventId 재전송 기존 처리 상태 확인
다른 eventId, 같은 sourceId 새 변경인지 같은 업무인지 operation 기준 확인
서명 불일치 검증 실패, 업무 처리 금지
오래된 processing 결과를 먼저 조회하고 복구 판단

서명 검증은 제공자가 정한 header·algorithm·원본 body 규칙을 따라야 합니다. JSON을 파싱해 다시 문자열로 만든 body는 원본 바이트와 달라 서명이 맞지 않을 수 있습니다. 로컬 POST 테스트는 실제 provider 서명 검증을 통과했다는 증거가 아닙니다. GitHub Webhook 운영 원칙

수신 확인 응답과 업무 완료를 분리합니다. 오래 걸리는 작업은 검증·안전한 수신 기록 후 별도 처리하는 구조를 고려하되, 저장 전에 2xx를 보내고 중단되면 이벤트를 잃을 수 있다는 경계를 알아야 합니다. 이 회차의 localhost 서버를 인터넷에 그대로 공개하지 않습니다.

자주 발생하는 오류와 해결

처리 전에 완료 Set에 넣으면 처리 실패 후 같은 이벤트가 영구히 건너뛰어질 수 있습니다. 진행 중과 완료 상태를 구분하고 성공한 후에 완료를 남깁니다. 실패한 이벤트는 재전송으로 다시 시도할 수 있어야 합니다. 메모리 상태는 재시작 후 유지되지 않습니다.

학습 완료 체크리스트

  • 같은 eventId는 duplicate, 다른 eventId와 같은 sourceId는 accepted가 되는지 확인했습니다.
  • 필수 ID 누락은 invalid이며 accepted가 실제 업무 완료를 뜻하지 않음을 설명할 수 있습니다.
  • 메모리 Set의 한계와 제공자 서명 검증이 별도라는 점을 구분했습니다.
선택 실습: 같은 프로젝트로 연습하기

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

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

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

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

TaskFlow Ops Automation 누적 적용

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

  • localhost 수신기에서 처리 실패 뒤 재전송을 다시 처리하는지 확인합니다.
  • 수신 응답과 실제 처리 완료를 구분하며 실제 제공자 서명은 검증 범위에 포함되지 않음을 확인합니다.

이어서 시작하기: 데이터 매핑 검증 자동화 입력 스키마와 실패 데이터 분리의 완료 상태를 사용합니다. 중간 합류용 시작 ZIP과 이번 완료 ZIP은 아래 기존 다운로드 안내에서 구분해 선택합니다.

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

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

이번 추가 파일: src/automation/demo-webhook.mjs, src/automation/webhook.mjs, tests/webhook.test.mjs.

이번 변경 파일: README.md.

webhook.mjs와 demo-webhook.mjs를 추가합니다. 진행 중과 완료 상태를 분리하고 처리 실패는 completed에 남기지 않습니다. 이 상태는 메모리에 있으므로 프로세스 재시작 이후의 중복 방지는 9회차 범위입니다.

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

전체 파일·코드 보기

실습 프로젝트 전체 코드

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

automation/input/external-tasks.json

[
  { "id": "external-1", "name": " API 작업 ", "due_at": "2026-09-21T00:00:00Z" },
  { "id": "external-2", "name": "" },
  { "id": "external-3", "name": "날짜 오류", "due_at": "2026-02-30T00:00:00Z" },
  { "id": "external-4", "name": "상태 오류", "status": "unknown" }
]

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 · 06 로컬 Webhook

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는 메모리의 모의 응답만 반환하며 실제 저장 서비스와 연결하지 않습니다.

## 05 외부 필드 매핑

    node src/automation/batch.mjs automation/input/external-tasks.json

예상: successCount=1, failedCount=3, exit 1. id→sourceId, name→title, due_at→dueAt을 매핑합니다. 내부/외부 이름이 동시에 있으며 값이 다르면 추측하지 않고 거부합니다. title 숫자를 문자열로 바꾸지 않으며 잘못된 달력 날짜와 미정의 status도 거부합니다. rejected에 원본 비식별 입력과 field/reason을 남깁니다. API·파일·다음 Webhook 모두 동일 normalizeTask를 사용합니다.

## 06 로컬 Webhook

    node src/automation/demo-webhook.mjs

같은 이벤트 2회 전달 결과 completed → duplicate. 처리 실패는 completed에 기록하지 않으므로 재시도가 가능하고, 진행 중 동일 eventId는 409입니다. 동일 eventId에 다른 내용도 409입니다. 테스트에서 실제 로컬 POST·인증오류·잘못된 JSON 계약·실패 후 재시도·동시 수신을 확인합니다.

local-webhook-only는 공개 교육 문자열이며 제공자 서명이 아닙니다. 실제 서비스 연결 때는 제공자 공식 raw body·서명 검증과 Secret 관리가 필요합니다. 현재 Map/Set은 프로세스 종료 시 사라집니다. 긴 작업은 수신 응답 전에 모두 처리하면 timeout 위험이 있으므로 운영에서는 영속 큐에 안전히 접수한 뒤 응답하는 별도 설계가 필요합니다. 이 예제는 짧은 로컬 처리 후 200을 반환합니다.

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/demo-webhook.mjs

import { startWebhook } from './webhook.mjs';
const hook = await startWebhook();
try {
  const event = { eventId: 'event-demo-1', eventType: 'task.created', data: { sourceId: 'sample-webhook', title: '모의 이벤트' } };
  for (let i = 0; i < 2; i += 1) {
    const response = await fetch(hook.url + '/automation/webhook', { method: 'POST', headers: { 'content-type': 'application/json', 'x-training-key': 'local-webhook-only' }, body: JSON.stringify(event) });
    if (!response.ok) throw new Error('Webhook 실패');
    console.log(await response.json());
  }
} catch { console.error('로컬 Webhook 실습 실패'); process.exitCode = 1; }
finally { await hook.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 mapped = { ...input };
  for (const [external, internal] of [['id', 'sourceId'], ['name', 'title'], ['due_at', 'dueAt']]) {
    if (external in input) {
      if (internal in input && input[internal] !== input[external]) throw invalid(internal, '서로 충돌하는 필드가 있습니다.');
      mapped[internal] = input[external];
    }
  }
  const { sourceId, title, status = 'todo', dueAt = null } = mapped;
  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 ?? input?.id ?? 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; }
}

src/automation/webhook.mjs

import { createServer } from 'node:http';
import { normalizeTask } from './normalize-task.mjs';
import { run } from './run.mjs';
import { listenLocal, readBody, send } from './local-http.mjs';
export function createEventHandler(processTask) {
  const completed = new Map(), inflight = new Set();
  return async (event) => {
    if (!event || typeof event.eventId !== 'string' || !/^[a-zA-Z0-9_-]{1,80}$/.test(event.eventId) || event.eventType !== 'task.created') throw Object.assign(new Error('이벤트 계약 오류'), { status: 400 });
    const task = normalizeTask(event.data);
    const fingerprint = JSON.stringify(task);
    if (completed.has(event.eventId)) {
      if (completed.get(event.eventId) !== fingerprint) throw Object.assign(new Error('eventId 내용 충돌'), { status: 409 });
      return { eventId: event.eventId, status: 'duplicate' };
    }
    if (inflight.has(event.eventId)) throw Object.assign(new Error('처리 중'), { status: 409 });
    inflight.add(event.eventId);
    try {
      await processTask(task, event.eventId);
      completed.set(event.eventId, fingerprint);
      return { eventId: event.eventId, status: 'completed' };
    } finally { inflight.delete(event.eventId); }
  };
}
export async function startWebhook({ processTask = (task) => run([task], { trigger: 'webhook' }) } = {}) {
  const handle = createEventHandler(processTask);
  const server = createServer(async (req, res) => {
    if (req.method !== 'POST' || req.url !== '/automation/webhook') return send(res, 404, { error: 'not_found' });
    if (req.headers['x-training-key'] !== 'local-webhook-only') return send(res, 401, { error: 'unauthorized' });
    let event;
    try { event = await readBody(req); }
    catch { return send(res, 400, { error: 'invalid_json_or_size' }); }
    try { send(res, 200, await handle(event)); }
    catch (error) { send(res, error.status ?? (error.field ? 400 : 500), { error: error.status === 409 ? 'conflict_or_inflight' : 'processing_failed' }); }
  });
  return listenLocal(server);
}

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');
});

tests/mapping.test.mjs

import test from 'node:test';
import assert from 'node:assert/strict';
import { normalizeTask } from '../src/automation/normalize-task.mjs';
test('외부 id/name/due_at를 내부 계약으로 매핑한다', () => {
  assert.deepEqual(normalizeTask({ id: 'external-1', name: ' 점검 ', due_at: '2026-09-21T00:00:00Z' }), { sourceId: 'external-1', title: '점검', status: 'todo', dueAt: '2026-09-21T00:00:00Z' });
});
test('모호한 중복 필드, 잘못된 날짜·상태·타입을 거부한다', () => {
  for (const patch of [{ id: 'other' }, { dueAt: '2026-02-30T00:00:00Z' }, { status: 'unknown' }, { title: 12 }]) {
    assert.throws(() => normalizeTask({ sourceId: 'sample-1', title: '점검', ...patch }));
  }
});

tests/webhook.test.mjs

import test from 'node:test';
import assert from 'node:assert/strict';
import { startWebhook, createEventHandler } from '../src/automation/webhook.mjs';
const event = { eventId: 'event-1', eventType: 'task.created', data: { sourceId: 'sample-1', title: '이벤트 점검' } };
test('실제 로컬 HTTP 수신·중복·인증·잘못된 payload', async () => {
  let count = 0;
  const hook = await startWebhook({ processTask: async () => { count += 1; } });
  const post = (body, token = 'local-webhook-only') => fetch(hook.url + '/automation/webhook', { method: 'POST', headers: { 'content-type': 'application/json', 'x-training-key': token }, body: JSON.stringify(body) });
  try {
    assert.equal((await post(event)).status, 200);
    assert.equal((await (await post(event)).json()).status, 'duplicate');
    assert.equal(count, 1);
    assert.equal((await post(event, 'wrong')).status, 401);
    assert.equal((await post({ eventId: 'bad' })).status, 400);
  } finally { await hook.close(); }
});
test('실패한 이벤트는 다시 처리하고 동시에 들어온 이벤트는 막는다', async () => {
  let attempts = 0;
  const handler = createEventHandler(async () => { if (++attempts === 1) throw new Error('simulated'); });
  await assert.rejects(handler(event));
  assert.equal((await handler(event)).status, 'completed');
  assert.equal(attempts, 2);
  await assert.rejects(handler({ ...event, data: { ...event.data, title: '충돌' } }), (e) => e.status === 409);
  let release;
  const gate = new Promise((resolve) => { release = resolve; });
  const slow = createEventHandler(() => gate);
  const first = slow(event);
  await assert.rejects(slow(event), (e) => e.status === 409);
  release();
  await first;
});

독자 확인: Webhook 제공자가 같은 이벤트를 다시 보내면 어떻게 되나요?

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

확인 항목 실습과 판단 기준
입력 로컬 demo-webhook.mjs의 교육용 eventId 하나를 동일 payload로 두 번 전송하는 시나리오를 사용합니다. 실제 제공자 Secret은 사용하지 않습니다.
처리 수신 status, eventId, 검증 결과, 업무 결과 ID를 첫 요청과 재전송에서 나란히 기록합니다. 다른 payload에 같은 eventId를 넣은 충돌도 별도 시험 항목으로 둡니다.
예상 결과 로컬 예제의 기대 상태는 첫 요청 completed, 같은 이벤트 재전송 duplicate입니다. 충돌한 이벤트는 기존 성공으로 포장하지 않고 확인 대상으로 분리합니다.
같은 입력 재실행 메모리 기반 또는 로컬 저장 중복 방지는 다중 서버·프로세스 재시작의 보장을 뜻하지 않습니다. 운영에서는 지속 저장소의 원자적 키 예약과 제공자 문서의 서명 검증이 필요합니다.
실패와 복구 처리 도중 실패한 eventId는 completed로 기록하지 않고 재전송 시 재개 경로를 확인합니다. 2xx 응답을 먼저 줬는데 후처리가 실패한 경우 재전송 여부와 실패 큐를 별도로 설계합니다.
다음 학습 7회차에서 Webhook과 달리 시간으로 시작되는 예약 실행을 다룹니다.

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

실행과 검증 결과

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

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

누적 테스트 13개가 통과했습니다. 실제 localhost POST로 정상·중복·오류·충돌·실패 후 재전송·동시 수신을 확인했습니다. demo-webhook 명령의 기대 출력은 같은 이벤트에 completed → duplicate입니다. 공개 교육 문자열은 실제 Secret이나 제공자 서명이 아닙니다.

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

완료 체크포인트

automation-step-06-webhook

다음 학습

7회차 예약 실행 자동화 cron 타임존 중복 실행 방지

실습 파일 다운로드

automation-step-06-webhook 전체 파일 ZIP

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

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

글 소개·학습 준비·전체 목차
알림 종과 수신함으로 표현한 웹훅 이벤트와 중복 방지 표지

업무·자동화 · 6 / 11

Webhook 자동화 이벤트 수신과 중복 처리 기준

Polling과 Webhook의 차이를 이해하고, 외부 이벤트를 받아 validation 계층을 거쳐 처리하는 이벤트 기반 자동화를 만듭니다. 응답 시간, 재전송, event ID, Secret 검증의 기본 경계를 학습합니다.

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

이번 글에서 배우는 내용

  • Polling vs Webhook
  • endpoint와 event payload
  • 빠른 2xx 응답과 비동기 후처리 개념
  • 제공자가 이벤트를 재전송할 수 있는 이유
  • event ID가 중복 방지에 필요한 이유
  • Webhook 서명 검증에서 자체 암호화 로직을 임의로 만들지 않는 원칙
  • request body 원문(raw body)이 필요한 제공자도 있다는 점

선수 지식

HTTP POST와 JSON, 비동기 함수의 성공·실패를 이해해야 합니다.

목차

  1. 핵심 원리
  2. 독립 실습: 같은 이벤트가 다시 오는 경우
  3. 자주 발생하는 오류와 해결
  4. 학습 완료 체크리스트
  5. TaskFlow Ops Automation 누적 적용
  6. 실습 프로젝트 전체 코드
  7. 실행과 검증 결과
  8. 완료 체크포인트
  9. 다음 학습

이 글이 도움이 되었나요?

조회 중

읽음 기록

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

이 글을 쓴 해비

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

새 글 받아보기

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

RSS 피드 구독하기

댓글 남기기