NestJS 캐싱이란?
NestJS는 @nestjs/cache-manager를 통해 캐싱을 추상화합니다. 하지만 기본 인메모리 캐시만으로는 프로덕션 요구사항을 충족할 수 없습니다. Redis 연동, 커스텀 TTL, 캐시 무효화 전략, Interceptor 기반 자동 캐싱까지 구현해야 실전에서 쓸 수 있는 캐시 레이어가 됩니다. 이 글에서는 CacheModule 설정부터 데코레이터 기반 캐싱, 수동 캐시 제어, 캐시 무효화 패턴까지 심화 패턴을 다룹니다.
CacheModule 설정
// 설치
// npm install @nestjs/cache-manager cache-manager cache-manager-redis-yet
// 1. 인메모리 캐시 (개발용)
@Module({
imports: [
CacheModule.register({
ttl: 30000, // 기본 TTL: 30초 (ms)
max: 1000, // 최대 캐시 항목 수
isGlobal: true, // 전역 모듈로 등록
}),
],
})
export class AppModule {}
// 2. Redis 캐시 (프로덕션)
import { redisStore } from 'cache-manager-redis-yet';
@Module({
imports: [
CacheModule.registerAsync({
isGlobal: true,
useFactory: async () => ({
store: await redisStore({
socket: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
},
password: process.env.REDIS_PASSWORD,
ttl: 60000, // 기본 TTL: 60초
}),
}),
}),
],
})
export class AppModule {}
// 3. 환경별 분기
@Module({
imports: [
CacheModule.registerAsync({
isGlobal: true,
inject: [ConfigService],
useFactory: async (config: ConfigService) => {
if (config.get('NODE_ENV') === 'production') {
return {
store: await redisStore({
socket: { host: config.get('REDIS_HOST'), port: config.get('REDIS_PORT') },
password: config.get('REDIS_PASSWORD'),
ttl: 60000,
}),
};
}
return { ttl: 30000, max: 500 }; // 인메모리
},
}),
],
})
export class AppModule {}
CacheInterceptor: 자동 캐싱
NestJS의 CacheInterceptor는 GET 요청의 응답을 자동으로 캐싱합니다. NestJS Interceptor 패턴의 실전 활용입니다.
// 컨트롤러 레벨: 모든 GET 엔드포인트 캐싱
@Controller('products')
@UseInterceptors(CacheInterceptor)
export class ProductController {
@Get()
@CacheTTL(30000) // 이 엔드포인트만 30초 TTL
findAll() {
return this.productService.findAll();
}
@Get(':id')
@CacheKey('product-detail') // 커스텀 캐시 키
@CacheTTL(60000)
findOne(@Param('id') id: string) {
return this.productService.findOne(id);
}
// POST, PUT, DELETE는 CacheInterceptor가 자동으로 건너뜀
@Post()
create(@Body() dto: CreateProductDto) {
return this.productService.create(dto);
}
}
커스텀 CacheInterceptor
기본 CacheInterceptor의 캐시 키는 URL 기반입니다. 쿼리 파라미터, 유저별 캐싱 등을 지원하려면 커스텀이 필요합니다.
@Injectable()
export class CustomCacheInterceptor extends CacheInterceptor {
// 캐시 키 생성 로직 커스터마이징
trackBy(context: ExecutionContext): string | undefined {
const request = context.switchToHttp().getRequest();
const { method, url, query, user } = request;
// GET 이외 메서드는 캐싱 안 함
if (method !== 'GET') return undefined;
// 커스텀 데코레이터로 캐싱 비활성화
const noCache = this.reflector.get('no-cache', context.getHandler());
if (noCache) return undefined;
// 유저별 캐시가 필요한 경우
const isUserScoped = this.reflector.get('user-scoped-cache', context.getHandler());
if (isUserScoped && user) {
return `${url}:user:${user.id}:${JSON.stringify(query)}`;
}
// 기본: URL + 쿼리 파라미터
return `${url}:${JSON.stringify(query)}`;
}
}
// 데코레이터 정의
export const NoCache = () => SetMetadata('no-cache', true);
export const UserScopedCache = () => SetMetadata('user-scoped-cache', true);
// 사용
@Controller('dashboard')
@UseInterceptors(CustomCacheInterceptor)
export class DashboardController {
@Get('stats')
@UserScopedCache() // 유저별 다른 캐시
@CacheTTL(120000)
getStats(@CurrentUser() user: User) {
return this.dashboardService.getStats(user.id);
}
@Get('realtime')
@NoCache() // 캐싱 안 함
getRealtime() {
return this.dashboardService.getRealtime();
}
}
수동 캐시 제어: CACHE_MANAGER
Interceptor 방식이 아닌 서비스 레이어에서 직접 캐시를 제어하는 패턴입니다. 더 세밀한 캐시 키 설계와 무효화가 가능합니다.
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
@Injectable()
export class ProductService {
constructor(
@Inject(CACHE_MANAGER) private cacheManager: Cache,
private productRepository: ProductRepository,
) {}
async findById(id: string): Promise<Product> {
const cacheKey = `product:${id}`;
// 1. 캐시 조회
const cached = await this.cacheManager.get<Product>(cacheKey);
if (cached) return cached;
// 2. DB 조회
const product = await this.productRepository.findOneOrFail(id);
// 3. 캐시 저장 (TTL: 5분)
await this.cacheManager.set(cacheKey, product, 300000);
return product;
}
async findByCategory(category: string): Promise<Product[]> {
const cacheKey = `products:category:${category}`;
const cached = await this.cacheManager.get<Product[]>(cacheKey);
if (cached) return cached;
const products = await this.productRepository.find({
where: { category },
order: { createdAt: 'DESC' },
});
await this.cacheManager.set(cacheKey, products, 120000);
return products;
}
async update(id: string, dto: UpdateProductDto): Promise<Product> {
const product = await this.productRepository.findOneOrFail(id);
Object.assign(product, dto);
const saved = await this.productRepository.save(product);
// 관련 캐시 무효화
await this.invalidateProductCache(id, product.category);
return saved;
}
private async invalidateProductCache(id: string, category: string) {
await Promise.all([
this.cacheManager.del(`product:${id}`),
this.cacheManager.del(`products:category:${category}`),
this.cacheManager.del('products:all'),
]);
}
}
캐시 데코레이터 패턴
반복되는 캐시 로직을 커스텀 데코레이터로 추상화합니다. NestJS Custom Decorators의 고급 활용입니다.
// 캐시 어사이드 데코레이터
export function Cacheable(keyPrefix: string, ttlMs: number = 60000) {
const injectCacheManager = Inject(CACHE_MANAGER);
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor,
) {
// CACHE_MANAGER 주입
injectCacheManager(target, '__cacheManager');
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
const cacheManager: Cache = this.__cacheManager;
const cacheKey = `${keyPrefix}:${args.map(a =>
typeof a === 'object' ? JSON.stringify(a) : String(a)
).join(':')}`;
// 캐시 히트
const cached = await cacheManager.get(cacheKey);
if (cached !== undefined && cached !== null) {
return cached;
}
// 캐시 미스 → 원본 실행
const result = await originalMethod.apply(this, args);
// 캐시 저장
if (result !== undefined && result !== null) {
await cacheManager.set(cacheKey, result, ttlMs);
}
return result;
};
return descriptor;
};
}
// 캐시 무효화 데코레이터
export function CacheEvict(...keyPatterns: string[]) {
const injectCacheManager = Inject(CACHE_MANAGER);
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor,
) {
injectCacheManager(target, '__cacheManager');
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
const result = await originalMethod.apply(this, args);
const cacheManager: Cache = this.__cacheManager;
// 패턴에 매칭되는 키 삭제
for (const pattern of keyPatterns) {
const key = pattern.replace(/{(d+)}/g, (_, i) =>
typeof args[i] === 'object' ? JSON.stringify(args[i]) : String(args[i])
);
await cacheManager.del(key);
}
return result;
};
};
}
// 사용: 깔끔한 서비스 코드
@Injectable()
export class ProductService {
@Cacheable('product', 300000)
async findById(id: string): Promise<Product> {
return this.productRepository.findOneOrFail(id);
}
@CacheEvict('product:{0}', 'products:all')
async update(id: string, dto: UpdateProductDto): Promise<Product> {
const product = await this.productRepository.findOneOrFail(id);
Object.assign(product, dto);
return this.productRepository.save(product);
}
}
Redis 패턴 삭제
카테고리 변경 시 관련 캐시를 패턴으로 일괄 삭제하는 패턴입니다.
@Injectable()
export class CacheService {
constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}
// Redis SCAN으로 패턴 매칭 키 삭제
async deleteByPattern(pattern: string): Promise<number> {
const store = this.cacheManager.store as any;
const client = store.client; // Redis 클라이언트 접근
let cursor = 0;
let deletedCount = 0;
do {
const result = await client.scan(cursor, {
MATCH: pattern,
COUNT: 100,
});
cursor = result.cursor;
if (result.keys.length > 0) {
await client.del(result.keys);
deletedCount += result.keys.length;
}
} while (cursor !== 0);
return deletedCount;
}
// 사용 예: 특정 카테고리의 모든 캐시 삭제
async invalidateCategory(category: string) {
await this.deleteByPattern(`products:category:${category}*`);
}
// 전체 캐시 초기화
async resetAll() {
await this.cacheManager.reset();
}
}
캐시 워밍
애플리케이션 시작 시 자주 조회되는 데이터를 미리 캐시에 적재합니다.
@Injectable()
export class CacheWarmupService implements OnModuleInit {
constructor(
@Inject(CACHE_MANAGER) private cacheManager: Cache,
private productService: ProductService,
private categoryService: CategoryService,
) {}
async onModuleInit() {
const startTime = Date.now();
// 인기 카테고리 캐시 워밍
const categories = await this.categoryService.findPopular(10);
for (const category of categories) {
const products = await this.productService.findByCategory(category.slug);
await this.cacheManager.set(
`products:category:${category.slug}`,
products,
600000, // 10분
);
}
// 설정 데이터 캐시 워밍
const configs = await this.configService.findAll();
await this.cacheManager.set('app:configs', configs, 3600000);
const elapsed = Date.now() - startTime;
console.log(`Cache warmup completed in ${elapsed}ms`);
}
}
정리
NestJS 캐싱은 CacheInterceptor의 자동 캐싱과 CACHE_MANAGER의 수동 제어를 조합하여 구현합니다. 인메모리에서 시작하고 Redis로 확장하며, 커스텀 CacheInterceptor로 유저별·쿼리별 캐시 키를 생성합니다. 캐시 무효화는 직접 del()하거나 Redis SCAN 패턴 삭제로 처리하고, 캐시 워밍으로 콜드 스타트를 방지하세요. 가장 중요한 것은 무효화 전략입니다. 캐시를 넣는 것보다 언제 지울지가 더 어렵습니다.