icon
9장 : 성능 최적화와 스케일링

캐싱 전략 (Redis, in-memory)


8장에서는 NestJS 애플리케이션의 견고함을 보장하기 위한 다양한 테스팅 전략과 지속적 통합(CI)에 테스트를 통합하는 방법을 살펴보았습니다. 이제 9장에서는 애플리케이션의 사용자 경험과 리소스 효율성을 직접적으로 개선하는 성능 최적화와 스케일링에 대해 다루며, 그 첫 번째 주제로 캐싱 전략에 대해 알아보겠습니다.

애플리케이션 성능 문제는 사용자 이탈, 시스템 과부하, 운영 비용 증가 등 다양한 부정적인 영향을 미칩니다. 캐싱은 이러한 문제를 해결하기 위한 가장 효과적인 방법 중 하나로, 자주 접근하는 데이터를 빠르게 제공하여 응답 시간을 단축하고 백엔드 시스템의 부하를 줄여줍니다.


캐싱(Caching)이란 무엇인가?

캐싱(Caching) 은 데이터나 계산 결과를 임시 저장소(캐시)에 보관하여, 동일한 데이터나 결과를 다시 요청할 때 원본 소스에서 가져오는 대신 캐시에서 빠르게 제공하는 기술입니다. 이는 데이터 접근 속도를 향상시키고, 원본 데이터 소스(데이터베이스, 외부 API 등)의 부하를 줄이는 데 목적이 있습니다.

캐싱의 기본 원리

클라이언트가 데이터를 요청합니다.

애플리케이션은 먼저 캐시에 해당 데이터가 있는지 확인합니다.

캐시 히트(Cache Hit): 데이터가 캐시에 있는 경우 (최신 데이터라고 가정), 캐시에서 데이터를 즉시 반환합니다.

캐시 미스(Cache Miss): 데이터가 캐시에 없거나 만료된 경우, 원본 데이터 소스(예: 데이터베이스)에서 데이터를 가져옵니다.

가져온 데이터를 캐시에 저장하고, 클라이언트에게 반환합니다.

다음 번 동일한 요청 시에는 캐시 히트가 발생하여 빠르게 응답할 수 있습니다.

캐싱의 장점

  • 응답 시간 단축: 데이터를 캐시에서 직접 가져오므로 네트워크 I/O나 복잡한 계산을 피할 수 있어 응답 시간이 크게 줄어듭니다.
  • 백엔드 부하 감소: 데이터베이스나 외부 API 호출 횟수가 줄어들어 백엔드 시스템의 부하가 경감됩니다.
  • 비용 절감: 클라우드 환경에서 데이터베이스 사용량이나 네트워크 트래픽에 따른 비용을 절감할 수 있습니다.
  • 가용성 향상: 캐시 계층이 원본 데이터 소스의 장애 시에도 제한적으로 데이터를 제공하여 서비스의 가용성을 높일 수 있습니다.

캐싱 사용 시 고려사항

  • 데이터 정합성(Cache Invalidation): 원본 데이터가 변경되었을 때 캐시 데이터도 업데이트되거나 무효화되어야 합니다. 이는 캐싱 전략에서 가장 어려운 부분 중 하나입니다.
  • 메모리 사용량: 캐시는 메모리를 사용하므로, 캐싱할 데이터의 양과 캐시 서버의 메모리 용량을 고려해야 합니다.
  • 캐시 미스 비용: 캐시 미스 시 원본 데이터를 가져오는 비용이 캐싱으로 얻는 이점보다 크지 않아야 합니다.

캐싱 전략의 종류와 NestJS 적용

캐싱은 저장 방식에 따라 크게 두 가지로 나눌 수 있습니다: 인메모리(In-memory) 캐싱분산 캐싱(Distributed Caching).

인메모리 캐싱

인메모리 캐싱은 애플리케이션이 실행되는 서버의 RAM에 데이터를 저장하는 방식입니다.

