@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/cli/index.js +517 -59
- package/cli/templates/make/app-spa/views/default/spa/package.json +1 -1
- package/index.js +2 -1
- package/lib/cache/CacheManager.js +183 -0
- package/lib/cache/drivers/FileDriver.js +228 -0
- package/lib/cache/drivers/RedisDriver.js +166 -0
- package/lib/core/Application.js +26 -1
- package/lib/core/Base.js +3 -0
- package/lib/database/ConnectionManager.js +22 -3
- package/lib/database/Model.js +15 -1
- package/lib/database/SqlModel.js +14 -0
- package/lib/database/SqlQueryBuilder.js +91 -6
- package/lib/middleware/index.js +1 -0
- package/lib/middleware/roleGuard.js +49 -0
- package/lib/middleware/theme.js +56 -3
- package/lib/services/Service.js +23 -5
- package/package.json +10 -2
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
|
+
}
|
package/lib/core/Application.js
CHANGED
|
@@ -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
|
}
|