NestJS 캐싱이 필요한 이유
API 응답 속도를 극적으로 개선하는 가장 효과적인 방법이 캐싱이다. NestJS는 @nestjs/cache-manager 패키지를 통해 인메모리부터 Redis까지 다양한 캐시 스토어를 통합 인터페이스로 제공한다. v5부터는 cache-manager v5 기반으로 전면 리팩터링되어 TTL 관리와 멀티 스토어 구성이 한층 깔끔해졌다.
기본 설정과 스토어 구성
인메모리 캐시는 별도 인프라 없이 즉시 사용할 수 있지만, 프로덕션에서는 Redis를 캐시 스토어로 사용하는 것이 표준이다.
// 설치
// npm i @nestjs/cache-manager cache-manager cache-manager-redis-yet redis
// app.module.ts — Redis 스토어 설정
import { CacheModule } from '@nestjs/cache-manager';
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: 30_000, // 기본 TTL 30초 (밀리초)
}),
}),
}),
],
})
export class AppModule {}
CacheInterceptor: 자동 응답 캐싱
GET 요청의 응답을 자동으로 캐싱하려면 CacheInterceptor를 적용한다. 컨트롤러 또는 메서드 단위로 선언할 수 있으며, 캐시 키는 기본적으로 요청 URL이 된다.
import { CacheInterceptor, CacheTTL, CacheKey } from '@nestjs/cache-manager';
@Controller('products')
@UseInterceptors(CacheInterceptor) // 컨트롤러 전체 적용
export class ProductController {
constructor(private readonly productService: ProductService) {}
@Get()
@CacheTTL(60_000) // 60초 TTL 오버라이드
findAll(@Query('category') category?: string) {
return this.productService.findAll(category);
}
@Get(':id')
@CacheKey('product') // 커스텀 캐시 키 프리픽스
@CacheTTL(120_000)
findOne(@Param('id') id: string) {
return this.productService.findOne(id);
}
@Post()
create(@Body() dto: CreateProductDto) {
// POST는 CacheInterceptor가 자동 스킵
return this.productService.create(dto);
}
}
커스텀 CacheInterceptor 구현
기본 CacheInterceptor는 URL만으로 캐시 키를 생성한다. 쿼리 파라미터, 사용자 ID, 헤더 등을 키에 포함하려면 커스텀 인터셉터가 필요하다.
import { CacheInterceptor, ExecutionContext, Injectable } from '@nestjs/common';
@Injectable()
export class SmartCacheInterceptor extends CacheInterceptor {
trackBy(context: ExecutionContext): string | undefined {
const request = context.switchToHttp().getRequest();
// POST, PUT, DELETE는 캐시하지 않음
if (request.method !== 'GET') {
return undefined;
}
// URL + 쿼리 파라미터 + 사용자 ID 조합
const userId = request.user?.id ?? 'anonymous';
const queryString = JSON.stringify(request.query);
return `${request.url}:${userId}:${queryString}`;
}
}
서비스 레벨 수동 캐싱
인터셉터 기반 자동 캐싱보다 세밀한 제어가 필요할 때는 CACHE_MANAGER를 직접 주입해서 사용한다. 캐시 적중·미스 로직, 조건부 캐싱, 복잡한 무효화가 가능하다.
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
@Injectable()
export class ProductService {
constructor(
@Inject(CACHE_MANAGER) private readonly cache: Cache,
private readonly prisma: PrismaService,
) {}
async findOne(id: string): Promise<Product> {
const cacheKey = `product:${id}`;
// 1. 캐시 조회
const cached = await this.cache.get<Product>(cacheKey);
if (cached) return cached;
// 2. DB 조회
const product = await this.prisma.product.findUniqueOrThrow({
where: { id },
include: { category: true, variants: true },
});
// 3. 캐시 저장 (TTL 5분)
await this.cache.set(cacheKey, product, 300_000);
return product;
}
async update(id: string, dto: UpdateProductDto): Promise<Product> {
const product = await this.prisma.product.update({
where: { id },
data: dto,
});
// 관련 캐시 무효화
await this.cache.del(`product:${id}`);
await this.cache.del('products:list'); // 목록 캐시도 무효화
return product;
}
}
캐시 무효화 전략
캐싱에서 가장 어려운 문제가 무효화(invalidation)다. NestJS에서 활용할 수 있는 3가지 전략을 비교한다.
| 전략 | 장점 | 단점 | 적합한 경우 |
|---|---|---|---|
| TTL 기반 | 구현 간단, 자동 만료 | 만료 전 stale 데이터 | 실시간성 낮은 데이터 |
| 이벤트 기반 | 즉시 무효화, 정확함 | 이벤트 누락 위험 | CUD 작업이 명확한 경우 |
| 패턴 기반 삭제 | 관련 키 일괄 삭제 | Redis SCAN 비용 | 계층적 캐시 키 구조 |
// 이벤트 기반 캐시 무효화
import { OnEvent } from '@nestjs/event-emitter';
@Injectable()
export class CacheInvalidationService {
constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {}
@OnEvent('product.updated')
async onProductUpdated(event: ProductUpdatedEvent) {
await this.cache.del(`product:${event.productId}`);
await this.invalidatePattern('products:list:*');
}
@OnEvent('product.deleted')
async onProductDeleted(event: ProductDeletedEvent) {
await this.cache.del(`product:${event.productId}`);
await this.invalidatePattern('products:*');
}
// Redis 패턴 기반 일괄 삭제
private async invalidatePattern(pattern: string) {
const store = this.cache.store as any;
if (!store.client) return;
const keys = await store.client.keys(pattern);
if (keys.length > 0) {
await store.client.del(keys);
}
}
}
NestJS EventEmitter2 이벤트 설계 글에서 다룬 도메인 이벤트 패턴을 캐시 무효화에 결합하면 깔끔한 아키텍처가 완성된다.
커스텀 캐시 데코레이터
반복되는 캐시 로직을 데코레이터로 추상화하면 서비스 코드가 훨씬 깔끔해진다. TypeScript 데코레이터와 NestJS DI를 결합한 실전 패턴이다.
// cacheable.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const CACHE_OPTIONS_KEY = 'cache_options';
interface CacheOptions {
key: string; // 캐시 키 템플릿 (예: 'user:${0}')
ttl?: number; // TTL (밀리초)
unless?: string; // 캐시하지 않을 조건
}
export const Cacheable = (options: CacheOptions) =>
SetMetadata(CACHE_OPTIONS_KEY, options);
// cacheable.interceptor.ts
@Injectable()
export class CacheableInterceptor implements NestInterceptor {
constructor(@Inject(CACHE_MANAGER) private cache: Cache) {}
async intercept(context: ExecutionContext, next: CallHandler) {
const options = Reflector.get<CacheOptions>(
CACHE_OPTIONS_KEY,
context.getHandler(),
);
if (!options) return next.handle();
const args = context.getArgs();
const cacheKey = options.key.replace(
/${(d+)}/g,
(_, i) => String(args[parseInt(i)])
);
const cached = await this.cache.get(cacheKey);
if (cached) return of(cached);
return next.handle().pipe(
tap(async (result) => {
await this.cache.set(cacheKey, result, options.ttl ?? 60_000);
}),
);
}
}
// 사용 예시
@Injectable()
export class UserService {
@Cacheable({ key: 'user:${0}', ttl: 300_000 })
async findById(id: string): Promise<User> {
return this.prisma.user.findUniqueOrThrow({ where: { id } });
}
}
멀티 레이어 캐싱
인메모리(L1) + Redis(L2) 2단계 캐싱으로 레이턴시와 확장성을 동시에 잡을 수 있다. cache-manager v5의 multiCaching을 활용한다.
import { multiCaching, MemoryCache, memoryStore } from 'cache-manager';
import { redisStore, RedisCache } from 'cache-manager-redis-yet';
@Module({
providers: [
{
provide: 'MULTI_CACHE',
useFactory: async () => {
const memCache: MemoryCache = await memoryStore({
max: 500, // 최대 500개 항목
ttl: 10_000, // L1: 10초 (짧게)
});
const redCache: RedisCache = await redisStore({
socket: { host: 'localhost', port: 6379 },
ttl: 300_000, // L2: 5분
});
return multiCaching([memCache, redCache]);
},
},
],
})
export class CacheConfigModule {}
// L1 히트 → 즉시 반환 (< 1ms)
// L1 미스 → L2 조회 → L1에 승격 (< 5ms)
// 둘 다 미스 → DB 조회 → L1+L2에 저장
캐시 워밍과 프리로딩
서버 시작 시 자주 조회되는 데이터를 미리 캐싱하면 콜드 스타트 문제를 방지할 수 있다. NestJS Terminus 헬스체크와 결합하면 캐시 상태도 헬스체크 항목에 포함할 수 있다.
@Injectable()
export class CacheWarmupService implements OnModuleInit {
constructor(
@Inject(CACHE_MANAGER) private cache: Cache,
private prisma: PrismaService,
) {}
async onModuleInit() {
// 인기 상품 Top 100 프리로딩
const topProducts = await this.prisma.product.findMany({
take: 100,
orderBy: { viewCount: 'desc' },
include: { category: true },
});
await Promise.all(
topProducts.map((p) =>
this.cache.set(`product:${p.id}`, p, 600_000),
),
);
// 카테고리 목록 프리로딩
const categories = await this.prisma.category.findMany();
await this.cache.set('categories:all', categories, 3600_000);
console.log(`Cache warmed: ${topProducts.length} products, ${categories.length} categories`);
}
}
마무리
NestJS 캐싱은 단순한 TTL 설정을 넘어, 무효화 전략·멀티 레이어·워밍까지 고려해야 프로덕션 품질이 된다. CacheInterceptor로 시작해서 서비스 레벨 수동 캐싱으로 확장하고, 이벤트 기반 무효화로 데이터 정합성을 확보하는 것이 권장 성장 경로다.