icon

캐싱 전략 (Redis, in-memory)


 캐싱은 자주 액세스되는 데이터를 빠르게 접근할 수 있는 저장소에 보관하여 애플리케이션의 성능을 향상시키는 기술입니다.

 NestJS 애플리케이션에서 적절한 캐싱 전략을 적용하면 데이터베이스 쿼리 횟수를 줄이고 응답 시간을 단축시켜 시스템 성능을 개선할 수 있습니다.

NestJS 내장 캐시 관리자를 이용한 인메모리 캐싱

 NestJS는 @nestjs/common 패키지를 통해 내장 캐시 관리자를 제공합니다.

  1. 캐시 모듈 설정
import { CacheModule, Module } from '@nestjs/common';
import { AppController } from './app.controller';
 
@Module({
  imports: [CacheModule.register()],
  controllers: [AppController],
})
export class AppModule {}
  1. 컨트롤러에서 캐시 사용
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는 분산 캐싱에 널리 사용되는 인메모리 데이터 구조 저장소입니다.

  1. 의존성 설치
npm install cache-manager cache-manager-redis-store
  1. 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)

  • 데이터를 먼저 캐시에 쓰고, 나중에 비동기적으로 데이터베이스에 업데이트합니다.

캐시 무효화와 갱신

  1. TTL (Time-to-Live) 설정
this.cacheManager.set('key', value, { ttl: 3600 }); // 1시간 후 만료
  1. 명시적 삭제
this.cacheManager.del('key');
  1. 패턴 기반 삭제 (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');
    });
  });
}

캐시 계층화

 다중 레벨 캐시를 구현하여 성능과 비용의 균형을 맞출 수 있습니다.

  1. L1 캐시 : 인메모리 (빠르지만 용량 제한)
  2. 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 }),
    ]);
  }
}

성능 테스트 및 분석

  1. Apache JMeter나 autocannon을 사용하여 부하 테스트 수행
  2. 캐싱 적용 전후의 응답 시간과 처리량 비교
  3. 캐시 적중률 (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 및 주의사항

  1. 적절한 TTL 설정 : 데이터의 변경 주기를 고려하여 설정
  2. 캐시 키 설계 : 고유하고 예측 가능한 키 사용
  3. 캐시 크기 관리 : 메모리 사용량 모니터링 및 제한 설정
  4. 장애 대응 : 캐시 서버 장애 시 대체 로직 구현
  5. 보안 : 민감한 정보는 암호화하여 저장
  6. 모니터링 : 캐시 히트율, 메모리 사용량 등 주요 지표 모니터링
  7. 캐시 워밍업 : 주요 데이터를 미리 캐시에 로드
  8. 선택적 캐싱 : 모든 데이터를 캐싱하지 않고, 비용 대비 효과를 고려하여 선택적으로 캐싱
  9. 버전 관리 : 데이터 스키마 변경 시 캐시 키에 버전 정보 포함
  10. 정기적인 캐시 갱신 : 백그라운드 작업으로 주기적으로 캐시 데이터 갱신

 NestJS 애플리케이션에서 효과적인 캐싱 전략을 구현하면 성능을 크게 향상시킬 수 있습니다.

 인메모리 캐싱은 단일 서버 환경에서 빠른 응답 시간을 제공하며 Redis를 활용한 분산 캐싱은 다중 서버 환경에서 데이터 일관성과 고가용성을 보장합니다.

 캐시 계층화를 통해 성능과 비용의 균형을 맞출 수 있으며 지속적인 테스트를 통해 캐싱 전략의 효과를 측정하고 최적화해야 합니다.