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

이번 글은 실제로 실행해 볼 수 있는 예제 이야기입니다. bluetape4k-workshop에는 Spring Boot 기반 캐시 예제가 여러 개 있습니다.
목표는 “캐시가 좋다”를 반복하는 것이 아닙니다. 어떤 전략이 어떤 코드 모양을 만들고, 어떤 비용을 숨기고, 어떤 운영 계약을 요구하는지 보는 것입니다.

예제 위치
섹션 제목: “예제 위치”관련 예제는 아래에 있습니다.
| 예제 | 역할 |
|---|---|
| cache-caffeine | Spring Cache + Caffeine local cache |
| cache-redis | Spring Data Redis + Lettuce |
| cache-benchmark | No cache, Caffeine, Redis, Near Cache profile 비교 |
| cache-resilience | Redis 장애 시 Caffeine fallback |
Caffeine: 가장 가까운 캐시
섹션 제목: “Caffeine: 가장 가까운 캐시”cache-caffeine 예제는 Spring Cache abstraction에 Caffeine을 연결합니다.
@Configurationclass 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 캐시에 좋습니다. 빠르고 단순합니다. 단점도 단순합니다. 인스턴스가 둘이면 캐시도 둘입니다. 둘은 서로 대화하지 않습니다. 의외로 많은 문제가 “서로 대화하지 않음”에서 시작합니다.
Redis: 공유되는 캐시
섹션 제목: “Redis: 공유되는 캐시”cache-redis 예제는 Redis를 Spring Cache와 RedisTemplate에 연결합니다.
@Beanfun 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-benchmark는 ProductCacheService라는 같은 인터페이스 아래 여러 전략을 둡니다.
interface ProductCacheService { fun findById(id: Long): Product? fun save(product: Product): Product fun evict(id: Long) fun clearAll()}전체 소스: ProductCacheService.kt
구현체는 전략을 분리합니다.
| 구현체 | 의미 |
|---|---|
NoCacheService | DB baseline |
CaffeineService | local in-JVM cache |
RedisCacheService | remote distributed cache |
NearCacheService | local + Redis near cache |
WriteThroughService | 동기형 write-through: save 시 DB와 Redis를 같은 요청 경로에서 즉시 반영 |
ReadThroughService | cache 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-benchmark의 WriteThroughService는 동기 write-through입니다. save() 시 DB와 Redis 캐시를 같은 흐름에서 반영합니다.
반면 ReadThroughService는 DB 저장 후 cache update를 애플리케이션이 직접 관리하는 cache-aside 성격이 있고, WriteBehindService는 캐시 업데이트를 먼저 수행한 뒤 업데이트 DB 반영은 비동기로 넘기는 구성입니다.

Benchmark 결과
섹션 제목: “Benchmark 결과”cache-benchmark는 7개 profile을 같은 ProductCacheService 계약으로 비교합니다. 읽기/쓰기 모두 ops/s 기준이고 높을수록 좋습니다.

관련 소스: cache-benchmark README.ko.md, CaffeineBenchmark.kt, NearCacheBenchmark.kt, WriteBehindBenchmark.kt
읽기 처리량
섹션 제목: “읽기 처리량”| 프로파일 | 읽기 ops/s | No Cache 대비 |
|---|---|---|
| No Cache | ~8,200 | 1x |
| Caffeine | ~490,000 | 60x |
| Redis Cache | ~43,000 | 5x |
| Near Cache | ~465,000 | 57x |
| Read-Through | ~42,000 | 5x |
| Write-Through | ~41,000 | 5x |
| Write-Behind | ~42,000 | 5x |
쓰기 처리량
섹션 제목: “쓰기 처리량”| 프로파일 | 쓰기 ops/s | 비고 |
|---|---|---|
| No Cache | ~8,200 | DB만 |
| Caffeine | ~8,100 | DB write + local cache |
| Redis Cache | ~7,300 | DB write + Redis SET |
| Near Cache | ~7,200 | DB write + RLocalCachedMap PUT |
| Write-Through | ~5,600 | 동기 DB + Redis |
| Write-Behind | ~24,000 | cache 우선, DB는 비동기 flush |
이 표만 보면 Write-Behind가 왕처럼 보입니다. 하지만 왕관에는 약관이 붙습니다. 최종 일관성을 허용할 수 있고, flush 실패/재시도/종료 시 drain 정책을 정할 수 있을 때만 좋은 선택입니다.
NearCacheService: local과 remote 사이
섹션 제목: “NearCacheService: local과 remote 사이”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을 남길 수 있습니다.