본문으로 건너뛰기

안동민 개발노트

본문 시작

캐싱 전략 (Redis, in-memory)

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

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

캐시는 자주 읽는 데이터나 계산 결과를 원본보다 가까운 저장소에 두어 응답 시간을 줄이고 원본 시스템의 부하를 낮춥니다. 다만 저장소를 고르는 것만으로는 충분하지 않습니다. 같은 요청을 식별하는 key, 값을 믿을 수 있는 TTL, 쓰기 뒤 무효화 범위, 캐시 미스가 몰릴 때의 동시성 제어를 함께 설계해야 합니다.

아래 그림은 이 절에서 사용할 cache-aside 읽기 흐름과 운영 기준을 한 번에 보여 줍니다.

요청 key를 조회해 유효한 캐시 hit는 즉시 반환하고, miss는 single-flight로 합친 뒤 원본 조회와 TTL 저장을 거쳐 반환하며 쓰기 뒤 관련 key 삭제와 핵심 지표를 함께 운영하는 cache-aside 흐름

NestJS · Cache-aside flow

hit는 바로 끝나고 miss는 같은 key 조회를 합칩니다. 읽기는 key 경계, TTL, 쓰기 뒤 무효화, 원본 보호 지표가 함께 있어야 안전합니다.

Cache-aside 읽기 흐름 요청 key 조회 뒤 유효한 hit는 즉시 반환하고, miss는 single-flight, 원본 조회, TTL 저장 순서로 처리해 반환한다. HIT MISS GET 요청 도착 캐시 조회 cache.get(key) 값이 있고 업무상 유효한가? 즉시 반환 cached value 같은 key 요청 합치기 single-flight 원본 조회 TTL과 함께 저장 cache.set(key, value, ttlMs) 응답 반환
cache.get(key)로 hit와 miss를 구분합니다.
Hit

유효한 값은 즉시 반환

원본 조회와 재저장을 건너뜁니다.

Miss

single-flight 뒤 원본 조회

같은 key의 요청을 합친 뒤 원본을 읽고 cache.set()으로 TTL과 함께 저장해 반환합니다.

Write boundary

원본 쓰기 성공 뒤 관련 key를 지운다

Key. 이름에 tenant·권한·조건·version 경계를 넣습니다.

Invalidation. del() 실패를 기록하고 재시도합니다.

TTL. 무효화 누락의 stale 상한을 허용 시간으로 정합니다.

Operating signals

hit만 보지 말고 miss의 비용을 본다

Efficiency. hit ratio와 miss latency를 함께 봅니다.

Protection. 원본 요청량, pool wait, timeout이 병합 뒤 안정적인지 봅니다.

Correctness. stale·무효화 실패·key 수를 추적합니다.

TTL은 무효화를 대신하지 않고 병합도 stale 값을 고치지 않습니다. key·TTL·삭제·원본 보호는 각기 다른 실패를 맡습니다.


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

cache-aside 패턴에서 애플리케이션은 다음 순서로 데이터를 읽습니다.

요청 조건, 사용자 범위, 스키마 버전을 반영해 캐시 key를 만듭니다.

캐시에서 key를 조회합니다. 값이 있고 업무상 유효하면 즉시 반환합니다.

값이 없거나 만료됐다면 같은 key의 동시 요청을 single-flight 같은 방식으로 합쳐 원본 조회 폭주를 막습니다.

원본에서 가져온 값을 TTL과 함께 캐시에 저장한 뒤 반환합니다.

원본 쓰기가 성공하면 영향받는 key를 del()하고, hit ratio·miss latency·원본 부하를 함께 관측합니다.

캐싱의 장점
  • 응답 시간 단축: 캐시 히트에서는 네트워크 I/O나 반복 계산을 줄일 수 있습니다.
  • 원본 부하 감소: 데이터베이스나 외부 API 호출 횟수를 낮춥니다.
  • 비용 절감: 원본 처리량과 네트워크 사용량이 줄면 운영 비용도 낮아질 수 있습니다.
