@cache 데코레이터를 사용하여 Model이나 Frame 메서드의 결과를 자동으로 캐싱할 수 있습니다.
기본 사용법
간단한 예제
복사
import { BaseModel, cache, api } from "sonamu";
class UserModelClass extends BaseModel {
@cache({ ttl: "10m" })
@api()
async findById(id: number) {
// 이 메서드는 10분간 캐싱됨
return this.findOne(['id', id]);
}
}
- 첫 번째 호출: DB 조회 → 결과 캐싱
- 두 번째 호출 (10분 내): 캐시에서 반환 (DB 조회 없음)
- 10분 경과 후: 다시 DB 조회 → 캐시 갱신
데코레이터 옵션
전체 옵션
복사
@cache({
key?: string | ((...args: unknown[]) => string), // 캐시 키
store?: string, // 사용할 스토어
ttl?: string | number, // 만료 시간
grace?: string | number, // Grace period (Stale-While-Revalidate)
tags?: string[], // 태그 목록
forceFresh?: boolean, // 캐시 무시하고 강제 갱신
})
key: 캐시 키 설정
키를 지정하지 않으면 자동으로 생성됩니다.- 자동 생성 (기본)
- 문자열 키
- 함수 키
복사
class UserModelClass extends BaseModel {
@cache({ ttl: "10m" })
async findById(id: number) {
return this.findOne(['id', id]);
}
}
// 호출: userModel.findById(123)
// 자동 생성 키: "User.findById:123"
ModelName.methodName:serializedArgs인자 직렬화 규칙:- 단일 primitive (string/number/boolean): 그대로 사용
- 복잡한 객체: JSON.stringify 사용
- 인자 없음: suffix 없이
ModelName.methodName
복사
@cache({ key: "user-list", ttl: "5m" })
async findAll() {
return this.findMany({});
}
// 호출: userModel.findAll()
// 키: "user-list"
@cache({ key: "user", ttl: "10m" })
async findById(id: number) {
return this.findOne(['id', id]);
}
// 호출: userModel.findById(123)
// 키: "user:123" (인자가 suffix로 자동 추가)
key:args 형태로 자동 생성복사
@cache({
key: (userId: number, status: string) => `user:${userId}:${status}`,
ttl: "10m"
})
async findByStatus(userId: number, status: string) {
return this.findMany({ user_id: userId, status });
}
// 호출: model.findByStatus(123, "active")
// 키: "user:123:active"
ttl: 만료 시간
TTL(Time To Live)은 캐시가 유효한 시간입니다.복사
// 문자열 형식 (권장)
@cache({ ttl: "10s" }) // 10초
@cache({ ttl: "5m" }) // 5분
@cache({ ttl: "1h" }) // 1시간
@cache({ ttl: "1d" }) // 1일
@cache({ ttl: "1w" }) // 1주
@cache({ ttl: "forever" }) // 영구
// 숫자 형식 (밀리초)
@cache({ ttl: 60000 }) // 60000ms = 1분
grace: Stale-While-Revalidate
Grace period는 TTL 만료 후에도 오래된 캐시(Stale)를 반환하면서 백그라운드에서 갱신하는 기능입니다.복사
@cache({
ttl: "1m", // 1분 후 만료
grace: "10m" // 만료 후 10분간 Stale 값 반환
})
async getExpensiveData() {
// 시간이 오래 걸리는 작업
return await this.heavyComputation();
}
- 0~1분: 신선한 캐시 반환
- 1~11분: Stale 캐시 즉시 반환 + 백그라운드 갱신
- 11분 이후: 캐시 미스, 새로 계산
- 사용자는 항상 빠른 응답 (Stale이라도 즉시 반환)
- 백그라운드에서 갱신되어 다음 사용자는 신선한 데이터 받음
tags: 태그 기반 무효화
태그를 사용하여 관련 캐시를 그룹으로 무효화할 수 있습니다.복사
class ProductModelClass extends BaseModel {
@cache({ ttl: "1h", tags: ["product", "list"] })
@api()
async findAll() {
return this.findMany({});
}
@cache({ ttl: "1h", tags: ["product"] })
@api()
async findById(id: number) {
return this.findOne(['id', id]);
}
@api()
async update(id: number, data: Partial<Product>) {
const result = await this.updateOne(['id', id], data);
// product 태그가 있는 모든 캐시 무효화
await Sonamu.cache.deleteByTag({ tags: ["product"] });
return result;
}
}
복사
// update() 호출 시
await Sonamu.cache.deleteByTag({ tags: ["product"] });
// 결과: findAll()과 findById() 캐시 모두 삭제됨
store: 특정 스토어 사용
여러 스토어를 설정한 경우, 특정 스토어를 지정할 수 있습니다.복사
// sonamu.config.ts에 여러 스토어 정의
export const config: SonamuConfig = {
server: {
cache: {
default: "api",
stores: {
api: store().useL1Layer(drivers.memory({ maxSize: "200mb" })),
database: store()
.useL1Layer(drivers.memory({ maxSize: "100mb" }))
.useL2Layer(drivers.redis({ connection: redis })),
},
},
},
};
// Model에서 스토어 지정
class UserModelClass extends BaseModel {
// api 스토어 사용 (기본)
@cache({ ttl: "5m" })
@api()
async findAll() {
return this.findMany({});
}
// database 스토어 사용 (Redis 공유)
@cache({ store: "database", ttl: "1h" })
@api()
async findById(id: number) {
return this.findOne(['id', id]);
}
}
forceFresh: 캐시 무시
항상 새로운 데이터를 가져오고 싶을 때 사용합니다.복사
@cache({
ttl: "1h",
forceFresh: true // 캐시를 무시하고 항상 factory 실행
})
async getRealTimeData() {
return await this.fetchFromExternalAPI();
}
실전 예제
1. API 응답 캐싱
복사
class PostModelClass extends BaseModel {
// 게시글 목록 (5분 캐시)
@cache({ ttl: "5m", tags: ["post", "list"] })
@api()
async findAll(page: number = 1) {
return this.findMany({
num: 20,
page,
order: [['created_at', 'desc']]
});
}
// 게시글 상세 (10분 캐시)
@cache({ ttl: "10m", tags: ["post"] })
@api()
async findById(id: number) {
return this.findOne(['id', id]);
}
// 인기 게시글 (1시간 캐시)
@cache({
key: "popular-posts",
ttl: "1h",
tags: ["post", "popular"]
})
@api()
async findPopular() {
return this.findMany({
num: 10,
where: [['view_count', '>', 1000]],
order: [['view_count', 'desc']]
});
}
}
2. 데이터 변경 시 캐시 무효화
복사
class ProductModelClass extends BaseModel {
@cache({ ttl: "1h", tags: ["product"] })
@api()
async findById(id: number) {
return this.findOne(['id', id]);
}
@cache({ ttl: "30m", tags: ["product", "list"] })
@api()
async findAll() {
return this.findMany({});
}
@api()
async create(data: ProductSave) {
const result = await this.saveOne(data);
// list 태그만 무효화 (목록에 새 항목 추가됨)
await Sonamu.cache.deleteByTag({ tags: ["list"] });
return result;
}
@api()
async update(id: number, data: Partial<ProductSave>) {
const result = await this.updateOne(['id', id], data);
// product 태그 전체 무효화 (상세 + 목록)
await Sonamu.cache.deleteByTag({ tags: ["product"] });
return result;
}
}
3. 복잡한 키 생성
복사
class OrderModelClass extends BaseModel {
@cache({
key: (userId: number, status: string, startDate: string) =>
`order:user:${userId}:${status}:${startDate}`,
ttl: "10m",
tags: ["order"]
})
@api()
async findByUserAndStatus(
userId: number,
status: string,
startDate: string
) {
return this.findMany({
where: [
['user_id', userId],
['status', status],
['created_at', '>=', startDate]
]
});
}
}
// 호출: model.findByUserAndStatus(123, "pending", "2025-01-01")
// 키: "order:user:123:pending:2025-01-01"
4. Stale-While-Revalidate 활용
복사
class AnalyticsModelClass extends BaseModel {
@cache({
ttl: "5m", // 5분 후 만료
grace: "1h", // 1시간 동안 Stale 값 사용
tags: ["analytics"]
})
@api()
async getDashboardStats() {
// 무거운 집계 쿼리
const [users, orders, revenue] = await Promise.all([
this.countUsers(),
this.countOrders(),
this.calculateRevenue(),
]);
return { users, orders, revenue };
}
}
- 0~5분: 신선한 캐시
- 5~65분: Stale 캐시 즉시 반환 + 백그라운드 재계산
- 65분 이후: 캐시 미스, 새로 계산
5. 설정값 영구 캐싱
복사
class ConfigModelClass extends BaseModel {
@cache({
key: "app-config",
ttl: "forever", // 영구 캐싱
tags: ["config"]
})
@api()
async getAppConfig() {
return this.findOne(['key', 'app_config']);
}
@api()
async updateConfig(data: ConfigSave) {
const result = await this.saveOne(data);
// 설정 변경 시 캐시 무효화
await Sonamu.cache.deleteByTag({ tags: ["config"] });
return result;
}
}
내부 메서드 호출과 캐시 공유
Model 내부에서 다른 메서드를 호출할 때도 캐시가 공유됩니다.복사
class UserModelClass extends BaseModel {
// findMany에 캐시 적용
@cache({ ttl: "10m", tags: ["user"] })
@api()
async findMany(params: FindManyParams<User>) {
return super.findMany(params);
}
// findById는 내부적으로 findMany를 호출
async findById(id: number) {
const { rows } = await this.findMany({
where: [['id', id]],
num: 1
});
return rows[0];
}
}
복사
// 첫 번째 호출
await userModel.findById(123);
// → findMany({ where: [['id', 123]], num: 1 }) 호출
// → DB 조회 및 캐싱
// 두 번째 호출 (같은 파라미터)
await userModel.findById(123);
// → findMany({ where: [['id', 123]], num: 1 }) 호출
// → 캐시에서 반환 (DB 조회 없음)
// findMany를 직접 호출해도 같은 캐시 사용
await userModel.findMany({ where: [['id', 123]], num: 1 });
// → 캐시에서 반환
캐시 키 생성 로직
인자 직렬화
복사
// decorator.ts 내부
function serializeArgs(args: unknown[]): string {
if (args.length === 0) return "";
// 단일 primitive 값
if (args.length === 1) {
const arg = args[0];
if (arg === null || arg === undefined) return "";
if (typeof arg === "string" || typeof arg === "number" || typeof arg === "boolean") {
return String(arg);
}
}
// 복잡한 값은 JSON 직렬화
try {
return JSON.stringify(args);
} catch {
// 직렬화 실패 시 toString 사용
return args.map((arg) => String(arg)).join(":");
}
}
복사
// 단일 primitive
serializeArgs([123]) → "123"
serializeArgs(["hello"]) → "hello"
// 복잡한 객체
serializeArgs([{ id: 1, name: "test" }]) → '[{"id":1,"name":"test"}]'
// 여러 인자
serializeArgs([123, "active"]) → '[123,"active"]'
전체 키 생성
복사
function generateCacheKey(
modelName: string,
methodName: string,
args: unknown[],
keyOption?: string | Function,
): string {
// 1. 커스텀 키 함수
if (typeof keyOption === "function") {
return keyOption(...args);
}
// 2. 문자열 키 + args suffix
if (typeof keyOption === "string") {
const argsSuffix = serializeArgs(args);
return argsSuffix ? `${keyOption}:${argsSuffix}` : keyOption;
}
// 3. 자동 생성
const baseKey = `${modelName}.${methodName}`;
const argsSuffix = serializeArgs(args);
return argsSuffix ? `${baseKey}:${argsSuffix}` : baseKey;
}
직접 캐시 조작
데코레이터 없이 직접 캐시를 조작할 수도 있습니다.복사
import { Sonamu } from "sonamu";
class UserModelClass extends BaseModel {
@api()
async findById(id: number) {
const cacheKey = `user:${id}`;
// 캐시 확인
const cached = await Sonamu.cache.get({ key: cacheKey });
if (cached) {
return cached;
}
// DB 조회
const user = await this.findOne(['id', id]);
// 캐싱
await Sonamu.cache.set({
key: cacheKey,
value: user,
ttl: "10m",
tags: ["user"]
});
return user;
}
}
- 데코레이터: 간결, 선언적, 자동 키 생성
- 직접 조작: 복잡한 로직, 조건부 캐싱, 세밀한 제어
주의사항
@cache 데코레이터 사용 시 주의사항:
-
캐시 매니저 초기화 필수:
sonamu.config.ts에 캐시 설정이 없으면 에러 발생복사// 에러: CacheManager is not initialized @cache({ ttl: "10m" }) async findById(id: number) { ... } -
비동기 메서드만 가능: 동기 메서드에는 사용 불가
복사
// ❌ 에러 @cache({ ttl: "10m" }) findByIdSync(id: number) { ... } // ✅ 정상 @cache({ ttl: "10m" }) async findById(id: number) { ... } -
스토어 이름 일치:
store옵션은 설정에 정의된 이름과 일치해야 함복사// sonamu.config.ts stores: { myStore: store()... } // ❌ 에러 @cache({ store: "wrongName", ttl: "10m" }) // ✅ 정상 @cache({ store: "myStore", ttl: "10m" }) -
직렬화 가능한 값만: 함수, Symbol 등은 캐싱 불가
복사
// ❌ 잘못된 예 @cache({ ttl: "10m" }) async getProcessor() { return { process: () => { ... } // 함수는 직렬화 안됨 }; } // ✅ 올바른 예 @cache({ ttl: "10m" }) async getData() { return { id: 1, name: "test", values: [1, 2, 3] }; } -
인자 순서 중요: 같은 값이라도 순서가 다르면 다른 키
복사
@cache({ ttl: "10m" }) async find(name: string, age: number) { ... } find("John", 30) // 키: "Model.find:["John",30]" find(30, "John") // 키: "Model.find:[30,"John"]" (다른 키!)
