본문으로 건너뛰기

안동민 개발노트

본문 시작

캐싱 전략 (Redis, in-memory)

캐시 히트와 미스, 키·TTL·무효화 기준을 세우고 NestJS에 인메모리 캐시와 Redis 분산 캐시를 적용합니다.

9장에서는 성능 최적화와 스케일링을 다루며, 먼저 캐싱 전략을 설명합니다.

애플리케이션 성능 문제는 사용자 이탈, 시스템 과부하, 운영 비용 증가 등 다양한 부정적인 영향을 미칩니다.

캐싱은 이러한 문제를 해결하기 위한 가장 효과적인 방법 중 하나로, 자주 접근하는 데이터를 빠르게 제공하여 응답 시간을 단축하고 백엔드 시스템의 부하를 줄여줍니다.

먼저 캐시를 설계할 때 함께 봐야 하는 key, TTL, 무효화, 장애 대응 기준을 전체 그림으로 잡아봅니다.

캐시는 key, TTL, 무효화, 장애 대응을 같이 설계한다

저장소를 고르기 전에 같은 요청을 어떻게 이름 붙이고, 얼마나 오래 믿고, 언제 지우며, 미스가 몰릴 때 어떻게 버틸지 정합니다.

  1. Key

    요청 조건, 사용자 범위, 버전을 같은 이름 규칙에 넣습니다.

  2. TTL

    데이터 변경 주기보다 짧게 시작하고 hit ratio로 보정합니다.

  3. Invalidation

    쓰기 성공 뒤 어떤 key를 지울지 로그로 추적 가능해야 합니다.

  4. Fallback

    miss 폭주, 원본 장애, stale 응답 허용선을 먼저 정합니다.

항목결정 질문나빠지는 신호운영 기준
KEY같은 요청이 항상 같은 이름인가권한·버전이 섞여 잘못된 값 반환scope와 version을 key에 포함
TTL변경 주기보다 짧은가stale window가 업무 허용치를 넘음짧은 TTL에서 시작해 보정
DEL쓰기 후 관련 key를 찾을 수 있는가수정 직후 예전 값이 계속 보임무효화 이벤트와 재시도 로그
SAFEmiss 폭주와 원본 장애를 막는가miss latency와 DB pool wait 증가single flight, rate limit, stale 허용선

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

아래 그림은 캐시 히트와 미스, TTL, 원본 조회가 어떤 흐름으로 이어지는지 간단히 정리합니다.

캐시는 먼저 묻고, 없거나 만료되면 원본으로 간다

같은 API라도 캐시 값과 TTL이 살아 있으면 즉시 응답하고, 없으면 원본을 읽은 뒤 TTL을 붙여 다음 요청을 준비합니다.

  1. HIT

    네트워크와 DB 비용을 대부분 생략한다.

  2. MISS

    원본 조회와 저장 비용을 한 번 부담한다.

  3. TTL

    값을 믿을 수 있는 시간 울타리다.

  4. Request key

    URL, user id, query option을 같은 규칙으로 묶습니다.

  5. Cache lookup

    애플리케이션은 원본보다 캐시를 먼저 확인합니다.

  6. Origin fill

    miss일 때만 DB나 외부 API를 읽고 결과를 저장합니다.

  7. Hit path

    값 있음 + TTL 유효 → 캐시에서 바로 반환

  8. Miss path

    값 없음 또는 만료 → 원본 조회 → set cache