캐싱 사용 시 고려사항
  • key 경계: 테넌트, 사용자 권한, 언어, 페이지, 데이터 버전이 다른 요청은 같은 key를 공유하면 안 됩니다.
  • TTL: 데이터 변경 주기와 stale 허용 시간을 기준으로 짧게 시작하고 지표로 조정합니다.
  • 무효화: 원본 쓰기 성공 뒤 관련 key를 삭제할 경로와 실패 재시도 정책이 필요합니다.
  • 캐시 미스 비용: 동시에 같은 key가 만료될 때 원본 요청이 폭주하지 않도록 동시성 제어를 둡니다.
  • 장애 경계: 캐시가 있다고 원본 장애 때 자동으로 stale 값을 제공하는 것은 아닙니다. 필요하다면 별도의 stale 허용 정책을 설계합니다.

NestJS 기본 인메모리 캐시

NestJS 캐시 통합에는 @nestjs/cache-managercache-manager가 필요합니다. 별도 store를 지정하지 않으면 기본값은 인메모리 캐시입니다.

npm install @nestjs/cache-manager cache-manager

CacheModule과 전역 CacheInterceptor 등록

src/app.module.ts
import { Module } from '@nestjs/common';
import { CacheInterceptor, CacheModule } from '@nestjs/cache-manager';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { AppController } from './app.controller';

@Module({
  imports: [
    CacheModule.register({
      ttl: 5_000, // 밀리초: 5초
    }),
  ],
  controllers: [AppController],
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: CacheInterceptor,
    },
  ],
})
export class AppModule {}

HTTP 애플리케이션의 CacheInterceptorGET 엔드포인트만 자동 캐싱합니다. 네이티브 응답 객체를 직접 주입하는 @Res() 라우트에는 응답 매핑을 사용할 수 없으므로 CacheInterceptor도 적용할 수 없습니다.

route별 key와 TTL 재정의

CacheKey, CacheTTL, CacheInterceptor, CacheModule, CACHE_MANAGER는 모두 @nestjs/cache-manager에서 가져옵니다. ttl@CacheTTL()의 숫자는 밀리초입니다.

src/app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { CacheKey, CacheTTL } from '@nestjs/cache-manager';

@Controller('app')
export class AppController {
  @Get('hello')
  @CacheKey('hello_world')
  getHello(): string {
    return 'Hello World!';
  }

  @Get('cached-sum')
  @CacheKey('sum_10_20')
  @CacheTTL(10_000) // 밀리초: 10초
  sumNumbersCached(): number {
    return 10 + 20;
  }
}

첫 요청은 handler를 실행하고 응답을 저장합니다. TTL 안의 같은 key 요청은 캐시에서 반환되고, 만료 뒤 요청은 handler를 다시 실행합니다. 자동 생성 key 대신 @CacheKey()를 쓸 때는 서로 다른 사용자나 요청 조건이 같은 응답을 공유해도 되는지 먼저 확인해야 합니다.


Redis를 사용하는 분산 캐시

여러 애플리케이션 인스턴스가 하나의 캐시 namespace를 공유해야 한다면 Redis 같은 외부 store를 사용할 수 있습니다. 공유 store는 인스턴스별 캐시 불일치를 줄이지만, 올바른 key 범위와 무효화가 자동으로 해결되는 것은 아닙니다.

로컬 실습용 Redis는 다음처럼 실행할 수 있습니다.

docker run --name nest-redis -p 6379:6379 -d redis:7-alpine

현재 NestJS 캐시 모듈은 Keyv 기반 store 구성을 사용합니다. Redis adapter를 설치합니다.

npm install @keyv/redis
src/app.module.ts
import { Module } from '@nestjs/common';
import { CacheModule } from '@nestjs/cache-manager';
import KeyvRedis from '@keyv/redis';
import { AppController } from './app.controller';

@Module({
  imports: [
    CacheModule.registerAsync({
      useFactory: () => ({
        stores: [new KeyvRedis('redis://localhost:6379')],
      }),
    }),
  ],
  controllers: [AppController],
})
export class AppModule {}

stores에는 현재 cache-manager가 사용할 Keyv 호환 store를 넣습니다. 실제 환경에서는 연결 문자열을 설정 서비스와 secret으로 주입하고, timeout·재연결·장애 시 원본 보호 정책을 함께 정합니다.


