작은 Node.js 자동화에서 입력 검증과 실행 안의 중복 방지는 어떻게 시작하나요?
작은 자동화는 입력 한 건을 확인하고 같은 규칙으로 정리한 뒤 결과를 남기는 것부터 시작합니다. 제목 공백 제거와 필수값 검사를 먼저 정하고, 실행 ID와 업무 대상을 구분하세요.
적용 조건·범위: 단일 task.json 입력, normalizeTask 검증, 실행별 결과 폴더와 집계 로그를 추가합니다. 실행 안의 sourceId 중복만 건너뛰며 영속 상태는 아직 없습니다.
공식 문서 참고: OWASP: 파싱 후 타입·필수값·허용값 검증 · MDN: Set의 중복 없는 값과 has·add
독립 실습: 입력과 출력의 계약
코드 직접 실행
본문 예제를 선택하고 실제 계산 결과를 확인합니다.
예제 크게 보기 (새 탭)TaskFlow를 몰라도 입력 한 건을 정규화하는 규칙부터 정할 수 있습니다. sourceId는 비어 있지 않은 문자열, title은 앞뒤 공백을 제거한 비어 있지 않은 문자열로 정합니다. 실행마다 새로 만든 runId를 업무 중복 판정 키로 쓰면 같은 입력도 매번 다른 작업이 되므로 구분합니다.
| 항목 | 역할 | 예 |
|---|---|---|
| sourceId | 원본 업무의 식별자 | sample-1 |
| title | 정규화할 업무 제목 | 공백을 제거한 제목 |
| runId | 이번 실행의 추적 식별자 | 실행마다 새로 기록 |
| 결과 | 처리 성공 여부와 출력 위치 | 성공·실패를 구분 |
다음은 중복 입력을 관찰하는 독립 실행 예제입니다. duplicate-demo.mjs로 저장해 node duplicate-demo.mjs로 실행할 수 있습니다. 메모리 Set은 한 번의 실행 안에서만 중복을 거릅니다.
const tasks = [{ sourceId: 'sample-1' }, { sourceId: 'sample-1' }];
const processed = new Set();
for (const task of tasks) {
if (processed.has(task.sourceId)) {
console.log('duplicate', task.sourceId);
continue;
}
processed.add(task.sourceId);
console.log('processed', task.sourceId);
}
기대 출력은 processed 한 번과 duplicate 한 번입니다. 프로그램을 다시 실행하면 Set이 새로 생기므로 실행 간 멱등성이 완성된 것은 아닙니다. 지속 상태와 복구는 9회차에서 다룹니다.
혼자 판단하고 확인하기
입력 배열 끝에 sourceId가 sample-2인 한 건을 추가해 출력이 어떻게 바뀌는지 예상하고 실행합니다.
확인 기준: sample-1의 processed·duplicate에 이어 sample-2는 processed로 출력됩니다. 프로그램을 다시 실행하면 Set이 새로 만들어지므로 이전 실행을 기억하지 못합니다.
핵심 원리
첫 자동화는 작은 입력 하나를 같은 규칙으로 반복 처리하는 것부터 시작합니다. 재실행했을 때 같은 결과가 중복 생성되지 않는지 확인하면 이후 멱등성 개념으로 연결하기 쉽습니다.
자동화는 한 번 성공하는 스크립트와 반복 운영 가능한 파이프라인이 다릅니다. 다음 회차로 갈수록 입력과 결과뿐 아니라 실패·재시도·중복 실행·감사 기록까지 관리합니다.
자주 발생하는 오류와 해결
입력 검증에 실패하면 rejected 결과와 종료 코드 1을 함께 확인합니다. 새 runId로 두 번 실행해 결과가 두 세트 생기는 것은 이 단계의 관찰 대상입니다. 파일 저장이 실패했는데 성공으로 종료하지 않도록 결과 저장까지 await합니다.
학습 완료 체크리스트
- 같은 실행에서 sample-1이 processed 한 번·duplicate 한 번으로 출력되는지 확인했습니다.
- sample-2를 추가하면 별개의 업무 ID로 처리되는지 확인했습니다.
- 프로그램을 다시 시작하면 Set이 초기화되어 실행 간 중복 방지는 제공하지 않음을 설명할 수 있습니다.
선택 실습: 같은 프로젝트로 연습하기
TaskFlow Ops Automation은 작업 처리 상태와 실패·재시도를 관리하는 자동화 예제입니다. 이 선택 실습에서는 처리 흐름을 이어서 만듭니다.
이번 주제: 작은 자동화 실습 입력 처리 결과와 중복 방지.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow Ops Automation 누적 적용
프로젝트에서 추가로 확인할 항목
- 정상 입력의 title 앞뒤 공백 제거와 잘못된 입력의 필드·이유를 확인합니다.
- 실행별 runId와 결과 파일을 비교하고 실행 간 멱등성이 아직 없음을 확인합니다.
이어서 시작하기: 업무 자동화 도구 비교 Zapier Make n8n과 코드 자동화 기준의 완료 상태를 사용합니다. 중간 합류용 시작 ZIP과 이번 완료 ZIP은 아래 기존 다운로드 안내에서 구분해 선택합니다.
시작 체크포인트: automation-step-01-tool-choice. 이전 회차의 로컬 실습을 이어가거나 이번 회차의 완성 ZIP을 새 폴더에 풀어 시작할 수 있습니다.
현재 파일 구조는 아래 전체 코드의 경로 목록과 같습니다. 생성된 output·logs는 실행 결과이며 배포 소스가 아닙니다. 원본 input 파일을 덮어쓰지 않습니다.
이번 추가 파일: automation/input/task.json, src/automation/normalize-task.mjs, src/automation/paths.mjs, src/automation/run.mjs, tests/core.test.mjs.
이번 변경 파일: package.json, README.md.
단일 task.json 입력, normalizeTask 검증, 실행별 결과 폴더와 집계 로그를 추가합니다. 실행 안의 sourceId 중복만 건너뛰며 영속 상태는 아직 없습니다.
Before는 이전 체크포인트까지의 기능만 있는 상태입니다. 적용은 위 파일을 해당 경로에 저장하고 README 순서대로 실행하는 단계이며, After는 아래 기대 결과와 실패 경로를 설명할 수 있는 상태입니다. 이미 수정한 파일이 있다면 새 ZIP으로 덮어쓰지 말고 별도 폴더에서 비교합니다.
전체 파일·코드 보기
실습 프로젝트 전체 코드
이 체크포인트는 Node.js 24 이상의 로컬 비식별 실습입니다. 외부 전송과 실제 예약 실행은 포함하지 않습니다.
automation/input/task.json
{
"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 · 02 첫 파이프라인
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입니다. 로그는 집계만 남기며 실제 개인정보는 입력하지 않습니다. 테스트는 임시 폴더에 작은 교육 결과를 남깁니다.
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/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/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');
});
독자 확인: 같은 입력을 두 번 실행하면 어떤 결과가 남을까요?
아래는 비식별 로컬 예제 또는 설계표를 직접 점검하기 위한 절차입니다. 이전에 기록된 로컬 테스트 결과와 구분해 읽으세요.
| 확인 항목 | 실습과 판단 기준 |
|---|---|
| 입력 | automation/input/task.json의 sourceId를 sample-1로 유지하고 title 앞뒤에 공백을 넣습니다. |
| 처리 | 문서의 node src/automation/run.mjs 명령을 한 번 실행한 뒤 같은 파일로 한 번 더 실행하도록 안내합니다. 각 회차의 runId, normalized title, successCount와 출력 경로를 적습니다. |
| 예상 결과 | 정상 입력의 예상값은 매 실행 successCount=1, failedCount=0, 공백이 제거된 제목입니다. 서로 다른 runId는 서로 다른 실행을 뜻하며 중복 방지의 증거가 아닙니다. |
| 같은 입력 재실행 | 두 실행의 sourceId가 같아도 결과 파일 또는 외부 부작용이 한 번만 생성됐다고 단정하지 않습니다. 이번 회차는 중복 가능성을 발견하고 9회차의 멱등성 설계 항목에 기록합니다. |
| 실패와 복구 | title을 공백만 남기고 실행해 성공 결과를 만들지 않는지 확인합니다. 오류 원문·exit code와 이전 성공 파일 보존 여부를 비교한 뒤 입력을 고쳐 다시 시작합니다. |
| 다음 학습 | 3회차에서 입력을 JSON·CSV 여러 행으로 늘리고 실패 행을 분리합니다. |
이번 편집의 검증 범위: 문서와 공개 본문 반영을 확인합니다. 이 회차의 자동화·외부 서비스·예약 작업을 이번 편집에서 새로 실행하지 않았으므로 실행 결과는 미확인입니다. 위 수치는 기존 예제의 예상값이며 실제 환경에서 직접 확인해야 합니다.
실행과 검증 결과
ZIP을 푼 프로젝트 루트에서 다음 명령을 각각 실행합니다. Node.js 24 기준이며 외부 패키지 설치는 필요하지 않습니다.
node --test
node src/automation/run.mjs
node src/automation/run.mjs
테스트 4개가 통과했습니다. 정상 입력 CLI는 successCount=1, failedCount=0이며 재실행에는 서로 다른 runId가 생성되었습니다. 이는 정상 실행 증거이며 실행 간 중복 결과 방지를 구현했다는 뜻은 아닙니다.
검증은 비식별 로컬 실습 범위입니다. 실제 외부 API·인증·메시지·게시·운영 예약 실행은 포함하지 않습니다.
완료 체크포인트
automation-step-02-first-pipeline
다음 학습
3회차 CSV JSON 파일 자동화 Node.js 읽기 검증 변환 저장
실습 파일 다운로드
automation-step-02-first-pipeline 전체 파일 ZIP
합성 데이터와 로컬 파일·localhost 모의 서버로 실행합니다. 외부 서비스 쓰기·실제 알림·운영 예약 작업은 실행하지 않습니다.
글 소개·학습 준비·전체 목차

업무·자동화 · 2 / 11
작은 자동화 실습 입력 처리 결과와 중복 방지
자동화의 가장 작은 단위를 직접 구현합니다. sample JSON 한 건을 입력받아 필수 필드를 확인하고 정규화한 뒤 결과 파일과 실행 로그를 남깁니다. 멱등성은 깊게 구현하지 않고 “같은 입력이 다시 들어올 수 있다”는 문제를 처음 경험하는 수준으로 다룹니다.
상단 실습은 이전 TaskFlow 파일 없이 시작합니다. 아래 누적 프로젝트에서는 같은 원리를 파일 입력·API·복구 기록에 차례로 적용합니다.
이번 글에서 배우는 내용
- 자동화의 입력 계약과 출력 계약
- 순수 처리 로직과 파일 입출력 분리
- process exit code의 의미
- “처리 성공”과 “파일 저장 성공”을 구분하는 방법
- 실행 로그에 민감정보를 그대로 남기지 않는 기준
- 작은 스크립트를 여러 함수로 분리하는 이유
선수 지식
Node.js 실행과 JavaScript 객체·배열의 기초가 필요합니다. 설치는 1회차를 참고합니다.
목차
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 11개 · 필수 11개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.