Cache Invalidation 전략 - TTL, 이벤트 기반, 수동 무효화

2025년 12월 05일

experience

시리즈캐시 패턴#4
# Cache# Redis# Cache Invalidation# TTL# Spring Boot

들어가며

“컴퓨터 과학에서 어려운 것은 딱 두 가지다: 캐시 무효화와 네이밍.” — Phil Karlton

캐시를 적용하면 항상 따라오는 문제가 있습니다. “언제 캐시를 비울 것인가?”

이번 편에서는 캐시 무효화(Cache Invalidation) 전략을 정리합니다.

ℹ️

이번 글의 범위

  • 다루는 것: TTL 전략, 이벤트 기반 무효화, 수동 무효화, 조합 전략
  • 다루지 않는 것: 분산 캐시 동기화, CDC 기반 무효화

캐시 무효화가 어려운 이유

단순해 보이지만…

// 간단해 보이는 코드
fun updatePrice(id: Long, price: Int) {
    db.update(id, price)
    cache.delete(id)  // 끝?
}
  1. 여러 곳에서 DB 수정: 관리자 페이지, 배치, 다른 서비스
  2. 연관 데이터 무효화: 상품 수정 → 카테고리 목록 캐시도 갱신?
  3. 타이밍 문제: 삭제했는데 바로 다시 캐싱됨 (stale 데이터로)
  4. 비용 문제: 너무 자주 무효화하면 캐시 의미 없음

전략 1: TTL (Time To Live)

개념

캐시에 만료 시간을 설정합니다. 시간이 지나면 자동으로 삭제됩니다.

// 30분 후 자동 만료
redisTemplate.opsForValue().set(cacheKey, product, Duration.ofMinutes(30))

TTL 설정 기준

기준짧은 TTL (1-5분)긴 TTL (30분-24시간)
데이터 변경 빈도자주 바뀜거의 안 바뀜
불일치 허용도실시간 정확해야 함잠깐 틀려도 괜찮음
DB 부하높음 (캐시 미스 많음)낮음
예시재고, 좋아요 수카테고리 목록, 설정

데이터별 권장 TTL

"categories" to Duration.ofHours(24)
"site-config" to Duration.ofHours(12)

안전 긴 TTL 사용 가능

TTL의 역할: 보험

💡

수정 시 캐시를 삭제하는 로직이 있어도, TTL은 보험으로 필요합니다:

  • 캐시 삭제 로직을 깜빡했을 때
  • 외부 시스템(배치, 다른 서비스)이 DB를 직접 수정했을 때
  • 버그로 캐시 삭제가 실패했을 때

TTL이 없으면 캐시가 영원히 stale 상태로 남을 수 있습니다.


전략 2: 명시적 삭제 (Explicit Invalidation)

개념

데이터 변경 시 관련 캐시를 명시적으로 삭제합니다.

@Transactional
fun updatePrice(id: Long, price: Int) {
    db.update(id, price)
    cache.delete("product:$id")  // 명시적 삭제
}

Spring @CacheEvict 사용

@CacheEvict(cacheNames = ["products"], key = "'product:' + #id")
@Transactional
fun updatePrice(id: Long, price: Int): Product? {
    productRepository.updatePrice(id, price)
    return productRepository.findById(id).orElse(null)
}

연관 캐시 삭제

상품이 수정되면 관련 캐시도 삭제해야 할 수 있습니다:

@Transactional
fun updateProduct(id: Long, data: ProductUpdateRequest) {
    val product = productRepository.findById(id).orElseThrow()
    val oldCategoryId = product.categoryId

    product.update(data)
    productRepository.save(product)

    // 연관 캐시들 삭제
    cache.delete("product:$id")
    cache.delete("category-products:$oldCategoryId")
    if (data.categoryId != oldCategoryId) {
        cache.delete("category-products:${data.categoryId}")
    }
}
⚠️

문제점: 삭제 대상 파악의 어려움

// 상품이 수정되면 어떤 캐시를 삭제해야 할까?
fun updateProduct(id: Long, data: ProductUpdateRequest) {
    db.update(id, data)

    cache.delete("product:$id")              // 상품 상세
    cache.delete("product-list:category:?")   // 카테고리별 목록?
    cache.delete("search:?")                  // 검색 결과?
    cache.delete("recommendation:?")          // 추천 상품?
    cache.delete("ranking:?")                 // 랭킹?
    // 끝이 없다...
}

