캐싱 전략 (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: AppModule
에 CacheModule
등록
// 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-store
는 cache-manager
의 create
함수를 통해 스토어를 제공합니다.
// 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 *
명령어로 캐시 키를 확인할 수 있습니다.
AppService
에 getCachedData
와 resetCache
메서드를 추가하고, 이를 호출하는 임시 컨트롤러 엔드포인트를 만들어 수동 캐싱 로직을 테스트해 볼 수 있습니다.
캐싱은 애플리케이션 성능 최적화의 핵심 전략이며, 사용 시나리오와 시스템 아키텍처에 따라 인메모리 또는 분산 캐싱을 적절히 선택해야 합니다. NestJS는 CacheModule
을 통해 인메모리 캐싱과 Redis와 같은 분산 캐싱 솔루션 모두를 쉽게 통합할 수 있는 강력한 추상화를 제공합니다. 캐시를 적절히 활용하여 애플리케이션의 응답 시간을 단축하고, 백엔드 시스템의 부하를 줄여 효율적인 시스템을 구축하시길 바랍니다.
이것으로 9장 "성능 최적화와 스케일링"의 첫 번째 절을 마칩니다.