CACHE_MANAGER로 수동 캐시 관리

route 응답 전체가 아니라 서비스 결과를 캐싱하거나, 쓰기 성공 뒤 정확한 key를 지워야 한다면 CACHE_MANAGER를 주입해 직접 제어합니다.

아래 시퀀스는 get()의 hit와 miss가 갈라지는 지점, miss에서 원본 조회와 TTL 저장을 거쳐 같은 응답으로 합쳐지는 과정을 보여 줍니다.

Client 요청을 Service가 CACHE_MANAGER에서 조회하고 hit면 저장 값을, miss면 undefined를 받은 뒤 Origin 조회와 60초 TTL 저장을 거쳐 같은 응답으로 반환하는 수동 캐시 시퀀스

NestJS · CACHE_MANAGER sequence

get(key)의 결과가 hit와 miss를 가릅니다. hit는 저장 값을 쓰고, miss의 undefined만 원본 조회와 밀리초 TTL 저장을 거칩니다.

CACHE_MANAGER의 cache-aside 시퀀스 Client, Service, CACHE_MANAGER, Origin 사이에서 cache hit와 miss가 갈라지고 miss만 원본 조회와 60000밀리초 저장을 수행한 뒤 응답으로 합쳐진다. ALT [cached !== undefined] [cached === undefined] GET /products/:id get(key) value undefined load origin value set(key, value, 60_000) 200 value Client Service CACHE_MANAGER Origin
Client → Service → CACHE_MANAGER.get(key)
Hit

value를 받아 바로 응답

캐시 hit면 Origin 호출 없이 Client에 응답합니다.

Miss

undefined 뒤 원본을 읽고 저장

Service가 Origin 값을 받은 뒤 set()60_000 ms TTL로 호출하고 같은 Client 응답으로 합류합니다.

점선 화살표는 동기 호출의 반환입니다. miss 판정은 undefined하고, 저장 TTL 60_000은 밀리초이므로 60초입니다.

src/products/products.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager';
import { ProductRepository } from './product.repository';
import { UpdateProductDto } from './update-product.dto';

@Injectable()
export class ProductsService {
  constructor(
    @Inject(CACHE_MANAGER) private readonly cache: Cache,
    private readonly products: ProductRepository,
  ) {}

  async findOne(id: string) {
    const key = `product:v1:${id}`;
    const cached = await this.cache.get(key);

    if (cached !== undefined) {
      return cached;
    }

    const product = await this.products.findOne(id);
    await this.cache.set(key, product, 60_000); // 밀리초: 60초
    return product;
  }

  async update(id: string, input: UpdateProductDto) {
    const product = await this.products.update(id, input);
    await this.cache.del(`product:v1:${id}`);
    return product;
  }
}

현재 cache-manager에서 get()이 key를 찾지 못하면 undefined를 반환합니다. 따라서 truthy 검사 대신 cached !== undefined로 hit를 판정하면 0, false, 빈 문자열 같은 값도 올바르게 캐싱할 수 있습니다.

수동 API의 핵심은 다음과 같습니다.

  • get(key): 저장된 값을 읽고, miss면 undefined를 반환합니다.
  • set(key, value, 60_000): 세 번째 인자에 밀리초 TTL을 전달합니다.
  • del(key): 특정 key를 삭제합니다. 일반적인 쓰기 경로에서는 관련 key만 좁게 지웁니다.
  • clear(): store 전체를 비웁니다. 운영 복구나 관리 작업처럼 범위가 명확할 때만 사용합니다.

예제의 get()→원본 조회 구간은 동시에 여러 요청이 들어오면 중복 실행될 수 있습니다. 같은 key의 진행 중 Promise를 합치거나 검증된 single-flight 메커니즘을 추가하고, hit ratio뿐 아니라 miss latency와 원본 부하도 함께 측정해야 합니다.


캐싱은 읽기 속도만 높이는 기능이 아니라 key, TTL, 무효화, 동시성, 관측 가능성을 함께 운영하는 계약입니다. 한 프로세스의 빠른 임시 저장이면 기본 인메모리 store로 시작하고, 여러 인스턴스가 cache namespace를 공유해야 하면 Keyv 호환 Redis store를 선택합니다.

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