장점

  • 매우 빠름: 네트워크 지연 없이 프로세스 내부에서 직접 데이터에 접근하므로 가장 빠릅니다.
  • 설정 용이: 별도의 캐시 서버를 구축할 필요가 없어 구현이 간단합니다.

단점

  • 확장성 부족: 서버가 여러 대일 경우 각 서버마다 다른 캐시 데이터를 가질 수 있어 데이터 일관성 문제가 발생합니다 (캐시 불일치).
  • 휘발성: 애플리케이션 재시작 시 캐시 데이터가 모두 손실됩니다.
  • 메모리 제한: 애플리케이션 서버의 메모리 용량에 따라 저장할 수 있는 데이터 양이 제한됩니다.

NestJS에서 인메모리 캐싱 구현

NestJS는 @nestjs/common 패키지의 CacheModule을 통해 인메모리 캐싱 기능을 내장하고 있습니다.

단계 1: 필요한 패키지 설치

별도의 패키지 설치는 필요 없습니다. @nestjs/common에 포함되어 있습니다.

단계 2: AppModuleCacheModule 등록

// src/app.module.ts
import { Module, CacheModule, CacheInterceptor } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { APP_INTERCEPTOR } from '@nestjs/core'; // APP_INTERCEPTOR 임포트

@Module({
  imports: [
    CacheModule.register({
      ttl: 5, // 캐시 TTL(Time To Live) 5초 (캐시 데이터가 유효한 시간)
      max: 100, // 캐시에 저장할 최대 항목 수
    }),
  ],
  controllers: [AppController],
  providers: [
    AppService,
    {
      provide: APP_INTERCEPTOR, // 전역 인터셉터로 CacheInterceptor 등록
      useClass: CacheInterceptor,
    },
  ],
})
export class AppModule {}
  • CacheModule.register(): 인메모리 캐시를 설정합니다. ttl은 캐시 항목이 얼마나 오랫동안 유효할지 초 단위로 정의하고, max는 캐시에 저장될 최대 항목 수를 정의합니다.
  • APP_INTERCEPTOR: CacheInterceptor를 전역 인터셉터로 등록하여, @CacheKey()@CacheTTL() 데코레이터가 적용된 모든 HTTP 요청에 대해 자동으로 캐싱 로직을 적용하도록 합니다.

단계 3: 컨트롤러에 @CacheKey()@CacheTTL() 적용

// src/app.controller.ts
import { Controller, Get, Post, Body, CacheKey, CacheTTL } from '@nestjs/common'; // CacheKey, CacheTTL 임포트
import { AppService } from './app.service';

@Controller('app')
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get('hello')
  // 'hello_world'라는 캐시 키를 사용하고, 캐시 TTL은 기본값(AppModule에서 설정한 5초)을 따릅니다.
  @CacheKey('hello_world')
  getHello(): string {
    console.log('AppService.getHello() called - NOT FROM CACHE'); // 캐시 히트 시에는 이 로그가 출력되지 않습니다.
    return this.appService.getHello();
  }

  @Get('cached-sum')
  @CacheKey('sum_10_20') // 특정 키
  @CacheTTL(10) // 이 엔드포포인트는 10초 동안 캐시
  sumNumbersCached(): number {
    console.log('AppService.sum(10, 20) called - NOT FROM CACHE');
    return this.appService.sum(10, 20); // 예시를 위해 고정된 값
  }

  // 캐시가 적용되지 않는 일반 POST 엔드포인트
  @Post('sum')
  sumNumbers(@Body() data: { a: number; b: number }): number {
    return this.appService.sum(data.a, data.b);
  }
}
  • @CacheKey('key_name'): 특정 라우트의 응답을 캐시할 때 사용할 키를 정의합니다.
  • @CacheTTL(seconds): 해당 라우트의 캐시 유효 시간을 재정의합니다 (Module에서 설정한 전역 TTL보다 우선합니다).
  • console.log 문을 추가하여 실제 서비스 메서드가 호출되는지(캐시 미스) 캐시에서 바로 응답이 나가는지(캐시 히트) 확인할 수 있습니다.

