캐싱 전략 (Redis, in-memory)
캐싱은 자주 액세스되는 데이터를 빠르게 접근할 수 있는 저장소에 보관하여 애플리케이션의 성능을 향상시키는 기술입니다.
NestJS 애플리케이션에서 적절한 캐싱 전략을 적용하면 데이터베이스 쿼리 횟수를 줄이고 응답 시간을 단축시켜 시스템 성능을 개선할 수 있습니다.
NestJS 내장 캐시 관리자를 이용한 인메모리 캐싱
NestJS는 @nestjs/common
패키지를 통해 내장 캐시 관리자를 제공합니다.
- 캐시 모듈 설정
import { CacheModule, Module } from '@nestjs/common';
import { AppController } from './app.controller';
@Module({
imports: [CacheModule.register()],
controllers: [AppController],
})
export class AppModule {}
- 컨트롤러에서 캐시 사용
import { Controller, Get, UseInterceptors, CacheInterceptor } from '@nestjs/common';
@Controller()
@UseInterceptors(CacheInterceptor)
export class AppController {
@Get()
@CacheKey('all_items')
@CacheTTL(30)
async getAllItems() {
// 데이터베이스에서 아이템 조회
return await this.itemsService.findAll();
}
}
이 예제에서 @UseInterceptors(CacheInterceptor)
는 컨트롤러 전체에 캐싱을 적용하고 @CacheKey
와 @CacheTTL
은 각각 캐시 키와 유효 시간을 설정합니다.
Redis를 이용한 캐싱
Redis는 분산 캐싱에 널리 사용되는 인메모리 데이터 구조 저장소입니다.
- 의존성 설치
npm install cache-manager cache-manager-redis-store
- Redis 캐시 설정
import { CacheModule, Module } from '@nestjs/common';
import * as redisStore from 'cache-manager-redis-store';
@Module({
imports: [
CacheModule.register({
store: redisStore,
host: 'localhost',
port: 6379,
}),
],
controllers: [AppController],
})
export class AppModule {}
다양한 캐싱 전략
1. Cache-Aside (Lazy Loading)
- 애플리케이션이 먼저 캐시를 확인하고, 캐시 미스 시 데이터베이스에서 조회 후 캐시에 저장합니다.
@Injectable()
export class ItemService {
constructor(
@Inject(CACHE_MANAGER) private cacheManager: Cache,
private readonly itemRepository: ItemRepository,
) {}
async getItem(id: number): Promise<Item> {
const cachedItem = await this.cacheManager.get<Item>(`item_${id}`);
if (cachedItem) {
return cachedItem;
}
const item = await this.itemRepository.findOne(id);
await this.cacheManager.set(`item_${id}`, item, { ttl: 3600 });
return item;
}
}
2. Read-Through
- 캐시 계층이 데이터베이스에서 데이터를 로드하고 캐시에 저장합니다.
3. Write-Through
- 데이터를 캐시와 데이터베이스에 동시에 쓰는 방식입니다.
async updateItem(id: number, itemData: Partial<Item>): Promise<Item> {
const updatedItem = await this.itemRepository.update(id, itemData);
await this.cacheManager.set(`item_${id}`, updatedItem, { ttl: 3600 });
return updatedItem;
}
4. Write-Behind (Write-Back)
- 데이터를 먼저 캐시에 쓰고, 나중에 비동기적으로 데이터베이스에 업데이트합니다.
캐시 무효화와 갱신
- TTL (Time-to-Live) 설정
this.cacheManager.set('key', value, { ttl: 3600 }); // 1시간 후 만료
- 명시적 삭제
this.cacheManager.del('key');
- 패턴 기반 삭제 (Redis)
import { RedisClient } from 'redis';
// Redis 클라이언트 설정
const redisClient = new RedisClient(/* 설정 */);
// 특정 패턴의 키 삭제
redisClient.keys('user:*', (err, keys) => {
keys.forEach(key => redisClient.del(key));
});
분산 환경에서의 캐싱
1. 데이터 일관성
- 여러 노드 간 캐시 동기화를 위해 Redis pub/sub 메커니즘 활용
- 캐시 업데이트 시 다른 노드에 알림 전송
2. 동시성 제어
- Redis의 원자적 연산 활용 (예 : SETNX)
- 분산 락 구현
import { RedisClient } from 'redis';
async function distributedLock(key: string, ttl: number): Promise<boolean> {
return new Promise((resolve) => {
redisClient.set(key, '1', 'NX', 'PX', ttl, (err, result) => {
resolve(result === 'OK');
});
});
}
캐시 계층화
다중 레벨 캐시를 구현하여 성능과 비용의 균형을 맞출 수 있습니다.
- L1 캐시 : 인메모리 (빠르지만 용량 제한)
- L2 캐시 : Redis (L1보다 느리지만 더 큰 용량)
@Injectable()
export class MultilevelCacheService {
constructor(
@Inject(CACHE_MANAGER) private l1Cache: Cache,
@Inject('REDIS_CACHE') private l2Cache: Cache,
) {}
async get(key: string): Promise<any> {
let value = await this.l1Cache.get(key);
if (value) return value;
value = await this.l2Cache.get(key);
if (value) {
await this.l1Cache.set(key, value, { ttl: 300 }); // 5분 동안 L1에 저장
}
return value;
}
async set(key: string, value: any, ttl: number): Promise<void> {
await Promise.all([
this.l1Cache.set(key, value, { ttl: Math.min(ttl, 300) }),
this.l2Cache.set(key, value, { ttl }),
]);
}
}
성능 테스트 및 분석
- Apache JMeter나 autocannon을 사용하여 부하 테스트 수행
- 캐싱 적용 전후의 응답 시간과 처리량 비교
- 캐시 적중률 (Hit Ratio) 모니터링
@Injectable()
export class CacheMetricsService {
private hits = 0;
private misses = 0;
recordHit() {
this.hits++;
}
recordMiss() {
this.misses++;
}
getHitRatio(): number {
const total = this.hits + this.misses;
return total > 0 ? this.hits / total : 0;
}
}
Best Practices 및 주의사항
- 적절한 TTL 설정 : 데이터의 변경 주기를 고려하여 설정
- 캐시 키 설계 : 고유하고 예측 가능한 키 사용
- 캐시 크기 관리 : 메모리 사용량 모니터링 및 제한 설정
- 장애 대응 : 캐시 서버 장애 시 대체 로직 구현
- 보안 : 민감한 정보는 암호화하여 저장
- 모니터링 : 캐시 히트율, 메모리 사용량 등 주요 지표 모니터링
- 캐시 워밍업 : 주요 데이터를 미리 캐시에 로드
- 선택적 캐싱 : 모든 데이터를 캐싱하지 않고, 비용 대비 효과를 고려하여 선택적으로 캐싱
- 버전 관리 : 데이터 스키마 변경 시 캐시 키에 버전 정보 포함
- 정기적인 캐시 갱신 : 백그라운드 작업으로 주기적으로 캐시 데이터 갱신
NestJS 애플리케이션에서 효과적인 캐싱 전략을 구현하면 성능을 크게 향상시킬 수 있습니다.
인메모리 캐싱은 단일 서버 환경에서 빠른 응답 시간을 제공하며 Redis를 활용한 분산 캐싱은 다중 서버 환경에서 데이터 일관성과 고가용성을 보장합니다.
캐시 계층화를 통해 성능과 비용의 균형을 맞출 수 있으며 지속적인 테스트를 통해 캐싱 전략의 효과를 측정하고 최적화해야 합니다.