본문 바로가기

캐싱 전략 (Redis, in-memory)

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

9장에서는 성능 최적화와 확장을 다룹니다. 첫 글인 이 글에서는 캐싱 전략을 설명합니다.

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

Cache-aside 읽기와 운영 경계

cache-aside 운영 설명 중 miss 한 경로를 선택한 확장 설계입니다. single-flight는 원래 수동 예제에 구현되지 않았으므로 각 장면에 설계 범위를 표시하고 hit·쓰기 무효화·장애 경계는 원 표에 보존합니다.

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

Nest · Cache-aside design

캐시 hit·miss와 원본 보호

캐시 hit는 즉시 반환하고 miss는 원본 조회 뒤 저장합니다. 같은 key 요청을 합치는 single-flight는 운영 설계 단계이며 본문의 수동 캐시 코드에는 별도 구현이 필요합니다.

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) 응답 반환
캐시 hit는 우회하고 miss만 원본을 읽는다 key와캐시조회뒤유효값hit는바로응답으로우회한다. miss만single-flight원본보호와원본조회및TTL저장을거친다. 왼쪽긴우회선은hit가원본을호출하지않는분기이다. single-flight는본문수동코드에추가할설계이고쓰기무효화는별도업무조건이다. 아래기존비교는key/TTL/삭제/관측책임을유지한다. HIT MISS GET 요청 · key tenant · 조건 · 버전 캐시 조회 cache.get(key) 유효한 값인가? 업무 유효성도 확인 HIT 캐시 값 즉시 반환 MISS 원본 필요 조회 합치기 동일 key 보호 single-flight 추가 원본 조회 권한 범위 · 현재 값 TTL 저장 set(key, value, ttl) 응답 반환 hit 또는 원본 값
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·삭제·원본 보호는 각기 다른 실패를 맡습니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 요청 key 조회 뒤 유효한 hit는 즉시 반환하고, miss는 single-flight, 원본 조회, TTL 저장 순서로 처리해 반환한다.
  • key로 캐시를 조회한 뒤 유효한 값이 hit이면 바로 응답으로 우회한다. miss만 single-flight 원본 보호와 원본 조회 및 TTL 저장을 거친다. 왼쪽 긴 우회선은 hit가 원본을 호출하지 않는 분기다. single-flight는 본문 수동 코드에 추가할 설계이고 쓰기 무효화는 별도 업무 조건이다. 아래 기존 비교는 key/TTL/삭제/관측 책임을 유지한다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1Cache-aside 읽기와 운영 경계
25key로 캐시를 조회한 뒤 유효한 값이 hit이면 바로 응답으로 우회한다. miss만 single-flight 원본 보호와 원본 조회 및 TTL 저장을 거친다. 왼쪽 긴 우회선은 hit가 원본을 호출하지 않는 분기다. single-flight는 본문 수동 코드에 추가할 설계이고 쓰기 무효화는 별도 업무 조건이다. 아래 기존 비교는 key/TTL/삭제/관측 책임을 유지한다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
확장 설계의 cache miss

tenant·권한·조건·버전을 반영한 유효한 key가 캐시에서 miss인 경우를 선택한 운영 설계 흐름입니다.

범위: 수동 코드에 추가할 운영 설계 · 선택 조건: 유효한 key·cache miss · hit 대안: 유효한 캐시 값 즉시 반환, 원 표 보존선택한 miss만 원본 조회 준비로 진행합니다. 현재 수동 코드에는 아래 single-flight가 구현되어 있지 않습니다.
설계의 miss 병합

추가 설계에서는 같은 key의 동시 miss를 single-flight로 합쳐 원본 조회 폭주를 제한합니다.

범위: 미구현 single-flight 확장 설계 · 동시 miss: 같은 key의 원본 조회 공유 · 현재 수동 코드: miss마다 repository 호출 가능동시 요청의 대기와 원본 조회 공유를 구현해야 이 변화가 성립합니다. 수동 예제의 실행 보장으로 읽지 않습니다.
설계의 저장과 반환

