Bluetape4k Cache Part 1: 캐시 모듈은 왜 이렇게 생겼나

bluetape4k-cache는 “Map 하나 감싸면 되지 않나?”라는 질문에서 출발합니다. 그리고 운영 환경을 만나는 순간, 그 질문이 생각보다 작지 않다는 사실을 다룹니다.
로컬 캐시는 빠릅니다. Redis는 상태를 공유합니다. JCache에는 표준 API가 있습니다. Kotlin coroutine 코드에는 suspend 경로가 필요합니다. remote cache가 잠깐 불안정해지면 재시도와 fallback도 필요합니다.
이 조각들을 애플리케이션마다 손으로 붙이면, 어느 날 캐시 코드가 도메인 코드보다 더 많은 자리를 차지합니다. 그때부터 캐시는 성능 최적화가 아니라 운영 복잡도가 됩니다.
그래서 bluetape4k의 Cache 모듈은 “캐시 구현체 모음”으로 시작하지 않습니다. 먼저 캐시를 다루는 공통 언어를 잡고, 그 위에 provider별 구현체를 붙입니다.

cache-core가 공통 계약을 잡고, provider 모듈들이 같은 사용 경험을 공유합니다.큰 그림
섹션 제목: “큰 그림”출발점은 cache-core입니다. provider가 무엇이든 애플리케이션 코드가 비슷한 모양으로 캐시를 읽고, 저장하고, 무효화하고, 실패를 다루게 만드는 층입니다.
| 영역 | 역할 |
|---|---|
| JCache helpers | JSR-107 스타일 캐시 설정과 provider 초기화 |
SuspendCache | coroutine 코드에서 자연스럽게 쓰는 suspend 캐시 계약 |
| Memoizer | 함수 결과를 sync, async, suspend 방식으로 캐싱 |
| NearCache interfaces | L1 local + L2 backing cache 구조를 공통 API로 표현 |
| Resilient decorator | retry, fallback, 통계를 캐시 구현 바깥에서 붙임 |
provider 모듈은 그 계약 뒤에서 실제 저장소와 통신합니다.
| 모듈 | 대표 역할 |
|---|---|
cache-core | Caffeine, Cache2k, Ehcache, JCache, NearCache 공통 API |
cache-lettuce | Redis/Lettuce 기반 JCache, RESP3 NearCache |
cache-redisson | Redisson 기반 JCache, RLocalCachedMap NearCache |
cache-hazelcast | Hazelcast 기반 JCache, IMap NearCache |
전체 소스: bluetape4k-projects/cache
로컬 캐시: 빠른데 혼자 삽니다
섹션 제목: “로컬 캐시: 빠른데 혼자 삽니다”로컬 캐시는 JVM 안에 있습니다. 네트워크 왕복이 없으니 빠르고, 설정도 단순합니다.
import io.bluetape4k.cache.caffeine.caffeineimport java.time.Duration
val cache = caffeine { maximumSize(10_000) expireAfterWrite(Duration.ofMinutes(10))}.build<String, Any>()전체 소스: cache-core README
한계도 분명합니다. 로컬 캐시는 “내 JVM”만 압니다. 서버가 둘이면 서랍도 둘입니다. 한쪽 서랍에 넣은 값을 다른 쪽 서랍이 안다고 믿으면, 그때부터 장애 회고가 문학 장르가 됩니다.
SuspendCache: coroutine 코드에 맞춘 계약
섹션 제목: “SuspendCache: coroutine 코드에 맞춘 계약”Kotlin 서비스에서 cache miss가 나면 DB를 읽거나 remote API를 호출하는 경우가 많습니다. 그 경로가 suspend라면 캐시 API도 suspend여야 합니다.
import io.bluetape4k.cache.jcache.CaffeineSuspendJCache
val cache = CaffeineSuspendJCache<String, String>("sessions")
cache.put("token:1", "user-1")val userId = cache.get("token:1")관련 소스: CaffeineSuspendJCache.kt
Memoizer: 함수 결과를 캐시로 승격하기
섹션 제목: “Memoizer: 함수 결과를 캐시로 승격하기”Memoizer는 “이 함수는 같은 입력에 같은 결과를 자주 낸다”는 성질을 API로 끌어올립니다. 호출하는 쪽은 함수를 그대로 쓰고, 반복 계산이나 반복 조회는 캐시가 맡습니다.
import io.bluetape4k.cache.memoizer.suspendMemoizer
val userSummary = suspendMemoizer<Long, String> { userId -> loadUserSummaryFromDatabase(userId)}
val summary = userSummary(42L)관련 소스: Memoizer.kt, LettuceMemoizer.kt
NearCache: 빠른 서랍과 공유 창고를 같이 쓰기
섹션 제목: “NearCache: 빠른 서랍과 공유 창고를 같이 쓰기”NearCache는 L1 local cache와 L2 back cache를 함께 씁니다. 자주 읽는 값은 가까운 JVM에서 처리하고, 공유 상태는 remote cache에 둡니다.
import io.bluetape4k.cache.LettuceCaches
val cache = LettuceCaches.nearCache<String>(redisClient) { cacheName = "users" maxLocalSize = 10_000 recordStats = true}
cache.put("42", "Debop")val name = cache.get("42")NearCacheOperations는 get, put, remove, clearLocal, clearAll, stats 같은 공통 동작을 제공합니다. provider가 Lettuce든 Redisson이든 Hazelcast든, 애플리케이션 코드는 같은 모양으로 접근합니다.
관련 소스: NearCacheOperations.kt, LettuceCaches.kt
Resilience는 장식이 아닙니다
섹션 제목: “Resilience는 장식이 아닙니다”캐시는 성능 기능처럼 보입니다. 하지만 remote cache를 붙이는 순간 장애 전파 경로도 됩니다. Redis가 잠깐 불안정해졌을 때 모든 요청이 같이 영향을 받으면 “캐시를 붙였더니 더 느려졌다”가 아닙니다. “캐시가 장애 증폭기가 됐다”에 가깝습니다.
그래서 bluetape4k는 resilience decorator를 제공합니다.
val resilient = cache.withResilience { retryMaxAttempts = 5 retryWaitDuration = Duration.ofMillis(200) getFailureStrategy = GetFailureStrategy.RETURN_FRONT_OR_NULL}관련 소스: ResilientNearCacheDecorator.kt, NearCacheResilienceConfig.kt
Cache 모듈이 정리하려는 문제는 단순합니다.
- 로컬 캐시는 빠르게 씁니다.
- remote cache는 공유 상태를 맡깁니다.
- suspend API는 coroutine 코드와 맞춥니다.
- NearCache는 두 세계를 연결합니다.
- resilience는 운영 중 “잠깐만요”를 “전체 장애”로 키우지 않게 합니다.
다음 글에서는 NearCache만 따로 꺼내 봅니다. remote cache만으로 부족한 이유, Redisson의 Pub/Sub 방식과 Lettuce의 RESP3 CLIENT TRACKING 방식, 그리고 benchmark 숫자를 같이 보겠습니다. 숫자는 가끔 냉정합니다. 그래서 좋습니다.
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.