n8n과 Make로 쇼츠 자동화를 시작하기 전에 단계별 상태와 승인 기준을 어떻게 정하나요?
쇼츠 자동화 전에는 입력·대본·에셋·렌더·업로드별 결과와 실패 상태를 정하세요. 작업 ID·수정 버전·중복 판정·승인을 연결합니다. 이 실습은 상태표와 판단 연습이며 실제 영상 생성·업로드를 하는 완성 자동화는 아닙니다.
적용 조건·범위: 이 글은 도구 설치와 실제 영상 생성 강좌가 아니라 실행 전에 필요한 설계를 다룹니다. n8n·Make 계정 생성, credential 연결, workflow 활성화, 영상 생성·업로드는 필요하지 않습니다. 도구 사용 시에는 각 서비스의 실제 API 권한과 콘텐츠 이용 조건을 확인해야 합니다.
본문 근거: n8n Webhook · n8n 오류 처리 · Make 공식 Webhook 문서
핵심 원리
n8n이나 Make로 옮기기 전에 코드 자동화에서 입력·출력·실패·멱등성 계약을 먼저 만들어야 합니다. 그래야 시각적 workflow가 복잡해져도 각 node의 책임을 설명할 수 있습니다.
이번 단계부터는 정상 경로만으로 자동화를 평가하지 않습니다. 중복 이벤트, 네트워크 실패, 늦은 응답, 부분 성공, 재실행이 실제 운영에서 더 중요한 조건이 됩니다.
독립 실습: 쇼츠 작업의 상태표
이 글은 도구 설치와 실제 영상 생성 강좌가 아니라 실행 전에 필요한 설계를 다룹니다. n8n·Make 계정 생성, credential 연결, workflow 활성화, 영상 생성·업로드는 필요하지 않습니다. 도구 사용 시에는 각 서비스의 실제 API 권한과 콘텐츠 이용 조건을 확인해야 합니다.
| 단계 | 입력 | 출력·상태 | 실패 후 판단 |
|---|---|---|---|
| source | content_id | 입력 원문 위치 | 원본 계약 확인 |
| script | content_id·원문 | 대본 revision | 검토 후 수정 |
| asset | 대본 revision | asset ID | 권리·생성 결과 확인 |
| render | content_id·render_revision | render ID·파일 | 기존 render 결과 조회 |
| approval | 미리보기 | 승인 대상 revision | 승인 전 업로드 중단 |
| upload | 승인된 render ID | 외부 post/video ID | 응답 유실 시 기존 결과 조회 |
같은 content_id라도 render_revision이 달라지면 승인 대상도 달라집니다. 이전 버전의 승인을 새 영상에 자동 적용하지 않습니다. 업로드 완료 여부를 모르는 상태는 실패와 별도로 두고 서버 결과를 확인해야 합니다.
혼자 판단하고 확인하기
content_id가 같아도 승인 후 자막을 수정해 render_revision이 3에서 4로 바뀌었습니다. 승인 기록은 revision 3이고 업로드 결과는 아직 없습니다. revision 4를 즉시 업로드해도 되는지 판단하세요.
확인 기준: 새 revision 4는 승인된 대상과 다르므로 새 미리보기를 검토하고 승인한 뒤 업로드합니다. 응답 유실로 외부 결과가 불명확한 상황이라면 새 업로드 전에 기존 결과부터 조회합니다.
n8n과 Make에 대응시키기
n8n은 Webhook의 테스트 URL과 운영 URL을 구분하고 오류 workflow로 실패 흐름을 분리할 수 있습니다. 테스트 수신 성공이 운영 workflow 실행이나 외부 업로드 성공은 아닙니다. n8n Webhook, n8n 오류 처리
Make의 instant trigger도 Webhook 입력을 workflow로 전달하는 방식입니다. 입력 bundle의 필드를 다음 module에 매핑하더라도 업무 ID와 결과 상태를 추적하는 설계가 필요합니다. Make 공식 Webhook 문서
두 도구를 하나의 기능 목록으로 단정하지 않고 입력·검증·상태 저장·재시도·승인·외부 쓰기의 책임을 각각 대응시킵니다. 아래 누적 예제는 이 대응 문서와 로컬 상태 판정만 검증하며 서비스 UI 설치·실행은 검증하지 않습니다.
자주 발생하는 오류와 해결
업로드 응답이 없으면 기존 외부 결과부터 조회합니다. render_revision이 바뀌면 다시 승인해야 합니다. 재시도 버튼을 누르기 전에 입력·완료 결과·현재 상태를 나란히 확인하며 실제 서비스 권한은 별도로 검토합니다.
학습 완료 체크리스트
- content_id와 render_revision을 단계별로 연결했습니다.
- 승인 대상과 업로드 결과 ID를 구분합니다.
- 응답 유실을 무조건 재업로드로 처리하지 않습니다.
선택 실습: 같은 프로젝트로 연습하기
TaskFlow Ops Automation은 작업 처리 상태와 실패·재시도를 관리하는 자동화 예제입니다. 이 선택 실습에서는 처리 흐름을 이어서 만듭니다.
이번 주제: n8n과 Make.com으로 쇼츠 자동화하기 전 반드시 준비할 것들.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow Ops Automation 누적 적용
이어서 시작하기: 멱등성 체크포인트 재개 자동화 중복 실행과 부분 실패 복구의 완료 상태를 사용합니다. 중간 합류용 시작 ZIP과 이번 완료 ZIP은 아래 기존 다운로드 안내에서 구분해 선택합니다.
시작 체크포인트: automation-step-09-idempotency-resume. 이전 회차 결과를 확인하고 새 ZIP은 별도 폴더에서 비교합니다.
현재 파일 구조는 아래 전체 코드의 경로 목록과 같습니다. 원본 input을 덮어쓰지 않으며 output·logs는 실행 결과입니다.
이번 추가 파일: docs/workflow-design.md.
이번 변경 파일: README.md.
docs/workflow-design.md에 TaskFlow 처리 책임과 쇼츠 제작의 콘텐츠·승인·업로드 경계를 대응시킵니다. 두 업무를 같은 데이터로 처리하는 것이 아니라 상태 추적 원리를 비교하며 실제 도구에 가져올 workflow 파일을 제공하는 단계는 아닙니다.
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/resume-tasks.json
[
{ "sourceId": "sample-1", "title": "작업 1" },
{ "sourceId": "sample-2", "title": "작업 2" },
{ "sourceId": "sample-3", "title": "작업 3" }
]
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편에서 파일 상태와 결과를 확인하며 재개합니다.
docs/workflow-design.md
# n8n / Make 쇼츠 워크플로 설계 실습
이 파일은 설계 문서이며 가져오기 가능한 workflow export가 아닙니다. 실제 n8n/Make 연결, 영상 생성, 파일 업로드, 알림은 실행하지 않습니다. 앞선 Node 실습의 상태 원칙을 옮기는 연습입니다.
## 기준 입력
| 필드 | 비식별 예시 | 계약 |
| --- | --- | --- |
| content_id | training-short-1 | 한 콘텐츠를 연결하는 불변 식별자 |
| render_revision | 1 | 렌더 입력이 바뀌면 검토 후 새 버전 |
| source_revision | 1 | 원문·대본의 버전 |
| approval | pending | 업로드 전 사람 승인 |
## 단계별 입출력
| 단계 | 입력 | 성공 결과 | 실패 시 다음 행동 |
| --- | --- | --- | --- |
| source | content_id, source_revision | 검증된 원문 위치 | 입력 수정 후 검증 |
| script | 원문 위치 | script_id, 버전 | 같은 버전 결과 조회 |
| asset | script_id | asset_ids | 권리·누락 파일 확인 |
| render | asset_ids, render_revision | render_id, 결과 위치 | 진행 중 작업 조회 후 재시도 판정 |
| approval | render_id | 승인자·시각·범위 | pending 상태로 대기 |
| upload | 승인된 render_id | remote_post_id | 응답 유실이면 결과 조회, 확인 전 재업로드 금지 |
상태 레코드는 content_id, render_revision, step, idempotencyKey, status, attempt, resultId, runId, updatedAt을 연결합니다. 키는 content_id + render_revision + step을 사용하며 결과가 있는 completed는 skip합니다. 오래 남은 processing은 바로 재실행하지 않고 결과를 확인합니다.
## 시각적 도구에 대응하기
1. 수동 Trigger로 비식별 입력 한 건을 준비합니다. 외부 Webhook과 예약 활성화는 별도 승인 범위입니다.
2. n8n의 데이터 편집·조건 분기, Make의 매핑·필터/Router에 입력 계약 검사를 대응합니다. UI 이름과 제공 기능은 현재 공식 문서에서 확인합니다.
3. HTTP 호출 앞에 상태 조회 분기를 둡니다. 이 문서 실습에서는 HTTP 노드를 실제 외부 URL에 연결하거나 실행하지 않습니다.
4. 정상 경로와 오류 경로를 나눕니다. 400/401은 검토, 429/일시적 서버 오류는 제한된 retry, 응답 유실은 결과 조회로 분기합니다.
5. 사람 승인 상태가 없으면 업로드 분기로 진행하지 않도록 설계합니다. credential은 도구의 전용 저장 기능으로 분리하고 문서나 로그에 쓰지 않습니다.
6. 완료 결과 ID를 상태에 기록한 뒤 다음 단계로 갑니다. 도구의 실행 성공 표시와 실제 외부 처리 성공을 따로 확인합니다.
## 종이 위 재개 판정 연습
| 조건 | 판정 |
| --- | --- |
| 동일 content_id/revision, render completed | 기존 render_id 재사용 |
| render 입력 변경 | 기존 revision 덮어쓰기 대신 새 revision 검토 |
| upload 응답 timeout, 결과 미확인 | manual_review, 기존 게시물 조회 |
| upload 완료와 remote_post_id 확인 | skip, 다시 업로드하지 않음 |
| credential 만료 | 권한 복구 후 같은 작업 상태 확인 |
각 행의 runId·상태·판정 근거·다음 행동을 메모하면 설계 실습을 마칩니다. 다음 11편에서 docs/automation-runbook.md의 장애 기록 형식으로 확장합니다. 09의 로컬 파일 상태가 외부 서비스 트랜잭션이나 제공자 idempotency 지원을 대체하지 않습니다.
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 · 10 워크플로 설계
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을 반환합니다.
## 07 예약 시뮬레이션
node src/automation/demo-scheduler.mjs
고정 시각 2026-09-21T00:00:00Z는 Asia/Seoul 09시입니다. 동시에 수동 호출한 두 작업은 completed와 skipped_overlap이 됩니다. 기한 경과 Task를 같은 run()으로 처리하고 state/last-schedule-success.json에 마지막 성공을 기록합니다. isMissed는 마지막 성공 이후 27시간을 넘겼는지 점검하는 예시 기준입니다.
OS cron·작업 스케줄러·서비스 예약을 등록하지 않습니다. 0 9 * * *는 Asia/Seoul 시간대로 동작하는 스케줄러를 선택했을 때의 설계 예시입니다. 메모리 lock은 단일 프로세스 내부만 막으며 여러 서버·재시작을 보호하지 않습니다. 실행 완료 후 같은 시각을 다시 수동 호출하는 중복은 09의 영속 멱등성에서 다룹니다. DST 지역은 해당 스케줄러 정책을 별도로 확인합니다.
## 08 제한된 재시도
node src/automation/demo-retry.mjs
모의 HTTP 429 → 503 → 200, attempt 1/2의 retrying과 3의 completed, 조회 2건이 예상됩니다. 기본 최대3회, 총 대기5초, 지수 backoff+jitter를 적용합니다. Retry-After 초 또는 HTTP 날짜가 있으면 더 짧게 기다리지 않으며 예산을 초과하면 중단합니다. 요청별 timeout 2초와 총 대기 예산은 다른 제한입니다.
401/403/400은 자동 재시도하지 않습니다. GET만 getWithRetry를 사용하며 POST를 넣으면 거부합니다. 9편의 파일 멱등성이 외부 POST의 안전한 재시도를 보장하지 않습니다. 테스트의 가짜 sleep은 긴 지연을 없애지만 demo는 실제 짧은 지연을 사용합니다.
## 09 영속 상태와 재개
node src/automation/resume.mjs automation/input/resume-tasks.json automation/resume-demo --fail-third
node src/automation/resume.mjs automation/input/resume-tasks.json automation/resume-demo
node src/automation/resume.mjs automation/input/resume-tasks.json automation/resume-demo
처음 사용하는 resume-demo 폴더 기준 결과: 2성공/1실패(exit1) → 1성공/2skip(exit0) → 3skip(exit0). 각 명령은 별도 Node 프로세스이며 output/results에 최종3파일만 남습니다. 이미 실습한 폴더라면 첫 명령부터 skip이므로 새 실습 폴더명을 지정합니다. 삭제나 상태 초기화는 필요하지 않습니다.
key는 sourceId + normalize-store-v1의 해시입니다. 같은 key에 다른 정규화 결과가 오면 manual_review로 분리합니다. state는 processing/completed/failed, 결과파일은 수정 없이 생성합니다. 결과 저장 후 상태 완료 전 실패해도 기존 결과가 기대 바이트와 같으면 복구하고 다르면 중단합니다. 손상된 상태를 빈 상태로 취급하지 않습니다. completed인데 결과가 없으면 사람이 확인합니다.
이것은 단일 프로세스·로컬 파일 교육 예제이며 여러 프로세스가 같은 폴더에서 동시에 실행하면 안 됩니다. 파일 상태 기록 자체는 DB 트랜잭션이 아니므로 손상 시 자동 복구를 보장하지 않습니다. 운영 외부 API에 exactly-once를 보장하지 않으며 업로드 응답 유실은 실제 결과 조회 후 판정해야 합니다. 테스트는 결과 저장 뒤의 실패와 새 프로세스 재개를 검증합니다.
## 10 n8n / Make 워크플로 설계
새 산출물은 docs/workflow-design.md입니다. 앞선 실행 소스는 그대로 유지하며 content_id/render_revision별 source→script→asset→render→approval→upload의 입력·출력·실패·재개 판정을 설계합니다. 이 파일은 실제 도구에 import하는 export가 아닙니다. 계정 연결·workflow 활성화·영상 생성·외부 업로드는 실행하거나 검증하지 않았습니다. node --test로 기존 로컬23테스트를 다시 확인할 수 있습니다. 운영 runbook은 다음11편에서 추가합니다.
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-retry.mjs
import { startMock } from './mock-api.mjs';
import { getWithRetry } from './retry.mjs';
const mock = await startMock({ statuses: [429, 503, 200] });
try {
const result = await getWithRetry(mock.url + '/tasks', { token: 'local-training-only' }, { onAttempt: (entry) => console.log(JSON.stringify(entry)) });
console.log('로컬 조회 결과:', result.items.length);
} catch { console.error('재시도 소진 또는 영구 오류'); process.exitCode = 1; }
finally { await mock.close(); }
src/automation/demo-scheduler.mjs
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { automationRoot } from './paths.mjs';
import { createScheduler, isKstNine } from './scheduler.mjs';
import { run } from './run.mjs';
const simulatedAt = '2026-09-21T00:00:00Z';
const tasks = [{ sourceId: 'overdue-1', title: '기한 경과 모의 작업', status: 'todo', dueAt: '2026-09-20T00:00:00Z' }];
const schedule = createScheduler((at) => run(tasks.filter((t) => t.status !== 'done' && t.dueAt && Date.parse(t.dueAt) < Date.parse(at)), { trigger: 'scheduled-simulation' }), async (log) => {
await mkdir(join(automationRoot, 'state'), { recursive: true });
await writeFile(join(automationRoot, 'state', 'last-schedule-success.json'), JSON.stringify(log, null, 2) + '\n');
});
try {
console.log('KST 09시:', isKstNine(simulatedAt));
console.log(await Promise.all([schedule(simulatedAt), schedule(simulatedAt)]));
} catch { console.error('예약 시뮬레이션 실패'); process.exitCode = 1; }
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/resume.mjs
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { createHash, randomUUID } from 'node:crypto';
import { join } from 'node:path';
import { normalizeTask } from './normalize-task.mjs';
import { readJSON } from './read-json.mjs';
import { automationRoot, isMain } from './paths.mjs';
const json = (value) => JSON.stringify(value, null, 2) + '\n';
const hash = (value) => createHash('sha256').update(value).digest('hex');
async function existing(file) {
try { return await readFile(file, 'utf8'); }
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
}
export async function resumeBatch(inputs, { root = automationRoot, failBefore, failAfter, trigger = 'manual-resume' } = {}) {
if (!Array.isArray(inputs)) throw new Error('배열이 필요합니다.');
for (const dir of ['state', 'output/results', 'logs']) await mkdir(join(root, dir), { recursive: true });
const log = { runId: randomUUID(), trigger, startedAt: new Date().toISOString(), inputCount: inputs.length, successCount: 0, skippedCount: 0, recoveredCount: 0, failedCount: 0, items: [] };
for (const [index, input] of inputs.entries()) {
let stateFile, state, task, key;
let canSaveState = false;
try {
task = normalizeTask(input);
key = hash(JSON.stringify([task.sourceId, 'normalize-store-v1']));
stateFile = join(root, 'state', key + '.json');
const resultFile = join(root, 'output', 'results', key + '.json');
const expected = json(task), inputHash = hash(expected);
const rawState = await existing(stateFile);
state = rawState === null ? null : JSON.parse(rawState);
if (rawState !== null && (!state || typeof state !== 'object' || Array.isArray(state) || state.inputHash !== inputHash || state.key !== key || !['processing', 'completed', 'failed'].includes(state.status) || !Number.isInteger(state.attempt) || state.attempt < 1)) throw new Error('state_conflict');
const result = await existing(resultFile);
if (result !== null && result !== expected) throw new Error('result_conflict');
if (state?.status === 'completed') {
if (result === null) throw new Error('completed_result_missing');
log.skippedCount += 1;
log.items.push({ sourceId: task.sourceId, key, status: 'skipped', attempt: state.attempt });
continue;
}
state = { key, sourceId: task.sourceId, inputHash, status: 'processing', attempt: (state?.attempt ?? 0) + 1, runId: log.runId, startedAt: new Date().toISOString() };
canSaveState = true;
await writeFile(stateFile, json(state));
if (failBefore === task.sourceId) throw new Error('simulated_before_result');
if (result === null) await writeFile(resultFile, expected, { flag: 'wx' });
else log.recoveredCount += 1;
if (failAfter === task.sourceId) throw new Error('simulated_after_result');
state.status = 'completed';
state.completedAt = new Date().toISOString();
state.checkpoint = 'local-result-saved';
await writeFile(stateFile, json(state));
log.successCount += 1;
log.items.push({ sourceId: task.sourceId, key, status: 'completed', attempt: state.attempt });
} catch (error) {
// 손상되거나 충돌하는 state는 덮어쓰지 않고 수동 확인 대상으로 남깁니다.
if (canSaveState) {
state.status = 'failed';
state.errorClass = error.message.startsWith('simulated_') ? error.message : 'storage_failure';
await writeFile(stateFile, json(state));
}
log.failedCount += 1;
log.items.push({ row: index + 1, sourceId: task?.sourceId ?? input?.sourceId ?? input?.id ?? null, key: key ?? null, status: 'manual_review', attempt: state?.attempt ?? 0, errorClass: error.field ? 'validation' : canSaveState ? 'processing_or_storage' : 'state_or_result_conflict', nextAction: '입력과 기존 결과를 확인한 뒤 같은 명령으로 재개' });
}
}
log.finishedAt = new Date().toISOString();
log.status = log.failedCount ? (log.successCount + log.skippedCount ? 'partial_failure' : 'failed') : 'completed';
await writeFile(join(root, 'logs', log.runId + '.json'), json(log), { flag: 'wx' });
return log;
}
if (isMain(import.meta.url)) {
try {
const file = process.argv[2] ?? new URL('../../automation/input/resume-tasks.json', import.meta.url);
const inputs = await readJSON(file);
const result = await resumeBatch(inputs, { root: process.argv[3] ?? automationRoot, ...(process.argv[4] === '--fail-third' ? { failBefore: inputs[2]?.sourceId } : {}) });
console.log(JSON.stringify(result, null, 2));
if (result.failedCount) process.exitCode = 1;
} catch { console.error('입력 또는 상태 저장 실패. 기존 파일을 보존하고 수동 확인하세요.'); process.exitCode = 1; }
}
src/automation/retry.mjs
import { setTimeout as defaultSleep } from 'node:timers/promises';
import { request } from './api-client.mjs';
export async function retry(fn, { maxAttempts = 3, baseMs = 100, maxWaitMs = 5000, sleep = defaultSleep, random = Math.random, onAttempt = () => {} } = {}) {
if (!Number.isInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 10 || !Number.isFinite(baseMs) || baseMs < 0 || !Number.isFinite(maxWaitMs) || maxWaitMs < 0) throw new Error('재시도 설정 오류');
let waited = 0;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const value = await fn(attempt);
onAttempt({ attempt, status: 'completed', delayMs: 0 });
return value;
} catch (error) {
const transient = [408, 429, 500, 502, 503, 504].includes(error.status) || error.name === 'TimeoutError' || (error instanceof TypeError && error.message === 'fetch failed');
if (!transient || attempt === maxAttempts) {
onAttempt({ attempt, status: 'failed', httpStatus: error.status ?? null, delayMs: 0 });
throw error;
}
let delay = baseMs * 2 ** (attempt - 1) + Math.floor(random() * baseMs);
if (error.retryAfter !== null && error.retryAfter !== undefined) {
const seconds = Number(error.retryAfter);
const serverDelay = Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : Date.parse(error.retryAfter) - Date.now();
if (Number.isFinite(serverDelay)) delay = Math.max(delay, serverDelay, 0);
}
if (waited + delay > maxWaitMs) throw Object.assign(new Error('총 재시도 대기 예산 초과'), { status: error.status });
onAttempt({ attempt, status: 'retrying', httpStatus: error.status ?? null, delayMs: delay });
await sleep(delay);
waited += delay;
}
}
}
export async function getWithRetry(url, options = {}, policy = {}) {
if (options.method && options.method !== 'GET') throw new Error('자동 재시도는 GET만 허용합니다.');
return retry(() => request(url, { ...options, method: 'GET' }), policy);
}
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/scheduler.mjs
import { randomUUID } from 'node:crypto';
export function isKstNine(iso) {
const parts = new Intl.DateTimeFormat('en-GB', { timeZone: 'Asia/Seoul', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(new Date(iso));
return parts.find((p) => p.type === 'hour').value === '09' && parts.find((p) => p.type === 'minute').value === '00';
}
export function isMissed(lastSuccess, now) {
return !lastSuccess || Date.parse(now) - Date.parse(lastSuccess) > 27 * 60 * 60 * 1000;
}
export function createScheduler(job, recordSuccess = async () => {}) {
let running = false;
return async (scheduledAt) => {
if (!Number.isFinite(Date.parse(scheduledAt))) throw new Error('scheduledAt 날짜 오류');
if (running) return { status: 'skipped_overlap', scheduledAt };
running = true;
const runId = randomUUID(), startedAt = new Date().toISOString();
try {
const result = await job(scheduledAt);
if (result?.failedCount) throw new Error('예약 트리거는 실행됐지만 업무 처리 일부가 실패했습니다.');
const log = { runId, scheduledAt, startedAt, finishedAt: new Date().toISOString(), timezone: 'Asia/Seoul', status: 'completed' };
await recordSuccess(log);
return log;
} finally { running = false; }
};
}
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/resume.test.mjs
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readdir, readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { fileURLToPath } from 'node:url';
import { resumeBatch } from '../src/automation/resume.mjs';
const inputs = [1, 2, 3].map((n) => ({ sourceId: `sample-${n}`, title: `작업 ${n}` }));
test('부분 실패 후 재개·중복·동일 키 내용 충돌', async () => {
const root = await mkdtemp(join(tmpdir(), 'taskflow-resume-'));
const first = await resumeBatch(inputs, { root, failBefore: 'sample-3' });
assert.equal(first.successCount, 2);
assert.equal(first.failedCount, 1);
const next = await resumeBatch(inputs, { root });
assert.equal(next.successCount, 1);
assert.equal(next.skippedCount, 2);
assert.equal((await resumeBatch(inputs, { root })).skippedCount, 3);
const changed = await resumeBatch([{ sourceId: 'sample-1', title: '다른 내용' }], { root });
assert.equal(changed.failedCount, 1);
assert.equal((await readdir(join(root, 'output', 'results'))).length, 3);
});
test('결과 저장 이후 체크포인트 실패를 결과 대조로 복구한다', async () => {
const root = await mkdtemp(join(tmpdir(), 'taskflow-window-'));
assert.equal((await resumeBatch([inputs[0]], { root, failAfter: 'sample-1' })).failedCount, 1);
const next = await resumeBatch([inputs[0]], { root });
assert.equal(next.successCount, 1);
assert.equal(next.recoveredCount, 1);
assert.equal((await readdir(join(root, 'output', 'results'))).length, 1);
});
test('손상된 체크포인트는 조용히 초기화하지 않는다', async () => {
const root = await mkdtemp(join(tmpdir(), 'taskflow-corrupt-'));
await resumeBatch([inputs[0]], { root });
const state = join(root, 'state', (await readdir(join(root, 'state')))[0]);
await writeFile(state, '{broken');
assert.equal((await resumeBatch([inputs[0]], { root })).failedCount, 1);
assert.equal(await readFile(state, 'utf8'), '{broken');
});
test('새 Node 프로세스가 파일 상태를 읽어 재개한다', async () => {
const root = await mkdtemp(join(tmpdir(), 'taskflow-process-'));
const input = join(root, 'input.json');
await writeFile(input, JSON.stringify(inputs));
const cli = fileURLToPath(new URL('../src/automation/resume.mjs', import.meta.url));
const exec = promisify(execFile);
await assert.rejects(exec(process.execPath, [cli, input, root, '--fail-third']), (e) => e.code === 1);
const result = JSON.parse((await exec(process.execPath, [cli, input, root])).stdout);
assert.equal(result.skippedCount, 2);
assert.equal(result.successCount, 1);
assert.equal(result.failedCount, 0);
});
test('JSON 원시값·배열 상태는 신규 상태로 덮어쓰지 않는다', async () => {
const root = await mkdtemp(join(tmpdir(), 'taskflow-state-type-'));
await resumeBatch([inputs[0]], { root });
const file = join(root, 'state', (await readdir(join(root, 'state')))[0]);
for (const value of [null, false, 0, '', [], {}]) {
const raw = JSON.stringify(value);
await writeFile(file, raw);
assert.equal((await resumeBatch([inputs[0]], { root })).failedCount, 1);
assert.equal(await readFile(file, 'utf8'), raw);
}
});
tests/retry.test.mjs
import test from 'node:test';
import assert from 'node:assert/strict';
import { retry, getWithRetry } from '../src/automation/retry.mjs';
import { startMock } from '../src/automation/mock-api.mjs';
test('429·503은 제한된 지수 지연으로 재시도한다', async () => {
let attempts = 0;
const waits = [];
const value = await retry(async () => { if (++attempts < 3) throw Object.assign(new Error('temporary'), { status: 503 }); return 'ok'; }, { baseMs: 100, random: () => 0, sleep: async (ms) => waits.push(ms) });
assert.equal(value, 'ok');
assert.deepEqual(waits, [100, 200]);
});
test('401은 즉시 중단하며 attempts와 대기 예산을 넘지 않는다', async () => {
let count = 0;
await assert.rejects(retry(async () => { count += 1; throw Object.assign(new Error('auth'), { status: 401 }); }));
assert.equal(count, 1);
count = 0;
await assert.rejects(retry(async () => { count += 1; throw Object.assign(new Error('limit'), { status: 429, retryAfter: '100' }); }, { maxWaitMs: 1000 }));
assert.equal(count, 1);
count = 0;
await assert.rejects(retry(async () => { count += 1; throw Object.assign(new Error('limit'), { status: 503 }); }, { sleep: async () => {} }));
assert.equal(count, 3);
});
test('실제 모의 HTTP 429 다음 GET 성공, POST 자동재시도 금지', async () => {
const mock = await startMock({ statuses: [429, 200] });
try {
const data = await getWithRetry(mock.url + '/tasks', { token: 'local-training-only' }, { sleep: async () => {} });
assert.equal(data.items.length, 2);
assert.equal(mock.calls(), 2);
await assert.rejects(getWithRetry(mock.url + '/tasks', { method: 'POST' }), /GET/);
} finally { await mock.close(); }
});
tests/scheduler.test.mjs
import test from 'node:test';
import assert from 'node:assert/strict';
import { createScheduler, isKstNine, isMissed } from '../src/automation/scheduler.mjs';
test('UTC와 KST 09시·누락 실행을 판정한다', () => {
assert.equal(isKstNine('2026-09-21T00:00:00Z'), true);
assert.equal(isKstNine('2026-09-21T09:00:00Z'), false);
assert.equal(isMissed('2026-09-19T00:00:00Z', '2026-09-21T00:00:00Z'), true);
});
test('중복 실행 skip과 실패 후 lock 해제를 확인한다', async () => {
let release;
const gate = new Promise((resolve) => { release = resolve; });
const schedule = createScheduler(() => gate);
const first = schedule('2026-09-21T00:00:00Z');
assert.equal((await schedule('2026-09-21T00:00:00Z')).status, 'skipped_overlap');
release({ failedCount: 0 });
assert.equal((await first).status, 'completed');
let calls = 0;
const fail = createScheduler(async () => { if (++calls === 1) throw new Error('simulated'); return { failedCount: 0 }; });
await assert.rejects(fail('2026-09-21T00:00:00Z'));
assert.equal((await fail('2026-09-21T00:00:00Z')).status, 'completed');
});
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;
});
독자 확인: 쇼츠 업로드 응답이 사라졌을 때 다시 업로드해도 될까요?
아래는 비식별 로컬 예제 또는 설계표를 직접 점검하기 위한 절차입니다. 이전에 기록된 로컬 테스트 결과와 구분해 읽으세요.
| 확인 항목 | 실습과 판단 기준 |
|---|---|
| 입력 | content_id=sample-1, render_revision=1의 비식별 작업을 source → script → asset → render → upload 단계로 표에 배치합니다. |
| 처리 | 각 단계에 입력 ID·출력 ID·상태·재시도 여부·사람 승인 지점을 표시합니다. upload 단계만 응답 유실로 바꾸고, 이전 단계의 파일과 상태는 유지합니다. |
| 예상 결과 | 완성한 docs/workflow-design.md에는 단계별 입출력 계약과 upload 결과 ID 조회 경로가 있어야 합니다. 실제 n8n/Make 실행이나 영상 생성·업로드 결과로 해석하지 않습니다. |
| 같은 입력 재실행 | 같은 content_id와 render_revision의 렌더 결과가 있으면 재렌더 전에 기존 파일을 확인합니다. 업로드 응답 유실은 서버 성공 가능성이 있으므로 게시물 조회 후에만 재전송을 결정합니다. |
| 실패와 복구 | credential 만료는 자동 반복보다 권한 재설정과 수동 검토로 보냅니다. 실패 단계부터 재개할 때 상위 단계의 승인·에셋 버전이 바뀌지 않았는지 확인합니다. |
| 다음 학습 | 11회차에서 실행 로그·실패 큐·감사 기록을 연결합니다. |
이번 편집의 검증 범위: 문서와 공개 본문 반영을 확인합니다. 이 회차의 자동화·외부 서비스·예약 작업을 이번 편집에서 새로 실행하지 않았으므로 실행 결과는 미확인입니다. 위 수치는 기존 예제의 예상값이며 실제 환경에서 직접 확인해야 합니다.
실행과 검증 결과
프로젝트 루트에서 각각 실행합니다. 설치 준비는 1회차를 참고하며 새 의존성은 없습니다.
node --test
누적 테스트 23개가 통과했습니다. 이번 단계는 docs/workflow-design.md를 추가하는 설계 단계로, 앞 회차의 로컬 실행 코드를 유지합니다. n8n workflow import·credential 연결·Make scenario 실행·영상 생성·업로드는 실행하지 않았습니다.
검증 결과는 Node.js 24와 비식별 로컬 모의 환경에 한정됩니다. 실제 외부 API·제공자 서명·운영 인증·예약 실행은 미검증입니다.
완료 체크포인트
automation-step-10-workflow-design
다음 학습
11회차 업무 자동화 운영 가이드: 로그·실패 복구·감사 기록
실습 파일 다운로드
automation-step-10-workflow-design 전체 파일 ZIP
합성 데이터와 로컬 파일·localhost 모의 서버로 실행합니다. 외부 서비스 쓰기·실제 알림·운영 예약 작업은 실행하지 않습니다.
글 소개·학습 준비·전체 목차