선택한 miss 설계에서 원본 값을 TTL과 함께 저장한 뒤 반환하도록 정합니다.

범위: 확장 설계의 정상 원본 조회 이후 · 산출: TTL 캐시 값·요청 결과 · 쓰기 후: 영향 key 삭제는 별도 경로 · 장애: stale 자동 제공 보장 없음새 캐시 값이 후속 hit의 후보가 됩니다. TTL은 쓰기 무효화와 장애 정책을 대신하지 않으며 수동 코드의 추가 구현 책임은 원 표에 보존합니다.

Cache-aside 읽기와 운영 경계

cache-aside 운영 설명 중 miss 한 경로를 선택한 확장 설계입니다. single-flight는 원래 수동 예제에 구현되지 않았으므로 각 장면에 설계 범위를 표시하고 hit·쓰기 무효화·장애 경계는 원 표에 보존합니다.

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

Nest · Cache-aside design

캐시 hit·miss와 원본 보호

캐시 hit는 즉시 반환하고 miss는 원본 조회 뒤 저장합니다. 같은 key 요청을 합치는 single-flight는 운영 설계 단계이며 본문의 수동 캐시 코드에는 별도 구현이 필요합니다.

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) 응답 반환
캐시 hit는 우회하고 miss만 원본을 읽는다 key와캐시조회뒤유효값hit는바로응답으로우회한다. miss만single-flight원본보호와원본조회및TTL저장을거친다. 왼쪽긴우회선은hit가원본을호출하지않는분기이다. single-flight는본문수동코드에추가할설계이고쓰기무효화는별도업무조건이다. 아래기존비교는key/TTL/삭제/관측책임을유지한다. HIT MISS GET 요청 · key tenant · 조건 · 버전 캐시 조회 cache.get(key) 유효한 값인가? 업무 유효성도 확인 HIT 캐시 값 즉시 반환 MISS 원본 필요 조회 합치기 동일 key 보호 single-flight 추가 원본 조회 권한 범위 · 현재 값 TTL 저장 set(key, value, ttl) 응답 반환 hit 또는 원본 값
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·삭제·원본 보호는 각기 다른 실패를 맡습니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 요청 key 조회 뒤 유효한 hit는 즉시 반환하고, miss는 single-flight, 원본 조회, TTL 저장 순서로 처리해 반환한다.
  • key로 캐시를 조회한 뒤 유효한 값이 hit이면 바로 응답으로 우회한다. miss만 single-flight 원본 보호와 원본 조회 및 TTL 저장을 거친다. 왼쪽 긴 우회선은 hit가 원본을 호출하지 않는 분기다. single-flight는 본문 수동 코드에 추가할 설계이고 쓰기 무효화는 별도 업무 조건이다. 아래 기존 비교는 key/TTL/삭제/관측 책임을 유지한다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1Cache-aside 읽기와 운영 경계
25key로 캐시를 조회한 뒤 유효한 값이 hit이면 바로 응답으로 우회한다. miss만 single-flight 원본 보호와 원본 조회 및 TTL 저장을 거친다. 왼쪽 긴 우회선은 hit가 원본을 호출하지 않는 분기다. single-flight는 본문 수동 코드에 추가할 설계이고 쓰기 무효화는 별도 업무 조건이다. 아래 기존 비교는 key/TTL/삭제/관측 책임을 유지한다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
확장 설계의 cache miss

tenant·권한·조건·버전을 반영한 유효한 key가 캐시에서 miss인 경우를 선택한 운영 설계 흐름입니다.

범위: 수동 코드에 추가할 운영 설계 · 선택 조건: 유효한 key·cache miss · hit 대안: 유효한 캐시 값 즉시 반환, 원 표 보존선택한 miss만 원본 조회 준비로 진행합니다. 현재 수동 코드에는 아래 single-flight가 구현되어 있지 않습니다.
설계의 miss 병합

