Write-Through와 Write-Behind 패턴 구현

2025년 12월 05일

experience

시리즈캐시 패턴#3
# Cache# Redis# Spring Boot# Kotlin# Write-Through# Write-Behind

들어가며

2편에서 Cache-Aside 패턴을 구현했습니다. 이번 편에서는 쓰기 전략인 Write-ThroughWrite-Behind 패턴을 구현합니다.

ℹ️

이번 글의 범위

  • 다루는 것: Write-Through 구현, Write-Behind 구현 (조회수 카운터), 적합한 사용 케이스
  • 다루지 않는 것: 분산 환경에서의 Write-Behind 동기화

패턴 복습

패턴쓰기 동작특징
Cache-AsideDB 쓰고 캐시 삭제가장 안전, 캐시 미스 구간 존재
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 = 1

Write-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 설정, 이벤트 기반 무효화, 수동 무효화 등을 알아봅니다.


시리즈 목차

  1. 캐시 패턴 개요 및 비교
  2. Spring Boot + Redis 설정 및 Cache-Aside 구현
  3. Write-Through / Write-Behind 패턴 구현 (현재 글)
  4. Cache Invalidation 전략
  5. Thundering Herd / Cache Stampede 해결
  6. 실전 시나리오별 캐시 전략 가이드
© 2025, 미나리와 함께 만들었음