업무·자동화 · 10 / 11
n8n과 Make.com으로 쇼츠 자동화하기 전 반드시 준비할 것들
쇼츠 자동화 전에 단계별 입력·출력, 작업 ID와 버전, 실패 상태, 재시도·중복 판정 키, 사람의 승인 기준을 먼저 정합니다. 쇼츠 제작을 “콘텐츠 입력 → 대본/에셋 → 렌더 → 업로드” 단계로 나눠 실패 복구가 가능한 구조를 설계합니다.
상단 실습은 이전 TaskFlow 파일 없이 시작합니다. 아래 누적 프로젝트에서는 같은 원리를 파일 입력·API·복구 기록에 차례로 적용합니다.
이번 글에서 배우는 내용
- 워크플로 노드 수보다 상태 모델이 중요한 이유
- 단계별 입출력 ID 연결
- 파일 생성/렌더링/업로드처럼 side effect가 큰 작업의 중복 위험
- credential/Secret 관리
- retry 정책과 idempotency key
- manual approval과 완전 자동화의 trade-off
- n8n/Make 도구별 세부 UI는 바뀔 수 있으므로 개념 중심으로 설명
선수 지식
단계별 입력·출력과 중복 처리의 개념이 필요합니다. 실제 n8n·Make 계정은 필요하지 않습니다.
목차
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 11개 · 필수 11개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.