추가 설계에서는 같은 key의 동시 miss를 single-flight로 합쳐 원본 조회 폭주를 제한합니다.

범위: 미구현 single-flight 확장 설계 · 동시 miss: 같은 key의 원본 조회 공유 · 현재 수동 코드: miss마다 repository 호출 가능동시 요청의 대기와 원본 조회 공유를 구현해야 이 변화가 성립합니다. 수동 예제의 실행 보장으로 읽지 않습니다.
설계의 저장과 반환

선택한 miss 설계에서 원본 값을 TTL과 함께 저장한 뒤 반환하도록 정합니다.

범위: 확장 설계의 정상 원본 조회 이후 · 산출: TTL 캐시 값·요청 결과 · 쓰기 후: 영향 key 삭제는 별도 경로 · 장애: stale 자동 제공 보장 없음새 캐시 값이 후속 hit의 후보가 됩니다. TTL은 쓰기 무효화와 장애 정책을 대신하지 않으며 수동 코드의 추가 구현 책임은 원 표에 보존합니다.

위 그림은 이 글에서 쓸 cache-aside 읽기 흐름입니다. 요청의 key로 캐시를 조회해 유효한 값이 있으면(hit) 원본을 건드리지 않고 바로 반환하고, 없으면(miss) 같은 key의 요청을 하나로 합친 뒤 원본을 조회해 TTL과 함께 저장하고 반환합니다. 왼쪽의 긴 선은 hit가 원본을 호출하지 않고 지나가는 경로입니다. 같은 key의 요청을 합치는 single-flight는 운영에서 추가할 설계이며, 이 글의 수동 캐시 코드에는 따로 구현해야 한다는 점이 그림 위에 적혀 있습니다. 아래쪽은 key, TTL, 삭제, 지표가 각각 어떤 실패를 막는지 정리합니다.


캐싱이란

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

  1. 요청 조건, 사용자 범위, 스키마 버전을 반영해 캐시 key를 만듭니다.
  2. 캐시에서 key를 조회합니다. 값이 있고 업무상 유효하면 바로 반환합니다.
  3. 값이 없거나 만료되었다면, 같은 key의 동시 요청을 single-flight 같은 방식으로 하나로 합쳐 원본 조회가 몰리지 않게 합니다.
  4. 원본에서 가져온 값을 TTL과 함께 캐시에 저장한 뒤 반환합니다.
  5. 원본 쓰기가 성공하면 영향받는 key를 del()로 지우고, hit 비율, miss일 때의 지연 시간, 원본 부하를 함께 지켜봅니다.

캐싱의 장점은 다음과 같습니다.

  • 응답 시간 단축: 캐시 hit에서는 네트워크 I/O나 반복 계산을 줄일 수 있습니다.
  • 원본 부하 감소: 데이터베이스나 외부 API 호출 횟수를 낮춥니다.
  • 비용 절감: 원본 처리량과 네트워크 사용량이 줄면 운영 비용도 낮아질 수 있습니다.

캐시를 쓸 때는 다음을 고려해야 합니다.

  • key의 범위: tenant, 사용자 권한, 언어, 페이지, 데이터 버전이 다른 요청이 같은 key를 공유하면 안 됩니다.
  • TTL: 데이터가 바뀌는 주기와 오래된 값을 허용할 수 있는 시간을 기준으로 짧게 시작하고, 지표를 보며 조정합니다.
  • 무효화: 원본 쓰기가 성공한 뒤 관련 key를 지우는 경로와, 지우기에 실패했을 때의 재시도 정책이 필요합니다.
  • 캐시 미스 비용: 같은 key가 동시에 만료될 때 원본 요청이 몰리지 않도록 동시성 제어를 둡니다.
  • 장애 대응: 캐시가 있다고 원본 장애 때 오래된 값을 자동으로 돌려주지는 않습니다. 필요하다면 오래된 값을 허용하는 정책을 따로 설계합니다.

