NestJS 서비스에 Redis 캐시를 처음 붙일 때는 조회 메서드 앞에 데코레이터를 추가하는 일로 끝났습니다. 목록, 상세, 통계처럼 호출이 잦은 API의 응답 시간이 줄었고 데이터베이스 부하도 낮아졌습니다. 분산 락에서 사용한 데코레이터와 인터셉터 패턴을 캐시에도 적용했지만, 문제는 기능이 늘어난 뒤 나타났습니다.
프로젝트 이름을 수정했는데 상세 API는 새 값을 보여주고 목록 API는 예전 값을 보여주는 식의 부분 불일치가 반복됐습니다. 캐시 저장 코드는 메서드 가까이에 있지만 삭제 시점은 여러 도메인의 변경 흐름에 흩어졌습니다. 프로젝트 하나를 바꾸면 project 상세뿐 아니라 workspace tree, model 목록, prediction 통계까지 영향을 받을 수 있었습니다.
TTL이 끝나면 언젠가 맞아진다는 설명은 사용자에게 데이터가 틀려 보이는 시간을 정당화하지 못했습니다. 결국 캐시를 성능용 부수 기능이 아니라 조회 모델의 상태 전파 문제로 다시 설계했습니다.
한곳에서 관리하는 캐시 키 규칙
초기 코드에서는 각 서비스가 문자열을 직접 조합했습니다. 인자 순서, undefined, 배열 정렬, tenant prefix가 조금씩 달랐습니다. 조회에서 만든 키와 삭제에서 추정한 키가 일치하지 않는 경우도 있었습니다.
캐시 데코레이터는 클래스명, 메서드명, 정적 key, cluster key, hash를 조합하도록 표준화했습니다.
@Cacheable({ key: 'return', ttl: 300_000 })
async getProjectDetail(
@CacheClusterKeyArg() projectId: UUID,
@CacheHashedKeyArg() query: ProjectDetailQuery,
): Promise<ProjectDetailDto> {
return this.repository.findDetail(projectId, query);
}
const clusterKey = clusterArgs.length ? `${clusterArgs.join(':')}:` : '';
const hashedKey = md5(JSON.stringify(normalize(hashedArgs)));
const key = [
target.constructor.name,
propertyKey,
options.key,
clusterKey,
hashedKey,
].join(':');
cluster key는 프로젝트나 사용자처럼 관련 캐시를 묶어 삭제할 수 있는 안정적인 식별자입니다. hash에는 실제 query 조건을 넣어 같은 프로젝트의 여러 페이지와 필터를 구분합니다. JSON.stringify만 사용하면 object key 순서와 배열 순서에 따라 의미가 같은 요청이 다른 hash를 만들 수 있습니다.
query DTO를 validation과 transformation 뒤의 정규화된 형태로 만들고, 순서가 의미 없는 ID 배열은 정렬했습니다. 비밀번호나 token 같은 민감 값은 캐시 키 원문과 로그에 포함하지 않았습니다.
변경 명령과 조회 영향의 매핑
무효화 누락의 원인은 Redis API가 아니라 영향 관계가 문서와 코드에 없다는 점이었습니다. 생성, 수정, 삭제 유스케이스마다 어떤 조회 모델이 바뀌는지 정리했습니다.
| 변경 명령 | 반드시 지울 캐시 | 조건부로 지울 캐시 |
|---|---|---|
| 프로젝트 이름 수정 | 프로젝트 상세, 그룹 프로젝트 목록 | workspace tree |
| label item 수정 | label set 상세, 프로젝트 통계 | snapshot 후보, model 입력 요약 |
| model 상태 변경 | model 상세·목록 | 프로젝트 진행 통계 |
| prediction 삭제 | prediction 상세·목록 | model 평가 요약 |
| 프로젝트 휴지통 이동 | 모든 프로젝트 cluster | model·prediction 관련 cluster |
이 표는 완전한 런타임 의존성 그래프가 아니라 리뷰 체크리스트입니다. 새 캐시를 추가할 때 읽기 코드만 작성하지 않고 어떤 명령이 해당 결과를 오래되게 만드는지도 함께 추가합니다.
pattern 삭제보다 우선한 cluster key
처음에는 ProjectService:*:${projectId}:* 같은 패턴을 SCAN해 삭제했습니다. 구현은 쉽지만 key 수가 많을수록 Redis round trip이 늘고 잘못된 wildcard가 다른 프로젝트 캐시까지 지울 수 있습니다. 정확한 query를 알고 있으면 완전한 key를 삭제합니다.
여러 query 조합이 있고 모두 알 수 없다면 project ID 같은 cluster prefix를 사용합니다. cluster에 속한 실제 key 목록을 별도 set으로 기록하는 방법도 있습니다.
async atomicWrap<T>(key: string, loader: () => Promise<T>, ttl: number) {
const cached = await this.redis.get(key);
if (cached !== null) return JSON.parse(cached) as T;
const value = await loader();
await this.redis.set(key, JSON.stringify(value), 'PX', ttl);
await this.redis.sadd(clusterSetKey(key), key);
return value;
}
async evictCluster(cluster: string) {
const keys = await this.redis.smembers(cluster);
if (keys.length) await this.redis.unlink(...keys);
await this.redis.del(cluster);
}
cluster set 방식은 삭제가 빠르지만 캐시 key가 만료된 뒤에도 set에 잔여 key가 남을 수 있습니다. set에도 TTL을 두거나, 삭제할 때 존재하지 않는 key를 허용하고 주기적으로 정리해야 합니다. Redis KEYS 명령은 큰 keyspace에서 서버를 막을 수 있으므로 운영에서 사용하지 않았습니다. 부득이하게 패턴으로 삭제할 때는 SCAN cursor와 batch UNLINK를 사용하고 metric으로 삭제량을 관찰했습니다.
DB transaction이 성공한 뒤에만 무효화했다
데이터베이스 UPDATE 전에 캐시를 지우면 동시 조회가 이전 DB 값을 다시 캐시에 채울 수 있습니다. UPDATE가 롤백돼도 캐시만 사라져 불필요한 miss를 만듭니다.
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(Project).update(
{ id: projectId },
{ name: request.name, updatedBy: userId },
);
});
await this.projectCacheService.evictProjectRelatedCaches(projectId);
더 엄밀하게는 transaction commit hook 또는 domain event outbox를 사용할 수 있습니다. 단순히 service 메서드 뒤에 두면 commit은 끝났지만 Redis 삭제 전에 프로세스가 죽는 틈이 남습니다. 캐시가 correctness source가 아니고 TTL이 짧다면 이 작은 틈을 허용할 수 있습니다.
반드시 즉시 일치해야 하는 조회라면 outbox 이벤트로 cache eviction을 재시도하거나 versioned key를 사용하는 편이 낫습니다.
삭제 실패와 원본 변경 실패를 섞지 않았다
DB 변경은 성공했지만 Redis가 잠시 실패할 수 있습니다. 이때 API 전체를 실패로 반환하면 사용자가 같은 변경을 다시 보내 중복 side effect를 만들 수 있습니다. 반대로 오류를 완전히 숨기면 오래된 캐시가 TTL까지 남습니다.
정책은 데이터 중요도에 따라 나눴습니다.
- authorization처럼 오래되면 위험한 캐시는 변경 transaction과 더 강하게 결합하거나 cache miss로 fail closed합니다.
- 목록과 통계 캐시는 DB 성공을 반환하되 eviction 실패 metric과 retry 이벤트를 남깁니다.
- 모든 캐시에는 유한 TTL을 두어 무효화 누락의 최대 지속 시간을 제한합니다.
try {
await this.cacheService.evictCluster(`project:${projectId}`);
} catch (error) {
this.logger.error('Project cache eviction failed', { projectId, error });
await this.outbox.enqueue({ type: 'PROJECT_CACHE_EVICT', projectId });
}
API 응답과 retry 정책은 명시적으로 정해야 합니다. Promise.all 안에 DB 변경과 eviction을 함께 넣으면 어느 작업이 성공했는지 모호해지기 쉽습니다.
Cache stampede도 같이 다뤘다
관련 캐시를 한 번에 지우면 인기 프로젝트의 요청이 동시에 DB로 몰릴 수 있습니다. atomicWrap은 key별 짧은 lock을 획득해 하나의 요청만 loader를 실행하고 나머지는 결과를 기다리게 했습니다.
const lock = await this.acquireLock(`${key}:refresh`, 5_000);
if (!lock) {
await sleep(50);
return this.getOrLoad(key, loader, ttl);
}
try {
const cachedAgain = await this.redis.get(key);
if (cachedAgain) return JSON.parse(cachedAgain);
const value = await loader();
await this.redis.set(key, JSON.stringify(value), 'PX', jitter(ttl));
return value;
} finally {
await this.releaseLock(`${key}:refresh`, lock);
}
lock을 기다리는 요청 수와 최대 시간을 제한해야 합니다. loader 실패를 cache에 저장할지, 오래된 값을 잠시 제공할지(stale-while-revalidate)도 조회 성격에 따라 다릅니다. TTL에 작은 jitter를 넣어 같은 시점에 생성된 수많은 key가 동시에 만료되는 현상을 줄였습니다.
하지만 stampede 방지가 무효화 정확성을 대신하지는 않습니다.
영향 범위를 드러내는 전용 Cache Service
각 비즈니스 서비스가 Redis key를 직접 알게 하지 않고 ProjectCacheService, ModelCacheService, PredictionCacheService처럼 영향 범위 단위의 메서드를 제공했습니다.
await Promise.all([ this.projectCacheService.evictByProjectIds(projectIds), this.modelCacheService.evictByProjectIds(projectIds), this.predictionCacheService.evictByProjectIds(projectIds), ]);
이름은 구현 방법이 아니라 업무 영향을 드러냅니다. project 삭제 유스케이스가 어떤 key prefix를 지워야 하는지 알 필요가 없습니다. 반대로 cache service는 DB mutation을 수행하지 않습니다.
대량 변경에서 같은 project ID가 반복될 수 있으므로 입력을 deduplicate하고, batch UNLINK 크기에 상한을 뒀습니다. 삭제된 key 개수와 처리 시간을 domain tag와 함께 metric으로 기록했습니다.
변경 후 miss를 중심에 둔 테스트
캐시 테스트는 두 번째 호출에서 repository가 한 번만 실행되는지 확인하기 쉽습니다. 하지만 실제 장애는 변경 후 오래된 값이 남는 경우였습니다. 다음 시나리오를 우선했습니다.
- query 인자 순서가 달라도 같은 정규화 key를 만드는가.
- 서로 다른 project cluster가 영향을 받지 않는가.
- transaction 실패 시 eviction이 실행되지 않는가.
- 수정 성공 후 상세·목록·통계 key가 모두 사라지는가.
- eviction 실패 시 retry event와 metric이 남는가.
- 동시에 cache miss가 발생해도 loader가 한 번만 실행되는가.
- TTL과 cluster set의 잔여 key가 정리되는가.
실제 Redis를 사용한 통합 테스트에서 SCAN cursor, Lua release lock, TTL 동작을 확인했습니다. mock만으로는 key 만료와 원자성을 검증하기 어렵습니다.
운영에서 본 지표
- domain·method별 hit/miss 비율.
- loader 실행 시간과 오류율.
- eviction 명령 수, 삭제 key 수, 처리 시간.
- pattern scan 횟수와 cursor iteration 수.
- refresh lock 획득 실패와 대기 요청 수.
- Redis 메모리와 keyspace 크기.
- eviction retry backlog와 가장 오래된 이벤트.
hit rate만 높다고 좋은 캐시는 아닙니다. 오래된 값이 계속 hit되면 숫자는 좋아 보입니다. 데이터 변경 후 첫 조회가 최신 값을 반환하는지 샘플 검증도 필요합니다.
캐시 무효화의 책임 경계
캐시는 저장보다 삭제가 어렵다는 말은 API가 복잡해서가 아닙니다. 저장은 한 조회 메서드만 알면 되지만 삭제는 그 결과를 오래되게 만드는 모든 변경을 알아야 하기 때문입니다. 키 정규화, cluster prefix, 변경 영향표, commit 이후 eviction, 실패 retry, TTL, stampede 제어를 하나의 정책으로 묶으면서 부분 불일치를 줄일 수 있었습니다.
가장 큰 변화는 각 서비스가 임의 문자열을 삭제하지 않고, 어떤 조회 모델이 바뀌었는지를 도메인 cache service에 알리게 된 점입니다. 캐시를 붙일 때는 hit 경로와 동시에 삭제 경로를 설계해야 합니다. 무효화 주체와 실패 시 최대 stale 시간을 설명할 수 없다면 캐시 설계가 아직 끝난 것이 아닙니다.
답글 남기기