@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;
}
복잡한 캐시 키 생성 예제
실전에서 마주치는 복잡한 캐시 키 생성 시나리오와 해결 방법입니다.예제 1: 중첩된 객체 파라미터
복사
interface SearchParams {
filters: {
category?: string;
priceRange?: { min: number; max: number };
tags?: string[];
};
sort?: { field: string; order: "asc" | "desc" };
pagination: { page: number; pageSize: number };
}
class ProductModelClass extends BaseModel {
// ❌ 문제: 객체 순서가 바뀌면 다른 키 생성
@cache({ ttl: "5m" })
async search(params: SearchParams) {
// JSON.stringify({ page: 1, pageSize: 20 }) !== JSON.stringify({ pageSize: 20, page: 1 })
// ...
}
// ✅ 해결책 1: 정렬된 키 생성 함수
@cache({
key: (params: SearchParams) => {
// 객체를 정규화하여 일관된 키 생성
const normalized = {
category: params.filters.category || "",
priceMin: params.filters.priceRange?.min || 0,
priceMax: params.filters.priceRange?.max || Infinity,
tags: (params.filters.tags || []).sort().join(","),
sortField: params.sort?.field || "id",
sortOrder: params.sort?.order || "asc",
page: params.pagination.page,
pageSize: params.pagination.pageSize,
};
return `products:search:${JSON.stringify(normalized)}`;
},
ttl: "5m",
})
async searchNormalized(params: SearchParams) {
// ...
}
// ✅ 해결책 2: 주요 필드만 사용
@cache({
key: (params: SearchParams) => {
const { category } = params.filters;
const { page, pageSize } = params.pagination;
return `products:search:${category}:${page}:${pageSize}`;
},
ttl: "5m",
})
async searchSimple(params: SearchParams) {
// ...
}
}
예제 2: 배열 파라미터
복사
class PostModelClass extends BaseModel {
// ❌ 문제: 배열 순서가 바뀌면 다른 키
@cache({ ttl: "5m" })
async findByTags(tags: string[]) {
// cache key: "Post.findByTags:[\"react\",\"typescript\"]"
// cache key: "Post.findByTags:[\"typescript\",\"react\"]" ← 다른 키!
// ...
}
// ✅ 해결책: 배열 정렬
@cache({
key: (tags: string[]) => {
const sortedTags = [...tags].sort().join(",");
return `posts:tags:${sortedTags}`;
},
ttl: "5m",
})
async findByTagsSorted(tags: string[]) {
// cache key: "posts:tags:react,typescript" (항상 동일)
// ...
}
}
예제 3: 날짜 파라미터
복사
class ReportModelClass extends BaseModel {
// ❌ 문제: Date 객체가 다르게 직렬화될 수 있음
@cache({ ttl: "1h" })
async generateReport(startDate: Date, endDate: Date) {
// Date 객체는 ISO 문자열로 변환되지만 시간대 문제 가능
// ...
}
// ✅ 해결책: ISO 문자열로 정규화
@cache({
key: (startDate: Date, endDate: Date) => {
const start = startDate.toISOString().split("T")[0]; // "2024-01-01"
const end = endDate.toISOString().split("T")[0];
return `reports:${start}:${end}`;
},
ttl: "1h",
})
async generateReportNormalized(startDate: Date, endDate: Date) {
// ...
}
}
예제 4: 사용자별 캐싱
복사
class DashboardModelClass extends BaseModel {
// ✅ 사용자 ID를 키에 포함
@cache({
key: (userId: number) => `dashboard:user:${userId}`,
ttl: "10m",
})
async getUserDashboard(userId: number) {
const context = Sonamu.getContext();
// 권한 체크
if (context.user?.id !== userId && context.user?.role !== "admin") {
throw new ForbiddenError();
}
// 대시보드 데이터 조회
// ...
}
// ✅ Context 기반 캐싱
@cache({
key: () => {
const context = Sonamu.getContext();
return `dashboard:user:${context.user?.id || "guest"}`;
},
ttl: "10m",
})
async getMyDashboard() {
// Context에서 자동으로 사용자 ID 가져옴
// ...
}
}
예제 5: 순환 참조 객체
복사
interface Node {
id: number;
name: string;
parent?: Node; // 순환 참조 가능
}
class TreeModelClass extends BaseModel {
// ❌ 문제: 순환 참조 객체는 JSON.stringify 실패
@cache({ ttl: "5m" })
async buildTree(node: Node) {
// TypeError: Converting circular structure to JSON
// ...
}
// ✅ 해결책: ID만 사용
@cache({
key: (node: Node) => `tree:${node.id}`,
ttl: "5m",
})
async buildTreeSafe(node: Node) {
// ...
}
// ✅ 해결책: 직렬화 가능한 데이터만 사용
@cache({
key: (nodeId: number, depth: number) => `tree:${nodeId}:${depth}`,
ttl: "5m",
})
async buildTreeById(nodeId: number, depth: number = 3) {
// ...
}
}
예제 6: 다중 필터 조건
복사
interface ListFilters {
status?: "active" | "inactive" | "pending";
category?: string;
minPrice?: number;
maxPrice?: number;
createdAfter?: Date;
searchQuery?: string;
}
class ProductModelClass extends BaseModel {
// ✅ 필터를 정규화하여 일관된 키 생성
@cache({
key: (filters: ListFilters, page: number, pageSize: number) => {
// undefined/null 값 제거
const normalized: Record<string, string> = {};
if (filters.status) normalized.status = filters.status;
if (filters.category) normalized.category = filters.category;
if (filters.minPrice !== undefined) normalized.minPrice = String(filters.minPrice);
if (filters.maxPrice !== undefined) normalized.maxPrice = String(filters.maxPrice);
if (filters.createdAfter) {
normalized.createdAfter = filters.createdAfter.toISOString().split("T")[0];
}
if (filters.searchQuery) normalized.q = filters.searchQuery;
// 정렬된 키=값 쌍으로 변환
const filterStr = Object.keys(normalized)
.sort()
.map((key) => `${key}=${normalized[key]}`)
.join("&");
return `products:list:${filterStr}:${page}:${pageSize}`;
},
ttl: "5m",
})
async list(filters: ListFilters, page: number, pageSize: number) {
// cache key 예: "products:list:category=electronics&status=active:1:20"
// ...
}
}
예제 7: 큰 객체 최적화
복사
class AnalyticsModelClass extends BaseModel {
// ❌ 문제: 큰 객체는 캐시 키가 너무 길어짐
@cache({ ttl: "1h" })
async analyze(config: LargeConfigObject) {
// cache key: "Analytics.analyze:[{...매우 긴 JSON...}]"
// ...
}
// ✅ 해결책: 해시 사용
@cache({
key: (config: LargeConfigObject) => {
const hash = crypto
.createHash("md5")
.update(JSON.stringify(config))
.digest("hex");
return `analytics:${hash}`;
},
ttl: "1h",
})
async analyzeWithHash(config: LargeConfigObject) {
// cache key: "analytics:a3f2b9c8d1e0..."
// ...
}
// ✅ 해결책: 주요 속성만 사용
@cache({
key: (config: LargeConfigObject) => {
const { dataSource, dateRange, metrics } = config;
return `analytics:${dataSource}:${dateRange}:${metrics.join(",")}`;
},
ttl: "1h",
})
async analyzeOptimized(config: LargeConfigObject) {
// ...
}
}
캐시 키 설계 Best Practices
| 시나리오 | 권장 방법 | 이유 |
|---|---|---|
| 객체 파라미터 | 주요 필드만 추출 또는 정규화 | 키 길이 최소화, 일관성 |
| 배열 파라미터 | 정렬 후 join | 순서 무관하게 동일 키 |
| 날짜 파라미터 | ISO 문자열 (날짜만) | 시간대 문제 방지 |
| 사용자별 캐싱 | user:${userId} 포함 | 권한 분리 |
| 순환 참조 | ID만 사용 | 직렬화 오류 방지 |
| 큰 객체 | 해시 또는 주요 속성만 | 키 길이 제한 |
| 다중 필터 | 정렬된 key=value 쌍 | 필터 순서 무관 |
캐시 키 설계 주의사항:
- 키 길이: 너무 긴 키는 성능 저하 (권장: 250자 이내)
- 직렬화 오류: 순환 참조, Date, Function 등 주의
- 키 충돌: 서로 다른 데이터가 같은 키를 가지지 않도록
- 정규화: 객체/배열 순서가 달라도 동일한 키 생성
캐시 키 디버깅 팁:
- 생성된 키를 로깅하여 확인
- 예상치 못한 캐시 미스는 키 정규화 확인
- 캐시 통계로 히트율 모니터링
직접 캐시 조작
데코레이터 없이 직접 캐시를 조작할 수도 있습니다.복사
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. Please configure 'cache' in sonamu.config.ts.- @cache 데코레이터가 적용된 메서드를 처음 호출할 때 발생
- 서버 시작 시가 아니라 실제 메서드 호출 시점에 발생
sonamu.config.ts의server.cache설정이 없음- 또는 설정이 잘못됨
sonamu.config.ts에 최소한의 캐시 설정을 추가하세요:최소 설정 설명:복사import { store, drivers } from "sonamu/cache"; export const config: SonamuConfig = { server: { // ... 기타 설정 cache: { default: "main", stores: { main: store().useL1Layer(drivers.memory({ maxSize: "50mb" })), }, }, }, };default: 기본 스토어 이름 (여기서는 “main”)stores: 스토어 객체 정의main: 스토어 이름 (원하는 이름 사용 가능)store().useL1Layer(...): 메모리 드라이버를 L1 캐시로 사용maxSize: "50mb": 메모리 캐시 최대 크기
일반적인 실수:테스트 환경에서는:bootstrap(vi)호출 시 자동으로 메모리 드라이버가 설정되므로 별도 설정이 불필요합니다.실수 증상 해결 cache 설정 자체가 없음 메서드 호출 시 에러 발생 위의 최소 설정 추가 default와 stores 이름 불일치 에러 발생 default 값이 stores 키와 일치하는지 확인 drivers import 누락 빌드 에러 import { drivers } from "sonamu/cache"추가메모리 크기 설정 누락 maxSize 기본값 사용 명시적으로 maxSize 지정 권장 -
비동기 메서드만 가능: 동기 메서드에는 사용 불가
복사
// ❌ 에러 @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"]" (다른 키!)