전략 3: 이벤트 기반 무효화

개념

데이터 변경 이벤트를 발행하고, 캐시 무효화 로직이 이벤트를 구독합니다.

📦
상품 서비스
데이터 수정
📦
이벤트 발행
ProductUpdatedEvent
📦
리스너
이벤트 수신
📦
캐시 삭제
관련 캐시 모두 삭제

구현

1
이벤트 정의
캐시 무효화에 필요한 정보를 담은 이벤트 클래스 생성
data class ProductUpdatedEvent(
    val productId: Long,
    val categoryId: Long,
    val previousCategoryId: Long?,
    val updatedFields: Set<String>
)
2
이벤트 발행
데이터 수정 후 이벤트를 발행
@Transactional
fun updateProduct(id: Long, data: ProductUpdateRequest): Product {
    val product = productRepository.findById(id).orElseThrow()
    val previousCategoryId = product.categoryId
    val updatedFields = mutableSetOf<String>()

    if (data.name != product.name) {
        product.name = data.name
        updatedFields.add("name")
    }
    if (data.price != product.price) {
        product.price = data.price
        updatedFields.add("price")
    }

    val saved = productRepository.save(product)

    // 이벤트 발행
    eventPublisher.publishEvent(
        ProductUpdatedEvent(id, saved.categoryId, previousCategoryId, updatedFields)
    )

    return saved
}
3
캐시 무효화 리스너
이벤트를 수신하여 관련 캐시 삭제
@Component
class ProductCacheInvalidationListener(
    private val redisTemplate: RedisTemplate<String, Any>
) {
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    fun handleProductUpdated(event: ProductUpdatedEvent) {
        val keysToDelete = mutableListOf<String>()

        // 1. 상품 상세 캐시
        keysToDelete.add("product:${event.productId}")

        // 2. 가격이 변경됐으면 랭킹 캐시도
        if ("price" in event.updatedFields) {
            keysToDelete.add("ranking:price:${event.categoryId}")
        }

        // 3. 카테고리 목록 캐시
        keysToDelete.add("category-products:${event.categoryId}")
        event.previousCategoryId?.let {
            if (it != event.categoryId) {
                keysToDelete.add("category-products:$it")
            }
        }

        redisTemplate.delete(keysToDelete)
    }
}
이벤트 기반 무효화
장점
  • 관심사 분리 - 비즈니스 로직과 캐시 로직 분리
  • 확장성 - 새로운 캐시가 추가되면 리스너만 수정
  • 추적 가능 - 어떤 이벤트가 어떤 캐시를 삭제하는지 명확
단점
  • 복잡도 증가 - 이벤트/리스너 코드 추가 필요
  • 타이밍 주의 - 트랜잭션 커밋 전후 처리 고려 필요

주의점: 트랜잭션과 이벤트 타이밍

🚨

잘못된 예시:

@Transactional
fun updateProduct(id: Long, data: ProductUpdateRequest) {
    productRepository.save(product)
    eventPublisher.publishEvent(event)  // 트랜잭션 커밋 전에 발행됨!
}

// 리스너에서 캐시 삭제 → 아직 DB 커밋 안 됨
// 다른 요청이 캐시 미스로 DB 조회 → 옛날 데이터 캐싱!
💡

해결: @TransactionalEventListener 사용

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handleProductUpdated(event: ProductUpdatedEvent) {
    // 트랜잭션 커밋 후에 실행됨
    redisTemplate.delete("product:${event.productId}")
}

전략 4: 수동 무효화 (관리 기능)

필요한 이유

  • 비개발자도 캐시를 갱신할 수 있어야 함
  • “왜 안 바뀌어요?” 문의 대응
  • 긴급 상황 대응

구현

