콘텐츠로 이동

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

Spring Boot cache workshop workbench with robot workers, service blocks, Redis, local cache, and benchmark gauges
캐시 전략은 문서에서 이해하고, 예제에서 확인하고, 운영에서 다시 검증합니다.

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

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

Workshop cache profiles comparing no cache, Caffeine, Redis, Near Cache, and benchmark path
같은 Product service도 cache profile에 따라 성능과 일관성 계약이 달라집니다.

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

예제역할
cache-caffeineSpring Cache + Caffeine local cache
cache-redisSpring Data Redis + Lettuce
cache-benchmarkNo cache, Caffeine, Redis, Near Cache profile 비교
cache-resilienceRedis 장애 시 Caffeine fallback

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가 후보가 됩니다.

cache-benchmark: 같은 인터페이스, 다른 전략

섹션 제목: “cache-benchmark: 같은 인터페이스, 다른 전략”

cache-benchmarkProductCacheService라는 같은 인터페이스 아래 여러 전략을 둡니다.

interface ProductCacheService {
fun findById(id: Long): Product?
fun save(product: Product): Product
fun evict(id: Long)
fun clearAll()
}

전체 소스: ProductCacheService.kt

구현체는 전략을 분리합니다.

구현체의미
NoCacheServiceDB baseline
CaffeineServicelocal in-JVM cache
RedisCacheServiceremote distributed cache
NearCacheServicelocal + Redis near cache
WriteThroughService동기형 write-through: save 시 DB와 Redis를 같은 요청 경로에서 즉시 반영
ReadThroughServicecache miss 시 DB load
WriteBehindService캐시는 즉시 갱신하고, update의 DB 반영은 비동기로 수행

관련 소스: NoCacheService.kt, CaffeineService.kt, RedisCacheService.kt, NearCacheService.kt, ReadThroughService.kt, WriteThroughService.kt, WriteBehindService.kt

이 구조가 좋은 이유는 benchmark와 production code가 같은 질문을 공유한다는 점입니다. 같은 인터페이스 뒤에 전략을 바꿔 끼우면, 성능 숫자와 코드 복잡도를 나란히 볼 수 있습니다.

  • 캐시가 없으면 baseline은 얼마인가?
  • local cache만으로 충분한가?
  • Redis 공유가 필요한가?
  • Near Cache가 read path를 충분히 줄이는가?
  • write-through 비용은 받아들일 수 있는가?

단, cache-benchmarkWriteThroughService는 동기 write-through입니다. save() 시 DB와 Redis 캐시를 같은 흐름에서 반영합니다.

반면 ReadThroughService는 DB 저장 후 cache update를 애플리케이션이 직접 관리하는 cache-aside 성격이 있고, WriteBehindService는 캐시 업데이트를 먼저 수행한 뒤 업데이트 DB 반영은 비동기로 넘기는 구성입니다.

Workshop cache-benchmark strategies and their read/write contracts
동일 인터페이스 아래에서 전략마다 쓰기 계약과 동기화 시점이 다릅니다. 운영에서 안전도를 먼저 가늠해야 할 지점입니다.

cache-benchmark는 7개 profile을 같은 ProductCacheService 계약으로 비교합니다. 읽기/쓰기 모두 ops/s 기준이고 높을수록 좋습니다.

Workshop cache benchmark profiles comparing read and write throughput
읽기에서는 Caffeine과 Near Cache가 압도적이고, 쓰기에서는 Write-Behind가 가장 빠릅니다. 대신 의미론은 더 신중해야 합니다.

관련 소스: cache-benchmark README.ko.md, CaffeineBenchmark.kt, NearCacheBenchmark.kt, WriteBehindBenchmark.kt

프로파일읽기 ops/sNo Cache 대비
No Cache~8,2001x
Caffeine~490,00060x
Redis Cache~43,0005x
Near Cache~465,00057x
Read-Through~42,0005x
Write-Through~41,0005x
Write-Behind~42,0005x
프로파일쓰기 ops/s비고
No Cache~8,200DB만
Caffeine~8,100DB write + local cache
Redis Cache~7,300DB write + Redis SET
Near Cache~7,200DB write + RLocalCachedMap PUT
Write-Through~5,600동기 DB + Redis
Write-Behind~24,000cache 우선, DB는 비동기 flush

이 표만 보면 Write-Behind가 왕처럼 보입니다. 하지만 왕관에는 약관이 붙습니다. 최종 일관성을 허용할 수 있고, flush 실패/재시도/종료 시 drain 정책을 정할 수 있을 때만 좋은 선택입니다.

Near Cache 예제는 Redisson의 local cached map을 사용합니다.

class NearCacheService(
private val productRepository: ProductRepository,
redissonClient: RedissonClient,
) : ProductCacheService {
companion object : KLoggingChannel() {
const val CACHE_NAME = "products-near-cache"
}
}

전체 소스: NearCacheService.kt

코드는 짧아 보이지만 의미는 큽니다. hot read는 local에서 끝내고, remote cache는 공유와 invalidation을 담당합니다. 여기서 중요한 것은 “Near Cache를 썼다”가 아니라 “L1 hit이 충분히 나온다”입니다. hit이 안 나오면 작업대 위 서랍을 샀는데 계속 창고에 뛰어가는 상황입니다.

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

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

cache-resilience는 Redis를 primary cache로 쓰고 Caffeine을 fallback으로 둡니다.

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가 잠깐 불안정해졌다고 서비스 전체가 같이 영향을 받으면 이상합니다. 캐시는 보조 인프라입니다. 보조 인프라가 서비스의 중심 장애가 되면 설계를 다시 봐야 합니다.

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

질문답해야 하는 이유
baseline latency는?캐시가 실제로 개선하는지 알아야 함
hit ratio 목표는?hit이 낮으면 비용만 늘 수 있음
stale 허용 시간은?Near Cache와 write-behind의 핵심 계약
write 실패 정책은?throw, retry, queue, best effort 중 선택
invalidate 범위는?단일 key, bulk, prefix, 전체 clear 구분
장애 시 fallback은?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을 남길 수 있습니다.