mega-framework 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. package/package.json +1 -1
  2. package/sample/crud/apps/main/controllers/upload-controller.js +28 -5
  3. package/sample/crud/apps/main/routes/upload.js +20 -1
  4. package/sample/crud/apps/main/services/guide-service.js +4 -3
  5. package/sample/crud/apps/main/services/upload-demo-service.js +6 -5
  6. package/sample/crud/apps/main/views/upload/index.ejs +4 -1
  7. package/sample/crud/docs/guide/01-cli.md +587 -0
  8. package/sample/crud/docs/guide/02-router-controller.md +497 -0
  9. package/sample/crud/docs/guide/03-service-model-db.md +929 -0
  10. package/sample/crud/docs/guide/04-websocket-asp.md +632 -0
  11. package/sample/crud/docs/guide/05-scheduler-job-worker.md +400 -0
  12. package/sample/crud/docs/guide/06-config-auth-session-security.md +495 -0
  13. package/sample/crud/docs/guide/07-view-i18n-static-multipart.md +462 -0
  14. package/sample/crud/docs/guide/08-observability.md +373 -0
  15. package/sample/crud/var/uploads/(/355/212/271/352/270/260/354/236/220) 2026 /353/251/264/354/240/221/354/225/210/353/202/264-mqbnwq5v-d2125aa8.txt" +1 -0
  16. package/sample/crud/var/uploads/(/355/212/271/352/270/260/354/236/220) 2026 /353/251/264/354/240/221/354/225/210/353/202/264-mqbo0nbf-842b6135.txt" +1 -0
  17. package/sample/crud/var/uploads/00_b-mqbnh7lc-c9790ab8.png +0 -0
  18. package/sample/crud/var/uploads/2025-07-22 13 35 56-mqbngvvk-e545e90e.png +0 -0
  19. package/sample/crud/var/uploads/big-file-mqbo1h9e-2957eaf5.png +0 -0
  20. package/sample/crud/var/uploads//341/204/200/341/205/247/341/206/274/341/204/200/341/205/265/341/204/211/341/205/265/341/206/257/341/204/214/341/205/245/341/206/250/341/204/214/341/205/263/341/206/274/341/204/206/341/205/247/341/206/274/341/204/211/341/205/245-mqbo5yxh-5288d8ef.pdf +0 -0