실행 및 테스트

npm run start:dev로 NestJS 애플리케이션을 실행합니다.

브라우저나 Postman으로 http://localhost:3000/app/hello에 처음 요청하면 AppService.getHello() called - NOT FROM CACHE 로그가 콘솔에 출력됩니다.

5초 이내에 다시 요청하면 로그가 출력되지 않고, 응답이 즉시 반환됩니다 (캐시 히트).

5초가 지난 후 다시 요청하면 로그가 다시 출력됩니다 (캐시 만료 및 재호출).

http://localhost:3000/app/cached-sum 엔드포인트도 10초 TTL로 동일하게 테스트해 봅니다.

분산 캐싱

분산 캐싱은 별도의 캐시 서버(예: Redis, Memcached)를 사용하여 여러 애플리케이션 서버가 캐시를 공유하는 방식입니다.

장점

  • 확장성: 여러 애플리케이션 서버가 동일한 캐시를 공유하므로 데이터 일관성 문제가 발생하지 않습니다.
  • 고가용성: 캐시 서버 클러스터를 구성하여 단일 장애 지점을 제거할 수 있습니다.
  • 영속성(Persistence): Redis와 같은 캐시 서버는 데이터를 디스크에 저장할 수 있어 서버 재시작 후에도 데이터를 유지할 수 있습니다 (구성 방식에 따라).
  • 풍부한 데이터 구조: Redis는 문자열, 해시, 리스트, 셋, 정렬된 셋 등 다양한 데이터 구조를 지원하여 유연한 캐싱이 가능합니다.

단점

  • 설정 복잡성: 별도의 캐시 서버를 구축하고 관리해야 하므로 초기 설정 및 운영이 더 복잡합니다.
  • 네트워크 지연: 인메모리 캐싱보다 약간의 네트워크 지연이 발생합니다.

NestJS에서 Redis를 이용한 분산 캐싱 구현

NestJS의 CacheModule은 다양한 스토어 어댑터를 지원하며, Redis 어댑터는 @nestjs/cache-manager-redis-store 패키지를 통해 제공됩니다.

단계 1: Redis 서버 실행

Docker를 사용하여 Redis를 실행하는 것이 가장 간편합니다.

docker run --name my-redis -p 6379:6379 -d redis/redis-stack-server:latest # Redis 스택 이미지 사용

단계 2: 필요한 패키지 설치

npm install @nestjs/cache-manager cache-manager cache-manager-redis-store
npm install --save-dev @types/cache-manager-redis-store
  • @nestjs/cache-manager: NestJS의 캐싱 통합을 위한 코어 패키지.
  • cache-manager: Node.js 캐싱 라이브러리.
  • cache-manager-redis-store: cache-manager와 Redis를 연결하는 스토어 어댑터.

단계 3: AppModule에 Redis CacheModule 등록

cache-manager-redis-storecache-managercreate 함수를 통해 스토어를 제공합니다.

// src/app.module.ts
import { Module, CacheModule, CacheInterceptor } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { APP_INTERCEPTOR } from '@nestjs/core';
import * as redisStore from 'cache-manager-redis-store'; // redis store 임포트

@Module({
  imports: [
    CacheModule.register({
      // @ts-ignore
      store: redisStore, // Redis 스토어 사용 선언
      host: 'localhost', // Redis 서버 호스트
      port: 6379, // Redis 서버 포트
      ttl: 300, // 캐시 TTL 300초 (5분)
    }),
  ],
  controllers: [AppController],
  providers: [
    AppService,
    {
      provide: APP_INTERCEPTOR,
      useClass: CacheInterceptor,
    },
  ],
})
export class AppModule {}
  • store: redisStore: 캐시 스토어로 cache-manager-redis-store를 사용하도록 지정합니다.
  • host, port: Redis 서버의 연결 정보를 입력합니다.
  • ttl: Redis에 저장될 캐시의 유효 시간을 설정합니다.

