메인 콘텐츠로 건너뛰기
@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]);
  }
}
작동 방식:
  1. 첫 번째 호출: DB 조회 → 결과 캐싱
  2. 두 번째 호출 (10분 내): 캐시에서 반환 (DB 조회 없음)
  3. 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

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분
TTL 없이 사용하면: BentoCache 기본값 적용 (일반적으로 무제한)

grace: Stale-While-Revalidate

Grace period는 TTL 만료 후에도 오래된 캐시(Stale)를 반환하면서 백그라운드에서 갱신하는 기능입니다.
@cache({ 
  ttl: "1m",      // 1분 후 만료
  grace: "10m"    // 만료 후 10분간 Stale 값 반환
})
async getExpensiveData() {
  // 시간이 오래 걸리는 작업
  return await this.heavyComputation();
}
작동 방식:
  1. 0~1분: 신선한 캐시 반환
  2. 1~11분: Stale 캐시 즉시 반환 + 백그라운드 갱신
  3. 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;
  }
}
데코레이터 vs 직접 조작:
  • 데코레이터: 간결, 선언적, 자동 키 생성
  • 직접 조작: 복잡한 로직, 조건부 캐싱, 세밀한 제어

주의사항

@cache 데코레이터 사용 시 주의사항:
  1. 캐시 매니저 초기화 필수: sonamu.config.ts에 캐시 설정이 없으면 에러 발생
    // 에러: CacheManager is not initialized
    @cache({ ttl: "10m" })
    async findById(id: number) { ... }
    
  2. 비동기 메서드만 가능: 동기 메서드에는 사용 불가
    // ❌ 에러
    @cache({ ttl: "10m" })
    findByIdSync(id: number) { ... }
    
    // ✅ 정상
    @cache({ ttl: "10m" })
    async findById(id: number) { ... }
    
  3. 스토어 이름 일치: store 옵션은 설정에 정의된 이름과 일치해야 함
    // sonamu.config.ts
    stores: {
      myStore: store()...
    }
    
    // ❌ 에러
    @cache({ store: "wrongName", ttl: "10m" })
    
    // ✅ 정상
    @cache({ store: "myStore", ttl: "10m" })
    
  4. 직렬화 가능한 값만: 함수, Symbol 등은 캐싱 불가
    // ❌ 잘못된 예
    @cache({ ttl: "10m" })
    async getProcessor() {
      return {
        process: () => { ... }  // 함수는 직렬화 안됨
      };
    }
    
    // ✅ 올바른 예
    @cache({ ttl: "10m" })
    async getData() {
      return {
        id: 1,
        name: "test",
        values: [1, 2, 3]
      };
    }
    
  5. 인자 순서 중요: 같은 값이라도 순서가 다르면 다른 키
    @cache({ ttl: "10m" })
    async find(name: string, age: number) { ... }
    
    find("John", 30)  // 키: "Model.find:["John",30]"
    find(30, "John")  // 키: "Model.find:[30,"John"]" (다른 키!)
    

다음 단계