NestJS 기본 인메모리 캐시

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

npm install @nestjs/cache-manager cache-manager

현재 버전은 @nestjs/cache-manager 12.0, cache-manager 7.2이며, @nestjs/cache-manager 12는 NestJS 12를 지원합니다. 이 글의 수동 캐시 예제는 miss일 때 undefined를 반환하는 cache-manager 7을 기준으로 합니다. 공식 캐싱 문서에 따르면 cache-manager 6에서는 miss가 null이었으므로, 6에서 옮겨 오는 동안에는 둘 다 miss로 취급해야 합니다.

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 애플리케이션의 CacheInterceptor는 기본적으로 GET 엔드포인트만 자동으로 캐싱합니다. 다만 공식 문서의 경고대로, route에 @CacheKey()를 명시하면 이 HTTP 메서드 검사를 건너뜁니다. 그래서 POST 같은 상태 변경 작업도 무조건 제외된다고 판단하면 안 됩니다. 이 예제는 조회용 GET에만 적용하고, 변경 작업에는 응답 캐싱을 붙이지 않습니다. 또 Express의 응답 객체를 직접 주입하는 @Res() route에는 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()를 쓸 때는, 서로 다른 사용자나 요청 조건이 같은 응답을 공유해도 되는지 먼저 확인해야 합니다.

더 깊이: 자동으로 만든 key에 들어가지 않는 것

전역 CacheInterceptor가 HTTP 요청에 자동으로 붙이는 key는 요청 URL입니다. @nestjs/cache-manager의 소스를 보면 trackBy()는 허용된 메서드(기본은 GET)일 때 HTTP 어댑터가 돌려준 요청 URL을 그대로 key로 쓰며, Authorization 헤더나 쿠키는 key에 들어가지 않습니다. 그래서 같은 GET /profile이 로그인한 사용자마다 다른 응답을 돌려준다면, 전역 캐시는 처음 저장된 한 사용자의 응답을 TTL 동안 다른 사용자에게도 돌려줄 수 있습니다. 캐싱 문서는 이런 경우 CacheInterceptor를 상속해 trackBy()를 다시 정의하라고 안내하며, 이 메서드가 key 대신 빈 값을 돌려주면 그 요청은 캐시를 거치지 않고 handler로 바로 갑니다.

TTL을 빠뜨렸을 때의 기본값도 알아 둡니다. 문서에 따르면 register()에 ttl을 주지 않으면 값을 0으로 준 것과 같아서, 저장된 항목이 만료되지 않습니다. 이 글의 예제처럼 모듈 수준의 ttl을 명시하거나 route마다 @CacheTTL()을 붙이지 않으면, 원본이 바뀌어도 프로세스를 다시 시작하거나 key를 지울 때까지 예전 응답이 계속 나갈 수 있습니다.


Redis를 쓰는 분산 캐시

여러 애플리케이션 인스턴스가 하나의 캐시를 함께 써야 한다면 Redis 같은 외부 store를 씁니다. 공유 store는 인스턴스마다 캐시가 달라지는 문제를 줄이지만, 올바른 key 범위와 무효화까지 저절로 해결해 주지는 않습니다.

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

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

-p 6379:6379로 호스트의 6379번 포트를 열었으므로, 아래 코드의 기본 주소 redis://localhost:6379로 이 컨테이너에 접속할 수 있습니다. 볼륨을 연결하지 않았으므로 컨테이너를 지우면 캐시 데이터도 사라지지만, 캐시는 원본에서 다시 만들 수 있는 데이터이므로 실습에는 충분합니다. cache-manager는 내부적으로 Keyv를 쓰므로, Redis를 쓰려면 Keyv용 Redis adapter인 @keyv/redis를 설치합니다.

