이 글에서 배우는 내용
로컬 JSON 작업 목록을 읽어 제목 공백을 정리하고 중복 ID를 검사한 뒤 보고서 한 개를 만듭니다. 같은 입력을 다시 실행해도 결과가 늘어나지 않는 멱등성, 충돌 입력 중단, 실패 때 기존 결과 보존을 계정 연결 없이 연습합니다.
목차
- 1. 작은 자동화의 입출력 계약을 정합니다
- 2. 같은 작업을 구분할 ID를 준비합니다
- 3. 검증·정규화·저장을 분리합니다
- 4. 두 번 실행하고 결과를 비교합니다
- 5. 실패를 재현하고 적용 범위를 정합니다
입력 계약을 바꿔 보는 추적 과제
선수 지식은 JSON 배열, JavaScript 함수와 객체, 터미널에서 Node.js 파일을 실행하는 방법입니다. 이 실습의 완성 결과는 output/report.json 한 파일이며 메일이나 외부 서비스에 보내는 기능은 없습니다. 코드 전체는 아래의 input.json, automate.cjs, test.cjs로 나누어 저장합니다.
아래 표를 먼저 예상한 뒤 실제 input.json을 수정해 확인하세요. 원본 샘플은 별도 사본에 보관하고 출력이 언제 변경되는지 관찰합니다. 같은 ID에 내용을 수정하는 것과 중복 행 사이에서 내용이 충돌하는 것은 다른 상황입니다.
| 입력 변화 | 기대 결과 | 그 이유 |
|---|---|---|
| 같은 항목을 반대 순서로 제공 | unchanged | 출력은 ID 순으로 정렬됨 |
| 같은 ID·같은 정규화 내용 반복 | 항목 수 유지 | 한 작업으로 병합 |
| 같은 ID의 두 행 중 하나만 상태 변경 | 오류·기존 출력 보존 | 어느 내용이 정답인지 계약에 없음 |
| 동일 ID의 모든 중복 행을 함께 변경 | written | 충돌 없이 새 스냅샷으로 갱신 |
| 일부 항목을 입력에서 제거 | 출력에서도 제거 | 전체 스냅샷 계약 |
| 빈 배열 | 항목 0개 보고서 | 변경분 없음 신호가 아님 |
멱등성과 변경 무시를 구분하기
멱등성은 모든 재실행을 무조건 건너뛰는 것이 아닙니다. 같은 업무 상태를 다시 제공하면 결과가 같아야 하지만 정당하게 바뀐 업무 상태는 반영되어야 합니다. 이 코드는 결과 문자열을 비교하므로 같은 내용이면 쓰기를 생략하고 바뀐 내용이면 보고서를 교체합니다.
이 방식을 웹훅 이벤트에 옮길 때는 입력 계약부터 바꿔야 합니다. 웹훅 한 건이 전체 목록을 뜻하지 않는다면 기존 결과를 통째로 대체하면 안 됩니다. 이벤트별 처리 이력과 현재 상태 저장을 나누고 삭제 이벤트의 의미도 정의해야 합니다. 본문 코드의 단일 프로세스·전용 폴더 범위를 넘는 동시 실행 제어는 별도 구현 대상입니다.
1. 작은 자동화의 입출력 계약을 정합니다
자동화 도구 비교에서 도구를 골랐다면 다음에는 작은 업무 한 개를 끝까지 실행해 보세요. 이번 작업은 퍼블리싱 점검 목록을 정리해 JSON 보고서를 만드는 것입니다. 입력은 전체 작업 목록, 처리는 검증·공백 제거·중복 정리, 출력은 output/report.json 하나입니다. 실행을 시작하는 트리거는 터미널 명령입니다.
| 단계 | 이 실습의 계약 | 확인할 결과 |
|---|---|---|
| 입력 | 전체 작업 목록을 담은 input.json | 각 행에 id, title, status |
| 처리 | ID별 중복 정리와 제목 공백 제거 | 같은 ID·다른 내용이면 중단 |
| 출력 | 정렬된 전체 스냅샷 보고서 | 같은 입력이면 같은 내용 |
| 실행 기록 | 터미널의 written 또는 unchanged | 결과 파일과 실행 횟수 구분 |
새 작업을 결과 뒤에 계속 덧붙이는 방식으로 만들면 재실행할 때 중복될 수 있습니다. 여기서는 전체 입력으로 전체 결과를 다시 계산합니다. 같은 업무 입력으로 여러 번 실행해도 의도한 결과가 한 번 실행한 것과 같도록 만드는 성질을 멱등성이라고 부릅니다. HTTP에서의 공식 정의는 RFC 9110의 멱등성 설명에 있으며, 이 실습은 그 개념을 로컬 보고서 생성에 적용한 예입니다.

