@fuzionx/framework 0.1.61 → 0.1.62

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.
package/index.js CHANGED
@@ -36,6 +36,7 @@ export { default as Pagination } from './lib/database/Pagination.js';
36
36
  export { default as Service } from './lib/services/Service.js';
37
37
  export { default as EventBus } from './lib/services/EventBus.js';
38
38
  export { default as Storage } from './lib/services/Storage.js';
39
+ export { default as CacheManager } from './lib/cache/CacheManager.js';
39
40
 
40
41
  // ── Realtime ──
41
42
  export { default as WsHandler, EventBuilder } from './lib/realtime/WsHandler.js';
@@ -64,4 +65,4 @@ export { default as OpenAPI } from './lib/view/OpenAPI.js';
64
65
  export { PaginationUtil, StrUtil, NumUtil, DateUtil, ArrUtil, FunctionUtil, ObjectUtil } from './lib/utilities/index.js';
65
66
 
66
67
  // ── Built-in Middleware ──
67
- export { bodyParser, cors, auth, apiAuth, csrf, session, theme, loadUser } from './lib/middleware/index.js';
68
+ export { bodyParser, cors, auth, apiAuth, csrf, session, theme, loadUser, roleGuard } from './lib/middleware/index.js';
@@ -0,0 +1,183 @@
1
+ /**
2
+ * CacheManager — 캐시 관리자
3
+ *
4
+ * fuzionx.yaml의 `cache` 설정을 읽어 적절한 드라이버를 초기화.
5
+ * 지원 드라이버: redis, file
6
+ *
7
+ * 워커 간 공유가 필요하므로 인메모리(Map)는 지원하지 않음.
8
+ * Redis 또는 파일시스템을 통해 프로세스 간 캐시를 공유.
9
+ *
10
+ * @example
11
+ * // Service 내에서 사용
12
+ * const stats = await this.cache.get('dashboard:stats');
13
+ * await this.cache.set('dashboard:stats', data, 300);
14
+ *
15
+ * // withCache 패턴 (캐시 미스 시 자동 실행)
16
+ * const result = await this.cache.remember('user:1', 600, async () => {
17
+ * return this.db.User.find(1);
18
+ * });
19
+ *
20
+ * @see docs/framework/cache.md
21
+ */
22
+ export default class CacheManager {
23
+ /**
24
+ * @param {object} config - cache 설정 객체
25
+ * @param {string} config.driver - 'redis' | 'file'
26
+ * @param {string} [config.redis_url] - Redis 접속 URL (driver='redis')
27
+ * @param {string} [config.prefix] - 캐시 키 접두사
28
+ * @param {number} [config.ttl] - 기본 TTL (초)
29
+ * @param {string} [config.file_dir] - 파일 캐시 디렉토리 (driver='file')
30
+ */
31
+ constructor(config = {}) {
32
+ /** @type {string} 캐시 드라이버 */
33
+ this.driverName = (config.driver || 'file').toLowerCase();
34
+
35
+ /** @type {string} 캐시 키 접두사 */
36
+ this.prefix = config.prefix || '';
37
+
38
+ /** @type {number} 기본 TTL (초) */
39
+ this.defaultTtl = config.ttl || 3600;
40
+
41
+ /** @type {import('./drivers/RedisDriver.js').default | import('./drivers/FileDriver.js').default | null} 드라이버 인스턴스 */
42
+ this._driver = null;
43
+
44
+ /** @type {object} 원본 설정 */
45
+ this._config = config;
46
+
47
+ /** @type {boolean} 초기화 완료 여부 */
48
+ this._ready = false;
49
+ }
50
+
51
+ /**
52
+ * 드라이버 초기화 (지연 로딩)
53
+ *
54
+ * @returns {Promise<void>}
55
+ */
56
+ async init() {
57
+ if (this._ready) return;
58
+
59
+ if (this.driverName === 'redis') {
60
+ const { default: RedisDriver } = await import('./drivers/RedisDriver.js');
61
+ this._driver = new RedisDriver({
62
+ url: this._config.redis_url,
63
+ prefix: this.prefix,
64
+ });
65
+ await this._driver.connect();
66
+ } else if (this.driverName === 'file') {
67
+ const { default: FileDriver } = await import('./drivers/FileDriver.js');
68
+ this._driver = new FileDriver({
69
+ dir: this._config.file_dir || './storage/cache',
70
+ prefix: this.prefix,
71
+ });
72
+ await this._driver.init();
73
+ } else {
74
+ throw new Error(`[CacheManager] 지원하지 않는 캐시 드라이버: ${this.driverName}`);
75
+ }
76
+
77
+ this._ready = true;
78
+ }
79
+
80
+ /**
81
+ * 캐시 값 조회
82
+ *
83
+ * @param {string} key - 캐시 키
84
+ * @returns {Promise<*|null>} 캐시 값 또는 null
85
+ */
86
+ async get(key) {
87
+ if (!this._ready) await this.init();
88
+ return this._driver.get(key);
89
+ }
90
+
91
+ /**
92
+ * 캐시 값 저장
93
+ *
94
+ * @param {string} key - 캐시 키
95
+ * @param {*} value - 저장할 값 (JSON 직렬화 가능)
96
+ * @param {number} [ttl] - TTL (초), 기본값: this.defaultTtl
97
+ * @returns {Promise<void>}
98
+ */
99
+ async set(key, value, ttl) {
100
+ if (!this._ready) await this.init();
101
+ return this._driver.set(key, value, ttl || this.defaultTtl);
102
+ }
103
+
104
+ /**
105
+ * 캐시 키 존재 여부
106
+ *
107
+ * @param {string} key - 캐시 키
108
+ * @returns {Promise<boolean>}
109
+ */
110
+ async has(key) {
111
+ if (!this._ready) await this.init();
112
+ return this._driver.has(key);
113
+ }
114
+
115
+ /**
116
+ * 캐시 키 삭제
117
+ *
118
+ * @param {string} key - 캐시 키
119
+ * @returns {Promise<void>}
120
+ */
121
+ async delete(key) {
122
+ if (!this._ready) await this.init();
123
+ return this._driver.delete(key);
124
+ }
125
+
126
+ /**
127
+ * 패턴 기반 캐시 삭제
128
+ *
129
+ * @param {string} pattern - 키 패턴 (예: 'user:*')
130
+ * @returns {Promise<number>} 삭제된 키 수
131
+ */
132
+ async deletePattern(pattern) {
133
+ if (!this._ready) await this.init();
134
+ return this._driver.deletePattern(pattern);
135
+ }
136
+
137
+ /**
138
+ * 전체 캐시 초기화 (prefix 범위)
139
+ *
140
+ * @returns {Promise<void>}
141
+ */
142
+ async flush() {
143
+ if (!this._ready) await this.init();
144
+ return this._driver.flush();
145
+ }
146
+
147
+ /**
148
+ * 캐시 미스 시 자동 실행 (remember 패턴)
149
+ *
150
+ * 캐시가 존재하면 반환, 없으면 fn()을 실행하고 결과를 캐시에 저장.
151
+ *
152
+ * @param {string} key - 캐시 키
153
+ * @param {number} ttl - TTL (초)
154
+ * @param {Function} fn - 캐시 미스 시 실행할 함수
155
+ * @returns {Promise<*>}
156
+ *
157
+ * @example
158
+ * const user = await this.cache.remember('user:1', 300, async () => {
159
+ * return this.db.User.find(1);
160
+ * });
161
+ */
162
+ async remember(key, ttl, fn) {
163
+ const cached = await this.get(key);
164
+ if (cached !== null) return cached;
165
+
166
+ const value = await fn();
167
+ await this.set(key, value, ttl);
168
+ return value;
169
+ }
170
+
171
+ /**
172
+ * 연결 해제
173
+ *
174
+ * @returns {Promise<void>}
175
+ */
176
+ async disconnect() {
177
+ if (this._driver?.disconnect) {
178
+ await this._driver.disconnect();
179
+ }
180
+ this._ready = false;
181
+ this._driver = null;
182
+ }
183
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * FileDriver — 파일 기반 캐시 드라이버
3
+ *
4
+ * 캐시 데이터를 JSON 파일로 저장.
5
+ * 각 키는 개별 .json 파일로 관리되며, 메타데이터(expires)를 함께 저장.
6
+ * GC(Garbage Collection)로 만료된 파일을 주기적으로 정리.
7
+ *
8
+ * 파일명은 키를 SHA-256 해시하여 충돌 방지.
9
+ *
10
+ * @see lib/cache/CacheManager.js
11
+ */
12
+ import { createHash } from 'node:crypto';
13
+ import { promises as fs } from 'node:fs';
14
+ import path from 'node:path';
15
+
16
+ export default class FileDriver {
17
+ /**
18
+ * @param {object} opts
19
+ * @param {string} opts.dir - 캐시 파일 저장 디렉토리
20
+ * @param {string} [opts.prefix] - 캐시 키 접두사
21
+ */
22
+ constructor(opts = {}) {
23
+ /** @type {string} 캐시 디렉토리 */
24
+ this._dir = path.resolve(opts.dir || './storage/cache');
25
+
26
+ /** @type {string} 키 접두사 */
27
+ this._prefix = opts.prefix ? `${opts.prefix}:` : '';
28
+
29
+ /** @type {NodeJS.Timeout|null} GC 타이머 */
30
+ this._gcTimer = null;
31
+ }
32
+
33
+ /**
34
+ * 초기화 — 캐시 디렉토리 생성 + GC 스케줄링
35
+ *
36
+ * @returns {Promise<void>}
37
+ */
38
+ async init() {
39
+ await fs.mkdir(this._dir, { recursive: true });
40
+
41
+ // 5분마다 만료 캐시 정리
42
+ this._gcTimer = setInterval(() => this._gc(), 5 * 60 * 1000);
43
+ if (this._gcTimer.unref) this._gcTimer.unref(); // 프로세스 종료 차단 방지
44
+ }
45
+
46
+ /**
47
+ * 키 → 파일 경로 변환
48
+ *
49
+ * SHA-256 해시로 파일명 생성 (키에 특수문자가 있어도 안전).
50
+ *
51
+ * @param {string} key
52
+ * @returns {string} 파일 절대 경로
53
+ * @private
54
+ */
55
+ _filePath(key) {
56
+ const fullKey = `${this._prefix}${key}`;
57
+ const hash = createHash('sha256').update(fullKey).digest('hex');
58
+ return path.join(this._dir, `${hash}.json`);
59
+ }
60
+
61
+ /**
62
+ * 캐시 값 조회
63
+ *
64
+ * 파일을 읽어 만료 여부를 확인하고 값을 반환.
65
+ *
66
+ * @param {string} key
67
+ * @returns {Promise<*|null>}
68
+ */
69
+ async get(key) {
70
+ const filePath = this._filePath(key);
71
+ try {
72
+ const raw = await fs.readFile(filePath, 'utf8');
73
+ const entry = JSON.parse(raw);
74
+
75
+ // 만료 확인
76
+ if (entry.expires > 0 && entry.expires < Date.now()) {
77
+ // 만료된 파일 삭제 (비동기, 에러 무시)
78
+ fs.unlink(filePath).catch(() => {});
79
+ return null;
80
+ }
81
+
82
+ return entry.value;
83
+ } catch {
84
+ return null; // 파일 없거나 파싱 실패
85
+ }
86
+ }
87
+
88
+ /**
89
+ * 캐시 값 저장
90
+ *
91
+ * @param {string} key
92
+ * @param {*} value - JSON 직렬화 가능한 값
93
+ * @param {number} ttl - TTL (초)
94
+ * @returns {Promise<void>}
95
+ */
96
+ async set(key, value, ttl) {
97
+ const filePath = this._filePath(key);
98
+ const entry = {
99
+ key: `${this._prefix}${key}`, // 디버깅용 원본 키 보존
100
+ value,
101
+ expires: ttl > 0 ? Date.now() + (ttl * 1000) : 0, // 0이면 무기한
102
+ created_at: Date.now(),
103
+ };
104
+ await fs.writeFile(filePath, JSON.stringify(entry), 'utf8');
105
+ }
106
+
107
+ /**
108
+ * 캐시 키 존재 여부
109
+ *
110
+ * @param {string} key
111
+ * @returns {Promise<boolean>}
112
+ */
113
+ async has(key) {
114
+ const value = await this.get(key); // get()이 만료 체크도 수행
115
+ return value !== null;
116
+ }
117
+
118
+ /**
119
+ * 캐시 키 삭제
120
+ *
121
+ * @param {string} key
122
+ * @returns {Promise<void>}
123
+ */
124
+ async delete(key) {
125
+ const filePath = this._filePath(key);
126
+ await fs.unlink(filePath).catch(() => {});
127
+ }
128
+
129
+ /**
130
+ * 패턴 기반 캐시 삭제
131
+ *
132
+ * 파일 캐시에서는 모든 파일을 순회하며 키 매칭 수행.
133
+ * 와일드카드 '*'을 정규식으로 변환.
134
+ *
135
+ * @param {string} pattern - 키 패턴 (예: 'user:*')
136
+ * @returns {Promise<number>} 삭제된 키 수
137
+ */
138
+ async deletePattern(pattern) {
139
+ const fullPattern = `${this._prefix}${pattern}`;
140
+ // 와일드카드 → 정규식 변환
141
+ const regex = new RegExp('^' + fullPattern.replace(/\*/g, '.*') + '$');
142
+
143
+ let deleted = 0;
144
+ try {
145
+ const files = await fs.readdir(this._dir);
146
+ for (const file of files) {
147
+ if (!file.endsWith('.json')) continue;
148
+ const filePath = path.join(this._dir, file);
149
+ try {
150
+ const raw = await fs.readFile(filePath, 'utf8');
151
+ const entry = JSON.parse(raw);
152
+ if (entry.key && regex.test(entry.key)) {
153
+ await fs.unlink(filePath);
154
+ deleted++;
155
+ }
156
+ } catch {
157
+ // 파싱 실패 파일 무시
158
+ }
159
+ }
160
+ } catch {
161
+ // 디렉토리 없으면 무시
162
+ }
163
+ return deleted;
164
+ }
165
+
166
+ /**
167
+ * 전체 캐시 플러시
168
+ *
169
+ * @returns {Promise<void>}
170
+ */
171
+ async flush() {
172
+ try {
173
+ const files = await fs.readdir(this._dir);
174
+ const unlinkPromises = files
175
+ .filter(f => f.endsWith('.json'))
176
+ .map(f => fs.unlink(path.join(this._dir, f)).catch(() => {}));
177
+ await Promise.all(unlinkPromises);
178
+ } catch {
179
+ // 디렉토리 없으면 무시
180
+ }
181
+ }
182
+
183
+ /**
184
+ * 만료 캐시 파일 정리 (Garbage Collection)
185
+ *
186
+ * @returns {Promise<number>} 삭제된 파일 수
187
+ * @private
188
+ */
189
+ async _gc() {
190
+ let cleaned = 0;
191
+ try {
192
+ const files = await fs.readdir(this._dir);
193
+ const now = Date.now();
194
+
195
+ for (const file of files) {
196
+ if (!file.endsWith('.json')) continue;
197
+ const filePath = path.join(this._dir, file);
198
+ try {
199
+ const raw = await fs.readFile(filePath, 'utf8');
200
+ const entry = JSON.parse(raw);
201
+ if (entry.expires > 0 && entry.expires < now) {
202
+ await fs.unlink(filePath);
203
+ cleaned++;
204
+ }
205
+ } catch {
206
+ // 손상된 파일 삭제
207
+ await fs.unlink(filePath).catch(() => {});
208
+ cleaned++;
209
+ }
210
+ }
211
+ } catch {
212
+ // 디렉토리 없으면 무시
213
+ }
214
+ return cleaned;
215
+ }
216
+
217
+ /**
218
+ * 드라이버 종료 — GC 타이머 해제
219
+ *
220
+ * @returns {Promise<void>}
221
+ */
222
+ async disconnect() {
223
+ if (this._gcTimer) {
224
+ clearInterval(this._gcTimer);
225
+ this._gcTimer = null;
226
+ }
227
+ }
228
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * RedisDriver — Redis 기반 캐시 드라이버
3
+ *
4
+ * ioredis를 사용하여 Redis에 캐시 데이터를 저장.
5
+ * 값은 JSON 직렬화하여 저장하고, TTL은 SETEX로 관리.
6
+ * prefix로 네임스페이스를 분리하여 다른 데이터와 충돌 방지.
7
+ *
8
+ * @see lib/cache/CacheManager.js
9
+ */
10
+ export default class RedisDriver {
11
+ /**
12
+ * @param {object} opts
13
+ * @param {string} opts.url - Redis 접속 URL (예: redis://:password@localhost:6379)
14
+ * @param {string} [opts.prefix] - 캐시 키 접두사
15
+ */
16
+ constructor(opts = {}) {
17
+ /** @type {string} Redis URL */
18
+ this._url = opts.url || 'redis://localhost:6379';
19
+
20
+ /** @type {string} 키 접두사 (구분자 ':' 포함) */
21
+ this._prefix = opts.prefix ? `${opts.prefix}:` : '';
22
+
23
+ /** @type {import('ioredis').default | null} Redis 클라이언트 */
24
+ this._client = null;
25
+ }
26
+
27
+ /**
28
+ * Redis 연결
29
+ *
30
+ * @returns {Promise<void>}
31
+ */
32
+ async connect() {
33
+ // ioredis 동적 import (peerDependency)
34
+ const { default: Redis } = await import('ioredis');
35
+ this._client = new Redis(this._url, {
36
+ maxRetriesPerRequest: 3, // 요청당 재시도 횟수
37
+ retryStrategy: (times) => {
38
+ if (times > 5) return null; // 5회 초과 시 재연결 중단
39
+ return Math.min(times * 200, 2000); // 최대 2초 간격 재시도
40
+ },
41
+ lazyConnect: false, // 즉시 연결
42
+ });
43
+
44
+ // 연결 에러 무음 처리 (로깅만)
45
+ this._client.on('error', (err) => {
46
+ console.error(`[CacheManager:Redis] ${err.message}`);
47
+ });
48
+ }
49
+
50
+ /**
51
+ * 전체 키 생성 (접두사 + 키)
52
+ *
53
+ * @param {string} key
54
+ * @returns {string}
55
+ * @private
56
+ */
57
+ _key(key) {
58
+ return `${this._prefix}${key}`;
59
+ }
60
+
61
+ /**
62
+ * 캐시 값 조회
63
+ *
64
+ * @param {string} key
65
+ * @returns {Promise<*|null>} 역직렬화된 값 또는 null
66
+ */
67
+ async get(key) {
68
+ const raw = await this._client.get(this._key(key));
69
+ if (raw === null) return null;
70
+ try {
71
+ return JSON.parse(raw);
72
+ } catch {
73
+ return raw; // JSON 파싱 실패 시 문자열 그대로 반환
74
+ }
75
+ }
76
+
77
+ /**
78
+ * 캐시 값 저장
79
+ *
80
+ * @param {string} key
81
+ * @param {*} value - JSON 직렬화 가능한 값
82
+ * @param {number} ttl - TTL (초)
83
+ * @returns {Promise<void>}
84
+ */
85
+ async set(key, value, ttl) {
86
+ const serialized = JSON.stringify(value);
87
+ if (ttl > 0) {
88
+ await this._client.setex(this._key(key), ttl, serialized);
89
+ } else {
90
+ await this._client.set(this._key(key), serialized);
91
+ }
92
+ }
93
+
94
+ /**
95
+ * 캐시 키 존재 여부
96
+ *
97
+ * @param {string} key
98
+ * @returns {Promise<boolean>}
99
+ */
100
+ async has(key) {
101
+ const exists = await this._client.exists(this._key(key));
102
+ return exists === 1;
103
+ }
104
+
105
+ /**
106
+ * 캐시 키 삭제
107
+ *
108
+ * @param {string} key
109
+ * @returns {Promise<void>}
110
+ */
111
+ async delete(key) {
112
+ await this._client.del(this._key(key));
113
+ }
114
+
115
+ /**
116
+ * 패턴 기반 캐시 삭제
117
+ *
118
+ * SCAN 명령으로 키를 순회하며 삭제 (KEYS 대신 SCAN 사용 — 프로덕션 안전).
119
+ *
120
+ * @param {string} pattern - 키 패턴 (예: 'user:*')
121
+ * @returns {Promise<number>} 삭제된 키 수
122
+ */
123
+ async deletePattern(pattern) {
124
+ const fullPattern = this._key(pattern);
125
+ let deleted = 0;
126
+ let cursor = '0';
127
+
128
+ do {
129
+ const [nextCursor, keys] = await this._client.scan(cursor, 'MATCH', fullPattern, 'COUNT', 100);
130
+ cursor = nextCursor;
131
+ if (keys.length > 0) {
132
+ await this._client.del(...keys);
133
+ deleted += keys.length;
134
+ }
135
+ } while (cursor !== '0');
136
+
137
+ return deleted;
138
+ }
139
+
140
+ /**
141
+ * 전체 캐시 플러시 (prefix 범위)
142
+ *
143
+ * @returns {Promise<void>}
144
+ */
145
+ async flush() {
146
+ if (this._prefix) {
147
+ // prefix가 있으면 해당 범위만 삭제
148
+ await this.deletePattern('*');
149
+ } else {
150
+ // prefix 없으면 FLUSHDB (주의: 전체 DB 삭제)
151
+ await this._client.flushdb();
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Redis 연결 종료
157
+ *
158
+ * @returns {Promise<void>}
159
+ */
160
+ async disconnect() {
161
+ if (this._client) {
162
+ await this._client.quit();
163
+ this._client = null;
164
+ }
165
+ }
166
+ }
@@ -28,6 +28,7 @@ import Scheduler from '../schedule/Scheduler.js';
28
28
  import Queue from '../schedule/Queue.js';
29
29
  import Storage from '../services/Storage.js';
30
30
  import ConnectionManager from '../database/ConnectionManager.js';
31
+ import CacheManager from '../cache/CacheManager.js';
31
32
  import SqlModel from '../database/SqlModel.js';
32
33
  import MongoModel from '../database/MongoModel.js';
33
34
  import WorkerPool from '../schedule/WorkerPool.js';
@@ -283,7 +284,7 @@ export default class Application {
283
284
  // 2. i18n 로드 (04-bootstrap-lifecycle.md)
284
285
  await this.i18n.load();
285
286
 
286
- // 3. Scheduler / Queue / Storage 초기화
287
+ // 3. Scheduler / Queue / Storage / Cache 초기화
287
288
  this._scheduler = new Scheduler(this);
288
289
  this._queue = new Queue(this, { driver: this.config.get('queue.driver', 'memory') });
289
290
  this.storage = new Storage({
@@ -292,6 +293,24 @@ export default class Application {
292
293
  fileHelper: this.file,
293
294
  });
294
295
 
296
+ // 캐시 매니저 초기화 (cache 설정이 있을 때만)
297
+ const cacheConfig = this.config.get('app.cache');
298
+ if (cacheConfig?.driver) {
299
+ this._cacheManager = new CacheManager({
300
+ ...cacheConfig,
301
+ file_dir: cacheConfig.file_dir
302
+ ? path.resolve(this.baseDir, cacheConfig.file_dir)
303
+ : path.resolve(this.baseDir, './storage/cache'),
304
+ });
305
+ try {
306
+ await this._cacheManager.init();
307
+ this.logger.info(`[Cache] ${cacheConfig.driver} 드라이버 초기화 완료`);
308
+ } catch (err) {
309
+ this.logger.warn(`[Cache] 초기화 실패 (${cacheConfig.driver}): ${err.message}`);
310
+ this._cacheManager = null;
311
+ }
312
+ }
313
+
295
314
  // 4. Phase 1: 공유 리소스 로드 (database/models, shared/events,jobs,workers)
296
315
  const sharedLoader = new AutoLoader(this, this.baseDir, { mode: 'shared' });
297
316
  await sharedLoader.load();
@@ -336,6 +355,7 @@ export default class Application {
336
355
  if (dbConfig.connections) {
337
356
  this._connectionManager.configure(dbConfig);
338
357
  SqlModel.setConnectionManager(this._connectionManager);
358
+ SqlModel.setModelRegistry(this.db); // relation eager-loading용 레지스트리 주입
339
359
  MongoModel.setConnectionManager(this._connectionManager);
340
360
  }
341
361
  }
@@ -1268,6 +1288,11 @@ export default class Application {
1268
1288
  try { await this._connectionManager.closeAll(); } catch {}
1269
1289
  }
1270
1290
 
1291
+ // 3-0. 캐시 매니저 종료
1292
+ if (this._cacheManager) {
1293
+ try { await this._cacheManager.disconnect(); } catch {}
1294
+ }
1295
+
1271
1296
  // 3-1. Worker 종료
1272
1297
  if (this._workerPool) {
1273
1298
  try { await this._workerPool.terminate(); } catch {}
package/lib/core/Base.js CHANGED
@@ -61,4 +61,7 @@ export default class Base {
61
61
 
62
62
  /** @returns {import('./I18nHelper.js').default} */
63
63
  get i18n() { return this.app.i18n; }
64
+
65
+ /** @returns {import('../cache/CacheManager.js').default|null} 캐시 매니저 */
66
+ get cache() { return this.app._cacheManager || null; }
64
67
  }