npm install @keyv/redis@5

@keyv/redis의 최신 6 버전은 peer dependency로 keyv 6을 요구하지만, cache-manager 7.2는 keyv 5를 의존성으로 씁니다. 두 쪽의 keyv 주 버전을 맞추기 위해 keyv 5를 요구하는 @keyv/redis 5를 설치합니다. 설치한 뒤에는 npm ls keyv로 한 가지 주 버전만 쓰이는지 확인합니다.

아래 코드는 Redis store를 연결하는 CacheModule 등록입니다. 앞 예제의 CacheModule.register(...)를 registerAsync(...)로 바꾸고, 전역 CacheInterceptor 등록은 그대로 둡니다.

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

@Module({
  imports: [
    CacheModule.registerAsync({
      useFactory: () => {
        const redisStore = createKeyv(
          process.env.REDIS_URL ?? 'redis://localhost:6379',
        );
        // 연결이 끊기면 error 이벤트가 발생합니다. 듣는 곳이 없으면 앱이 종료될 수 있습니다.
        redisStore.on('error', (error: unknown) => {
          console.error('Redis cache error:', error);
        });

        return {
          stores: [redisStore],
          ttl: 5_000, // 밀리초: 5초
        };
      },
    }),
  ],
  controllers: [AppController],
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: CacheInterceptor,
    },
  ],
})
export class AppModule {}

createKeyv()는 @keyv/redis가 제공하는 함수로, Redis 연결 문자열을 받아 Keyv 인스턴스를 만듭니다. stores 배열의 첫 번째 store가 기본 store이고, 나머지는 대체 store로 쓰입니다. 공식 문서는 Redis store에 반드시 'error' 리스너를 붙이라고 경고합니다. Redis 연결이 끊기면 store가 'error' 이벤트를 내는데, 듣는 곳이 없으면 Keyv가 예외를 던져 애플리케이션이 멈출 수 있기 때문입니다. 실제 환경에서는 연결 문자열을 설정 서비스와 secret으로 주입하고, timeout, 재연결, 장애 때 원본을 보호하는 정책을 함께 정합니다.

인메모리 store와 Redis store는 값을 다루는 방식도 다릅니다. 공식 문서에 따르면 기본 인메모리 store는 값을 복사하지 않고 참조를 보관하므로, get()이 set()에 넘긴 바로 그 객체를 돌려줍니다. 반면 Keyv store는 값을 JSON으로 직렬화하므로 클래스 인스턴스는 일반 객체로, Date는 문자열로 돌아옵니다.


CACHE_MANAGER로 직접 캐시 관리하기

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

CACHE_MANAGER의 hit와 miss 시퀀스

Client/Service/cache/origin에서 miss만 조회/60000 ms 저장을 하고 응답으로 돌아오는 실제 시퀀스다.

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

Nest · CACHE_MANAGER sequence

수동 캐시의 조회·저장 순서

cache-manager v7의 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) value 반환 Client Service CACHE_MANAGER Origin
수동 get의 두 결과와 원본·TTL 저장 Client호출을받은Service가CACHE_MANAGER.get을실행한다. v7에서cached가undefined가아니면Origin조회와set을건너뛰어같은Client에반환한다. undefined이면Origin값을조회하고60000밀리초TTL로set한뒤반환한다. 이는본문수동코드이며single-flight나쓰기경합방지는이그림에구현된것이아니다. 점선은비동기값반환방향을뜻한다. 값 있음 없음 반환 Client → Service GET /products/:id CACHE_MANAGER get(key) v7 miss 판정 cached !== undefined HIT false도 값 바로 반환 MISS undefined 원본 필요 Service → Origin findOne(id) Service → Cache set(..., 60_000) Client 응답 60초 TTL · 같은 값
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초입니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • Client, Service, CACHE_MANAGER, Origin 사이에서 cache hit와 miss가 갈라지고 miss만 원본 조회와 60000밀리초 저장을 수행한 뒤 응답으로 합쳐진다.
  • Client 호출을 받은 Service가 CACHE_MANAGER.get을 실행한다. v7에서 cached가 undefined가 아니면 Origin 조회와 set을 건너뛰어 같은 Client에 반환한다. undefined이면 Origin 값을 조회하고 60000밀리초 TTL로 set한 뒤 반환한다. 이는 본문 수동 코드이며 single-flight나 쓰기 경합 방지는 이 그림에 구현된 것이 아니다. 점선은 비동기 값 반환 방향을 뜻한다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1CACHE_MANAGER의 hit와 miss 시퀀스