@RestController
@RequestMapping("/admin/cache")
class CacheAdminController(
    private val redisTemplate: RedisTemplate<String, Any>,
    private val cacheManager: CacheManager
) {

    /**
     * 특정 상품 캐시 삭제
     */
    @DeleteMapping("/products/{id}")
    fun evictProductCache(@PathVariable id: Long): ResponseEntity<String> {
        val keys = listOf(
            "product:$id",
            "cache-aside:product:$id",
            "read-through:product:$id",
            "write-through:product:$id"
        )
        val deleted = redisTemplate.delete(keys)
        return ResponseEntity.ok("Deleted $deleted keys")
    }

    /**
     * 카테고리 관련 캐시 전체 삭제
     */
    @DeleteMapping("/categories/{id}")
    fun evictCategoryCache(@PathVariable id: Long): ResponseEntity<String> {
        val keys = listOf(
            "category:$id",
            "category-products:$id",
            "category-ranking:$id"
        )
        val deleted = redisTemplate.delete(keys)
        return ResponseEntity.ok("Deleted $deleted keys")
    }

    /**
     * 전체 캐시 삭제 (위험!)
     */
    @DeleteMapping("/all")
    fun evictAllCache(): ResponseEntity<String> {
        cacheManager.cacheNames.forEach { cacheName ->
            cacheManager.getCache(cacheName)?.clear()
        }
        return ResponseEntity.ok("All caches cleared")
    }
}

주의: KEYS 명령의 위험성

🚨

절대 운영에서 쓰지 마세요!

KEYS product:*

KEYS 명령은:

  • O(N) 복잡도로 모든 키를 순회
  • Redis가 싱글스레드라서 블로킹됨
  • 키가 많으면 Redis가 멈춤 → 장애
// 점진적 순회, 블로킹 없음
fun scanAndDelete(pattern: String) {
    val scanOptions = ScanOptions.scanOptions()
        .match(pattern)
        .count(100)
        .build()

    redisTemplate.execute { connection ->
        val cursor = connection.scan(scanOptions)
        cursor.forEach { key ->
            connection.del(key)
        }
    }
}

조합 전략

📦
TTL (보험)
모든 캐시에 설정, 실패 대비
📦
명시적 삭제 (기본)
변경 시 관련 캐시 삭제
📦
이벤트 기반 (확장)
복잡한 연관 관계 처리
📦
수동 무효화 (비상)
관리자용, 긴급 대응

예시: 상품 서비스

@Service
class ProductService(
    private val productRepository: ProductRepository,
    private val redisTemplate: RedisTemplate<String, Any>,
    private val eventPublisher: ApplicationEventPublisher
) {

    // 조회: TTL 30분 캐시
    fun getProduct(id: Long): Product? {
        val cacheKey = "product:$id"
        val cached = redisTemplate.opsForValue().get(cacheKey)
        if (cached != null) return cached as Product

        val product = productRepository.findById(id).orElse(null) ?: return null
        redisTemplate.opsForValue().set(cacheKey, product, Duration.ofMinutes(30))
        return product
    }

    // 수정: 명시적 삭제 + 이벤트 발행
    @Transactional
    fun updateProduct(id: Long, data: ProductUpdateRequest): Product {
        val product = productRepository.findById(id).orElseThrow()
        product.update(data)
        val saved = productRepository.save(product)

        // 1. 명시적 삭제 (기본)
        redisTemplate.delete("product:$id")

        // 2. 이벤트 발행 (연관 캐시 삭제용)
        eventPublisher.publishEvent(ProductUpdatedEvent(id, saved.categoryId))

        return saved
    }
}

// 이벤트 리스너: 연관 캐시 삭제
@Component
class ProductCacheListener(private val redisTemplate: RedisTemplate<String, Any>) {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    fun onProductUpdated(event: ProductUpdatedEvent) {
        redisTemplate.delete("category-products:${event.categoryId}")
        redisTemplate.delete("search-cache:recent")
        // 추가 연관 캐시...
    }
}

마치며

전략용도특징
TTL보험모든 캐시에 필수, 실패 대비
명시적 삭제기본변경 시 즉시 삭제, 단순
이벤트 기반확장복잡한 연관 관계 처리
수동 무효화비상관리자용, 긴급 대응
💡

핵심: 여러 전략을 조합하고, TTL을 보험으로 항상 설정하세요.

다음 편에서는 Thundering Herd와 Cache Stampede 문제, 그리고 해결책을 다룹니다.


시리즈 목차

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