캐싱(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 토큰을 주입받아 캐시를 수동으로 제어할 수도 있습니다.

이때는 자동 인터셉터가 해주던 키 선택, 히트/미스 처리, TTL 저장, reset 범위를 서비스 코드가 직접 책임집니다.

CACHE_MANAGER를 쓰면 캐시 정책이 서비스 코드로 내려온다

자동 인터셉터와 달리 수동 캐싱은 key 생성, hit 판정, TTL 저장, reset 범위를 서비스 코드가 직접 책임집니다.

  1. 주입 대상

    서비스가 cache-manager 인스턴스를 직접 받습니다. @Inject(CACHE_MANAGER)

  2. 직접 호출

    읽기, 저장, 삭제 범위를 코드에서 명시합니다. get / set / reset

  3. key 만들기

    입력값과 권한 범위가 key에 반영되어야 합니다.

  4. get 판정

    hit면 원본 호출 없이 즉시 같은 DTO를 반환합니다.

  5. set with TTL

    miss 결과와 만료 시간을 함께 저장합니다.

  6. delete 범위

    쓰기 이후 필요한 key만 지우는지 점검합니다.

  7. key collision

    다른 사용자의 값이 섞이지 않게 한다.

  8. TTL default

    무한 캐시가 생기지 않게 한다.

  9. reset scope

    전체 삭제는 운영 영향이 큰 명령이다.

  10. error path

    캐시 장애가 API 장애로 번지지 않게 한다.

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


캐싱은 빠른 읽기만큼 무효화 기준이 중요합니다.

아래 다이어그램은 TTL, 이벤트 기반 삭제, 수동 복구를 함께 놓고 선택 기준을 정리합니다.

캐시 무효화는 “언제 stale을 끝낼지”의 선택이다

TTL은 시간이 해결하고, 이벤트 삭제는 쓰기 경로가 해결합니다. 수동 삭제는 자동 전략이 실패했을 때 좁은 범위로 복구하는 장치입니다.

  1. TTL 만료

    구현은 쉽지만 변경 직후 오래된 응답이 남을 수 있습니다.

  2. 이벤트 삭제

    쓰기 성공과 동시에 관련 key를 삭제해 정합성을 높입니다.

  3. 수동 복구

    잘못 저장된 값이나 누락된 이벤트를 운영자가 좁게 지웁니다.

판단TTL이벤트 삭제수동 삭제
좋은 대상공지, 목록, 외부 API처럼 약간 늦어도 되는 읽기프로필, 권한, 재고처럼 변경 즉시 반영할 데이터배포 후 잘못 저장된 값, 장애 대응 runbook
필수 조건변경 주기보다 TTL이 짧아야 함쓰기 경로에서 관련 key를 추적 가능해야 함전체 reset 대신 namespace 범위가 있어야 함
주의점stale window가 업무 허용치를 넘지 않게 한다이벤트 누락과 재시도 로그를 남긴다과도한 삭제로 원본 부하를 키우지 않는다

마지막으로 캐싱 전략을 운영에 올릴 때는 키 설계, 저장 위치, 무효화 신호, 관측 지표를 함께 점검해야 합니다.

배포 가능한 캐시는 key 경계와 실패 경로가 닫혀 있다

hit 속도뿐 아니라 값의 공유 범위와 stale·동시 miss·저장소 장애 때 원본 보호 여부까지 검증한다.

  1. hit / miss

    key·endpoint별 비율

  2. origin load

    miss 지연과 원본 QPS

  3. freshness

    무효화 지연·stale 신고

  4. store health

    오류·eviction·메모리

아래 다이어그램은 앞에서 다룬 키 설계, TTL, 무효화, stampede 대응을 운영 점검표처럼 다시 묶어 보여줍니다.

cache-aside는 miss 경로에서 원본을 보호해야 완성된다

hit는 즉시 반환하고, miss는 한 번만 원본을 조회해 TTL과 함께 채운다. 쓰기 성공 뒤에는 관련 키를 지운다.

  1. single-flight

    같은 key 잠금

  2. Origin

    DB / 외부 API

  3. SET + TTL

    stale window 제한

  4. Response

    대기 요청 공유

캐싱은 애플리케이션 성능 최적화의 핵심 전략입니다.

사용 시나리오와 시스템 아키텍처에 따라 인메모리 캐시와 분산 캐시를 적절히 선택해야 합니다.

NestJS는 CacheModule을 통해 인메모리 캐싱과 Redis 같은 분산 캐싱 솔루션을 쉽게 통합할 수 있는 추상화를 제공합니다.

캐시를 올바르게 활용하면 응답 시간을 줄이고 백엔드 부하를 낮춰 더 효율적인 시스템을 만들 수 있습니다.

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