들어가며
백엔드 개발을 하다 보면 “캐시 좀 붙여야겠다”는 말을 자주 하게 됩니다. 그런데 막상 캐시를 적용하려고 하면 여러 고민이 생깁니다.
- 캐시와 DB 데이터가 달라지면 어떡하지?
- 캐시를 갱신해야 할까, 삭제해야 할까?
- Write-Through? Write-Behind? 뭐가 다른 거지?
이 글에서는 캐시 패턴의 핵심 개념을 정리하고, 실전에서 어떤 기준으로 전략을 선택해야 하는지 알아봅니다.
이번 글의 범위
- 다루는 것: 캐시 패턴 4가지 비교, 데이터 특성별 전략 선택 기준
- 다루지 않는 것: Spring Boot 구현 코드 (2편에서 다룸), Thundering Herd 문제 (5편에서 다룸)
캐시를 왜 쓰나요?
가장 기본적인 질문부터 시작해봅시다.
// 그냥 DB에서 읽으면 안 되나?
fun getProduct(id: Long): Product {
return productRepository.findById(id)
}캐시의 트레이드오프
- DB 부하 감소 - 반복 조회를 캐시가 흡수
- 빠른 응답 속도 - Disk I/O(ms) vs Memory(μs), 수십~수백 배 차이
캐시 갱신 vs 삭제: 동시성 문제
DB를 수정할 때 캐시도 같이 처리해야 합니다. 크게 두 가지 방식이 있습니다.
// 방식 A: 캐시 갱신
fun updatePrice(id: Long, price: Int) {
productRepository.updatePrice(id, price)
cache.put(id, newProduct) // 새 값 저장
}// 방식 B: 캐시 삭제
fun updatePrice(id: Long, price: Int) {
productRepository.updatePrice(id, price)
cache.delete(id) // 캐시 삭제
}동시성 문제: 갱신 방식의 함정
두 개의 요청이 거의 동시에 들어온 상황을 생각해봅시다:
시간순서:
1. 요청A: 가격을 15,000원으로 수정 시작
2. 요청B: 가격을 20,000원으로 수정 시작
3. 요청A: DB 업데이트 완료 (15,000원)
4. 요청B: DB 업데이트 완료 (20,000원)
5. 요청A: 캐시 갱신 (15,000원) ← 네트워크 지연
6. 요청B: 캐시 갱신 (20,000원)
7. 요청A: 캐시 갱신 (15,000원) ← 지연됐던 게 실행최종 결과:
- DB: 20,000원
- 캐시: 15,000원 ❌
불일치 발생! 사용자는 캐시 TTL이 만료될 때까지 잘못된 가격을 보게 됩니다.
삭제 방식은 안전한가?
같은 상황에서 갱신 대신 삭제를 한다면:
3. 요청A: 캐시 삭제 ← 캐시 비어있음
4. 요청B: 캐시 삭제 ← 캐시 비어있음
5. 요청A: 캐시 삭제 ← 지연됐어도 삭제니까 동일삭제가 3번 실행되든 순서가 바뀌든, 결과는 캐시가 비어있는 상태입니다.
| 방식 | 동시성 문제 | 결과 | |-----|-----------|------| | 캐시 갱신 | 순서 꼬이면 | DB와 캐시 불일치 가능 | | 캐시 삭제 | 순서 꼬여도 | 다음 조회 시 최신값 로드 |
데이터 정합성 관점에서는 삭제가 더 안전합니다.
삭제 방식도 완벽하지 않다
삭제 방식이 갱신보다 안전하지만, 조회와 갱신이 동시에 일어나면 race condition이 발생합니다:
인기 상품처럼 동시 요청이 많은 경우 이런 상황이 자주 발생할 수 있습니다. 해결책(분산락, 캐시 워밍 등)은 5편에서 다룹니다.
4가지 캐시 패턴
패턴 비교 요약
가장 일반적인 패턴입니다. 애플리케이션이 캐시와 DB를 직접 관리합니다.
// 읽기
fun getProduct(id: Long): Product {
val cached = cache.get(id)
if (cached != null) return cached
val product = db.findById(id)
cache.put(id, product)
return product
}
// 쓰기
fun updateProduct(id: Long, data: ProductUpdateRequest) {
db.update(id, data)
cache.delete(id) // 갱신이 아닌 삭제!
}장점: 구현 단순, 정합성 관리 쉬움
단점: 캐시 미스 구간 존재, 실수 가능성 (캐시 삭제 깜빡)
실전: 데이터 특성별 전략 선택
상품 상세 페이지 예시
data class Product(
val id: Long,
val name: String,
val price: Int,
val viewCount: Int, // 조회수
val stock: Int // 재고
)Write-Behind 적합성 체크
분산락 기반 캐시 전략
재고처럼 동시성 제어 + 정확성이 둘 다 필요한 경우:
fun decreaseStock(id: Long, quantity: Int) {
val lockKey = "lock:stock:$id"
redisLock.tryLock(lockKey) {
val stock = redis.get("stock:$id")
if (stock < quantity) throw OutOfStockException()
redis.decrease("stock:$id", quantity) // Redis 차감
db.decreaseStock(id, quantity) // DB도 바로 저장
}
}Redis 싱글스레드 특성으로 동시성 제어하고, DB에 동기 저장해서 유실 방지!
| | DB 락 (SELECT FOR UPDATE) | Redis 분산락 | |—|-------------------------|--------------| | 속도 | 느림 (Disk I/O) | 빠름 (Memory) | | 동시성 처리량 | 낮음 | 높음 | | 정합성 | DB가 보장 | 앱이 관리 | | 장애 시 | 트랜잭션 롤백 | 유실 가능 |
마치며
캐시 패턴 선택의 핵심은 **“이 데이터가 틀리면 얼마나 큰일인가?”**입니다.
다음 편에서는 Spring Boot + Redis 환경에서 실제로 Cache-Aside 패턴을 구현해봅니다.