Cache-Aside(Lazy Loading)는 Redis를 도입할 때 가장 먼저 마주치는 기본 패턴이다. 이론상 "캐시에 먼저 물어보고 없으면 DB에서 읽어 캐시에 넣는다"는 단순한 흐름이지만, 실제 백엔드 시스템에서는 이 단순함이 다음과 같은 복합적 이슈로 이어진다. - 캐시 일관성: DB가 업데이트된 후 캐시가 여전히 과거 값을 반환하는 상황 - 동시...
Cache-Aside(Lazy Loading)는 Redis를 도입할 때 가장 먼저 마주치는 기본 패턴이다. 이론상 "캐시에 먼저 물어보고 없으면 DB에서 읽어 캐시에 넣는다"는 단순한 흐름이지만, 실제 백엔드 시스템에서는 이 단순함이 다음과 같은 복합적 이슈로 이어진다.
시니어 백엔드 면접에서 "Redis를 어떻게 쓰고 있나요?"라는 질문은 대부분 이 Cache-Aside의 실전 이해도를 묻는 것이다. 즉 개념 자체보다 "실패 경험과 trade-off 판단"이 합격 포인트다.
기존의 일반 Redis 개념 문서(기본 개념 등)가 커맨드와 자료구조 중심이라면, 이 문서는 패턴 적용과 실패 사례에 초점을 맞춘 deep-dive 역할을 맡는다.
Cache-Aside 패턴의 기본 흐름은 다음과 같다.
Read path
Write path
여기서 두 가지 중요한 설계 판단이 숨어 있다.
애플리케이션이 캐시와 DB를 모두 직접 관리한다. Redis는 "DB의 replica"가 아니라 애플리케이션이 의식적으로 유지하는 보조 저장소다. 그래서 캐시가 비어 있어도 시스템은 정상 동작해야 한다. Redis가 dump되어도 서비스가 느려질 뿐 죽어서는 안 된다는 전제가 깔려 있다.
DB 업데이트 → 캐시 삭제가 DB 업데이트 → 캐시 업데이트보다 안전한 이유는 동시성이다. 두 트랜잭션이 거의 동시에 같은 키를 업데이트할 때, 네트워크/스케줄링에 따라 캐시에 "오래된 값이 나중에 쓰여" 영구적으로 stale해질 수 있다. 삭제 방식이면 다음 read에서 최신 값을 다시 로드하므로 드리프트가 자체 복구된다.
모든 데이터를 캐시에 올리지 않는다. 다음 조건에서 효과가 크다.
반대로 트랜잭션성이 강한 데이터(결제, 재고 차감, 포인트 잔액)는 Cache-Aside만으로는 부족하며 분산락 또는 write-through, CDC 기반 패턴을 병행해야 한다.
TTL은 "일관성 예산"이다. 짧으면 DB 부하가 커지고, 길면 stale 데이터가 오래 남는다. 실무 감각은 다음과 같이 잡는다.
모든 키에 지터(jitter) 를 붙여 동시에 만료되지 않도록 한다. ttl = base + random(0, base * 0.2) 정도의 분산이면 만료 폭주를 크게 완화한다.
키 네임스페이스는 배포 전에 확정해두는 게 좋다.
{service}:{entity}:{id}:{version}
# 예시
catalog:product:12345:v2
user:profile:7788:v1
order:summary:2026-04-21:user:7788:v1version 필드를 미리 넣어두면 스키마 변경 시 기존 캐시를 일괄 폐기할 수 있다. 키 변경만으로 eviction을 자연스럽게 유도한다.
아래 예제는 Spring Boot + Spring Data Redis + JPA 전제다. 설명을 위해 필요한 부분만 남겼다.
@Transactional
public Product updatePrice(Long productId, BigDecimal newPrice) {
Product product = productRepository.findById(productId)
.orElseThrow();
product.changePrice(newPrice);
redisTemplate.delete("catalog:product:" + productId + ":v2");
return product;
}문제점이 세 가지다.
redisTemplate.delete가 예외를 던지면 @Transactional이 롤백된다. 캐시는 보조 저장소인데 주 경로를 망가뜨리는 구조다.@Transactional
public Product updatePrice(Long productId, BigDecimal newPrice) {
Product product = productRepository.findById(productId)
.orElseThrow();
product.changePrice(newPrice);
String cacheKey = "catalog:product:" + productId + ":v2";
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
safeDelete(cacheKey);
scheduler.schedule(() -> safeDelete(cacheKey),
Duration.ofMillis(500));
}
}
);
return product;
}
private void safeDelete(String key) {
try {
redisTemplate.delete(key);
} catch (Exception e) {
log.warn("cache delete failed key={}", key, e);
}
}핵심 개선점
afterCommit 로 옮겨 DB 커밋 이후에만 캐시를 삭제한다.public Product findById(Long id) {
String key = "catalog:product:" + id + ":v2";
Product cached = (Product) redisTemplate.opsForValue().get(key);
if (cached != null) return cached;
Product fromDb = productRepository.findById(id).orElseThrow();
redisTemplate.opsForValue().set(key, fromDb, Duration.ofMinutes(5));
return fromDb;
}단일 요청 기준으로는 정상이지만, 인기 상품 키의 TTL이 만료되는 순간 수천 개의 요청이 동시에 miss 처리되어 DB로 쏟아진다. 복구 직후 RT가 급등하고 DB CPU가 튄다.
public Product findById(Long id) {
String key = "catalog:product:" + id + ":v2";
String lockKey = key + ":lock";
Product cached = (Product) redisTemplate.opsForValue().get(key);
if (cached != null) {
maybeRefreshInBackground(id, key, cached);
return cached;
}
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(3));
if (Boolean.TRUE.equals(locked)) {
try {
Product fromDb = productRepository.findById(id).orElseThrow();
long ttlMs = 5 * 60_000L + ThreadLocalRandom.current().nextLong(30_000);
redisTemplate.opsForValue()
.set(key, fromDb, Duration.ofMillis(ttlMs));
return fromDb;
} finally {
redisTemplate.delete(lockKey);
}
}
for (int i = 0; i < 10; i++) {
sleep(50);
Product again = (Product) redisTemplate.opsForValue().get(key);
if (again != null) return again;
}
return productRepository.findById(id).orElseThrow();
}이 코드는 다음을 해결한다.
SET NX로 한 스레드만 DB를 읽고 나머지는 폴링으로 캐시를 기다린다.maybeRefreshInBackground는 TTL이 30% 이하 남았을 때 비동기로 미리 갱신하여 사용자 요청 경로에서의 만료를 줄인다(probabilistic early expiration의 단순 버전).# docker-compose.yml
services:
redis:
image: redis:7.2
ports: ["6379:6379"]
command: ["redis-server", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: shop
ports: ["3306:3306"]docker compose up -dMySQL 8 스키마 예시:
CREATE TABLE product (
id BIGINT PRIMARY KEY,
name VARCHAR(200) NOT NULL,
price DECIMAL(12,2) NOT NULL,
updated_at DATETIME(3) NOT NULL
);
INSERT INTO product (id, name, price, updated_at)
SELECT n, CONCAT('prod-', n), 1000 + n, NOW(3)
FROM (WITH RECURSIVE t(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM t WHERE n < 10000)
SELECT n FROM t) x;redis-cli 로 수동 체험해본다.
redis-cli SET catalog:product:1:v2 '{"id":1,"price":1001}' EX 300
redis-cli GET catalog:product:1:v2
redis-cli TTL catalog:product:1:v2
redis-cli DEL catalog:product:1:v2Spring Boot 앱에서 findById(1L)을 처음 호출하면 미스 + DB 쿼리 1회, 두 번째 호출부터 hit만 발생해야 한다. p6spy나 Hibernate SQL 로그로 쿼리 횟수를 확인한다.
redis-cli DEL catalog:product:1:v2
ab -n 500 -c 50 http://localhost:8080/products/1
# 또는
hey -n 500 -c 50 http://localhost:8080/products/1락 없는 구현에서는 MySQL SHOW PROCESSLIST에 동일 쿼리가 동시에 수십 개 찍힌다. 개선 구현에서는 1회만 찍혀야 한다.
# 터미널 A
while true; do curl -s http://localhost:8080/products/1; echo; done
# 터미널 B
curl -X PUT -d '{"price":9999}' http://localhost:8080/products/1/priceafterCommit 삭제가 없으면 B 직후에도 A에서 과거 가격이 수 초~TTL 길이만큼 관찰된다. Delayed Double Delete 적용 후 그 창이 거의 사라지는지 확인한다.
NOT_FOUND 표식을 짧은 TTL로 캐시해두면 Cache Penetration 공격을 완화한다.시니어 백엔드 관점에서 답할 때는 "패턴 설명"이 아니라 "내가 겪은 실패 → 어떻게 풀었는지 → 다음엔 어떻게 하겠는지" 흐름을 쓴다.
예시 답변 틀.
"Cache-Aside를 쓰면서 가장 고생한 건 캐시와 DB의 일관성이었습니다. 초반엔
@Transactional안에서 캐시를 삭제했는데, 커밋 전 삭제가 다른 요청의 miss 경로와 겹쳐 과거 값이 다시 캐시에 올라가는 현상이 있었습니다.afterCommit으로 옮기고, 지연 이중 삭제를 붙여서 드리프트를 크게 줄였습니다. 그 다음 문제는 만료 순간의 stampede였는데,SET NX기반 싱글 플라이트와 TTL 지터, 만료 임박 시 비동기 재조회로 해결했습니다. Redis 장애 시 주 경로가 같이 죽지 않도록 클라이언트 타임아웃을 짧게 두고 예외를 삼키도록 했습니다. 지금 돌이켜보면 Cache-Aside는 '캐시를 지운다'가 아니라 '일관성 예산을 설계한다'에 가깝다고 봅니다."
이 답변에는 개념, 실패, 해결, 재설계 관점이 모두 들어간다. 여기에 면접관이 꼬리 질문으로 들어오는 포인트는 대체로 다음과 같다.