25Client 호출을 받은 Service가 CACHE_MANAGER.get을 실행한다. v7에서 cached가 undefined가 아니면 Origin 조회와 set을 건너뛰어 같은 Client에 반환한다. undefined이면 Origin 값을 조회하고 60000밀리초 TTL로 set한 뒤 반환한다. 이는 본문 수동 코드이며 single-flight나 쓰기 경합 방지는 이 그림에 구현된 것이 아니다. 점선은 비동기 값 반환 방향을 뜻한다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
cache 조회

Service가 CACHE_MANAGER에서 key를 조회합니다.

조회: cache key · hit: 원본 생략 · miss: 원본 필요원본 호출 여부가 결정됩니다.
miss에서 원본 조회

miss인 경우만 Origin에서 데이터를 읽습니다.

Origin: miss에서 호출 · hit: 기존 cache 반환miss의 응답 자료가 원본에서 준비됩니다.
cache 저장 후 응답

miss 자료를 60000밀리초 TTL로 저장한 뒤 응답하고 hit도 응답으로 합류합니다.

TTL: 60000 ms · cache: miss 자료 저장 · 응답: hit·miss 합류miss 자료가 다음 조회의 cache 후보가 됩니다.

CACHE_MANAGER의 hit와 miss 시퀀스

Client/Service/cache/origin에서 miss만 조회/60000 ms 저장을 하고 응답으로 돌아오는 실제 시퀀스다.

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

Nest · CACHE_MANAGER sequence

수동 캐시의 조회·저장 순서

cache-manager v7의 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) value 반환 Client Service CACHE_MANAGER Origin
수동 get의 두 결과와 원본·TTL 저장 Client호출을받은Service가CACHE_MANAGER.get을실행한다. v7에서cached가undefined가아니면Origin조회와set을건너뛰어같은Client에반환한다. undefined이면Origin값을조회하고60000밀리초TTL로set한뒤반환한다. 이는본문수동코드이며single-flight나쓰기경합방지는이그림에구현된것이아니다. 점선은비동기값반환방향을뜻한다. 값 있음 없음 반환 Client → Service GET /products/:id CACHE_MANAGER get(key) v7 miss 판정 cached !== undefined HIT false도 값 바로 반환 MISS undefined 원본 필요 Service → Origin findOne(id) Service → Cache set(..., 60_000) Client 응답 60초 TTL · 같은 값
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초입니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • Client, Service, CACHE_MANAGER, Origin 사이에서 cache hit와 miss가 갈라지고 miss만 원본 조회와 60000밀리초 저장을 수행한 뒤 응답으로 합쳐진다.
  • Client 호출을 받은 Service가 CACHE_MANAGER.get을 실행한다. v7에서 cached가 undefined가 아니면 Origin 조회와 set을 건너뛰어 같은 Client에 반환한다. undefined이면 Origin 값을 조회하고 60000밀리초 TTL로 set한 뒤 반환한다. 이는 본문 수동 코드이며 single-flight나 쓰기 경합 방지는 이 그림에 구현된 것이 아니다. 점선은 비동기 값 반환 방향을 뜻한다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1CACHE_MANAGER의 hit와 miss 시퀀스