전체 스냅샷이라는 계약도 중요합니다. 다음 실행의 입력에서 작업을 빼면 출력에서도 빠집니다. []는 “새 작업 없음”이 아니라 “현재 전체 작업이 0개”를 뜻합니다. 변경분만 전달하는 웹훅이나 이벤트 로그에는 이 방식을 그대로 적용하지 않습니다.
2. 같은 작업을 구분할 ID를 준비합니다
Node.js가 설치된 빈 폴더에서 node --version을 확인하고 input.json, automate.cjs, test.cjs를 저장합니다. 외부 패키지와 서비스 계정은 필요하지 않습니다. 함수·객체·배열을 사용하므로 JavaScript 배열 메서드를 먼저 읽으면 처리 부분을 따라가기 쉽습니다. Map은 ID를 키로 작업을 보관하는 조회표로 사용합니다.
파일: input.json
[
{ "id": "task-1", "title": " 이미지 대체 텍스트 점검 ", "status": "done" },
{ "id": "task-2", "title": "모바일 메뉴 점검", "status": "todo" },
{ "id": "task-1", "title": "이미지 대체 텍스트 점검", "status": "done" }
]
task-1은 두 번 들어 있지만 제목 앞뒤 공백을 제거하면 같은 내용입니다. 이 경우 한 작업으로 합칩니다. id는 이번 실행에서 새로 만드는 번호가 아니라 원래 업무를 식별하는 안정적인 값이어야 합니다. 같은 업무에 매번 새 ID를 붙이면 중복 방지 기준이 사라집니다. title은 비어 있으면 안 되고 status는 todo 또는 done으로 제한합니다.
3. 검증·정규화·저장을 분리합니다
buildReport는 파일을 건드리지 않는 계산 함수입니다. 모든 행을 확인하고 결과를 완성한 뒤에만 run이 파일을 저장합니다. 앞 행을 저장한 다음 뒤 행의 오류를 발견하는 일을 막으려는 순서입니다. 이 실습은 한 번에 한 프로세스가 자기 전용 로컬 출력 폴더에 실행되는 조건을 사용합니다.
파일: automate.cjs
const fs = require('node:fs');
const path = require('node:path');
function buildReport(rows) {
if (!Array.isArray(rows)) throw new Error('입력은 배열이어야 합니다.');
const byId = new Map();
for (const row of rows) {
if (!row || typeof row !== 'object' || Array.isArray(row)
|| typeof row.id !== 'string' || !/^[a-z0-9-]{1,40}$/.test(row.id)
|| typeof row.title !== 'string' || row.title.trim() === ''
|| !['todo', 'done'].includes(row.status)) {
throw new Error('id, title, status를 확인하세요.');
}
const item = { id: row.id, title: row.title.trim(), status: row.status };
const previous = byId.get(item.id);
if (previous && (previous.title !== item.title || previous.status !== item.status)) {
throw new Error('같은 id에 서로 다른 내용이 있습니다: ' + item.id);
}
byId.set(item.id, item);
}
const items = [...byId.values()].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
return { version: 1, items };
}
function run(inputPath, outputPath) {
if (path.resolve(inputPath) === path.resolve(outputPath)) {
throw new Error('입력과 출력 경로를 다르게 지정하세요.');
}
const report = buildReport(JSON.parse(fs.readFileSync(inputPath, 'utf8')));
const text = JSON.stringify(report, null, 2) + '\n';
try {
if (fs.readFileSync(outputPath, 'utf8') === text) {
return { status: 'unchanged', count: report.items.length };
}
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
const temporaryPath = outputPath + '.tmp';
const descriptor = fs.openSync(temporaryPath, 'wx');
try {
try {
fs.writeFileSync(descriptor, text, 'utf8');
} finally {
fs.closeSync(descriptor);
}
fs.renameSync(temporaryPath, outputPath);
} finally {
if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath);
}
return { status: 'written', count: report.items.length };
}
if (require.main === module) {
try {
const [inputPath = 'input.json', outputPath = 'output/report.json'] = process.argv.slice(2);
console.log(JSON.stringify(run(inputPath, outputPath)));
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
module.exports = { buildReport, run };
처리는 네 부분입니다. 첫째, JSON을 읽어 입력 배열인지 확인합니다. 둘째, ID별로 공백을 정리한 작업을 저장합니다. 셋째, 같은 ID에 다른 제목이나 상태가 있으면 어느 쪽이 최신인지 알 수 없으므로 중단합니다. 넷째, ID를 기준으로 정렬하고 일정한 JSON 형식으로 직렬화합니다. 입력 순서가 달라도 같은 결과가 나오도록 한 선택입니다.
저장할 문자열이 기존 보고서와 같으면 unchanged를 반환합니다. 바뀌었다면 같은 폴더의 .tmp 파일에 완성 내용을 쓴 뒤 이름을 바꿉니다. fs.openSync의 wx는 그 임시 경로가 이미 있으면 실패하는 모드입니다. 기존 보고서를 직접 잘라 쓰기 전에 새 내용을 준비하는 방식이며, Node의 파일 열기 플래그와 renameSync 문서를 참고했습니다.
입력·출력은 서로 다른 일반 파일 경로로 지정하세요. 이 예제는 사용자가 관리하는 전용 폴더를 전제로 하며 링크 파일을 통한 별칭 경로, 공격자가 바꿀 수 있는 공유 폴더, 여러 프로세스 간 잠금 프로토콜까지 구현한 것은 아닙니다. 작은 단일 실행 배치이므로 동기 파일 API를 사용했고, 웹 서버 요청 안에서 큰 파일을 처리하는 용도로는 별도 설계가 필요합니다.
4. 두 번 실행하고 결과를 비교합니다
다음 두 명령을 같은 폴더에서 차례로 실행합니다. 첫 실행 전에 output/report.json이 없는 새 폴더를 기준으로 한 결과입니다.
node automate.cjs input.json output/report.json
node automate.cjs input.json output/report.json
{"status":"written","count":2}
{"status":"unchanged","count":2}
첫 실행은 정리한 작업 두 개를 저장하고 두 번째는 이미 같은 내용이 있으므로 쓰기를 생략합니다. 결과 파일의 내용은 아래와 같습니다. 터미널의 실행 결과는 두 줄이어도 업무 보고서는 한 개이며 항목도 두 개입니다.
{
"version": 1,
"items": [
{
"id": "task-1",
"title": "이미지 대체 텍스트 점검",
"status": "done"
},
{
"id": "task-2",
"title": "모바일 메뉴 점검",
"status": "todo"
}
]
}

이번에는 task-1 두 행 중 하나의 status만 todo로 바꿔 실행해 보세요. 같은 ID의 내용 충돌 오류가 나고 기존 보고서는 유지됩니다. 두 행 모두 todo로 바꾸면 서로 충돌하지 않으므로 새 보고서로 갱신됩니다. 이 규칙을 정해 두면 “중복이면 무조건 건너뛰기” 때문에 정당한 수정이 사라지는 일도 피할 수 있습니다.
5. 실패를 재현하고 적용 범위를 정합니다
test.cjs는 별도 임시 폴더에서 실제 automate.cjs를 호출합니다. 재실행과 입력 순서 변경은 unchanged여야 하고, 손상 JSON이나 ID 충돌은 기존 출력 문자열을 바꾸면 안 됩니다. 기존 output/report.json을 테스트용으로 지우지 않으므로 예제 결과와 검증을 분리해 확인할 수 있습니다.
파일: test.cjs
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { buildReport, run } = require('./automate.cjs');
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'small-automation-'));
try {
const input = path.join(directory, 'input.json');
const output = path.join(directory, 'output', 'report.json');
const a = { id: 'task-1', title: ' 이미지 점검 ', status: 'done' };
const b = { id: 'task-2', title: '메뉴 점검', status: 'todo' };
fs.writeFileSync(input, JSON.stringify([b, a, a]));
assert.deepEqual(run(input, output), { status: 'written', count: 2 });
const first = fs.readFileSync(output, 'utf8');
assert.deepEqual(run(input, output), { status: 'unchanged', count: 2 });
fs.writeFileSync(input, JSON.stringify([a, b]));
assert.equal(run(input, output).status, 'unchanged');
assert.equal(fs.readFileSync(output, 'utf8'), first);
fs.writeFileSync(input, JSON.stringify([a, { ...a, status: 'todo' }]));
assert.throws(() => run(input, output), /서로 다른 내용/);
assert.equal(fs.readFileSync(output, 'utf8'), first);
fs.writeFileSync(input, '{broken');
assert.throws(() => run(input, output), SyntaxError);
assert.equal(fs.readFileSync(output, 'utf8'), first);
assert.throws(() => buildReport([{ id: '../file', title: 'x', status: 'todo' }]), /확인/);
assert.throws(() => buildReport([{ id: 'a', title: ' ', status: 'todo' }]), /확인/);
assert.throws(() => run(input, input), /다르게/);
fs.writeFileSync(input, JSON.stringify([a, b, { id: 'task-3', title: '새 작업', status: 'todo' }]));
assert.equal(run(input, output).count, 3);
fs.writeFileSync(input, '[]');
fs.writeFileSync(output + '.tmp', '중단된 실행의 흔적');
assert.throws(() => run(input, output), { code: 'EEXIST' });
assert.equal(JSON.parse(fs.readFileSync(output, 'utf8')).items.length, 3);
fs.unlinkSync(output + '.tmp');
assert.equal(run(input, output).count, 0);
assert.equal(run(input, output).status, 'unchanged');
assert.deepEqual(JSON.parse(fs.readFileSync(output, 'utf8')), { version: 1, items: [] });
console.log('재실행, 중복, 충돌, 손상 입력, 결과 보존, 빈 입력 검증 통과');
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
node test.cjs
성공하면 “재실행, 중복, 충돌, 손상 입력, 결과 보존, 빈 입력 검증 통과”가 출력됩니다. 테스트는 새 항목 추가, 빈 제목, 잘못된 ID, 입력·출력 동일 경로, 남아 있는 임시 파일도 확인합니다. 마지막 [] 입력에서는 보고서 항목 수가 0으로 바뀌는 계약까지 검사합니다.
| 실패 신호 | 이 코드의 동작 | 다음 행동 |
|---|---|---|
| JSON 문법·필수값 오류 | 출력 교체 전에 중단 | 입력 파일을 고친 뒤 재실행 |
| 같은 ID에 다른 내용 | 전체 배치를 중단 | 업무 원본에서 올바른 상태를 확정 |
| EEXIST: .tmp가 남음 | 기존 보고서를 건드리지 않음 | 실행 중인 프로세스가 없는지 확인하고 임시 파일을 별도 보관한 뒤 재실행 |
| 권한·디스크 오류 | 오류를 드러내고 실패 종료 | 저장 환경을 복구한 후 결과와 입력 확인 |
임시 파일 후 교체는 전원 장애까지 견디는 트랜잭션을 보장하지 않습니다. 강제 종료 시 .tmp가 남을 수 있고, 동시에 여러 실행이 같은 출력을 갱신하는 상황도 이 예제의 범위 밖입니다. 프로세스가 중단됐다고 원본 보고서부터 지우지 말고, 입력과 기존 결과 및 임시 파일을 확인한 다음 복구하세요.
메일 전송이나 외부 게시에서는 로컬 결과를 다시 계산하는 것만으로 중복 전송을 막을 수 없습니다. 외부 요청이 성공한 뒤 응답만 유실될 수 있기 때문입니다. 그런 흐름은 서비스의 멱등성 키, 저장된 결과 ID 조회, 상태 전환과 재시도 기준을 함께 설계해야 합니다. n8n·Make 쇼츠 자동화 준비의 승인·실패 단계로 넘어갈 때, 이번 실습의 “같은 ID가 무엇을 뜻하는가”와 “실패 전에 어떤 결과가 이미 남았는가”를 그대로 질문해 보세요.
실제 실행과 다운로드 대조
2026-09-13 Node.js v24.19.0에서 본문의 전체 파일을 추출해 node test.cjs를 실행했습니다. 재실행, 중복, 충돌, 손상 입력, 결과 보존, 빈 입력 검증이 통과했습니다. 새 출력 경로에 CLI를 실행한 결과는 첫 실행 written·2건, 재실행 unchanged·2건이었습니다.
아래 공개 ZIP을 실제 다운로드해 압축을 풀고 complete 폴더의 automate.cjs, input.json, test.cjs가 본문 코드와 바이트 단위로 같은지 확인했습니다. ZIP의 complete에서도 테스트를 다시 실행해 통과했습니다. starter의 automate.cjs는 TODO가 남은 별도 시작 파일이며 본문 전체 코드는 complete에 해당합니다. 실행 결과는 로컬 파일 처리 범위이며 동시 프로세스, 정전, 외부 API를 검증한 것은 아닙니다.
시작·완성 예제 파일
ZIP에는 시작본(starter), 완성본(complete), 실행 안내가 들어 있습니다. 압축을 푼 뒤 README의 준비 사항과 실행 순서를 확인하세요.
추가 검토일: 2026-09-13. 기존 발행 시점의 제품 설명과 이번에 보강한 작업 계약을 구분해 읽어 주세요. 외부 API와 브라우저 연동은 없는 로컬 Node.js 실습입니다.
이 글이 도움이 되었나요?
업무·자동화 학습 순서
필수 2개 · 전체 3개
읽음 기록 관리
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.