들어가며
2편에서 Cache-Aside 패턴을 구현했습니다. 이번 편에서는 쓰기 전략인 Write-Through와 Write-Behind 패턴을 구현합니다.
ℹ️
이번 글의 범위
- 다루는 것: Write-Through 구현, Write-Behind 구현 (조회수 카운터), 적합한 사용 케이스
- 다루지 않는 것: 분산 환경에서의 Write-Behind 동기화
패턴 복습
| 패턴 | 쓰기 동작 | 특징 |
|---|---|---|
| Cache-Aside | DB 쓰고 캐시 삭제 | 가장 안전, 캐시 미스 구간 존재 |
| Write-Through | 캐시와 DB 동시에 동기적으로 | 항상 동기화, 느림 |
| Write-Behind | 캐시만 쓰고 나중에 배치로 DB | 빠름, 유실 위험 |
Write-Through 패턴 구현
개념
캐시에 쓰면 즉시 DB에도 동기적으로 저장합니다.
📦
애플리케이션
쓰기 요청
→
📦
캐시 저장
Redis에 저장
→
📦
DB 저장
동기적으로 DB에 저장
→
📦
응답
둘 다 성공해야 완료
구현
@Service
class WriteThroughProductService(
private val productRepository: ProductRepository,
private val redisTemplate: RedisTemplate<String, Any>
) {
private val log = LoggerFactory.getLogger(javaClass)
companion object {
private const val CACHE_KEY_PREFIX = "write-through:product:"
private val CACHE_TTL = Duration.ofMinutes(30)
}
/**
* 상품 조회
*/
fun getProduct(id: Long): Product? {
val cacheKey = "$CACHE_KEY_PREFIX$id"
val cached = redisTemplate.opsForValue().get(cacheKey)
if (cached != null) {
log.debug("Cache HIT - key: {}", cacheKey)
return cached as Product
}
log.debug("Cache MISS - key: {}", cacheKey)
val product = productRepository.findById(id).orElse(null) ?: return null
redisTemplate.opsForValue().set(cacheKey, product, CACHE_TTL)
return product
}
/**
* 상품 가격 수정 - Write-Through 패턴
*
* Cache-Aside와 차이점:
* - Cache-Aside: DB 쓰고 캐시 삭제
* - Write-Through: DB 쓰고 캐시도 갱신
*/
@Transactional
fun updatePrice(id: Long, price: Int): Product? {
val cacheKey = "$CACHE_KEY_PREFIX$id"
// 1. DB 업데이트 먼저
val updated = productRepository.updatePrice(id, price)
if (updated == 0) {
log.warn("Product not found - id: {}", id)
return null
}
// 2. 최신 데이터 조회
val product = productRepository.findById(id).orElse(null) ?: return null
// 3. 캐시도 갱신 (삭제가 아닌 갱신!)
redisTemplate.opsForValue().set(cacheKey, product, CACHE_TTL)
log.debug("Write-Through - DB and Cache updated - key: {}", cacheKey)
return product
}
/**
* 상품 생성 - Write-Through
*/
@Transactional
fun createProduct(name: String, price: Int, stock: Int, description: String): Product {
// 1. DB 저장
val product = Product(
name = name,
price = price,
stock = stock,
description = description
)
val saved = productRepository.save(product)
// 2. 캐시도 저장
val cacheKey = "$CACHE_KEY_PREFIX${saved.id}"
redisTemplate.opsForValue().set(cacheKey, saved, CACHE_TTL)
log.debug("Write-Through - Product created - key: {}", cacheKey)
return saved
}
}Write-Through의 문제점
1편에서 다뤘듯이, 갱신 방식은 순서가 꼬이면 불일치가 발생합니다:
T1
요청 A
DB에 15,000원 저장 완료
T2
요청 B
DB에 20,000원 저장 완료
T3
요청 B
캐시에 20,000원 저장
T4
요청 A
캐시에 15,000원 저장 (늦게 도착)
🚨
결과: DB는 20,000원, 캐시는 15,000원 - 불일치 발생!
🌳
동시성 문제를 감수할 수 있나요?
Write-Behind 패턴 구현
개념
캐시에만 먼저 쓰고, DB는 나중에 비동기로 저장합니다.
📦
애플리케이션
쓰기 요청
→
📦
캐시 저장
Redis에만 저장
→
📦
즉시 응답
빠름!
→
📦
배치 처리
나중에 DB에 반영
조회수 카운터로 구현
ℹ️
조회수는 Write-Behind에 적합한 대표적인 케이스입니다:
- 쓰기가 매우 빈번함
- 유실돼도 치명적이지 않음
- 성능이 정확성보다 중요
@Service
@EnableScheduling
class WriteBehindViewCountService(
private val productRepository: ProductRepository,
private val redisTemplate: RedisTemplate<String, Any>
) {
private val log = LoggerFactory.getLogger(javaClass)
companion object {
private const val VIEW_COUNT_KEY_PREFIX = "write-behind:viewcount:"
private const val DIRTY_SET_KEY = "write-behind:dirty-products"
}
/**
* 조회수 증가 - Write-Behind 패턴
*
* Redis에만 증가시키고 바로 리턴 (빠름!)
* DB 반영은 나중에 배치로
*/
fun incrementViewCount(productId: Long): Long {
val cacheKey = "$VIEW_COUNT_KEY_PREFIX$productId"
// 1. Redis에서 조회수 증가 (atomic operation)
val newCount = redisTemplate.opsForValue().increment(cacheKey) ?: 1L
// 2. dirty set에 추가 (나중에 flush할 대상 표시)
redisTemplate.opsForSet().add(DIRTY_SET_KEY, productId.toString())
log.debug("View count incremented - productId: {}, count: {}", productId, newCount)
return newCount
}
/**
* 현재 조회수 조회 (캐시 우선)
*/
fun getViewCount(productId: Long): Long {
val cacheKey = "$VIEW_COUNT_KEY_PREFIX$productId"
val cached = redisTemplate.opsForValue().get(cacheKey)
if (cached != null) {
return (cached as Number).toLong()
}
// 캐시에 없으면 DB에서 조회 후 캐시에 저장
val product = productRepository.findById(productId).orElse(null)
val viewCount = product?.viewCount ?: 0L
redisTemplate.opsForValue().set(cacheKey, viewCount)
return viewCount
}
/**
* DB로 flush - 주기적으로 실행
*
* dirty set에 있는 상품들의 조회수를 DB에 반영
*/
@Scheduled(fixedDelay = 5000) // 5초마다 실행
@Transactional
fun flushToDatabase() {
// 1. dirty set에서 flush할 상품 ID들 가져오기
val dirtyProductIds = redisTemplate.opsForSet().members(DIRTY_SET_KEY)
if (dirtyProductIds.isNullOrEmpty()) {
return
}
log.info("Flushing view counts to DB - count: {}", dirtyProductIds.size)
// 2. 각 상품의 조회수를 DB에 반영
dirtyProductIds.forEach { productIdStr ->
try {
val productId = (productIdStr as String).toLong()
val cacheKey = "$VIEW_COUNT_KEY_PREFIX$productId"
val viewCount = redisTemplate.opsForValue().get(cacheKey)
if (viewCount != null) {
val count = (viewCount as Number).toLong()
productRepository.updateViewCount(productId, count)
log.debug("Flushed - productId: {}, count: {}", productId, count)
}
// dirty set에서 제거
redisTemplate.opsForSet().remove(DIRTY_SET_KEY, productIdStr)
} catch (e: Exception) {
log.error("Failed to flush - productId: {}", productIdStr, e)
// 실패한 건은 다음 주기에 다시 시도
}
}
}
}동작 흐름
1
조회수 증가 요청
사용자가 상품 페이지 방문
incrementViewCount(1)2
Redis에만 저장
캐시에 조회수 증가, dirty-set에 추가
Redis: viewcount:1 = 1
Redis: dirty-set = {1}3
즉시 응답
DB 접근 없이 빠르게 응답
4
5초 후 스케줄러 실행
flushToDatabase() 호출
5
DB에 반영
dirty-set의 상품들 조회수를 DB에 UPDATE
UPDATE products SET view_count = 1 WHERE id = 1Write-Behind의 위험성
T1
사용자 A
조회수 +1 (Redis: 100)
T2
사용자 B
조회수 +1 (Redis: 101)
T3
사용자 C
조회수 +1 (Redis: 102)
T4
Redis 다운!
서버 장애 발생
T5
복구 후
DB: 97 (이전 flush 값)
🚨
유실: 5건의 조회수가 사라졌습니다!
유실 최소화 전략
@Scheduled(fixedDelay = 1000) // 1초마다
fun flushToDatabase() { ... }✅ 장점
- 유실 범위 감소 - 최대 1초 분량만 유실
❌ 단점
- DB 부하 증가 - 더 자주 DB에 쓰기 발생
API 테스트
Controller
@RestController
@RequestMapping("/api/products")
class ProductController(
private val writeThroughService: WriteThroughProductService,
private val writeBehindService: WriteBehindViewCountService
) {
// Write-Through
@PostMapping("/write-through")
fun createProduct(@RequestBody request: CreateProductRequest): ResponseEntity<Product> {
val product = writeThroughService.createProduct(
name = request.name,
price = request.price,
stock = request.stock,
description = request.description
)
return ResponseEntity.ok(product)
}
@PutMapping("/write-through/{id}/price")
fun updatePrice(
@PathVariable id: Long,
@RequestBody request: UpdatePriceRequest
): ResponseEntity<Product> {
val product = writeThroughService.updatePrice(id, request.price)
return product?.let { ResponseEntity.ok(it) }
?: ResponseEntity.notFound().build()
}
// Write-Behind (조회수)
@PostMapping("/{id}/view")
fun incrementViewCount(@PathVariable id: Long): ResponseEntity<ViewCountResponse> {
val count = writeBehindService.incrementViewCount(id)
return ResponseEntity.ok(ViewCountResponse(id, count))
}
@GetMapping("/{id}/view-count")
fun getViewCount(@PathVariable id: Long): ResponseEntity<ViewCountResponse> {
val count = writeBehindService.getViewCount(id)
return ResponseEntity.ok(ViewCountResponse(id, count))
}
}테스트 시나리오
# Write-Through: 상품 생성
curl -X POST http://localhost:8080/api/products/write-through \
-H "Content-Type: application/json" \
-d '{"name": "맥 미니", "price": 850000, "stock": 50}'
# Write-Through: 가격 수정 (캐시도 즉시 갱신)
curl -X PUT http://localhost:8080/api/products/write-through/1/price \
-H "Content-Type: application/json" \
-d '{"price": 900000}'
# Write-Behind: 조회수 증가 (Redis만, 빠름!)
curl -X POST http://localhost:8080/api/products/1/view
# {"productId": 1, "viewCount": 1}
curl -X POST http://localhost:8080/api/products/1/view
# {"productId": 1, "viewCount": 2}
# 현재 조회수 확인
curl http://localhost:8080/api/products/1/view-count
# {"productId": 1, "viewCount": 2}Redis에서 확인
docker exec -it cache-pattern-redis redis-cli
# Write-Through 캐시 확인
127.0.0.1:6379> GET "write-through:product:1"
# Write-Behind 조회수 확인
127.0.0.1:6379> GET "write-behind:viewcount:1"
"2"
# dirty set 확인
127.0.0.1:6379> SMEMBERS "write-behind:dirty-products"
1) "1"패턴 선택 가이드
데이터 특성별 정리
| 데이터 | 권장 패턴 | 이유 |
|---|---|---|
| 상품 기본 정보 | Cache-Aside | 자주 안 바뀜, 정합성 중요 |
| 상품 가격 | Cache-Aside | 정확해야 함, 동시성 이슈 방지 |
| 조회수 | Write-Behind | 빈번한 쓰기, 유실 허용 |
| 좋아요 수 | Write-Behind | 빈번한 쓰기, 유실 허용 |
| 재고 | 캐시 안 함 / 분산락 | 정확성 필수, 트랜잭션 필요 |
| 결제 정보 | 캐시 안 함 | 절대 유실 불가 |
패턴별 체크리스트
사용 전 확인:
- [ ] 동시성 문제를 감수할 수 있는가?
- [ ] 캐시 미스를 최소화해야 하는가?
- [ ] 읽기/쓰기 비율이 비슷한가?
⚠️
하나라도 “아니오”면 Cache-Aside를 쓰세요.
마치며
Write-Through
동시 갱신
동시성 문제 주의
Write-Behind
비동기
유실 위험, 성능 최적화
Cache-Aside
가장 안전
대부분의 경우 권장
다음 편에서는 Cache Invalidation 전략을 다룹니다. TTL 설정, 이벤트 기반 무효화, 수동 무효화 등을 알아봅니다.
시리즈 목차
- 캐시 패턴 개요 및 비교
- Spring Boot + Redis 설정 및 Cache-Aside 구현
- Write-Through / Write-Behind 패턴 구현 (현재 글)
- Cache Invalidation 전략
- Thundering Herd / Cache Stampede 해결
- 실전 시나리오별 캐시 전략 가이드