외부 데이터의 필드를 매핑하고 정상 행과 실패 행을 어떻게 구분하나요?
외부 데이터의 id·name을 내부에서 쓸 sourceId·title로 바꾸는 일이 매핑입니다. 변환 전에 타입과 필수값을 확인하고, 정상 행과 실패 행을 이유와 함께 나누세요.
적용 조건·범위: false와 문자열 "false"는 다릅니다. Boolean("false")는 true가 되므로 허용 문자열을 명시적으로 비교해야 합니다. null·배열·객체 여부를 확인하기 전에 input.title에 접근하면 검증 함수 자체가 오류를 낼 수 있습니다.
공식 문서 참고: OWASP: 파싱 후 타입·필수값·허용값 검증 · MDN: 조건 분기와 truthy·falsy 평가
외부 필드를 매핑하고 실패 행 분리하기
Node.js 24에서 한 파일로 실행합니다. 이번 최소 예제는 id/name을 sourceId/title로 매핑하고 문자열·공백을 검사합니다. 날짜와 status까지 포함한 전체 스키마는 하단 누적 프로젝트에서 확장합니다.
mapping-demo.mjs 전체 코드
function mapRow(input) {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
return { ok: false, reason: '객체 필요' };
}
const sourceId = input.sourceId ?? input.id;
const title = input.title ?? input.name;
if (typeof sourceId !== 'string' || !sourceId.trim()) {
return { ok: false, reason: 'sourceId 필요' };
}
if (typeof title !== 'string' || !title.trim()) {
return { ok: false, reason: 'title 필요' };
}
return { ok: true, value: { sourceId: sourceId.trim(), title: title.trim() } };
}
const input = [{ id: 'a', name: ' 읽기 ' }, { id: 'b', name: '' }, null];
const accepted = [];
const rejected = [];
for (const [index, row] of input.entries()) {
const result = mapRow(row);
if (result.ok) accepted.push(result.value);
else rejected.push({ row: index + 1, reason: result.reason });
}
console.log(JSON.stringify(accepted));
console.log(JSON.stringify(rejected));
저장한 폴더에서 node mapping-demo.mjs로 실행합니다. 예상 출력은 다음과 같습니다.
[{"sourceId":"a","title":"읽기"}]
[{"row":2,"reason":"title 필요"},{"row":3,"reason":"객체 필요"}]
직접 바꿔 보기: name을 숫자 0으로 바꾸어 거절되는지 확인합니다. String(0)으로 자동 변환하면 왜 입력 오류가 숨겨지는지 설명해 보세요. null과 빈 배열도 같은 객체로 처리하지 않아야 합니다.
핵심 원리
데이터 매핑은 이름을 바꾸는 작업만이 아닙니다. 외부 입력을 내부 스키마로 바꾸면서 누락·타입 오류·허용하지 않은 값·중복을 발견하고 실패 레코드를 따로 보존해야 합니다.
자동화는 한 번 성공하는 스크립트와 반복 운영 가능한 파이프라인이 다릅니다. 다음 회차로 갈수록 입력과 결과뿐 아니라 실패·재시도·중복 실행·감사 기록까지 관리합니다.
독립 실습: 외부 필드와 내부 모델 연결
코드 직접 실행
본문 예제를 선택하고 실제 계산 결과를 확인합니다.
예제 크게 보기 (새 탭)파싱은 자료를 읽는 과정이고, 매핑은 필드의 의미를 내부 모델에 연결하는 과정이며, 검증은 허용할 값인지 판단하는 과정입니다. 잘못된 입력을 무조건 기본값으로 바꾸면 원인과 업무 의미가 사라집니다.
| 외부 입력 | 내부 필드 | 규칙 |
|---|---|---|
| sourceId 또는 id | sourceId | 비어 있지 않은 문자열 |
| title 또는 name | title | 문자열 확인 후 trim, 빈 값 거절 |
| status | status | todo·doing·done만 허용 |
| dueAt 또는 due_at | dueAt | null 또는 유효한 UTC ISO 시각(Z) |
false와 문자열 “false”는 다릅니다. Boolean("false")는 true가 되므로 허용 문자열을 명시적으로 비교해야 합니다. null·배열·객체 여부를 확인하기 전에 input.title에 접근하면 검증 함수 자체가 오류를 낼 수 있습니다.
이 교육 예제는 dueAt의 UTC Z 형식을 엄격하게 받으며 다른 오프셋은 사전 변환 정책이 필요합니다.
한 행이 틀렸을 때 전체를 멈추는 fail-fast와 정상 행을 진행하는 partial success는 업무 정책입니다. 어느 방식을 쓰든 실패 행 번호·sourceId·field·reason을 남기고 원본 위치를 추적할 수 있어야 합니다. 실제 개인정보 전체를 오류 메시지에 복사하지 않습니다.
자주 발생하는 오류와 해결
누락과 빈 문자열을 구분하고 null·배열을 먼저 거절합니다. 원본 필드와 내부 필드의 관계를 명시합니다. 다른 시간대 문자열을 받으려면 먼저 UTC로 변환하는 별도 정책이 필요하며 임의 Date.parse 성공만으로 허용하지 않습니다.
학습 완료 체크리스트
- 외부 id/name과 내부 sourceId/title 매핑을 확인했습니다.
- 문자열·공백 검사와 null·배열·숫자 제목 거절 조건을 설명할 수 있습니다.
- 정상·실패 결과와 원본 행 번호의 연결을 확인했습니다.
선택 실습: 같은 프로젝트로 연습하기
TaskFlow Ops Automation은 작업 처리 상태와 실패·재시도를 관리하는 자동화 예제입니다. 이 선택 실습에서는 처리 흐름을 이어서 만듭니다.
Before는 이전 회차까지만 구현된 상태입니다. 위 파일을 해당 경로에 추가·교체하고 README 순서로 실행한 뒤 기대 결과와 오류 처리를 확인하면 이번 회차의 After 상태가 됩니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow Ops Automation 누적 적용
프로젝트에서 추가로 확인할 항목
- 전체 스키마에서 잘못된 status·dueAt을 거절하는지 확인합니다.
이어서 시작하기: REST API 업무 자동화 fetch 인증 페이지네이션 기준의 완료 상태를 사용합니다. 중간 합류용 시작 ZIP과 이번 완료 ZIP은 아래 기존 다운로드 안내에서 구분해 선택합니다.
시작 체크포인트: automation-step-04-api-client. 이전 로컬 실습을 이어가거나 이번 회차 완성 ZIP을 새 폴더에 풀어 시작합니다. 이미 수정한 파일은 새 ZIP으로 덮어쓰지 말고 별도 폴더에서 비교합니다.
현재 구조는 아래 전체 코드의 경로 목록과 같습니다. 생성되는 output·logs는 실행 결과이며 input 원본은 유지합니다.
이번 추가 파일: automation/input/external-tasks.json, tests/mapping.test.mjs.
이번 변경 파일: README.md, src/automation/normalize-task.mjs, src/automation/run.mjs.
normalize-task.mjs에 외부 id/name/due_at 매핑을 추가하고 충돌을 거절합니다. external-tasks.json과 관련 검증이 추가되며 파일·API·이후 Webhook이 같은 내부 계약을 재사용합니다.
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 · 05 데이터 매핑·검증
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를 사용합니다.
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 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; }
}
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 }));
}
});
독자 확인: 잘못된 외부 레코드는 어디로 가야 할까요?
아래는 비식별 로컬 예제 또는 설계표를 직접 점검하기 위한 절차입니다. 이전에 기록된 로컬 테스트 결과와 구분해 읽으세요.
| 확인 항목 | 실습과 판단 기준 |
|---|---|
| 입력 | external-tasks.json에서 정상 레코드, 제목 누락, 허용하지 않은 status, 잘못된 날짜 값을 가진 레코드를 준비합니다. |
| 처리 | 원본 sourceId와 행 위치를 남긴 채 parse → map → validate 순서로 보내 valid/rejected 결과를 비교합니다. title/name과 due_at/dueAt 대응 규칙을 확인합니다. |
| 예상 결과 | 제공된 fixture의 예상 집계는 1성공·3실패이며, 각 rejected 항목에 원본 식별자·문제 필드·이유가 남아야 합니다. 하나의 오류가 정상 레코드를 삭제하지 않아야 합니다. |
| 같은 입력 재실행 | 같은 sourceId의 값이 서로 다르게 다시 들어오면 최신 값으로 조용히 덮지 않고 충돌로 분리할지 정책을 정합니다. 필드 매핑 성공이 중복 제거를 뜻하지 않습니다. |
| 실패와 복구 | 타입 변환을 무조건 문자열화해 통과시키지 않습니다. 거절된 레코드만 수정하고 원본 입력을 보존한 채 재검증하며 처리 전/후 개수를 대조합니다. |
| 다음 학습 | 6회차에서 같은 검증 계층을 Webhook 이벤트에도 적용합니다. |
이번 편집의 검증 범위: 문서와 공개 본문 반영을 확인합니다. 이 회차의 자동화·외부 서비스·예약 작업을 이번 편집에서 새로 실행하지 않았으므로 실행 결과는 미확인입니다. 위 수치는 기존 예제의 예상값이며 실제 환경에서 직접 확인해야 합니다.
실행과 검증 결과
프로젝트 루트에서 각각 실행합니다. 설치 준비는 1회차를 참고하며 새 의존성은 없습니다.
node --test
node src/automation/batch.mjs automation/input/external-tasks.json
누적 테스트 11개가 통과했습니다. 외부 필드 fixture 실행의 기대 결과는 successCount=1, failedCount=3, 종료 코드 1입니다. 필드 충돌·잘못된 status·잘못된 날짜 등을 무조건 문자열로 바꾸지 않고 검증합니다.
검증 결과는 Node.js 24와 비식별 로컬 모의 환경에 한정됩니다. 실제 외부 API·제공자 서명·운영 인증·예약 실행은 미검증입니다.
완료 체크포인트
automation-step-05-validation
다음 학습
6회차 Webhook 자동화 이벤트 수신과 중복 처리 기준
실습 파일 다운로드
automation-step-05-validation 전체 파일 ZIP
합성 데이터와 로컬 파일·localhost 모의 서버로 실행합니다. 외부 서비스 쓰기·실제 알림·운영 예약 작업은 실행하지 않습니다.
글 소개·학습 준비·전체 목차

업무·자동화 · 5 / 11
데이터 매핑 검증 자동화 입력 스키마와 실패 데이터 분리
파일과 API에서 들어오는 데이터를 바로 업무 처리에 사용하지 않고 내부 데이터 계약으로 변환·검증하는 계층을 만듭니다. 누락 필드, 잘못된 타입, 외부 필드명 차이, 기본값 처리, 실패 레코드 보존을 다룹니다.
상단 실습은 이전 TaskFlow 파일 없이 시작합니다. 아래 누적 프로젝트에서는 같은 원리를 파일 입력·API·복구 기록에 차례로 적용합니다.
이번 글에서 배우는 내용
- parsing과 validation 차이
- mapping과 business rule 차이
- 필수값, 선택값, 기본값
- 문자열 날짜/숫자/boolean 변환 시 주의점
- 외부 API 응답을 신뢰하지 않는 이유
- fail-fast와 partial success 선택 기준
- validation library는 선택 사항으로 두고 기본 개념은 순수 JavaScript로 설명
선수 지식
객체·배열·조건문, 입력과 내부 모델의 구분을 알고 있어야 합니다.
목차
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 11개 · 필수 11개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.