캐시 전략이 중요한 이유: “어떻게 캐싱하느냐”가 성능과 정합성을 결정한다
Redis를 캐시로 도입하는 것은 쉽다. 하지만 언제 캐시에 쓰고, 언제 읽고, 언제 무효화할 것인가를 잘못 설계하면 stale 데이터, 캐시 미스 폭증, DB 부하 전가 등 캐시가 없는 것보다 나쁜 상황이 된다. Redis 공식 문서와 캐싱 아키텍처 문헌에서 정의하는 핵심 패턴은 크게 네 가지다: Cache-Aside, Read-Through, Write-Through, Write-Behind(Write-Back).
이 글에서는 각 패턴의 동작 흐름, 정합성 특성, Redis 명령어 기반 구현, 그리고 Spring Boot·NestJS에서의 적용 방법을 정리한다.
네 가지 캐시 전략 비교
| 전략 | 읽기 흐름 | 쓰기 흐름 | 정합성 | 복잡도 |
|---|---|---|---|---|
| Cache-Aside | 앱이 캐시 → 미스 시 DB → 캐시 적재 | 앱이 DB 쓰기 → 캐시 무효화 | 최종 일관성 | 낮음 |
| Read-Through | 캐시 라이브러리가 미스 시 자동 로드 | Cache-Aside와 동일 | 최종 일관성 | 낮음 |
| Write-Through | 캐시에서 읽기 | 캐시 + DB 동시 쓰기 (동기) | 강한 일관성 | 중간 |
| Write-Behind | 캐시에서 읽기 | 캐시만 쓰기 → DB 비동기 반영 | 최종 일관성 (지연) | 높음 |
Cache-Aside (Lazy Loading): 가장 널리 쓰이는 패턴
애플리케이션이 캐시와 DB를 직접 제어하는 패턴이다. 캐시 히트 시 즉시 반환하고, 미스 시 DB에서 읽어 캐시에 적재한다.
읽기 흐름
// NestJS + ioredis 예시
async findUser(id: number): Promise<User> {
const cacheKey = `user:${id}`;
// 1. 캐시 조회
const cached = await this.redis.get(cacheKey);
if (cached) {
return JSON.parse(cached); // 캐시 히트 → 즉시 반환
}
// 2. 캐시 미스 → DB 조회
const user = await this.userRepository.findOneBy({ id });
if (!user) throw new NotFoundException();
// 3. 캐시에 적재 (TTL 설정)
await this.redis.set(cacheKey, JSON.stringify(user), 'EX', 3600);
return user;
}
쓰기 흐름: 무효화(Invalidation) vs 갱신(Update)
// 방법 1: 캐시 무효화 (권장)
async updateUser(id: number, dto: UpdateUserDto): Promise<User> {
const user = await this.userRepository.save({ id, ...dto });
// DB 쓰기 후 캐시 삭제 → 다음 읽기 시 최신 데이터 적재
await this.redis.del(`user:${id}`);
return user;
}
// 방법 2: 캐시 갱신 (주의 필요)
async updateUser(id: number, dto: UpdateUserDto): Promise<User> {
const user = await this.userRepository.save({ id, ...dto });
// DB 쓰기 후 캐시도 갱신
await this.redis.set(`user:${id}`, JSON.stringify(user), 'EX', 3600);
return user;
}
| 쓰기 전략 | 장점 | 단점 | 권장 시점 |
|---|---|---|---|
| 무효화 (DEL) | 단순, race condition 적음 | 다음 읽기 시 캐시 미스 1회 | 대부분의 경우 (기본 선택) |
| 갱신 (SET) | 미스 없이 최신 데이터 유지 | 동시 쓰기 시 stale 데이터 위험 | 읽기 빈도 극히 높고, 쓰기 충돌이 드문 경우 |
Read-Through: 캐시 라이브러리가 로딩을 대신
Read-Through는 Cache-Aside와 읽기 흐름이 동일하지만, 캐시 라이브러리/프록시가 미스 시 DB 로딩을 자동 처리한다. 애플리케이션은 항상 캐시에만 요청하면 된다.
// Spring Boot @Cacheable — Read-Through 패턴의 대표적 구현
@Service
public class UserService {
@Cacheable(value = "users", key = "#id")
public User findUser(Long id) {
// 이 메서드는 캐시 미스 시에만 실행됨
// 캐시 히트 시에는 메서드가 호출되지 않고 캐시 값 반환
return userRepository.findById(id)
.orElseThrow(() -> new NotFoundException());
}
@CacheEvict(value = "users", key = "#id")
public User updateUser(Long id, UpdateUserDto dto) {
// 메서드 실행 후 캐시 무효화
return userRepository.save(dto.toEntity(id));
}
@CacheEvict(value = "users", allEntries = true)
public void clearAllUserCache() {
// users 캐시 전체 삭제
}
}
# Spring Boot Redis 캐시 설정
# application.yml
spring:
cache:
type: redis
data:
redis:
host: redis-service
port: 6379
timeout: 2000ms
cache:
redis:
time-to-live: 3600000 # 1시간 (밀리초)
cache-null-values: false
Write-Through: DB와 캐시에 동시 쓰기
Write-Through는 데이터를 쓸 때 캐시와 DB에 동기적으로 모두 기록하는 패턴이다. 캐시가 항상 최신 상태이므로 읽기 시 DB를 거칠 필요가 없다.
// Write-Through 구현
async createOrder(dto: CreateOrderDto): Promise<Order> {
// 1. DB에 쓰기
const order = await this.orderRepository.save(dto.toEntity());
// 2. 캐시에도 동기적으로 쓰기 (두 작업이 모두 성공해야 완료)
await this.redis.set(
`order:${order.id}`,
JSON.stringify(order),
'EX', 7200
);
return order;
}
// 읽기: 캐시에서만 읽으면 됨 (항상 최신)
async findOrder(id: number): Promise<Order> {
const cached = await this.redis.get(`order:${id}`);
if (cached) return JSON.parse(cached);
// 캐시 미스 (TTL 만료 등) → DB에서 복구
const order = await this.orderRepository.findOneBy({ id });
if (order) {
await this.redis.set(`order:${id}`, JSON.stringify(order), 'EX', 7200);
}
return order;
}
| 항목 | Cache-Aside | Write-Through |
|---|---|---|
| 쓰기 지연 | DB만 쓰기 (빠름) | DB + 캐시 동시 (느림) |
| 읽기 정합성 | 무효화 전까지 stale 가능 | 항상 최신 |
| 캐시 미스 가능성 | 있음 (첫 읽기, TTL 만료) | 낮음 (쓰기 시 적재) |
| 실패 처리 | 캐시 실패 무시 가능 | 캐시 실패 = 전체 실패 |
Write-Behind (Write-Back): 캐시만 쓰고 DB는 나중에
Write-Behind는 캐시에만 즉시 쓰고, DB 반영은 비동기로 지연하는 패턴이다. 쓰기 성능이 극대화되지만, 캐시 장애 시 데이터 유실 위험이 있다.
// Write-Behind 개념 구현 (Redis + Queue)
async updateInventory(productId: number, quantity: number): Promise<void> {
// 1. Redis에 즉시 쓰기 (앱 응답은 여기서 완료)
await this.redis.set(`inventory:${productId}`, quantity.toString());
// 2. DB 반영을 큐에 넣기 (비동기 처리)
await this.queue.add('sync-inventory', {
productId,
quantity,
timestamp: Date.now(),
});
}
// 큐 컨슈머: 배치로 DB 반영
@Process('sync-inventory')
async handleSync(job: Job): Promise<void> {
const { productId, quantity } = job.data;
await this.inventoryRepository.update(productId, { quantity });
}
| 항목 | Write-Through | Write-Behind |
|---|---|---|
| 쓰기 응답 시간 | DB + 캐시 완료까지 대기 | 캐시 쓰기만 (매우 빠름) |
| DB 부하 | 매 쓰기마다 DB 접근 | 배치로 줄임 |
| 데이터 유실 위험 | 없음 | 있음 (캐시 장애 시) |
| 구현 복잡도 | 중간 | 높음 (큐, 재시도, 멱등성) |
| 적합한 워크로드 | 정합성 중요 (결제, 주문) | 높은 쓰기 빈도 (조회수, 좋아요) |
TTL 설계: 만료 전략의 핵심
# Redis TTL 명령어
SET user:1 '{"id":1,"name":"Alice"}' EX 3600 # 1시간
SET session:abc '...' PX 1800000 # 30분 (밀리초)
# 동적 TTL: 자주 접근되는 키는 TTL 연장
GET user:1
EXPIRE user:1 3600 # 접근 시 TTL 리셋 (sliding expiration)
| 데이터 유형 | 권장 TTL | 이유 |
|---|---|---|
| 사용자 프로필 | 1~24시간 | 변경 빈도 낮음, stale 허용 |
| 세션 | 30분 (sliding) | 보안, 비활성 세션 자동 만료 |
| 상품 목록 | 5~15분 | 재고/가격 변동 반영 필요 |
| 검색 결과 | 1~5분 | 실시간성 중요 |
| 설정/메타데이터 | TTL 없음 + 이벤트 무효화 | 변경 시에만 갱신 |
캐시 키 설계 패턴
// 좋은 키 설계: 계층적, 예측 가능, 충돌 없음
user:{id} // user:42
user:{id}:profile // user:42:profile
user:{id}:orders:page:{n} // user:42:orders:page:1
product:list:category:{cat}:page:{n} // product:list:category:electronics:page:3
// 와일드카드 무효화 (주의: KEYS 명령은 프로덕션에서 사용 금지)
// Redis SCAN으로 패턴 매칭 삭제
async invalidateUserCache(userId: number): Promise<void> {
let cursor = '0';
do {
const [nextCursor, keys] = await this.redis.scan(
cursor, 'MATCH', `user:${userId}:*`, 'COUNT', 100
);
cursor = nextCursor;
if (keys.length > 0) {
await this.redis.del(...keys);
}
} while (cursor !== '0');
// 루트 키도 삭제
await this.redis.del(`user:${userId}`);
}
실전 체크리스트: Redis 캐시 전략 설계 7단계
- 패턴 선택 — 읽기 중심이면 Cache-Aside, 정합성 필수면 Write-Through, 쓰기 폭주면 Write-Behind
- 무효화 vs 갱신 결정 — 기본은 무효화(DEL), 동시 쓰기가 없고 읽기 빈도 극히 높으면 갱신(SET)
- TTL 정책 수립 — 데이터 유형별 TTL을 정하고 문서화, TTL 없는 키는 메모리 누수 원인
- 키 네이밍 규칙 통일 —
{엔티티}:{id}:{속성}패턴, 팀 전체 공유 - 직렬화 포맷 결정 — JSON(디버깅 용이) vs MessagePack(크기 절약), 일관되게 유지
- 장애 시 동작 설계 — Redis 다운 시 DB 직접 조회로 fallback, 캐시 실패가 서비스 실패로 이어지지 않도록
- 모니터링 지표 설정 — hit ratio, eviction count, memory usage를 Grafana에 등록
흔한 실수 4가지와 방지법
실수 1: DB 쓰기 후 캐시 무효화 순서를 거꾸로
증상: 캐시를 먼저 삭제하고 DB에 쓰는 사이에 다른 요청이 stale 데이터를 캐시에 다시 적재한다.
방지: 반드시 DB 쓰기 → 캐시 무효화 순서로 한다. DB 쓰기가 실패하면 캐시는 여전히 유효한 상태를 유지한다.
실수 2: TTL 없이 캐시에 적재
증상: Redis 메모리가 계속 증가하다 maxmemory에 도달해 eviction이 시작되고, 중요한 키까지 삭제된다.
방지: 모든 캐시 키에 TTL을 설정한다. 영구 데이터가 필요한 경우에도 매우 긴 TTL(24시간 등)을 설정하고 이벤트 기반으로 갱신한다.
실수 3: Write-Behind에서 큐 유실 미대비
증상: Redis 또는 큐 시스템이 장애로 중단되면서 DB에 반영되지 않은 쓰기가 유실된다.
방지: Write-Behind는 유실 허용 가능한 데이터(조회수, 좋아요 등)에만 사용한다. 결제, 주문 등 중요 데이터는 Write-Through 또는 Cache-Aside를 사용한다.
실수 4: KEYS 명령을 프로덕션에서 사용
증상: KEYS user:*로 패턴 삭제를 시도했더니 Redis가 수 초간 블로킹되어 전체 서비스 장애.
방지: Redis 공식 문서는 KEYS를 프로덕션에서 사용하지 말라고 명시한다. 패턴 매칭이 필요하면 SCAN 명령을 사용한다.
마무리
Redis 캐시 전략은 “캐시를 쓴다”가 아니라 “어떻게 읽고, 쓰고, 무효화할 것인가”를 설계하는 것이다. Cache-Aside는 가장 범용적이고, Write-Through는 정합성을 보장하며, Write-Behind는 쓰기 성능을 극대화한다. 대부분의 서비스는 Cache-Aside + TTL + 이벤트 무효화 조합으로 시작하는 것이 안전하다. 이 글은 Redis 공식 문서(Client-side Caching)와 Spring Boot 공식 문서(Caching)를 근거로 한다.