캐싱 전략 (Redis, in-memory)
캐시 히트와 미스, 키·TTL·무효화 기준을 세우고 NestJS에 인메모리 캐시와 Redis 분산 캐시를 적용합니다.
9장에서는 성능 최적화와 스케일링을 다루며, 먼저 캐싱 전략을 설명합니다.
캐시는 자주 읽는 데이터나 계산 결과를 원본보다 가까운 저장소에 두어 응답 시간을 줄이고 원본 시스템의 부하를 낮춥니다. 다만 저장소를 고르는 것만으로는 충분하지 않습니다. 같은 요청을 식별하는 key, 값을 믿을 수 있는 TTL, 쓰기 뒤 무효화 범위, 캐시 미스가 몰릴 때의 동시성 제어를 함께 설계해야 합니다.
아래 그림은 이 절에서 사용할 cache-aside 읽기 흐름과 운영 기준을 한 번에 보여 줍니다.
Cache-aside 읽기와 운영 경계
cache-aside 운영 설명 중 miss 한 경로를 선택한 확장 설계입니다. single-flight는 원래 수동 예제에 구현되지 않았으므로 각 장면에 설계 범위를 표시하고 hit·쓰기 무효화·장애 경계는 원 표에 보존합니다.
Nest · Cache-aside design
캐시 hit·miss와 원본 보호
캐시 hit는 즉시 반환하고 miss는 원본 조회 뒤 저장합니다. 같은 key 요청을 합치는 single-flight는 운영 설계 단계이며 본문의 수동 캐시 코드에는 별도 구현이 필요합니다.
cache.get(key)로 hit와 miss를 구분합니다.
유효한 값은 즉시 반환
원본 조회와 재저장을 건너뜁니다.
single-flight 뒤 원본 조회
같은 key의 요청을 합친 뒤 원본을 읽고 cache.set()으로 TTL과 함께 저장해 반환합니다.
원본 쓰기 성공 뒤 관련 key를 지운다
Key. 이름에 tenant·권한·조건·version 경계를 넣습니다.
Invalidation. del() 실패를 기록하고 재시도합니다.
TTL. 무효화 누락의 stale 상한을 허용 시간으로 정합니다.
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/삭제/관측 책임을 유지한다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | Cache-aside 읽기와 운영 경계 |
| 25 | key로 캐시를 조회한 뒤 유효한 값이 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·쓰기 무효화·장애 경계는 원 표에 보존합니다.
Nest · Cache-aside design
캐시 hit·miss와 원본 보호
캐시 hit는 즉시 반환하고 miss는 원본 조회 뒤 저장합니다. 같은 key 요청을 합치는 single-flight는 운영 설계 단계이며 본문의 수동 캐시 코드에는 별도 구현이 필요합니다.
cache.get(key)로 hit와 miss를 구분합니다.
유효한 값은 즉시 반환
원본 조회와 재저장을 건너뜁니다.
single-flight 뒤 원본 조회
같은 key의 요청을 합친 뒤 원본을 읽고 cache.set()으로 TTL과 함께 저장해 반환합니다.
원본 쓰기 성공 뒤 관련 key를 지운다
Key. 이름에 tenant·권한·조건·version 경계를 넣습니다.
Invalidation. del() 실패를 기록하고 재시도합니다.
TTL. 무효화 누락의 stale 상한을 허용 시간으로 정합니다.
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/삭제/관측 책임을 유지한다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | Cache-aside 읽기와 운영 경계 |
| 25 | key로 캐시를 조회한 뒤 유효한 값이 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은 쓰기 무효화와 장애 정책을 대신하지 않으며 수동 코드의 추가 구현 책임은 원 표에 보존합니다. |
캐싱(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 기본 인메모리 캐시
이 절의 수동 캐시 예제는 miss를 undefined로 반환하는 cache-manager v7을 기준으로 합니다. v6의 miss는 null이므로 설치 버전에 맞춰 판정해야 합니다. 공식 v7 마이그레이션 안내를 확인합니다.
NestJS 캐시 통합에는 @nestjs/cache-manager와 cache-manager가 필요합니다. 별도 store를 지정하지 않으면 기본값은 인메모리 캐시입니다.
npm install @nestjs/cache-manager cache-manager위 명령은 버전 범위를 생략한 설치 예시입니다. 이 절의 수동 hit 판정을 재현할 때는 cache-manager@7을 선택하고, 설치한 @nestjs/cache-manager의 peer dependency가 자신의 Nest major와 cache-manager 버전을 허용하는지 확인합니다. 현재 캐시 모듈의 공식 manifest는 Nest 11도 허용하지만 모듈 형식과 다른 요구 조건은 릴리스별로 달라질 수 있습니다. 설치 결과와 lockfile을 기록하고, 이 절의 v7 조건을 다른 major에 자동 적용하지 않습니다.
CacheModule과 전역 CacheInterceptor 등록
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 엔드포인트만 자동 캐싱합니다. 다만 라우트에 명시한 @CacheKey()가 있으면 이 HTTP 메서드 검사를 우회하므로, POST 같은 상태 변경 작업도 무조건 제외된다고 판단하면 안 됩니다. 이 예제는 조회용 GET에만 적용하며, 변경 업무에는 응답 캐싱을 붙이지 않습니다. 이 조건은 공식 캐싱 안내의 자동 응답 캐싱 경고를 기준으로 합니다. 네이티브 응답 객체를 직접 주입하는 @Res() 라우트에는 응답 매핑을 사용할 수 없으므로 CacheInterceptor도 적용할 수 없습니다.
route별 key와 TTL 재정의
CacheKey, CacheTTL, CacheInterceptor, CacheModule, CACHE_MANAGER는 모두 @nestjs/cache-manager에서 가져옵니다. ttl과 @CacheTTL()의 숫자는 밀리초입니다.
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아래 코드는 Redis store를 연결하는 CacheModule 등록 부분을 보여줍니다. 앞 예제에서 사용한 APP_INTERCEPTOR와 CacheInterceptor의 import·전역 provider 등록은 유지하고, CacheModule.register(...) 부분만 아래 registerAsync(...) 설정으로 바꿉니다. 기존 앱의 다른 모듈·controller·provider도 유지합니다.
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 저장을 거쳐 같은 응답으로 합쳐지는 과정을 보여 줍니다.
CACHE_MANAGER의 hit와 miss 시퀀스
Client/Service/cache/origin에서 miss만 조회/60000 ms 저장을 하고 응답으로 돌아오는 실제 시퀀스다.
Nest · CACHE_MANAGER sequence
수동 캐시의 조회·저장 순서
cache-manager v7의 miss는 undefined입니다. 저장 값이 있으면 바로 반환하고, 없으면 원본 조회와 밀리초 TTL 저장을 거칩니다.
CACHE_MANAGER.get(key)
value를 받아 바로 응답
캐시 hit면 Origin 호출 없이 Client에 응답합니다.
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나 쓰기 경합 방지는 이 그림에 구현된 것이 아니다. 점선은 비동기 값 반환 방향을 뜻한다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | CACHE_MANAGER의 hit와 miss 시퀀스 |
| 25 | Client 호출을 받은 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 저장을 하고 응답으로 돌아오는 실제 시퀀스다.
Nest · CACHE_MANAGER sequence
수동 캐시의 조회·저장 순서
cache-manager v7의 miss는 undefined입니다. 저장 값이 있으면 바로 반환하고, 없으면 원본 조회와 밀리초 TTL 저장을 거칩니다.
CACHE_MANAGER.get(key)
value를 받아 바로 응답
캐시 hit면 Origin 호출 없이 Client에 응답합니다.
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나 쓰기 경합 방지는 이 그림에 구현된 것이 아니다. 점선은 비동기 값 반환 방향을 뜻한다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | CACHE_MANAGER의 hit와 miss 시퀀스 |
| 25 | Client 호출을 받은 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 후보가 됩니다. |
아래는 이미 상품 저장소를 가진 프로젝트의 Service 부분입니다. ProductRepository.findOne()·update()의 구현과 UpdateProductDto, 두 provider의 모듈 등록은 별도로 있어야 합니다. 이 파일만으로 상품 HTTP route나 DB 연결을 완성하는 예제는 아닙니다.
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와 원본 부하도 함께 측정해야 합니다.
예시 쓰기 뒤 삭제만으로 막지 못하는 경합 읽기
위 코드는 원본 쓰기 뒤 캐시를 지우지만, 이미 진행 중인 읽기가 있는 경우를 함께 봐야 합니다. 캐시가 비어 있고 원본 가격이 100이라고 가정합니다. 요청 A가 원본의 100을 읽은 뒤 캐시 저장 직전 멈췄습니다. 요청 B가 가격을 120으로 저장하고 해당 key를 삭제했습니다. 그 뒤 A가 다시 진행하면 예전 100을 캐시에 넣으므로, B의 삭제가 성공해도 다음 읽기는 오래된 값을 받을 수 있습니다.
TTL은 이 값이 남는 시간을 제한할 수 있지만 쓰기 직후 일관성을 보장하지 않습니다. single-flight도 같은 key의 읽기 중복을 합칠 뿐 쓰기와의 경합을 자동 해결하지 않습니다. 즉시 새 값을 보여야 하는 업무라면 읽어 온 revision이 현재 revision과 같을 때만 캐시에 저장하는 조건, 읽기·쓰기 조정, 버전별 key 같은 추가 계약을 선택하고 검증해야 합니다.
직접 판단하기
위 수동 코드에서 cache.get(key)가 false를 반환했습니다. 원본 조회와 set()이 실행될까요? miss인 undefined와 같은 분기인가요? 단순 hit 판정만 비교하며, 값이 상품 응답으로 유효한지의 검증은 별도 조건이라고 가정합니다. TTL 인자 60_000은 몇 초인가요?
분기와 단위 확인
false !== undefined는 참이므로 현재 코드는 false를 바로 반환하고 원본 조회·저장을 건너뜁니다. undefined만 miss 분기로 갑니다. 따라서 if (cached)로 바꾸면 유효한 falsy 값도 miss로 취급하는 차이가 생깁니다. TTL은 밀리초이므로 60_000 ÷ 1_000 = 60초입니다. hit라는 사실과 업무상 유효한 응답이라는 판단은 서로 다른 조건입니다.
캐싱은 읽기 속도만 높이는 기능이 아니라 key, TTL, 무효화, 동시성, 관측 가능성을 함께 운영하는 계약입니다. 한 프로세스의 빠른 임시 저장이면 기본 인메모리 store로 시작하고, 여러 인스턴스가 cache namespace를 공유해야 하면 Keyv 호환 Redis store를 선택합니다.
이것으로 9장 성능 최적화와 스케일링의 첫 번째 절을 마칩니다.