캐싱 전략 (Redis, in-memory)
9장에서는 성능 최적화와 스케일링을 다루며, 먼저 캐싱 전략을 설명합니다.
애플리케이션 성능 문제는 사용자 이탈, 시스템 과부하, 운영 비용 증가 등 다양한 부정적인 영향을 미칩니다. 캐싱은 이러한 문제를 해결하기 위한 가장 효과적인 방법 중 하나로, 자주 접근하는 데이터를 빠르게 제공하여 응답 시간을 단축하고 백엔드 시스템의 부하를 줄여줍니다.
먼저 캐시를 설계할 때 함께 봐야 하는 key, TTL, 무효화, 장애 대응 기준을 전체 그림으로 잡아봅니다.
캐싱(Caching)이란 무엇인가?
아래 그림은 캐시 히트와 미스, TTL, 원본 조회가 어떤 흐름으로 이어지는지 간단히 정리합니다.
캐싱(Caching)은 데이터나 계산 결과를 임시 저장소(캐시)에 보관하여, 동일한 데이터나 결과를 다시 요청할 때 원본 소스에서 가져오는 대신 캐시에서 빠르게 제공하는 기술입니다. 이는 데이터 접근 속도를 향상시키고, 원본 데이터 소스(데이터베이스, 외부 API 등)의 부하를 줄이는 데 목적이 있습니다.
캐싱의 기본 원리클라이언트가 데이터를 요청합니다.
애플리케이션은 먼저 캐시에 해당 데이터가 있는지 확인합니다.
캐시 히트(Cache Hit): 데이터가 캐시에 있는 경우 (최신 데이터라고 가정), 캐시에서 데이터를 즉시 반환합니다.
캐시 미스(Cache Miss): 데이터가 캐시에 없거나 만료된 경우, 원본 데이터 소스(예: 데이터베이스)에서 데이터를 가져옵니다.
가져온 데이터를 캐시에 저장하고, 클라이언트에게 반환합니다.
다음 번 동일한 요청 시에는 캐시 히트가 발생하여 빠르게 응답할 수 있습니다.
- 응답 시간 단축: 데이터를 캐시에서 직접 가져오므로 네트워크 I/O나 복잡한 계산을 피할 수 있어 응답 시간이 크게 줄어듭니다.
- 백엔드 부하 감소: 데이터베이스나 외부 API 호출 횟수가 줄어들어 백엔드 시스템의 부하가 경감됩니다.
- 비용 절감: 클라우드 환경에서 데이터베이스 사용량이나 네트워크 트래픽에 따른 비용을 절감할 수 있습니다.
- 가용성 향상: 캐시 계층이 원본 데이터 소스의 장애 시에도 제한적으로 데이터를 제공하여 서비스의 가용성을 높일 수 있습니다.
- 데이터 정합성(Cache Invalidation): 원본 데이터가 변경되었을 때 캐시 데이터도 업데이트되거나 무효화되어야 합니다. 이는 캐싱 전략에서 가장 어려운 부분 중 하나입니다.
- 메모리 사용량: 캐시는 메모리를 사용하므로, 캐싱할 데이터의 양과 캐시 서버의 메모리 용량을 고려해야 합니다.
- 캐시 미스 비용: 캐시 미스 시 원본 데이터를 가져오는 비용이 캐싱으로 얻는 이점보다 크지 않아야 합니다.
캐싱 전략의 종류와 NestJS 적용
캐싱은 저장 방식에 따라 크게 두 가지로 나눌 수 있습니다. 인메모리(In-memory) 캐싱과 분산 캐싱(Distributed Caching).
인메모리 캐싱
인메모리 캐싱은 애플리케이션이 실행되는 서버의 RAM에 데이터를 저장하는 방식입니다.
장점- 매우 빠름: 네트워크 지연 없이 프로세스 내부에서 직접 데이터에 접근하므로 가장 빠릅니다.
- 설정 용이: 별도의 캐시 서버를 구축할 필요가 없어 구현이 간단합니다.
- 확장성 부족: 서버가 여러 대일 경우 각 서버마다 다른 캐시 데이터를 가질 수 있어 데이터 일관성 문제가 발생합니다 (캐시 불일치).
- 휘발성: 애플리케이션 재시작 시 캐시 데이터가 모두 손실됩니다.
- 메모리 제한: 애플리케이션 서버의 메모리 용량에 따라 저장할 수 있는 데이터 양이 제한됩니다.
NestJS는 @nestjs/common 패키지의 CacheModule을 통해 인메모리 캐싱 기능을 내장하고 있습니다.
별도의 패키지 설치는 필요 없습니다. @nestjs/common에 포함되어 있습니다.
AppModule에 CacheModule 등록
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 요청에 대해 자동으로 캐싱 로직을 적용하도록 합니다.
@CacheKey() 및 @CacheTTL() 적용
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의 CacheModule은 다양한 스토어 어댑터를 지원하며, Redis 어댑터는 @nestjs/cache-manager-redis-store 패키지를 통해 제공됩니다.
Docker를 사용하여 Redis를 실행하는 것이 가장 간편합니다.
docker run --name my-redis -p 6379:6379 -d redis/redis-stack-server:latest # Redis 스택 이미지 사용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를 연결하는 스토어 어댑터.
AppModule에 Redis CacheModule 등록
cache-manager-redis-store는 cache-manager의 create 함수를 통해 스토어를 제공합니다.
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에 저장될 캐시의 유효 시간을 설정합니다.
@CacheKey() 및 @CacheTTL() 데코레이터 사용 방식은 인메모리 캐싱과 동일합니다. NestJS의 추상화 덕분에 스토어만 변경하면 됩니다.
NestJS의 CACHE_MANAGER 토큰을 주입받아 캐시를 수동으로 제어할 수도 있습니다. 이때는 자동 인터셉터가 해주던 키 선택, 히트/미스 처리, TTL 저장, reset 범위를 서비스 코드가 직접 책임집니다.
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 메서드를 추가하고, 이를 호출하는 임시 컨트롤러 엔드포인트를 만들어 수동 캐싱 로직을 테스트해 볼 수 있습니다.
캐싱은 빠른 읽기만큼 무효화 기준이 중요합니다. 아래 다이어그램은 TTL, 이벤트 기반 삭제, 수동 복구를 함께 놓고 선택 기준을 정리합니다.
마지막으로 캐싱 전략을 운영에 올릴 때는 키 설계, 저장 위치, 무효화 신호, 관측 지표를 함께 점검해야 합니다.
아래 다이어그램은 앞에서 다룬 키 설계, TTL, 무효화, stampede 대응을 운영 점검표처럼 다시 묶어 보여줍니다.
캐싱은 애플리케이션 성능 최적화의 핵심 전략입니다.
사용 시나리오와 시스템 아키텍처에 따라 인메모리 캐시와 분산 캐시를 적절히 선택해야 합니다.
NestJS는 CacheModule을 통해 인메모리 캐싱과 Redis 같은 분산 캐싱 솔루션을 쉽게 통합할 수 있는 추상화를 제공합니다.
캐시를 올바르게 활용하면 응답 시간을 줄이고 백엔드 부하를 낮춰 더 효율적인 시스템을 만들 수 있습니다.
이것으로 9장 성능 최적화와 스케일링의 첫 번째 절을 마칩니다.