25Client 호출을 받은 Service가 CACHE_MANAGER.get을 실행한다. v7에서 cached가 undefined가 아니면 Origin 조회와 set을 건너뛰어 같은 Client에 반환한다. undefined이면 Origin 값을 조회하고 60000밀리초 TTL로 set한 뒤 반환한다. 이는 본문 수동 코드이며 single-flight나 쓰기 경합 방지는 이 그림에 구현된 것이 아니다. 점선은 비동기 값 반환 방향을 뜻한다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
cache 조회

Service가 CACHE_MANAGER에서 key를 조회합니다.

조회: cache key · hit: 원본 생략 · miss: 원본 필요원본 호출 여부가 결정됩니다.
miss에서 원본 조회

miss인 경우만 Origin에서 데이터를 읽습니다.

Origin: miss에서 호출 · hit: 기존 cache 반환miss의 응답 자료가 원본에서 준비됩니다.
cache 저장 후 응답

miss 자료를 60000밀리초 TTL로 저장한 뒤 응답하고 hit도 응답으로 합류합니다.

TTL: 60000 ms · cache: miss 자료 저장 · 응답: hit·miss 합류miss 자료가 다음 조회의 cache 후보가 됩니다.

위 그림은 Client, Service, CACHE_MANAGER, Origin 사이의 순서를 시퀀스로 보여 줍니다. Service가 get(key)을 호출해 값이 undefined가 아니면 원본 조회와 저장을 건너뛰고 바로 응답합니다. undefined이면 Origin에서 값을 읽어 60_000밀리초 TTL로 set()한 뒤 같은 응답으로 합류합니다. 그림의 false도 값으로 취급된다는 표시는, 이 판정이 truthy 검사가 아니라 undefined와의 비교라는 뜻입니다.

아래는 이미 상품 저장소가 있는 프로젝트의 Service 부분입니다. ProductRepository의 findOne()과 update(), UpdateProductDto, 두 provider의 모듈 등록은 따로 있어야 하며, 이 파일만으로 상품 HTTP route나 DB 연결이 완성되지는 않습니다.

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 7에서 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 비율뿐 아니라 miss일 때의 지연 시간과 원본 부하도 함께 측정해야 합니다.

쓰기 뒤 삭제만으로 막지 못하는 경합

위 코드는 원본 쓰기 뒤 캐시를 지우지만, 이미 진행 중인 읽기가 있는 경우도 생각해야 합니다. 캐시는 비어 있고 원본 가격은 100이라고 가정합니다. 요청 A가 원본에서 100을 읽은 뒤, 캐시에 저장하기 직전에 잠시 멈췄습니다. 그 사이 요청 B가 가격을 120으로 저장하고 해당 key를 지웠습니다. 그 뒤 A가 다시 진행하면 예전 값 100을 캐시에 넣으므로, B의 삭제가 성공했는데도 다음 읽기는 오래된 값을 받게 됩니다.

TTL은 이런 값이 남아 있는 시간을 제한할 수 있지만, 쓰기 직후의 일관성을 보장하지는 않습니다. single-flight도 같은 key의 읽기 중복을 합칠 뿐, 쓰기와의 경합을 해결하지는 않습니다. 쓰기 직후 새 값을 반드시 보여 줘야 하는 업무라면, 읽어 온 revision이 현재 revision과 같을 때만 캐시에 저장하는 조건, 읽기와 쓰기의 조정, 버전이 들어간 key 같은 장치를 추가로 고르고 검증해야 합니다.

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