단계 4: 컨트롤러 및 서비스에서 캐시 활용 (동일)

@CacheKey()@CacheTTL() 데코레이터 사용 방식은 인메모리 캐싱과 동일합니다. NestJS의 추상화 덕분에 스토어만 변경하면 됩니다.

수동으로 캐시 관리 (선택 사항)

NestJS의 CACHE_MANAGER 토큰을 주입받아 캐시를 수동으로 제어할 수도 있습니다.

// src/app.service.ts (또는 다른 서비스)
import { Injectable, Inject, CACHE_MANAGER } from '@nestjs/common';
import { Cache } from 'cache-manager'; // cache-manager의 Cache 타입 임포트

@Injectable()
export class AppService {
  constructor(
    @Inject(CACHE_MANAGER) private cacheManager: Cache, // 캐시 매니저 주입
  ) {}

  getHello(): string {
    return 'Hello World!';
  }

  sum(a: number, b: number): number {
    return a + b;
  }

  async getCachedData(key: string): Promise<string> {
    // 캐시에서 데이터 가져오기
    const cachedItem = await this.cacheManager.get<string>(key);
    if (cachedItem) {
      console.log(`Getting ${key} from cache: ${cachedItem}`);
      return cachedItem;
    }

    // 캐시 미스 시 데이터 생성 및 캐시에 저장
    const result = `Data for ${key} at ${new Date().toISOString()}`;
    await this.cacheManager.set(key, result, { ttl: 60 }); // 60초 TTL로 저장
    console.log(`Setting ${key} in cache: ${result}`);
    return result;
  }

  async resetCache(): Promise<void> {
    await this.cacheManager.reset(); // 모든 캐시 데이터 삭제
    console.log('Cache reset!');
  }
}
  • @Inject(CACHE_MANAGER) private cacheManager: Cache: CACHE_MANAGER 토큰을 사용하여 cache-manager의 캐시 인스턴스를 주입받습니다.
  • cacheManager.get(key): 특정 키에 해당하는 데이터를 캐시에서 가져옵니다.
  • cacheManager.set(key, value, { ttl: seconds }): 데이터를 캐시에 저장합니다.
  • cacheManager.reset(): 모든 캐시 데이터를 삭제합니다.

실행 및 테스트

Redis 서버가 실행 중인지 확인합니다 (docker ps 등).

npm run start:dev로 NestJS 애플리케이션을 실행합니다.

이전과 동일하게 http://localhost:3000/app/hello 또는 http://localhost:3000/app/cached-sum에 요청을 보냅니다. 이제 캐시 데이터가 Redis에 저장되고 관리됩니다. Redis CLI 등을 통해 keys * 명령어로 캐시 키를 확인할 수 있습니다.

AppServicegetCachedDataresetCache 메서드를 추가하고, 이를 호출하는 임시 컨트롤러 엔드포인트를 만들어 수동 캐싱 로직을 테스트해 볼 수 있습니다.


캐싱은 애플리케이션 성능 최적화의 핵심 전략이며, 사용 시나리오와 시스템 아키텍처에 따라 인메모리 또는 분산 캐싱을 적절히 선택해야 합니다. NestJS는 CacheModule을 통해 인메모리 캐싱과 Redis와 같은 분산 캐싱 솔루션 모두를 쉽게 통합할 수 있는 강력한 추상화를 제공합니다. 캐시를 적절히 활용하여 애플리케이션의 응답 시간을 단축하고, 백엔드 시스템의 부하를 줄여 효율적인 시스템을 구축하시길 바랍니다.

이것으로 9장 "성능 최적화와 스케일링"의 첫 번째 절을 마칩니다.