@@ -0,0 +1,400 @@
1
+ # Scheduler + JobQueue + Worker
2
+
3
+ MEGA-FRAMEWORK 는 "시간이 되면 도는 일", "큐에 쌓아두고 나중에 처리하는 일", "CPU 를 오래 잡아먹는 무거운 계산" 을 각각 별도 추상으로 나눠 다룬다. 세 가지는 이름이 비슷해 헷갈리기 쉬우니 먼저 한 줄로 구분한다.
4
+
5
+ | 추상 | 클래스 | 무엇을 하나 | 어디서 도나 |
6
+ | --- | --- | --- | --- |
7
+ | **Schedule** | `MegaSchedule` + `MegaScheduler` | cron 시각마다 자동 실행 | `mega scheduler` 프로세스 |
8
+ | **JobQueue** | `MegaJob` + `MegaJobQueue` + `MegaJobWorker` | 큐에 쌓고(enqueue) 나중에 처리(consume) | `mega worker` 프로세스 |
9
+ | **Worker(threads)** | `MegaWorker` | CPU-heavy 계산을 스레드/프로세스 풀로 격리 | `mega start`(앱) 안에서 명시 호출 |
10
+
11
+ > ⚠️ **이름 함정**: `MegaJobWorker`(잡 소비 런타임)와 `MegaWorker`(CPU worker_threads 풀)는 **전혀 다른 추상**이다. 전자는 NATS 잡을 자동 소비하는 IO-bound 런타임이고, 후자는 `ctx.workers.<name>.run(task)` 로 명시 호출하는 CPU 격리 풀이다(ADR-120/121).
12
+
13
+ ---
14
+
15
+ ## 1. Schedule (MegaCron + MegaSchedule, ADR-118)
16
+
17
+ ### 1-1. cron 표현식이란
18
+
19
+ "매일 새벽 3시", "5분마다" 같은 **반복 시각**을 짧은 문자열로 적는 표준 표기다. MEGA 는 검증된 라이브러리 `croner`(v10)를 `MegaCron` 으로 얇게 래핑한다(ADR-029).
20
+
21
+ ```text
22
+ 0 3 * * * → 매일 03:00 (분 시 일 월 요일 — 5필드)
23
+ */5 * * * * → 5분마다
24
+ 0 0 * * 1 → 매주 월요일 00:00
25
+ */30 * * * * * → 30초마다 (초 분 시 일 월 요일 — 6필드, 맨 앞이 초)
26
+ ```
27
+
28
+ croner 는 5필드(분 단위)와 6필드(초 포함)를 모두 지원한다. `MegaCron` 은 표현식을 **검증·계산만** 하는 순수 정적 유틸이라 타이머를 걸지 않는다 — 실제 실행은 `MegaScheduler` 가 담당한다.
29
+
30
+ ```js
31
+ import { MegaCron } from 'mega-framework'
32
+
33
+ MegaCron.isValid('0 3 * * *') // true (throw 없이 boolean)
34
+ MegaCron.validate('nope') // throws Error (삼키지 않음)
35
+ MegaCron.next('0 3 * * *', undefined, { timezone: 'Asia/Seoul' }) // 다음 새벽 3시(KST)
36
+ MegaCron.nextRuns('*/30 * * * * *', 5) // 다음 5개 발생 시각(미리보기/모니터링)
37
+ ```
38
+
39
+ ### 1-2. 스케줄 클래스 정의
40
+
41
+ 스케줄 작업은 `MegaSchedule` 를 상속해 **static 설정 + `async run(ctx)`** 로 정의한다(03-api-spec §6 의 "클래스 + static 설정" 패턴 — MegaJob/MegaWorker 와 동형).
42
+
43
+ `sample/crud/apps/main/schedules/cron-counter-schedule.js`:
44
+
45
+ ```js
46
+ import { MegaSchedule } from 'mega-framework'
47
+ import { CronDemoService } from '../services/cron-demo-service.js'
48
+
49
+ export class CronCounterSchedule extends MegaSchedule {
50
+ static cron = CronDemoService.CRON_EXPR // '*/30 * * * * *' (30초마다)
51
+ static timezone = CronDemoService.TIMEZONE // 'Asia/Seoul'
52
+ static lock = { lock: 'main', ttl: 20_000 } // 분산 중복방지 (밀리초!)
53
+
54
+ async run(ctx) {
55
+ await new CronDemoService(ctx).tick('schedule')
56
+ }
57
+ }
58
+ ```
59
+
60
+ | static 필드 | 의미 |
61
+ | --- | --- |
62
+ | `cron` | cron 표현식(필수 — 없으면 register 에서 throw) |
63
+ | `timezone` | IANA 타임존(예: `'Asia/Seoul'`). 미지정 시 호스트 로컬 시각으로 폴백하며 부팅 시 1회 경고 |
64
+ | `lock` | 분산 중복방지 락 설정 `{ lock, ttl, key? }`. 미지정 시 중복방지 없이 실행 |
65
+
66
+ ### 1-3. lock — 클러스터 중복 실행 방지 (leader election)
67
+
68
+ 같은 앱을 서버 2대(또는 scheduler 프로세스 2개)에 띄우면 "30초마다 카운터 +1" 같은 스케줄이 **양쪽에서 동시에** 돌아 카운터가 2씩 오른다. 이를 막으려고 실행 직전에 **분산 락(redlock)을 딱 1개만** 잡게 한다.
69
+
70
+ - `lock.lock` — lock 어댑터 별명(`ctx.lock(alias)` 로 해석). lock 도메인은 cache 와 별개다(ADR-113).
71
+ - `lock.ttl` — 락 보유 시간(**밀리초**, 양의 정수). 락은 ttl 뒤 자동 만료돼 다음 주기에 다시 경쟁한다.
72
+ - `lock.key` — 락 자원 키. 미지정 시 `mega:schedule:<클래스명>`.
73
+
74
+ 내부 동작은 `lock.acquire(key, { ttl, retryCount: 0 })` — **단 한 번만** 시도하고, 못 잡으면(이미 누가 보유) 즉시 **skip** 한다. retry 하지 않는 이유: retry+대기는 "중복방지" 가 아니라 "줄서기" 라 의미가 다르다. 락을 잡은 1대만 `run(ctx)` 을 돌리고, 끝나면 **반드시 release**(finally) 한다.
75
+
76
+ `mega.config.js` 의 락 어댑터 선언(`sample/crud/mega.config.js`):
77
+
78
+ ```js
79
+ services: {
80
+ caches: {
81
+ lock: { driver: 'redis', url: process.env.REDIS_LOCK_URL }, // db3
82
+ },
83
+ locks: {
84
+ main: { driver: 'redlock', redis: 'lock' }, // redlock 이 위 Redis 캐시를 빌려 씀
85
+ },
86
+ },
87
+ ```
88
+
89
+ > ⚠️ **acquire 실패 = skip 의 한계**(ADR-118): `acquire` 는 (a) 경합(다른 인스턴스 보유)과 (b) 락 backend 장애(Redis 다운)를 **둘 다** throw 로 알리고, 현재 어댑터 계약은 둘을 구분하지 못한다. 그래서 스케줄러는 acquire 실패를 모두 skip 으로 보되 **에러를 `skip` 이벤트에 실어** 관측 가능하게 한다 — 소비자가 로그로 인프라 장애를 알아챌 수 있다.
90
+
91
+ ### 1-4. 실행·이벤트·runNow
92
+
93
+ `MegaScheduler` 는 순수 런타임이라 logger 를 모른다. 대신 흐름 길목을 **이벤트**로 노출하므로 소비자(앱·CLI)가 구독해 로그를 박는다. 타이머 자동 실행 경로는 호출자가 없으므로 `fail`/`skip` 구독이 사실상 필수다.
94
+
95
+ | 이벤트 | 시점 |
96
+ | --- | --- |
97
+ | `run` | 실행 시작(락 획득 후, 또는 락 미사용) |
98
+ | `skip` | 락 미획득으로 건너뜀 — `event.error` 에 사유 |
99
+ | `done` | 성공 완료 |
100
+ | `fail` | `run()` 실패 또는 락 release 실패 — `event.error`/`event.phase` |
101
+
102
+ ```js
103
+ const scheduler = new MegaScheduler({ ctx }) // ctx 는 lock 사용 시 ctx.lock(alias) 필요
104
+ scheduler.on('skip', (e) => log.debug(e, 'schedule skipped'))
105
+ scheduler.on('fail', (e) => log.error(e, 'schedule failed'))
106
+ scheduler.register(CronCounterSchedule).start()
107
+
108
+ // 수동/테스트 트리거 — cron 시각 무시하고 지금 즉시 1회(분산 락 로직은 그대로 적용)
109
+ await scheduler.runNow('CronCounterSchedule')
110
+
111
+ // graceful shutdown — 안 부르면 타이머가 프로세스 종료를 늦출 수 있다
112
+ scheduler.stop()
113
+ ```
114
+
115
+ 이벤트명은 화이트리스트로 검증한다 — 오타 이벤트명은 조용히 무시하지 않고 `RangeError` 로 즉시 드러낸다(`on`/`off`/`once`/`addListener`/`prepend*` 모두 동일 보호).
116
+
117
+ > `register()` 는 cron 표현식·timezone·lock 설정·lock 별명을 **부팅 시점에 즉시 검증**한다. 잘못된 등록(무효 cron, 미선언 lock 별명, ttl 형식 오류 등)은 첫 트리거 때까지 숨지 않고 등록 시점에 throw 된다(fail-fast).
118
+
119
+ ---
120
+
121
+ ## 2. JobQueue (MegaJob + MegaJobQueue + MegaJobWorker)
122
+
123
+ ### 2-1. 왜 JetStream 인가
124
+
125
+ 보통 메시지(core NATS)는 받을 사람이 그 순간 없으면 사라진다(at-most-once). 잡은 "꼭 한 번은 처리돼야" 하므로 서버에 **저장(persist)** 해두고, 워커가 가져가 처리한 뒤 "처리 완료(ack)" 를 보내야 지워지는 큐가 필요하다 — 그게 JetStream 의 **workqueue** 스트림이다. 여러 워커가 같은 큐를 봐도 메시지 1건은 **딱 한 워커**에게만 간다(별도 leader election 없이 큐 자체가 분산 중복방지).
126
+
127
+ ### 2-2. 잡 클래스 정의
128
+
129
+ `MegaJob` 을 상속해 **static 설정 + `async run(payload, ctx)`** 로 정의한다.
130
+
131
+ `sample/crud/apps/main/jobs/email-job.js`:
132
+
133
+ ```js
134
+ import { MegaJob } from 'mega-framework'
135
+
136
+ export class EmailJob extends MegaJob {
137
+ static subject = 'demo.email'
138
+ static bus = 'jobs'
139
+ static concurrency = 2
140
+ static retries = 2 // 추가 재시도 2회 (첫 시도 포함 최대 3회)
141
+ static backoff = { type: 'exponential', initial: 500, max: 2000 }
142
+
143
+ async run(payload, ctx) {
144
+ const { id, to, mode } = payload
145
+ const redis = ctx.cache('demo').native
146
+ const attempt = await redis.incr(`demo:jobs:attempt:${id}`)
147
+
148
+ if (mode === 'flaky' && attempt < 2) {
149
+ throw new Error(`transient failure on attempt ${attempt} (will retry)`) // 재시도 트리거
150
+ }
151
+ if (mode === 'fail') {
152
+ throw new Error(`permanent failure (bound for DLQ)`) // 재시도 소진 → DLQ
153
+ }
154
+ return { id, status: 'sent', attempt }
155
+ }
156
+ }
157
+ ```
158
+
159
+ | static 필드 | 기본값 | 의미 |
160
+ | --- | --- | --- |
161
+ | `subject` | (필수) | 잡 NATS subject. 글자·숫자·`.`·`_`·`-` 만 허용(와일드카드·공백 금지), `.dlq` 로 끝나면 안 됨 |
162
+ | `bus` | (필수) | bus 별명(`ctx.bus(alias)` → NatsConnection). 워커 배선이 사용 |
163
+ | `concurrency` | `1` | 동시 처리 메시지 수(consumer `max_ack_pending`). **양의 정수만** — 0/음수는 NATS 가 "무제한" 으로 해석하는 풋건이라 거부. ⚠️ **워커 그룹(전 인스턴스) 합산 상한** — 인스턴스를 늘려도 합산 in-flight 가 이 값을 못 넘으므로 처리량 증설 시 함께 키울 것 |
164
+ | `retries` | `3` | run 실패 시 **추가** 재시도 횟수(첫 시도 제외) |
165
+ | `backoff` | `{ type:'exponential', initial:1000, max:30000 }` | 지수 백오프. **현재 `'exponential'` 만 지원**, `initial`/`max` 는 ms |
166
+
167
+ > **핵심**: 일시·영구 오류는 그냥 `throw` 하면 된다. `MegaJobQueue` 가 `static retries`/`static backoff` 만큼 인프로세스 지수 백오프(p-retry, factor=2·jitter on 고정)로 재시도하고, 모두 실패하면 DLQ 로 보낸다.
168
+
169
+ > ⚠️ 백오프 필드는 `{ type, initial, max }` 다 — `min`/`factor` 가 아니다. factor 는 2 로, jitter 는 on 으로 내부 고정된다(OQ-012 디폴트).
170
+
171
+ ### 2-3. enqueue (큐에 넣기)
172
+
173
+ producer 측은 `ctx.bus(alias).native` 로 raw `NatsConnection` 을 얻어 `MegaJobQueue` 를 만들고 `enqueue(JobClass, payload)` 를 호출한다. `MegaJobQueue` 는 별명을 해석하지 않고 **연결된 nc 를 직접 받는다**.
174
+
175
+ `sample/crud/apps/main/services/jobs-demo-service.js`:
176
+
177
+ ```js
178
+ import { MegaService, MegaJobQueue } from 'mega-framework'
179
+ import { EmailJob } from '../jobs/email-job.js'
180
+
181
+ export class JobsDemoService extends MegaService {
182
+ async enqueue(payload) {
183
+ const nc = this.ctx.bus('jobs').native // 연결된 NatsConnection (ADR-009)
184
+ const queue = new MegaJobQueue({ nc })
185
+ return queue.enqueue(EmailJob, payload) // JetStream publish — 서버에 영속 저장
186
+ }
187
+ }
188
+ ```
189
+
190
+ `MegaJobWorker` 인스턴스가 있으면 `worker.enqueue(JobClass, payload)` 로도 편의 위임할 수 있다.
191
+
192
+ ### 2-4. consume (처리) — MegaJobWorker
193
+
194
+ 소비는 `MegaJobWorker` 가 담당한다. `MegaJobQueue.consume()` 을 직접 호출하지 않고, 잡 클래스를 `register()` 한 뒤 `start()` 하면 된다. 같은 bus 의 여러 잡은 **하나의 `MegaJobQueue` 인스턴스를 공유**한다(bus 별명당 1개).
195
+
196
+ ```js
197
+ const worker = new MegaJobWorker({ ctx }) // ctx.bus(alias).native 로 nc 해석
198
+ worker.on('dlq', (e) => log.error(e, 'job moved to DLQ'))
199
+ worker.on('fail', (e) => log.error(e, 'job failed'))
200
+ worker.register(EmailJob)
201
+ await worker.start()
202
+
203
+ // graceful shutdown 통합
204
+ MegaShutdown.register('worker', async () => { await worker.stop() })
205
+ ```
206
+
207
+ `MegaJobWorker` 의 concurrency 모델은 **worker_threads 가 아니라 같은 이벤트루프의 Promise 동시성**이다(ADR-120). 잡은 대부분 IO-bound 이고 동시 처리 상한은 이미 두 겹(NATS `max_ack_pending` + 인프로세스 Promise 상한)으로 걸리므로 별도 스레드 풀이 필요 없다.
208
+
209
+ 워커는 하부 큐의 이벤트를 **그대로 재방출(forward)** 한다 — `dispatch`(enqueue) · `start`(처리 시작) · `done`(성공 ack) · `retry`(재시도 1회 실패) · `fail`(최종 실패, `phase` 로 단계 구분) · `dlq`(DLQ 라우팅). 소비자는 워커 1곳만 구독하면 모든 잡 큐의 길목을 관측할 수 있다.
210
+
211
+ ### 2-5. DLQ (Dead Letter Queue)
212
+
213
+ 재시도를 모두 소진하면 페이로드+에러 메타를 **DLQ subject `<subject>.dlq`** 로 발행하고 원본 메시지를 ack 한다. DLQ 발행이 실패하면 ack 하지 않고 `nak` 해 **잡을 잃지 않는다**(at-least-once).
214
+
215
+ - DLQ 스트림 이름: `MEGA_JOBS_<sanitized subject>_DLQ`(예: `demo.email` → `MEGA_JOBS_demo_email_DLQ`).
216
+ - DLQ 는 `Limits` retention 스트림이라 디폴트로는 무한 적재된다(디스크 소진 위험). 그래서 **`max_age` 디폴트 7일**(`dlqMaxAgeMs`)을 걸어 오래된 실패 잡을 자동 만료시킨다(ADR-134). `dlqMaxAgeMs: 0` 으로 끄면 영구 보존, `dlqMaxBytes` 로 디스크 상한도 걸 수 있다.
217
+ - 스트림 생성은 **멱등** — 이미 있으면 그대로 두므로 한도는 **신규 스트림에만** 적용된다(기존 스트림은 운영자가 NATS CLI 로 갱신).
218
+
219
+ DLQ 상태를 읽는 쪽 예시(`jobs-demo-service.js`):
220
+
221
+ ```js
222
+ const jsm = await this.ctx.bus('jobs').native.jetstreamManager()
223
+ const info = await jsm.streams.info('MEGA_JOBS_demo_email_DLQ') // count = info.state.messages
224
+ const msg = await jsm.streams.getMessage(DLQ_STREAM, { last_by_subj: 'demo.email.dlq' })
225
+ ```
226
+
227
+ ---
228
+
229
+ ## 3. Worker (worker_threads, MegaWorker, ADR-124)
230
+
231
+ ### 3-1. 무엇을 위한 것인가
232
+
233
+ SHA-256 N회 반복, 이미지 리사이즈, 압축 같은 **CPU-bound 작업**을 메인 스레드에서 돌리면 그 동안 HTTP 이벤트 루프가 멈춰 다른 요청에 응답하지 못한다. `MegaWorker` 는 이런 무거운 계산을 **worker_threads(또는 child_process) 풀**로 격리해, 계산이 도는 동안에도 메인 이벤트 루프가 막히지 않게 한다.
234
+
235
+ ### 3-2. 워커 클래스 + taskFile
236
+
237
+ worker_threads/child_process 는 별도 파일을 실행하고 **함수·클로저를 경계 너머로 넘길 수 없다**(structured clone/IPC 는 데이터만). 그래서 작업 로직은 서브클래스 메서드 인라인이 아니라 **`static taskFile` 모듈에 named export 한 async 함수**로 둔다(Piscina/workerpool 업계 표준, ADR-124).
238
+
239
+ `sample/crud/apps/main/workers/hash-worker.js`:
240
+
241
+ ```js
242
+ import { MegaWorker } from 'mega-framework'
243
+
244
+ export class HashWorker extends MegaWorker {
245
+ static name = 'hash'
246
+ static taskFile = 'apps/main/workers/hash.task.js'
247
+ static mode = 'thread' // 'thread'(가벼움) | 'process'(완전 격리, 더 무거움)
248
+ static poolSize = 2
249
+ }
250
+ ```
251
+
252
+ `sample/crud/apps/main/workers/hash.task.js` — **평범한 async 함수를 named export**(메시지 루프 보일러플레이트 없음):
253
+
254
+ ```js
255
+ import { createHash } from 'node:crypto'
256
+
257
+ export async function sha256Loop(task) {
258
+ const rounds = typeof task.rounds === 'number' && task.rounds > 0 ? Math.floor(task.rounds) : 1_000_000
259
+ let digest = String(task.input ?? 'mega')
260
+ for (let i = 0; i < rounds; i++) {
261
+ digest = createHash('sha256').update(digest).digest('hex')
262
+ }
263
+ return { digest, rounds }
264
+ }
265
+ ```
266
+
267
+ | static 필드 | 기본값 | 의미 |
268
+ | --- | --- | --- |
269
+ | `name` | 클래스명 | 전역 등록 키(`ctx.workers[name]`) |
270
+ | `taskFile` | (필수) | task 함수를 named export 한 모듈 경로(projectRoot 기준 상대경로 가능) |
271
+ | `mode` | `'thread'` | `'thread'`=worker_threads, `'process'`=child_process.fork. 둘 다 node 빌트인(의존성 0) |
272
+ | `poolSize` | `os.cpus().length - 1`(최소 1) | 풀 크기(양의 정수) |
273
+ | `maxRestarts` | `5` | crash 시 풀 전체 교체 워커 재시작 총량 |
274
+
275
+ ### 3-3. 호출 — ctx.workers[name].run(taskName, args, opts)
276
+
277
+ `config.workers` 에 등록하면 `mega start`(boot)가 `ctx.workers['hash']` 에 자동 배선한다.
278
+
279
+ `sample/crud/apps/main/controllers/worker-controller.js`:
280
+
281
+ ```js
282
+ const out = await ctx.workers['hash'].run(
283
+ 'sha256Loop', // taskFile 이 export 한 함수명
284
+ { input: 'mega', rounds }, // args — structured clone/IPC 가능한 데이터만
285
+ { timeoutMs: 60_000 }, // 초과 시 worker.task_timeout reject + 워커 교체
286
+ )
287
+ // out 은 task 함수의 반환값 그대로: { digest, rounds }
288
+ return reply.send({ digest: out.digest, rounds: out.rounds, ms })
289
+ ```
290
+
291
+ - 유휴 워커가 있으면 즉시 디스패치, 없으면 큐 대기 후 가용 시 처리한다.
292
+ - **crash 자동 재시작**: 워커가 예기치 않게 죽으면 in-flight task 를 `worker.crashed` 로 reject 하고 `maxRestarts` 까지 교체 워커를 띄운다.
293
+ - **graceful shutdown**: `stop()` 은 새 `run()` 을 거부하고 큐 대기분을 `worker.stopped` 로 reject, in-flight 완료를 기다린 뒤 워커를 terminate 한다.
294
+
295
+ 이벤트: `dispatch` · `done` · `fail` · `crash` · `stopped`.
296
+
297
+ ---
298
+
299
+ ## 4. CLI 호스트
300
+
301
+ 세 추상은 도는 프로세스가 다르다.
302
+
303
+ | 명령 | 역할 | 등록 소스 |
304
+ | --- | --- | --- |
305
+ | `mega start` | 웹 서버 + `ctx.workers` CPU 풀 배선(boot) | `config.workers` |
306
+ | `mega scheduler` | 분산 스케줄러 호스트(`MegaScheduler`) | `config.schedules` (+ 플러그인 등록분) |
307
+ | `mega worker` | 잡 소비 워커 런타임 호스트(`MegaJobWorker`) | `config.jobs` (+ 플러그인 등록분) |
308
+
309
+ `mega scheduler`/`mega worker` 는 `mega start` 와 **같은 토대**(config → 플러그인 install → 어댑터 connect → ctx)를 쓰되, 등록 소스가 다르다(ADR-123). 등록은 **명시 등록만**(auto-discovery 없음) — `config.schedules`/`config.jobs` 정적 배열 + 플러그인이 register 한 분을 합친다.
310
+
311
+ > CPU 워커 풀(`config.workers`)은 별도 호스트가 아니라 `mega start`/boot 가 `ctx.workers` 로 배선한다 — 워커는 글로벌 공유 자원이라 요청·앱 스코프 밖에서 전역 키(`static name`)로 관리된다.
312
+
313
+ `sample/crud/mega.config.js` 등록:
314
+
315
+ ```js
316
+ import { CronCounterSchedule } from './apps/main/schedules/cron-counter-schedule.js'
317
+ import { EmailJob } from './apps/main/jobs/email-job.js'
318
+ import { HashWorker } from './apps/main/workers/hash-worker.js'
319
+
320
+ export default {
321
+ // ...
322
+ schedules: [CronCounterSchedule], // mega scheduler 가 실행
323
+ jobs: [EmailJob], // mega worker 가 소비
324
+ workers: [HashWorker], // mega start(boot)가 ctx.workers 로 배선
325
+ }
326
+ ```
327
+
328
+ PM2 ecosystem(`sample/crud/ecosystem.config.cjs`) — 세 프로세스를 따로 띄운다:
329
+
330
+ ```js
331
+ module.exports = {
332
+ apps: [
333
+ { name: 'sample-crud-server', script: 'node_modules/.bin/mega', args: 'start', instances: 1 },
334
+ { name: 'sample-crud-scheduler', script: 'node_modules/.bin/mega', args: 'scheduler', instances: 1 },
335
+ { name: 'sample-crud-worker', script: 'node_modules/.bin/mega', args: 'worker', instances: 2 },
336
+ ],
337
+ }
338
+ ```
339
+
340
+ 잡 메트릭(ADR-132)은 `mega worker` 호스트가 `MegaMetrics.subscribeJobs(worker)` 로 구독한다 — `health.exposeMetrics` 옵트인 시 enqueue(dispatch)부터 done/retry/fail/dlq 까지 Prometheus 로 집계되고, 옵트인 OFF 면 no-op 이다.
341
+
342
+ ---
343
+
344
+ ## 5. 클러스터 환경 동작
345
+
346
+ 여러 인스턴스를 띄울 때 세 추상이 중복 없이 도는 방식이 각기 다르다.
347
+
348
+ | 추상 | 클러스터 중복방지 메커니즘 |
349
+ | --- | --- |
350
+ | **Schedule** | redlock **leader election** — 실행 직전 분산 락을 1개만 잡아 한 인스턴스만 실행(나머지는 skip) |
351
+ | **Job** | NATS **durable consumer group** — 같은 durable 이름을 공유하는 워커들에 메시지가 자연 분산(1건은 1워커) |
352
+ | **Worker** | 각 워커 프로세스가 **자체 worker_thread 풀** 보유 — 공유 자원 아님 |
353
+
354
+ 즉 scheduler 는 instances 를 늘려도 락으로 단일 실행이 보장되고(가용성용 다중화), worker 는 instances:2 로 늘리면 잡 처리량이 자연 분산되며(`ecosystem` 의 worker instances:2), CPU 워커 풀은 각 서버 프로세스 안에서 독립적으로 돈다.
355
+
356
+ ---
357
+
358
+ ## 6. 함정
359
+
360
+ ### Schedule lock TTL 튜닝
361
+ ttl 이 너무 **짧으면** 작업이 ttl 을 넘길 때 락이 자동 만료돼 다른 인스턴스가 같은 작업을 중복 실행할 수 있다(자동 연장 미사용). 너무 **길면** leader 가 죽었을 때 다른 인스턴스가 ttl 만료까지 대기해 그 주기를 건너뛴다. **작업 예상 소요보다 넉넉하되 다음 cron 주기보다는 짧게** 잡는다(데모는 30초 주기에 ttl 20초).
362
+
363
+ ### Job retry 멱등성
364
+ 재시도(p-retry)와 워커 크래시 재전달(JetStream `max_deliver`)로 인해 잡은 **at-least-once** 다 — 같은 메시지가 2번 이상 실행될 수 있다. `run(payload, ctx)` 이 멱등하지 않으면(예: "결제 +1" 을 비조건적으로) 중복 효과가 난다. 멱등 키나 조건부 갱신으로 설계한다.
365
+
366
+ ### worker_threads args 직렬화
367
+ `run(taskName, args)` 의 `args` 는 **structured clone / IPC 가능한 데이터만** 넘길 수 있다 — 함수·클래스 인스턴스·순환 참조는 안 된다. 직렬화 불가 인자는 디스패치 시점에 `worker.invalid_args` 로 즉시 reject 된다(silent drop 아님, fail-fast).
368
+
369
+ ### 워커 run 결과 vs HTTP 응답 엔벨로프
370
+ `ctx.workers[name].run(...)` 자체는 **task 함수의 반환값을 그대로** 돌려준다(예: `{ digest, rounds }`). 다만 이를 `reply.send(...)` 로 HTTP JSON 응답에 실으면 프레임워크가 응답을 `{ ok, data, meta }` 엔벨로프로 감싼다(정본 응답 포맷). 그래서 **브라우저 쪽은 `body.data.*`** 로 읽어야 한다 — 워커 결과 구조와 HTTP 응답 구조를 혼동하지 말 것.
371
+
372
+ ```js
373
+ // /demo/worker 클라이언트
374
+ .then((body) => {
375
+ var data = body && body.data ? body.data : body // 엔벨로프 언랩
376
+ show(data.digest, data.rounds, data.ms)
377
+ })
378
+ ```
379
+
380
+ ---
381
+
382
+ ## 7. sample/crud 데모
383
+
384
+ 세 추상을 실제로 조립한 데모 페이지(로그인 필요, `webRequireAuth`):
385
+
386
+ | 페이지 | 보여주는 것 | 관련 파일 |
387
+ | --- | --- | --- |
388
+ | `/demo/cron` | 누적 실행 횟수·이력·다음 실행 시각. 자동(30초 주기, `mega scheduler`)과 수동(POST 버튼)이 같은 redis 에 함께 쌓인다 | `controllers/cron-controller.js`, `services/cron-demo-service.js`, `schedules/cron-counter-schedule.js` |
389
+ | `/demo/jobs` | EmailJob enqueue 폼 + 처리 타임라인 + DLQ 격리분. `ok`/`flaky`(재시도 후 성공)/`fail`(DLQ) 세 흐름 시연 | `controllers/jobs-controller.js`, `services/jobs-demo-service.js`, `jobs/email-job.js` |
390
+ | `/demo/worker` | CPU 해시를 워커 풀에서 실행. 계산 도중에도 1초 하트비트 ping 이 끊기지 않아 **메인 스레드 non-block** 을 눈으로 확인 | `controllers/worker-controller.js`, `workers/hash-worker.js`, `workers/hash.task.js`, `public/js/worker-demo.js` |
391
+
392
+ ---
393
+
394
+ ## 관련 ADR
395
+
396
+ - **ADR-118** — Schedule lock(redlock leader election), acquire 실패=skip 의 한계
397
+ - **ADR-123** — `config.jobs`/`config.schedules` 등록 소스 흡수(정적 + 플러그인)
398
+ - **ADR-124** — Worker worker_threads, `static taskFile` named export, `run(taskName, args)`
399
+ - **ADR-132** — `MegaMetrics.subscribeJobs` 잡 큐 메트릭
400
+ - **ADR-162** — cron + jobs + worker sample 데모 조립