정리

  • cache-aside 패턴은 캐시를 먼저 조회하고, miss일 때만 원본을 읽어 TTL과 함께 저장하며, 원본 쓰기 뒤에는 관련 key를 지웁니다.
  • @nestjs/cache-manager의 CacheInterceptor는 기본적으로 GET만 캐싱하지만, @CacheKey()를 붙이면 이 검사를 건너뛰므로 변경 작업에 붙이지 않습니다.
  • TTL은 밀리초 단위이며, cache-manager 7의 miss는 undefined이므로 cached !== undefined로 판정합니다.
  • Redis store는 @keyv/redis의 createKeyv()로 만들고 반드시 'error' 리스너를 붙이며, cache-manager 7과 keyv 주 버전을 맞추려면 @keyv/redis 5를 씁니다.
  • 쓰기 뒤 삭제만으로는 진행 중인 읽기와의 경합을 막지 못하므로, 즉시 일관성이 필요하면 revision 조건이나 버전 key를 추가합니다.

연습 문제

1. false가 저장되어 있다면

위 수동 코드에서 cache.get(key)이 false를 반환했습니다. 원본 조회와 set()이 실행될까요? miss인 undefined와 같은 분기로 갈까요? 값이 상품 응답으로 유효한지는 따로 판단한다고 가정합니다. TTL 인자 60_000은 몇 초일까요?

해설 보기

false !== undefined는 참이므로 지금 코드는 false를 바로 반환하고 원본 조회와 저장을 건너뜁니다. undefined만 miss 분기로 갑니다. 만약 if (cached)로 바꾸면 유효한 falsy 값까지 miss로 취급하게 됩니다.

TTL은 밀리초이므로 60_000 ÷ 1_000 = 60초입니다. hit라는 사실과 업무상 유효한 응답이라는 판단은 서로 다른 조건입니다.

2. 꺼낸 객체를 고쳤더니

기본 인메모리 store에서 const product = await this.cache.get(key);로 꺼낸 상품 객체의 price를 화면 표시용으로 product.price = product.price * 1.1처럼 바꾼 뒤 응답했습니다. 다음 요청이 같은 key로 hit하면 가격은 어떻게 보일까요? store를 Redis로 바꾸면 결과가 달라질까요?

해설 보기

공식 문서의 설명대로 기본 인메모리 store는 값의 복사본이 아니라 참조를 보관합니다. 꺼낸 객체를 고치면 캐시에 있는 바로 그 객체가 바뀌므로, 다음 hit에서는 이미 1.1배가 된 가격이 나오고, 요청할 때마다 가격이 계속 올라갈 수 있습니다.

Redis store는 값을 JSON으로 직렬화해 저장하므로 꺼낼 때마다 새 객체가 만들어지고, 이 문제는 생기지 않습니다. 하지만 store에 따라 동작이 달라지는 코드는 위험하므로, 캐시에서 꺼낸 값은 읽기 전용으로 다루고 바꿔야 한다면 복사본을 만들어 고칩니다.

3. POST에 @CacheKey를 붙이면

전역 CacheInterceptor가 등록된 앱에서 누군가 주문 생성 route인 @Post('orders')에 @CacheKey('orders')를 붙였습니다. 두 사용자가 5초 안에 서로 다른 주문을 생성하면 어떤 일이 생길 수 있을까요?

해설 보기

@CacheKey()를 명시하면 CacheInterceptor의 GET 검사를 건너뛰므로, 이 POST route의 응답도 orders key로 캐싱됩니다. 첫 사용자의 요청은 handler를 실행해 주문을 만들고 그 응답이 저장되지만, TTL 안에 들어온 두 번째 사용자의 요청은 handler를 실행하지 않고 첫 사용자의 응답을 그대로 받을 수 있습니다.

그러면 두 번째 주문은 만들어지지 않았는데 다른 사람의 주문 정보가 응답으로 나가게 됩니다. 상태를 바꾸는 route에는 응답 캐싱을 붙이지 않고, 캐시는 조회 route나 서비스의 읽기 결과에만 씁니다.

다음 글 데이터베이스 쿼리 최적화에서는 캐시 앞단이 아니라 원본인 데이터베이스 쪽에서 느린 쿼리를 찾고 개선하는 방법을 다룹니다.