지금까지는 주로:
HTTP 요청
Webhook
Queue Event
Git Push
처럼 어떤 사건이 발생했을 때 자동화가 시작되는 구조를 다뤘다.
하지만 실제 운영에서는 정해진 시간마다 실행해야 하는 작업도 많다.
예:
매일 오전 9시
일일 통계 생성
매일 자정
오래된 임시 데이터 정리
매시간
UNKNOWN Job Reconciliation
5분마다
Provider Health 확인
매주 월요일
주간 운영 보고서 생성
매일 새벽
백업 검증
이런 작업을 Scheduled Job이라고 볼 수 있다.
예:
0 9 * * *
는 매일 오전 9시에 실행한다.
NestJS에서도 Scheduler를 이용해:
@Cron('0 9 * * *')
async handleDailyReport() {
// ...
}
같은 형태로 만들 수 있다.
작은 프로젝트에서는 이 정도로도 충분해 보인다.
하지만 운영 환경에서는 문제가 생긴다.
예를 들어 Production Instance가 두 개라고 하자.
Server A
Server B
두 서버 모두 같은 Cron 코드를 가지고 있다.
오전 9시가 되면:
Server A
→ Daily Report 실행
Server B
→ Daily Report 실행
결과:
같은 보고서 2개 생성
될 수 있다.
핵심 질문은:
여러 서버가 같은 시간을 감지했을 때 누가 실제 작업을 실행할 것인가?
다.
이를 해결하지 않으면 다음과 같은 일이 생길 수 있다.
알림톡 중복 발송
Export 중복 생성
통계 중복 집계
Notion 보고서 중복 업로드
정리 작업 동시 실행
좋은 구조는:
Scheduler
↓
Job 생성
↓
Queue
↓
Worker
이다.
Scheduler가 실제 무거운 작업까지 직접 하지 않는다.
예:
09:00
Scheduler
↓
DailyReportJob 생성
Queue
↓
Worker 실행
Scheduler는 가능한 한 단순하게 유지한다.
지금 실행해야 하는 Job이 있는가?
있다면 Job을 등록한다.
정도다.
실제:
DB 조회
Report 생성
Notion 업로드
파일 생성
은 Worker가 담당한다.
Scheduler 안에서 직접 모든 작업을 하면:
실행 시간이 길어짐
실패 Retry 어려움
중복 실행 제어 어려움
서버 재시작 시 상태 추적 어려움
이 생긴다.
Queue Job으로 만들면 기존에 다뤘던:
Retry
Idempotency
Heartbeat
Lease
Reconciliation
DLQ
구조를 그대로 재사용할 수 있다.
예:
2026-09-29 일일 보고서 생성
이라는 작업이 두 번 실행되었다고 하자.
다음 Key를 만들 수 있다.
daily-report:2026-09-29
이 Key가 이미 존재하면 두 번째 실행은 막는다.
Cron 정의와 실제 실행을 구분하면 좋다.
예:
Schedule
daily-report
매일 09:00
그리고 실제 실행:
Schedule Run
daily-report
2026-09-29 09:00
이다.
예:
Schedule
정책
ScheduleRun
실제 실행 기록
이다.
한 Schedule에는 시간이 지날수록 여러 Run이 생긴다.
daily-report
├─ 09/27 Run
├─ 09/28 Run
└─ 09/29 Run
interface Schedule {
id: string;
name: string;
cronExpression: string;
timezone: string;
enabled: boolean;
jobType: string;
}
interface ScheduleRun {
id: string;
scheduleId: string;
scheduledFor: Date;
status:
| 'PENDING'
| 'QUEUED'
| 'RUNNING'
| 'SUCCESS'
| 'FAILED'
| 'MISSED'
| 'SKIPPED';
jobId?: string;
}
Job이 실제로:
09:03
에 실행됐어도 원래 예정 시간이:
09:00
이면:
scheduledFor = 09:00
을 기록한다.
그래야 이 Run이 어떤 예약 실행인지 알 수 있다.
예:
scheduledFor
09:00
startedAt
09:03
completedAt
09:04
이렇게 보면:
Scheduler Delay = 3분
도 측정할 수 있다.
예:
schedule:{scheduleId}:{scheduledFor}
처럼 만들 수 있다.
예:
schedule:daily-report:2026-09-29T09:00
이 값에 UNIQUE를 걸면 동일 예약 실행 중복 생성을 막을 수 있다.
DB Unique Constraint를 사용하는 것이다.
서버 A와 B가 동시에:
09:00 Run 생성
을 시도한다고 하자.
DB에는:
(scheduleId, scheduledFor)
UNIQUE
를 둔다.
결과:
Server A
INSERT 성공
Server B
UNIQUE 충돌
→ Skip
이 된다.
무조건 Redis Lock 같은 것을 먼저 넣을 필요는 없다.
단순히:
이 시간의 Run Record가 존재하는가?
만 보장하면 된다면 DB UNIQUE로 충분하다.
model ScheduleRun {
id String @id @default(cuid())
scheduleId String
scheduledFor DateTime
status String
jobId String?
startedAt DateTime?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([scheduleId, scheduledFor])
@@index([status, scheduledFor])
}
예:
09:00
Server A
Server B
둘 다 Due Schedule 발견
↓
ScheduleRun INSERT 시도
↓
A 성공
B Unique Conflict
↓
A만 Queue Job 생성
이다.
더 복잡한 Scheduler에서는:
Scheduler Leader
하나만 실행하게 만들 수도 있다.
예:
distributed lock
을 잡은 서버만 Scheduling을 수행한다.
하지만 현재 규모에서는 DB Run Record 방식이 더 단순할 가능성이 높다.
예:
수천 개 Schedule
매초 Scheduling
복잡한 Calendar Rule
대규모 분산 시스템
정도라면 Leader Election을 고려할 수 있다.
현재 프로젝트에서는 과하다.
예:
ScheduleRun
run_123
과:
Queue Job
job_837
은 역할이 다르다.
ScheduleRun은:
왜, 언제 실행될 예정이었는가?
를 기록한다.
Queue Job은:
실제로 어떤 실행 작업을 처리하는가?
를 나타낸다.
예:
ScheduleRun
run_123
scheduledFor
09:00
↓
Queue Job
job_837
이렇게 연결한다.
까다로운 케이스다.
ScheduleRun INSERT 성공
↓
서버 Crash
↓
Queue Job 생성 못 함
DB에는:
PENDING
Run이 남는다.
예:
ScheduleRun
PENDING
createdAt
10분 전
인데 Job이 없다면 Reconciler가:
Queue Job 생성
↓
QUEUED
로 복구할 수 있다.
예:
Queue Publish 요청
↓
Connection Timeout
실제로 Queue에 들어갔는지 알 수 없다.
이때 무작정 다시 Publish하면 중복 Job이 생길 수 있다.
예:
{
"scheduleRunId": "run_123",
"idempotencyKey": "schedule:daily-report:2026-09-29T09:00"
}
Queue에서 중복 전달되더라도 Worker가 같은 Run인지 알 수 있다.
실제 분산 시스템에서는:
정확히 한 번
실행을 완벽히 보장하기 어렵다.
현실적인 접근은:
한 번 이상 전달될 수 있음
↓
실제 Side Effect는
한 번만 발생하도록 설계
하는 것이다.
개념적으로:
Exactly Once
를 완벽하게 구현하기보다:
At-Least-Once Delivery
+
Idempotency
=
Effectively Once
에 가까운 구조를 만든다.
예:
Scheduler 자체 실패
Job Queue 등록 실패
Worker 실패
외부 API 실패
DB 실패
를 구분해야 한다.
예:
09:00 Scheduler 프로세스 다운
이면 아예 Run이 생성되지 않을 수 있다.
이게 Missed Job이다.
원래:
09:00
실행돼야 하는 Job이 있었는데 서버가 내려가 있었다고 하자.
서버가:
09:30
복구됐다.
단순 Cron이라면 09:00 Job은 영원히 사라질 수 있다.
이게 Missed Job 문제다.
예:
매분 시스템 상태 확인
Job이 30분 동안 놓쳤다고:
30개 Run
을 복구할 필요는 없을 수 있다.
반면:
일일 정산
은 놓치면 반드시 복구해야 한다.
Schedule마다 놓친 실행을 어떻게 처리할지 정책을 둔다.
예:
SKIP
RUN_ONCE
CATCH_UP_ALL
MANUAL
놓친 작업은 그냥 건너뛴다.
예:
1분마다 Health Snapshot
서버가 20분 꺼졌다고 과거 20개 Snapshot을 다시 만들 필요는 없다.
놓친 작업이 여러 개여도 복구 후 한 번만 실행한다.
예:
Cache Refresh
이다.
과거 Refresh를 전부 실행할 필요 없이 현재 상태 한 번만 갱신하면 된다.
놓친 모든 작업을 실행한다.
예:
시간대별 정산
날짜별 데이터 집계
에서 각 시간의 결과가 모두 필요할 수 있다.
영향이 큰 작업은 자동 복구하지 않는다.
예:
대량 고객 메시지 발송
금전 정산
데이터 삭제
놓쳤다면 운영자가 확인 후 실행한다.
예:
| 작업 | Policy |
|---|---|
| Health Check | SKIP |
| Cache Refresh | RUN_ONCE |
| 일일 통계 | CATCH_UP_ALL |
| 대량 알림 발송 | MANUAL |
이런 식이다.
예정 시간에서 너무 오래 지난 작업은 실행 가치가 없을 수도 있다.
예:
오전 9시 안내 메시지
를 오후 6시에 보내면 오히려 문제가 된다.
그래서:
misfireGracePeriod
를 둘 수 있다.
Scheduled
09:00
Grace
30분
현재
09:20
→ 실행 가능
현재
11:00
→ SKIP / MANUAL
같이 판단한다.
예:
일일 리포트
2시간 늦어도 의미 있음
실시간 고객 안내
2시간 늦으면 의미 없음
이 차이를 Schedule Policy에 반영해야 한다.
Cron에서 매우 자주 실수하는 부분이다.
09:00
만 저장하면 어느 시간대인지 모호하다.
따라서:
timezone = Asia/Seoul
같이 명시한다.
개발 서버는:
Asia/Seoul
인데 Production은:
UTC
일 수 있다.
Cron이 서버 로컬 시간을 기준으로 하면 9시간 차이가 날 수 있다.
일반적으로:
DB Timestamp
UTC
로 저장하되,
Schedule Definition에는:
timezone
Asia/Seoul
을 유지하는 방식이 좋다.
한국은 현재 DST가 없지만 해외 사용자나 호주 워홀 이후 개인 자동화를 만들 경우에는 달라질 수 있다.
예:
Australia/Sydney
는 계절에 따라 UTC Offset이 바뀔 수 있다.
따라서:
UTC+10
처럼 Offset만 고정하기보다 Timezone 이름을 사용하는 것이 안전하다.
예:
0 0 9 * * 1-5
만 저장하면 운영자가 즉시 이해하기 어렵다.
관리자 화면에서:
평일 오전 9시
같은 설명도 같이 보여주는 것이 좋다.
운영 중 특정 Cron을 잠시 멈출 필요가 있다.
예:
enabled = false
를 지원한다.
코드를 삭제하거나 서버를 재배포해서 끄는 방식은 비효율적이다.
특히 위험한 Scheduled Job은 즉시 끌 수 있어야 한다.
예:
대량 알림톡
자동 데이터 정리
AI 자동 작업
이다.
예:
매일 09:00
→ 매시간
으로 잘못 바꾸면 작업량이 24배가 된다.
따라서 Schedule 변경도 Audit 대상이다.
예:
Action
SCHEDULE_UPDATED
Schedule
daily-report
Before
0 9 * * *
After
0 * * * *
Actor
admin_1
처럼 남긴다.
예:
0 * * * *
와:
* * * * *
는 큰 차이다.
한 글자 실수로:
시간당 1회
→ 분당 1회
가 될 수 있다.
Cron 변경 시:
Expression 유효성
다음 실행 시간
예상 하루 실행 횟수
최소 실행 간격
을 보여주면 좋다.
예:
최소 1분
또는 업무에 따라:
최소 5분
같은 정책을 둘 수 있다.
잘못된 Cron으로 폭주하는 것을 방지한다.
관리자에서 Cron을 설정할 때:
다음 실행:
2026-09-30 09:00
2026-10-01 09:00
2026-10-02 09:00
처럼 보여주면 실수를 크게 줄일 수 있다.
예:
daily-report
version 7
변경 후:
version 8
로 증가시킨다.
두 관리자가 같은 Schedule을 수정할 경우:
version = 7
을 조건으로 Update한다.
먼저 수정한 사람이 version 8로 바꾸면 다른 Update는 실패한다.
예:
PENDING
↓
QUEUED
↓
RUNNING
↓
SUCCESS
실패:
RUNNING
↓
FAILED
↓
RETRY_PENDING
놓친 경우:
MISSED
정책상 실행하지 않을 경우:
SKIPPED
예:
09:00 Daily Report
는 ScheduleRun 하나다.
실패해서 세 번 Retry했다고:
Run 3개
가 되는 것은 아니다.
Run 1개
└─ Attempt 1
└─ Attempt 2
└─ Attempt 3
이다.
Retry할 때도:
scheduleRunId = run_123
은 유지한다.
그래야 동일 예약 작업의 재시도라는 것을 알 수 있다.
예:
Hourly Job
10:00 Run
실패
Retry가 계속돼:
11:00
까지 이어졌다.
동시에:
11:00 Run
도 시작할 수 있다.
같은 Schedule의 실행이 겹칠 때 정책을 정한다.
예:
ALLOW
SKIP_NEW
QUEUE_NEW
CANCEL_OLD
이전 Run이 끝나지 않아도 새 Run을 실행한다.
예:
독립적인 통계 Snapshot
등에서 가능하다.
이전 Run이 아직 실행 중이면 새 Run을 건너뛴다.
예:
Cache Refresh
같은 작업에서 쓸 수 있다.
새 Run은 생성하지만 이전 Run이 끝난 뒤 실행한다.
예:
시간별 집계
처럼 순서대로 모두 처리해야 할 때 유용하다.
새 Schedule이 시작했다고 기존 Job을 강제 종료하면 중간 Side Effect가 남을 수 있다.
따라서 Cancel Safe한 작업에서만 사용한다.
예:
Reconciliation
→ SKIP_NEW
이전 Reconciliation이 아직 돌고 있다면 새 작업을 굳이 또 돌릴 필요가 없을 수 있다.
반면:
시간별 리포트
→ QUEUE_NEW
처럼 모든 기간 데이터가 필요한 경우 순차 처리한다.
Job이 비정상적으로 오래 실행될 수도 있다.
예:
평소 2분
현재 2시간
이라면 문제가 있다.
Schedule 정의에:
maxRuntime
을 둘 수 있다.
예:
RUNNING
2시간 초과
↓
STALLED 또는 UNKNOWN
으로 처리하고 Reconciliation을 시작한다.
DB Query가 중간에 실행 중일 수도 있고 외부 API Side Effect가 발생했을 수도 있다.
단순히 프로세스를 죽이고 재실행하면 위험하다.
0916~0917의:
UNKNOWN
→ Reconcile
원칙을 그대로 사용한다.
긴 Scheduled Job이라면:
heartbeatAt
을 주기적으로 갱신한다.
예:
Export
대량 집계
AI 분석
등이다.
Worker가 Run을 가져갔다면:
lockedBy
leaseUntil
을 관리할 수 있다.
Lease가 살아 있는 동안 다른 Worker는 가져가지 않는다.
예:
03:00
Daily aggregation 시작
03:05
서버 재시작
03:10
서버 복구
Scheduler만 다시 켜서는 안 된다.
기존:
RUNNING
상태의 Job도 검사해야 한다.
애플리케이션 시작 시:
오래된 RUNNING
PENDING
UNKNOWN
Run을 검사할 수 있다.
다만 모든 것을 앱 시작 Blocking으로 하지 않는 편이 좋다.
별도 Recovery Worker로 보내는 것이 안전하다.
주기적으로 다음을 검사한다.
실행 예정이었는데 Run 없음
PENDING인데 Job 없음
QUEUED인데 Queue Job 없음
RUNNING인데 Heartbeat 없음
각각 복구한다.
예:
Schedule
매일 09:00
Last Successful Run
09/28
현재
09/30 10:00
라면:
09/29 09:00
09/30 09:00
이 빠졌는지 계산한다.
Schedule이 몇 년 된 경우:
수십만 Run
을 계산할 수 있다.
따라서:
lastEvaluatedAt
이나:
nextRunAt
을 관리하는 편이 좋다.
Schedule에:
nextRunAt
을 저장한다.
Scheduler는:
nextRunAt <= now
인 Schedule만 찾는다.
예:
현재 nextRunAt
09/29 09:00
Run 생성 후:
다음
09/30 09:00
으로 업데이트한다.
예:
ScheduleRun 생성 성공
nextRunAt 업데이트 실패
하면 같은 시간을 다시 Scheduling할 수 있다.
하지만 ScheduleRun UNIQUE가 최종 방어선 역할을 한다.
애플리케이션 코드에서:
이미 Run 있나?
확인한 뒤 Insert하는 것만으로는 Race Condition이 생길 수 있다.
최종 방어는:
DB UNIQUE
로 둔다.
예:
매일 09:00
→ 매일 10:00
으로 변경했다.
이미 생성된:
09:00 Run
을 어떻게 할지 정책이 필요하다.
보통 이미 생성된 Run은 유지하고 미래 실행부터 새 Schedule을 적용하는 편이 이해하기 쉽다.
Run은 실행 당시의 기록이다.
Schedule Definition
이 바뀌었다고 과거:
scheduledFor
값을 수정하면 안 된다.
Run 생성 시 당시 Schedule 정보를 일부 저장할 수 있다.
예:
scheduleVersion
cronExpression
timezone
이다.
나중에 Schedule이 바뀌어도 당시 실행 조건을 확인할 수 있다.
시간이 지나면서 Job Payload 구조도 바뀔 수 있다.
예:
v1
date
에서:
v2
date + carrier
로 바뀐다.
Queue에 오래 대기 중인 Job이 있다면 새 Worker와 호환되지 않을 수 있다.
예:
{
"version": 2,
"scheduleRunId": "run_123",
"payload": {}
}
Worker가 지원하지 않는 Version이면 명확히 실패시킨다.
자동 실행은:
Actor
SYSTEM_SCHEDULER
로 남긴다.
예:
Action
DAILY_REPORT_TRIGGERED
ScheduleRun
run_123
관리자가:
지금 실행
버튼을 누를 수 있다.
이 경우:
triggerType
MANUAL
로 기록한다.
자동이면:
SCHEDULED
이다.
실행 엔진을 따로 만들 필요가 없다.
Scheduled Trigger
↓
Queue
↓
Worker
Manual Trigger
↓
Queue
↓
Worker
처럼 Trigger만 다르게 한다.
예:
일일 보고서 다시 생성
버튼을 연속으로 클릭할 수 있다.
그래서 수동 실행도:
Force New Run
Reuse Existing Run
정책을 명확히 한다.
예:
09/29 Daily Report
가 실패했다.
관리자가 Retry하면:
같은 ScheduleRun
새 Attempt
이다.
반면 수정된 설정으로 완전히 새 보고서를 만들고 싶다면:
Manual Run
을 새로 생성할 수 있다.
과거 기간의 작업을 다시 실행하는 것을 Backfill이라고 볼 수 있다.
예:
9월 1일 ~ 9월 10일
통계 재집계
이다.
Retry:
기존 Run을 다시 시도
Backfill:
과거 기간에 대해 새로운 실행 생성
이다.
예:
365일 통계
를 한꺼번에 Backfill하면:
DB Load 증가
Queue 폭주
API Rate Limit
이 생길 수 있다.
예:
동시에 2개 날짜
만 처리한다.
또는:
Batch 7일
씩 진행한다.
Queue Priority가 있다면:
고객 알림
HIGH
Backfill
LOW
로 두는 것이 좋다.
Scheduled Job에도 우선순위를 둘 수 있다.
예:
CRITICAL
주문 Reconciliation
HIGH
알림톡 Recovery
NORMAL
일일 Report
LOW
과거 통계 Backfill
예:
새벽 대량 Export
↓
DB CPU 100%
↓
고객 주문 API 느려짐
이면 Scheduler 설계가 잘못된 것이다.
대량 작업은:
새벽
에 배치하는 경우가 많다.
하지만 단순히 새벽이라는 이유만으로 안전한 것은 아니다.
다른 Batch가 같은 시간에 몰릴 수 있다.
예:
00:00
Daily Report
Backup
Cleanup
Statistics
Export
모두 동시에 시작하면 부하가 폭증한다.
이를 피해야 한다.
예:
00:05
Cleanup
00:15
Statistics
00:30
Report
01:00
Backup Verification
처럼 분산시킨다.
정확한 시간이 중요하지 않은 작업이라면:
09:00 ± 5분
처럼 약간 분산할 수 있다.
여러 Instance/서비스가 동시에 외부 API를 때리는 것을 줄일 수 있다.
예:
오전 9시 정각 발송
이 요구사항이라면 Random Delay를 함부로 추가하면 안 된다.
어떤 Job이 다른 Job 이후에 실행되어야 할 수 있다.
예:
Daily Data Aggregation
↓
Daily Report
단순히:
03:00 집계
03:30 보고서
로 시간만 벌려놓는 것은 불안하다.
예:
AggregationJob SUCCESS
↓
ReportJob 생성
처럼 Event/Workflow로 연결한다.
좋은 구조:
03:00 Cron
↓
Aggregation Workflow 시작
↓
집계 완료 Event
↓
Report 생성
↓
업로드
이다.
모든 Step을 각각 다른 Cron으로 만드는 것보다 안전하다.
하나의 Cron이 여러 Step을 실행할 수도 있다.
예:
DailyReportWorkflow
1. Git Activity 수집
2. 작업 내용 분석
3. Markdown 생성
4. Notion Upload
5. 완료 기록
예:
COLLECT
SUCCESS
GENERATE
SUCCESS
NOTION_UPLOAD
FAILED
이면 전체를 처음부터 다시 실행하지 않고 Upload부터 Resume할 수 있다.
예:
매일 오후 7시 30분
↓
Git Activity 수집
↓
Ollama Report 생성
↓
Markdown 저장
↓
Notion Upload
를 Schedule Workflow로 만들 수 있다.
예:
project
platform
date
2026-09-29
라면:
daily-report:platform:2026-09-29
같은 Key를 사용한다.
파일명도:
2026-09-29-platform-daily-summary.md
처럼 Logical Key와 연결하면 좋다.
이미 존재한다면:
overwrite
version 생성
skip
정책을 정한다.
같은 보고서를 Retry하다가 Notion 페이지가 두 개 생성될 수 있다.
그래서:
notionPageId
를 저장하거나:
externalId
daily-report:platform:2026-09-29
같은 값을 사용한다.
AI 자동화는 실행 시간이 길고 실패 가능성이 높기 때문에 Scheduler 안에서 직접 LLM을 호출하지 않는 편이 좋다.
Scheduler
↓
AITask 생성
↓
AI Worker
로 분리한다.
매주 월요일 오전 8시
↓
지난주 Git Commit 수집
↓
AI 주간 업무 요약
↓
Notion 저장
예:
매시간
전체 Repository 분석
같은 작업은 로컬 장비나 API 비용을 과도하게 사용할 수 있다.
Schedule을 설정할 때 예상 비용도 고려한다.
이전 AI Report가 아직 생성 중인데 다음 Schedule이 시작될 수 있다.
AI 작업에는 보통:
SKIP_NEW
또는:
QUEUE_NEW
가 더 안전하다.
0916에서 다뤘던:
task
commitSha
promptVersion
contextHash
policyVersion
등을 Fingerprint로 사용해 같은 입력의 중복 실행을 막을 수 있다.
예:
ScheduleRun
2026-09-29
promptVersion
v21
model
shn-coder
처럼 당시 실행 환경을 남긴다.
최소한 다음 지표를 보면 좋다.
schedule_runs_total
schedule_run_success_total
schedule_run_failed_total
schedule_run_missed_total
schedule_run_duration
schedule_start_delay
startedAt - scheduledFor
이다.
예:
예정
09:00
시작
09:08
이면 8분이다.
Scheduler 또는 Queue가 밀리고 있다는 신호가 될 수 있다.
예:
지난 30일
Daily Report
예정 Run
30
성공
29
실패
1
성공률:
96.7%
이다.
예:
Daily Report
예정 시간 이후
30분 이내 완료
95%
같은 SLO도 만들 수 있다.
예:
scheduledFor + gracePeriod
을 지났는데 Run이 없으면:
SCHEDULE_MISSED
Alert를 낸다.
Scheduler 자체 Health도 필요하다.
예:
scheduler heartbeat
을 주기적으로 기록한다.
예:
scheduler_instance
scheduler-a
lastHeartbeatAt
09:28
가 너무 오래되면 Scheduler가 죽었을 수 있다.
Scheduler 프로세스는 살아 있지만 Bug 때문에 Job을 생성하지 않을 수도 있다.
따라서:
Heartbeat 정상
만 믿으면 안 된다.
예:
매일 오전 9시 Daily Report가 하나 있어야 한다.
자체를 검사한다.
즉:
오늘 09:00 Run 존재?
를 확인하는 것이 더 직접적이다.
가장 위험한 경우다.
Error 없음
프로세스 정상
그런데 Cron Trigger 실행 안 됨
이다.
그래서 Expected Run 검사가 필요하다.
예:
매 10분
↓
최근 Due Schedule 확인
↓
ScheduleRun 존재 여부 검사
↓
없는 경우 Misfire Policy 적용
할 수 있다.
그런데 Reconciler도 Cron이면:
Reconciler도 안 돌 수 있음
이라는 문제가 있다.
그래서 핵심 시스템에서는 별도 외부 Scheduler나 Health Check가 도움이 될 수 있다.
현재 규모에서는 지나치게 복잡하게 갈 필요는 없다.
현재 프로젝트에서는:
NestJS Scheduler
+
PostgreSQL ScheduleRun
+
Queue Worker
+
Reconciliation
정도면 현실적인 구조다.
예:
AWS EventBridge Scheduler
같은 외부 스케줄러도 사용할 수 있지만 모든 Job에 필요하지는 않다.
애플리케이션이 항상 구동되고 관리할 Schedule이 많지 않다면 내부 Scheduler도 충분하다.
예:
애플리케이션과 독립적으로 반드시 실행되어야 함
서버가 Scale-to-zero 가능
정확한 예약 Trigger가 중요
AWS 중심 Infrastructure
등이다.
외부 서비스가 Trigger한다고 중복 가능성이 0이 되는 것은 아니다.
같은 Event 재전달
Network Retry
를 고려해야 한다.
가장 중요한 원칙이다.
Scheduler
=
실행을 시작시키는 장치
일 뿐이다.
업무 정확성은:
Idempotency
State
Transaction
Worker
Reconciliation
이 담당한다.
예:
90일 이상 임시 데이터 삭제
Cron을 만든다고 하자.
잘못된 날짜 계산 하나면 대량 데이터가 삭제될 수 있다.
예:
Dry Run
삭제 예상 건수
Maximum Delete Limit
Approval
Audit
을 둘 수 있다.
예:
평소 삭제
100~500건
인데 갑자기:
120,000건
이 조회됐다면 자동 실행하지 않는다.
예:
deleteCount > 5000
→ MANUAL_REQUIRED
로 처리한다.
실제 삭제 전:
대상 건수
가장 오래된 데이터
가장 최근 데이터
를 확인한다.
가능하다면 즉시 Hard Delete보다는:
Soft Delete
↓
Retention
↓
Hard Delete
단계로 운영할 수 있다.
0819에서 다룬 데이터 보존 전략과 연결된다.
예:
백업 성공
로그가 있다고 실제 복구 가능한 백업이라는 보장은 없다.
별도의 Scheduled Job으로:
Backup 존재 확인
Size 확인
최근 생성 시간
Restore Test
를 수행할 수 있다.
Production 전체 복구를 매일 할 필요는 없지만 주기적으로 Test Environment에 복구해볼 수 있다.
Backup
↓
Test DB Restore
↓
Integrity Check
형태다.
특히 일일 통계에서는:
실행 날짜
와:
집계 대상 날짜
가 다를 수 있다.
예:
09/30 01:00 실행
Business Date
09/29
이다.
Job 내부에서:
const date = new Date();
로 집계 날짜를 결정하면 Retry 시 날짜가 바뀔 수 있다.
예:
23:59 실행 시작
00:01 Retry
하면 다른 날짜를 집계할 수 있다.
예:
{
"scheduledFor": "2026-09-30T01:00:00+09:00",
"businessDate": "2026-09-29"
}
처럼 실행 시점에 고정한다.
같은 Run을 Retry해도 입력값이 바뀌지 않는 것이 좋다.
예:
Run ID
Business Date
Config Version
Prompt Version
등을 고정한다.
예:
09:00 Report
가 실패 후 11:00에 Retry됐다.
11:00 최신 데이터를 써야 하는지:
09:00 기준 Snapshot
을 써야 하는지 업무에 따라 결정한다.
재현성이 중요한 Job이라면:
inputSnapshotId
를 저장할 수 있다.
동일한 09/29 Report를 다시 생성했는데 Git Commit이 추가되면 결과가 달라질 수 있다.
원래 보고서를 재현하고 싶다면:
commit range
을 Run 생성 시 고정한다.
Daily Report Run
fromCommit
abc123
toCommit
def456
promptVersion
v21
로 저장한다.
관리자 화면에서:
Schedule
최근 실행
다음 실행
성공률
현재 상태
를 볼 수 있으면 운영성이 높아진다.
| 작업 | 주기 | 최근 결과 | 다음 실행 |
|---|---|---|---|
| Daily Report | 매일 19:30 | 성공 | 09/30 19:30 |
| Reconciliation | 10분 | 성공 | 09:40 |
| Cleanup | 매일 03:00 | 성공 | 09/30 03:00 |
| Weekly Report | 월 08:00 | 성공 | 10/05 08:00 |
예:
Daily Report
Status
ENABLED
Cron
30 19 * * *
Timezone
Asia/Seoul
Overlap
SKIP_NEW
Misfire
RUN_ONCE
Max Runtime
20m
Last Run
SUCCESS
Next Run
2026-09-30 19:30
예:
Scheduled
19:30
Started
19:31
Failed
19:36
Error
NOTION_UPLOAD_FAILED
Attempts
3
Current
DEAD_LETTER
관리자가:
Retry
할 수 있다.
수동 Retry라고 무조건 처음부터 실행하지 않는다.
Workflow State를 보고:
Report 생성 완료
Notion Upload만 실패
라면 Upload부터 재개한다.
Schedule을 삭제해도 과거 Run 기록은 남겨두는 편이 좋다.
따라서:
Hard Delete
보다는:
DISABLED / ARCHIVED
를 고려한다.
Schedule은 변경되거나 삭제될 수 있지만 과거 실행 기록은 운영 Audit에 필요하다.
대량 발송 같은 Job을 아무 관리자나 만들 수 있으면 위험하다.
Schedule Type별로 권한을 제한한다.
AI가 스스로:
매시간 이 작업 실행
Schedule을 만들게 두면 자동화가 폭증할 수 있다.
초기에는 AI가 Schedule을 제안하고 사람이 승인하는 것이 낫다.
예:
최근 7일간 매일 수행한 수동 작업이 있습니다.
매일 19:30 Scheduled Job으로
자동화할 수 있습니다.
정도다.
예:
Daily
30 Run / month
Hourly
720 Run / month
처럼 실행량을 비교한다.
LLM API라면 예상 Token 비용도 함께 계산할 수 있다.
새 Schedule 생성 시:
Estimated Runs Per Day
를 계산한다.
예:
1440/day
가 나오면 Warning을 띄운다.
예:
동시 Scheduled Worker
최대 5개
처럼 전체 제한을 둘 수 있다.
예:
Report
1
Export
2
Reconciliation
1
등이다.
중요도에 따라:
automation-critical
automation-normal
automation-low
Queue를 나눌 수도 있다.
현재 규모에서는 너무 많이 나누지 않는 편이 낫다.
현재 규모에서 필요한 것은:
Cron Parser
Run Record
Unique Constraint
Queue
Worker
Reconciliation
정도다.
자체적으로 완전한 Airflow 같은 시스템을 만들 필요는 없다.
예:
Workflow 수백 개
복잡한 DAG
장시간 작업
많은 Backfill
여러 개발팀
복잡한 Dependency
정도라면 별도 도구를 고려한다.
현재는 과하다.
NestJS Scheduler
↓
ScheduleService
↓
ScheduleRun DB
↓
Queue
↓
Worker
↓
Job / Workflow
↓
Audit + Metric
이다.
interface SchedulePolicy {
timezone: string;
overlap:
| 'ALLOW'
| 'SKIP_NEW'
| 'QUEUE_NEW';
misfire:
| 'SKIP'
| 'RUN_ONCE'
| 'CATCH_UP_ALL'
| 'MANUAL';
gracePeriodMs?: number;
maxRuntimeMs?: number;
}
model Schedule {
id String @id @default(cuid())
name String @unique
jobType String
cronExpression String
timezone String
enabled Boolean @default(true)
overlapPolicy String
misfirePolicy String
gracePeriodMs Int?
maxRuntimeMs Int?
nextRunAt DateTime?
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
runs ScheduleRun[]
}
model ScheduleRun {
id String @id @default(cuid())
scheduleId String
scheduledFor DateTime
triggerType String
status String
idempotencyKey String @unique
jobId String?
attemptCount Int @default(0)
startedAt DateTime?
completedAt DateTime?
lastHeartbeatAt DateTime?
schedule Schedule @relation(
fields: [scheduleId],
references: [id]
)
@@unique([scheduleId, scheduledFor, triggerType])
@@index([status, scheduledFor])
}
async function tick() {
const dueSchedules =
await scheduleRepository.findDue(
new Date(),
);
for (const schedule of dueSchedules) {
await createRunIfAbsent(schedule);
}
}
async function createRunIfAbsent(
schedule: Schedule,
) {
const scheduledFor =
schedule.nextRunAt;
try {
const run =
await scheduleRunRepository.create({
scheduleId: schedule.id,
scheduledFor,
idempotencyKey:
`${schedule.id}:${scheduledFor.toISOString()}`,
});
await queue.add({
scheduleRunId: run.id,
});
} catch (error) {
if (isUniqueViolation(error)) {
return;
}
throw error;
}
}
가능하면 Transaction 안에서:
Run 생성
Next Run 계산
을 처리한다.
Queue Publish는 DB Transaction 밖일 수 있으므로 Reconciliation으로 보완한다.
더 강하게 가면:
ScheduleRun 생성
Outbox Event 생성
COMMIT
후 Outbox Worker가 Queue에 전달한다.
이러면:
DB 저장 성공
Queue 등록 실패
문제를 줄일 수 있다.
이미 Outbox를 쓰고 있다면 재사용하고,
아니라면:
PENDING ScheduleRun Reconciliation
만으로도 충분할 수 있다.
async function reconcileMissedRuns() {
const schedules =
await scheduleRepository.findEnabled();
for (const schedule of schedules) {
const missed =
calculateMissedRuns(schedule);
await applyMisfirePolicy(
schedule,
missed,
);
}
}
예:
const activeRun =
await runRepository.findActive(
schedule.id,
);
if (
activeRun &&
schedule.overlapPolicy === 'SKIP_NEW'
) {
await markSkipped();
return;
}
active Run 존재
↓
새 Run은 PENDING
↓
이전 Run 종료
↓
다음 Run Queue 등록
형태다.
예:
PENDING
→ SUCCESS
같은 잘못된 전이는 막는다.
예:
SCHEDULE_MISSED
SCHEDULE_OVERLAP
SCHEDULE_RUN_TIMEOUT
SCHEDULE_QUEUE_FAILED
SCHEDULE_INVALID_CRON
SCHEDULE_DISABLED
등을 정의할 수 있다.
Schedule 이름을 Label로 사용할 때도 개수가 너무 많아지지 않도록 주의한다.
현재 수십 개 수준이라면 크게 문제되지 않을 수 있다.
Critical Schedule
2회 연속 실패
Daily Report
예정 시간 + 1시간 미완료
Reconciliation
15분 이상 실행 없음
등이다.
예:
주간 내부 AI Report 실패
는 P3일 수 있다.
반면:
주문 Reconciliation Scheduler 중단
은 훨씬 중요하다.
예:
CRITICAL
HIGH
NORMAL
LOW
을 Schedule에 넣을 수도 있다.
예:
CRITICAL
1회 Miss → Warning
2회 Miss → Critical
LOW
3회 실패 후 알림
처럼 운영한다.
위험한 Cron 변경이라면:
변경 Preview
실행 빈도 계산
중복 여부 확인
Approval
후 적용한다.
Before
1/day
After
1440/day
라면:
HIGH RISK
로 분류해야 한다.
새 Cron을 실제 적용하기 전에:
향후 24시간 예상 실행 시각
을 계산해볼 수 있다.
나중에 설정 백업을 위해:
name: daily-report
cron: "30 19 * * *"
timezone: Asia/Seoul
overlap: SKIP_NEW
misfire: RUN_ONCE
같은 Manifest로 관리할 수도 있다.
예:
전화번호 목록
API Key
JWT
를 Cron Definition에 넣는 것은 좋지 않다.
Schedule은 참조 ID만 가진다.
예:
{
"businessDate": "2026-09-29",
"projectId": "platform"
}
정도로 유지한다.
예:
고객 10,000명 ID
를 Queue Payload에 넣기보다:
campaignId
를 전달하고 Worker가 DB에서 조회한다.
예:
10,000건
을 하나의 Worker가 모두 처리하지 않는다.
Campaign Job
↓
Batch Job 1
500건
Batch Job 2
500건
...
으로 나눈다.
500건 중 일부 실패해도 전체 10,000건을 처음부터 재실행하지 않는다.
예:
Total
10,000
Processed
7,500
Success
7,420
Failed
80
를 확인할 수 있어야 한다.
예:
Campaign ScheduleRun
└─ Batch 1
└─ Batch 2
└─ Batch 3
형태로 연결할 수 있다.
모든 Child Job이:
SUCCESS
또는 허용 가능한 실패
상태가 되어야 Parent ScheduleRun도 완료된다.
대량 작업에서는:
9,990 성공
10 실패
가 있을 수 있다.
상태를:
PARTIAL_SUCCESS
로 두는 것도 가능하다.
예:
Daily Campaign
Scheduled
09:00
Completed
09:23
Success
9,990
Failed
10
Status
PARTIAL_SUCCESS
처럼 기록한다.
AI는:
최근 실패 Schedule 요약
반복 실패 원인 분류
Cron 변경 위험도 설명
Missed Job 목록 정리
Runbook 추천
등에 활용하기 좋다.
AI가:
매주 월요일 오전 9시
를 Cron으로 바꿨다고 무조건 저장하지 않는다.
Parser로 실제 다음 실행 시각을 계산한다.
Cron 유효성은 AI에게 판단시킬 문제가 아니다.
Cron Parser
Timezone Library
Validation
으로 검사한다.
AI는 사람에게 설명하는 용도로 사용한다.
요청:
매주 월요일 오전 9시에 주간 리포트를 생성
AI:
Schedule Draft 생성
↓
Parser:
Cron 검증
↓
Preview:
다음 5회 실행시간 표시
↓
Human Approval
↓
Schedule 저장
그렇지 않으면 잘못된 자동화가:
매분 실행
되거나 비용을 과도하게 사용할 수 있다.
예:
최소 Interval
일일 최대 실행 횟수
허용 Job Type
허용 Environment
을 정책으로 둔다.
예:
Staging
자동 생성 가능
Production
Manual Approval
형태가 안전하다.
현재 구조를 예로:
19:30
ScheduleRun 생성
↓
Git 변경사항 수집
↓
shn-coder 실행
↓
Markdown 생성
↓
Notion Upload
↓
Run SUCCESS
로 볼 수 있다.
예:
MODEL_UNAVAILABLE
로 실패한다.
즉시 무한 Retry하지 말고:
Retryable
Backoff
최대 시도
정책을 둔다.
전날 Run이 아직 실패 상태라면 새 Run 처리 정책이 필요하다.
예:
각 날짜는 독립
→ ALLOW / QUEUE_NEW
가 가능하다.
서버가 3일 꺼져 있었다면:
3일치 Report
를 생성하고 싶을 수 있다.
그렇다면:
misfire = CATCH_UP_ALL
이다.
overlap = QUEUE_NEW
concurrency = 1
로 순서대로 실행하면 된다.
Workflow Step:
Generate
SUCCESS
Upload
FAILED
이면 Upload Step만 Retry한다.
이것이 Schedule + Workflow + Resume 연결이다.
현재 프로젝트에서는 다음 순서가 현실적이다.
Schedule Definition
Timezone
nextRunAt
ScheduleRun
DB Unique Constraint
Queue Job 연결
Retry
Overlap Policy
Misfire Policy
Heartbeat
Stale Recovery
Missed Job Reconciliation
Admin Schedule UI
Run History
Manual Retry
Backfill
1. ScheduleRun 기록
2. scheduleId + scheduledFor UNIQUE
3. Timezone 명시
4. Worker 분리
5. Overlap Policy
6. Misfire Policy
7. Missed Run 탐지
이 정도만 있어도 단순 Cron보다 훨씬 안전하다.
현재 규모에서는:
직접 DAG Engine 개발
Leader Election Cluster
복잡한 Workflow DSL
대규모 Scheduler Platform
초단위 수십만 Schedule
까지 만들 필요는 없다.
현재 NestJS + Prisma 기반 프로젝트에
안전한 Scheduled Job 실행 구조를 추가해줘.
목표는 Airflow 같은 복잡한 Workflow 시스템을 만드는 것이 아니라,
기존 Cron 기반 자동화를
중복 실행, 서버 재시작, Missed Job 상황에서도
안전하게 운영할 수 있도록 개선하는 것이다.
현재 Queue / Worker / Job 구조가 있다면 반드시 재사용하고,
새로운 Queue 시스템을 중복 도입하지 않는다.
1. Schedule 모델을 추가한다.
필드 예:
- id
- name
- jobType
- cronExpression
- timezone
- enabled
- overlapPolicy
- misfirePolicy
- gracePeriodMs
- maxRuntimeMs
- nextRunAt
- version
- createdAt
- updatedAt
2. overlapPolicy는 다음을 지원한다.
- ALLOW
- SKIP_NEW
- QUEUE_NEW
CANCEL_OLD는 현재는 구현하지 않는다.
3. misfirePolicy는 다음을 지원한다.
- SKIP
- RUN_ONCE
- CATCH_UP_ALL
- MANUAL
4. ScheduleRun 모델을 추가한다.
필드 예:
- id
- scheduleId
- scheduledFor
- triggerType
- status
- idempotencyKey
- jobId
- attemptCount
- startedAt
- completedAt
- lastHeartbeatAt
5. 동일 Scheduled Run이 중복 생성되지 않도록
DB Unique Constraint를 사용한다.
기준:
scheduleId + scheduledFor + triggerType
또는 동등한 안전한 구조를 사용한다.
6. Scheduler는 실제 Business Logic을 직접 실행하지 않는다.
Scheduler 역할:
- Due Schedule 찾기
- ScheduleRun 생성
- Queue Job 등록
실제 실행은 기존 Worker가 담당한다.
7. Queue Payload에는 최소 다음을 포함한다.
- scheduleRunId
- idempotencyKey
필요한 Business Input은
ScheduleRun 또는 DB에서 조회한다.
8. Scheduler Instance가 여러 개 떠도
같은 scheduledFor에 실제 Run이 한 번만 생성되어야 한다.
애플리케이션의 선조회만 믿지 말고
DB Unique Constraint를 최종 방어선으로 사용한다.
9. ScheduleRun 상태는 다음을 고려한다.
- PENDING
- QUEUED
- RUNNING
- SUCCESS
- PARTIAL_SUCCESS
- FAILED
- RETRY_PENDING
- MISSED
- SKIPPED
- MANUAL_REQUIRED
10. Retry 시 새 ScheduleRun을 만들지 않고
기존 Run의 Attempt를 증가시킨다.
11. 실행이 오래 걸리는 Job을 위해
heartbeat를 지원할 수 있는 구조를 만든다.
12. maxRuntime을 초과하고
heartbeat가 오래된 RUNNING Job은
stale 후보로 탐지할 수 있게 한다.
13. Stale Job은 무조건 처음부터 Retry하지 않고
기존 Reconciliation 구조가 있다면 연결한다.
14. Schedule 자체가 실행되지 않은
Missed Run을 탐지할 수 있는 구조를 만든다.
15. Missed Run 발견 시
Schedule의 misfirePolicy에 따라 처리한다.
SKIP
→ Run을 SKIPPED 처리
RUN_ONCE
→ 최신 Missed Run 한 건 실행
CATCH_UP_ALL
→ 누락 Run 모두 생성
MANUAL
→ MANUAL_REQUIRED 처리
16. gracePeriodMs가 있다면
예정 시간에서 너무 오래 지난 Run은
정책에 따라 자동 실행하지 않는다.
17. overlapPolicy를 적용한다.
SKIP_NEW:
기존 Active Run이 있으면 새 Run Skip
QUEUE_NEW:
Run은 생성하지만 이전 Run 완료 후 Queue 등록
ALLOW:
동시 실행 허용
18. 모든 Schedule은 timezone을 명시하게 한다.
서버의 로컬 Timezone에 의존하지 않는다.
19. DB Timestamp는 기존 프로젝트 정책에 맞춰
UTC 기준으로 관리한다.
20. Cron Expression 저장 시
다음 실행 시간을 계산해 nextRunAt을 관리한다.
21. Cron 수정 시 Validation을 수행한다.
- Cron 문법
- Timezone
- 예상 다음 실행 시간
- 지나치게 짧은 실행 주기
22. 관리자가 확인할 수 있도록
다음 실행 시각 Preview 기능을 만들 수 있게 한다.
23. Schedule 변경 시 Audit Log에 기록한다.
- actor
- before
- after
- reason
- version
24. Schedule 수정에는 Optimistic Lock을 적용할 수 있게 한다.
25. Manual Run을 지원하되
Scheduled Run과 동일한 Worker를 재사용한다.
triggerType:
- SCHEDULED
- MANUAL
- BACKFILL
26. Backfill은 과거 기간에 대한
새로운 Run 생성으로 처리한다.
대량 Backfill이 동시에 실행되지 않도록
Concurrency 제한을 고려한다.
27. Destructive Job을 자동 생성하지 않는다.
삭제 작업 등은
Safety Threshold / Manual Approval 구조와
연결 가능하도록 한다.
28. Schedule Metric을 기록할 수 있게 한다.
- run count
- success
- failed
- missed
- duration
- start delay
29. 테스트를 작성한다.
필수 Scenario:
- 동일 Schedule 중복 Trigger
- 두 Scheduler 동시 Run 생성
- Unique Constraint에 의한 중복 방지
- Worker 실패 Retry
- SKIP_NEW
- QUEUE_NEW
- Missed Run + SKIP
- Missed Run + RUN_ONCE
- Missed Run + CATCH_UP_ALL
- Grace Period 초과
- 잘못된 Cron 거부
- Timezone 적용
- Stale RUNNING 탐지
- Schedule Optimistic Lock 충돌
30. 기존 코드 구조와 Queue 시스템을 먼저 분석하고,
없는 시스템을 새로 가정하지 말고
현재 프로젝트에 가장 단순한 형태로 통합해줘.
현재 local-llm-work-report의 Daily Report 자동화를
Scheduled Workflow로 운영할 수 있도록 설계를 검토해줘.
목표:
매일 지정된 시간에
프로젝트별 Git 작업 내용을 수집하고
로컬 Ollama 모델을 이용해 Markdown Report를 생성한 뒤
필요한 경우 Notion에 업로드한다.
요구사항:
1. Schedule과 실제 Report Run을 분리한다.
2. 날짜별 Report Run의 Idempotency Key는
project + businessDate를 기준으로 한다.
예:
daily-report:platform:2026-09-29
3. 같은 날짜/프로젝트 Report가
Scheduler 중복 실행으로 두 개 생성되지 않게 한다.
4. Workflow를 Step 단위로 나눈다.
- COLLECT_GIT
- GENERATE_REPORT
- SAVE_MARKDOWN
- UPLOAD_NOTION
5. GENERATE_REPORT 성공 후
UPLOAD_NOTION만 실패했다면
LLM을 다시 실행하지 않고 Upload 단계부터 Resume한다.
6. 각 Run에 다음 정보를 기록한다.
- project
- businessDate
- commit range
- model
- promptVersion
- startedAt
- completedAt
- status
7. 서버나 Scheduler가 며칠 중단되었을 때
누락된 날짜 Report를 Backfill할 수 있게 한다.
8. Backfill은 동시에 여러 Ollama Run을 실행하지 않고
기본 concurrency=1로 순차 처리한다.
9. 이미 생성된 Markdown 파일이 있다면
무조건 덮어쓰지 말고
기존 Run과 Artifact를 확인한다.
10. Notion 중복 페이지가 생성되지 않도록
Report Run과 notionPageId를 연결한다.
11. Ollama가 실행되지 않은 경우
MODEL_UNAVAILABLE로 분류하고
제한된 Retry + Backoff를 사용한다.
12. Report 자동화가 실패해도
다른 프로젝트 작업이나 Git Repository 상태에는
Side Effect를 주지 않도록 한다.
13. Production 서비스와 별개의 개인 자동화이므로
과도한 Infrastructure를 추가하지 않고
현재 CLI / Markdown / Notion 구조를 최대한 유지한다.
현재 구현을 먼저 분석한 후
변경 범위를 최소화한 설계안과
필요한 코드 수정 목록을 제시해줘.
Scheduled Job은 단순히:
Cron 하나 등록
한다고 끝나는 문제가 아니다.
운영 환경에서는:
서버가 여러 대일 수 있고
서버가 예약 시간에 꺼져 있을 수 있고
Worker가 중간에 죽을 수 있고
같은 Trigger가 두 번 발생할 수 있고
이전 작업과 다음 작업이 겹칠 수 있다.
그래서 안전한 구조는:
Scheduler
↓
ScheduleRun
↓
Queue
↓
Worker
↓
Result
로 분리한다.
특히 중요한 것은:
Schedule
≠
ScheduleRun
이다.
Schedule은:
매일 09:00 실행
이라는 규칙이고,
ScheduleRun은:
2026-09-29 09:00 실행 건
이라는 실제 실행 기록이다.
동일 시간의 Run에는:
(scheduleId, scheduledFor)
UNIQUE
같은 DB 제약을 둬 여러 Scheduler가 동시에 실행돼도 실제 Run은 하나만 생성되도록 한다.
하지만 Run 자체가 한 번만 만들어졌다고 Side Effect까지 한 번이라는 보장은 없다.
따라서:
Schedule 중복 방지
+
Worker Idempotency
둘 다 필요하다.
또 하나 중요한 것이 Missed Job이다.
09:00 실행 예정
↓
서버 중단
↓
09:30 서버 복구
같은 상황에서 단순 Cron은 09:00 작업을 잃어버릴 수 있다.
그래서 Schedule별로:
SKIP
RUN_ONCE
CATCH_UP_ALL
MANUAL
같은 Misfire Policy를 정의한다.
또한 이전 Run이 아직 끝나지 않은 상태에서 다음 시간이 오면:
ALLOW
SKIP_NEW
QUEUE_NEW
같은 Overlap Policy가 필요하다.
현재 구조에서는 예를 들어:
Reconciliation
→ SKIP_NEW
Daily Report
→ QUEUE_NEW
독립 Snapshot
→ ALLOW
처럼 업무 특성에 맞춰 사용할 수 있다.
Scheduled Workflow에서도 기존에 배운 원칙은 그대로 이어진다.
Retry
+
Idempotency
+
Heartbeat
+
Lease
+
Reconciliation
+
Observability
이다.
특히 Local LLM Report 자동화처럼 여러 Step이 있는 경우:
Git 수집
↓
LLM 생성
↓
Markdown 저장
↓
Notion Upload
를 하나의 거대한 Cron 함수로 만들기보다 Workflow로 나누는 것이 좋다.
예를 들어 Notion 업로드만 실패했다면:
LLM 재실행
이 아니라:
UPLOAD_NOTION Step만 Retry
하면 된다.
그리고 날짜 기반 작업에서는 반드시:
현재 실행 시간
과:
Business Date
를 구분해야 한다.
Retry하는 시점이 다음 날이 되더라도 동일 Run이 다른 날짜 데이터를 처리해서는 안 된다.
결국 Scheduled Automation의 핵심은:
“정해진 시간에 함수를 실행하는 것”이 아니라, 특정 시점에 실행되어야 할 업무를 하나의 상태 있는 Run으로 기록하고, 중복·누락·실패·재시작 상황에서도 최종 결과를 일관되게 만드는 것
이다.
지금까지의 흐름에 Scheduler를 연결하면:
Time / Event
↓
Trigger
↓
Create Run
↓
Idempotency
↓
Queue
↓
Worker
↓
Observe
↓
Retry / Reconcile
↓
Recover
↓
Audit
가 된다.