콘텐츠로 이동

Bluetape4k Cache Part 4: Workshop 예제로 캐시 전략 감 잡기

Spring Boot 캐시 워크숍 작업대와 로봇 작업자, 서비스 블록, Redis, 로컬 캐시, 관측 지표
캐시 전략은 문서에서 이해하고, 예제에서 확인하고, 운영에서 다시 검증합니다.

이번 글은 실제로 실행해 볼 수 있는 예제 이야기입니다. bluetape4k-workshop에는 Spring Boot 기반 캐시 예제가 여러 개 있습니다.

목표는 “캐시가 좋다”를 반복하는 것이 아닙니다. 어떤 전략이 어떤 코드 모양을 만들고, 어떤 비용을 숨기고, 어떤 운영 계약을 요구하는지 보는 것입니다.

관련 예제는 아래에 있습니다.

예제역할
cache-caffeineSpring Cache + Caffeine 로컬 캐시
cache-redisSpring Data Redis + Lettuce
cache-resilienceRedis 장애 시 Caffeine 대체 경로

cache-caffeine 예제는 Spring Cache abstraction에 Caffeine을 연결합니다.

@Configuration
class CaffeineConfig {
@Bean
fun cacheManager(caffeine: Caffeine<Any, Any>): CacheManager {
return CaffeineCacheManager("cache:countries", "cache:cities").apply {
setCaffeine(caffeine)
}
}
@Bean
fun caffeineBean(): Caffeine<Any, Any> = caffeine {
maximumSize(10_000)
expireAfterWrite(30.minutes)
}
}

전체 소스: CaffeineConfig.kt

이 예제는 단일 인스턴스 read-heavy 캐시에 좋습니다. 빠르고 단순합니다. 단점도 단순합니다. 인스턴스가 둘이면 캐시도 둘입니다. 둘은 서로 대화하지 않습니다. 의외로 많은 문제가 “서로 대화하지 않음”에서 시작합니다.

cache-redis 예제는 Redis를 Spring Cache와 RedisTemplate에 연결합니다.

@Bean
fun redisCacheManager(connectionFactory: RedisConnectionFactory): CacheManager =
RedisCacheManager.builder(connectionFactory)
.cacheDefaults(
RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofDays(1))
)
.build()

전체 소스: LettuceRedisCacheConfiguration.kt

Redis는 여러 인스턴스가 같은 값을 보게 해줍니다. 대신 read path마다 remote hop이 있습니다. 그래서 Part 2에서 본 것처럼 hot read가 많으면 Near Cache가 후보가 됩니다.

정식 캐시 전략 예제: Exposed Workshop 11장

섹션 제목: “정식 캐시 전략 예제: Exposed Workshop 11장”

Read-through, write-through, write-behind는 application service가 DB 접근 순서를 직접 조립하는 패턴이 아닙니다. application은 cache map을 사용하고, cache가 loader 또는 writer를 통해 DB 접근을 소유해야 합니다.

exposed-workshop 11장의 01-cache-strategies는 이 계약을 그대로 구현합니다. UserCacheRepositoryREAD_WRITE_THROUGH_WITH_NEAR_CACHE, UserEventCacheRepositoryWRITE_BEHIND_WITH_NEAR_CACHE를 사용합니다. 실제 DB 읽기와 쓰기는 EntityMapLoaderEntityMapWriter가 담당합니다.

Exposed Workshop 11장의 캐시 loader와 writer 계약
Exposed Workshop 11장은 repository가 map만 호출하고, EntityMapLoader와 EntityMapWriter가 DB load/write를 소유합니다.

resilience: 캐시 장애를 서비스 장애로 키우지 않기

섹션 제목: “resilience: 캐시 장애를 서비스 장애로 키우지 않기”

cache-resilience는 Redis를 기본 캐시로 쓰고 Caffeine을 대체 경로로 둡니다.

suspend fun getProduct(id: String): String? {
val redisKey = "$REDIS_KEY_PREFIX$id"
return SuspendDecorators.ofSupplier {
redisTemplate.opsForValue().get(redisKey)
}
.withCircuitBreaker(circuitBreaker)
.withFallback { localCache.getIfPresent(id) }
.invoke()
}

전체 소스: ResilientProductService.kt

이 예제는 운영에서 바로 중요해집니다. 캐시는 성능을 위해 붙였는데, Redis가 잠깐 불안정해졌다고 서비스 전체가 같이 영향을 받으면 이상합니다. 캐시는 보조 인프라입니다. 보조 인프라가 서비스의 중심 장애가 되면 설계를 다시 봐야 합니다.

서비스에 캐시를 넣기 전에 최소한 아래 질문에는 답해야 합니다.

질문답해야 하는 이유
기준 지연 시간은?캐시가 실제로 개선하는지 알아야 함
hit ratio 목표는?hit이 낮으면 비용만 늘 수 있음
stale 허용 시간은?Near Cache와 write-behind의 핵심 계약
쓰기 실패 정책은?throw, retry, queue, best effort 중 선택
invalidate 범위는?단일 key, bulk, prefix, 전체 clear 구분
장애 시 대체 경로는?Redis 장애가 서비스 장애가 되지 않게 함
관측 지표는?hit/miss, latency, queue depth, circuit state

이 시리즈의 결론은 “무조건 Near Cache”가 아닙니다.

더 정확한 결론은 이렇습니다.

  • 단일 JVM hot read면 Caffeine이 가장 단순합니다.
  • 여러 인스턴스가 공유해야 하면 Redis 같은 remote cache가 필요합니다.
  • remote read가 병목이면 Near Cache를 검토합니다.
  • Exposed repository에 붙일 때는 read/write 전략을 분리합니다.
  • 장애 동작을 정하지 않은 캐시는 아직 설계가 끝난 게 아닙니다.

캐시는 성능 최적화이기도 하지만, 운영 계약이기도 합니다. 운영 계약을 대충 쓰면 나중에 아주 또렷하게 돌아옵니다. 보통 새벽에요.

댓글

GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.