코루틴 suspend 함수에서 Redis 캐시를 논블로킹으로 적용하는 모듈입니다. Spring Cache 어노테이션(
@Cacheable)이 suspend 함수에 직접 적용되지 않는 제약을 해결하기 위해 Lettuce 코루틴 API(RedisCoroutinesCommands)로 직접 캐시를 제어하는
LettuceSuspendedCache / LettuceSuspendedCacheManager를 구현하고, 데코레이터 패턴으로 Repository에 캐시 계층을 추가하는 방법을 학습합니다.
@Cacheable이 suspend 함수에 적용되지 않는 이유를 이해하고 Lettuce 코루틴 API로 대안을 구현한다.LettuceSuspendedCache<K, V>로 TTL 기반 캐시 get/put/evict/clear를 suspend 함수로 처리한다.- 데코레이터 패턴(
CachedCountrySuspendedRepository)으로 캐시 로직과 DB 접근 로직을 분리한다. newSuspendedTransaction과 Lettuce 코루틴 캐시를 조합해 캐시 히트 시 DB 트랜잭션을 열지 않는 구조를 만든다.
erDiagram
COUNTRIES {
INT id PK
CHAR(2) code UK
VARCHAR name
TEXT description
}
classDiagram
class CountrySuspendedRepository {
<<interface>>
+findByCode(code) CountryRecord?
+update(countryRecord) Int
+evictCacheAll()
}
class DefaultCountrySuspendedRepository {
+findByCode(code) CountryRecord?
+update(countryRecord) Int
+evictCacheAll()
}
class CachedCountrySuspendedRepository {
-delegate: CountrySuspendedRepository
-cacheManager: LettuceSuspendedCacheManager
-cache: LettuceSuspendedCache~String, CountryRecord~
+CACHE_NAME: "caches:country:code"
+findByCode(code) CountryRecord?
+update(countryRecord) Int
+evictCacheAll()
}
class LettuceSuspendedCacheManager {
-redisClient: RedisClient
-ttlSeconds: Long
-codec: RedisCodec
+getOrCreate(name, ttlSeconds) LettuceSuspendedCache
}
class LettuceSuspendedCache {
+name: String
+commands: RedisCoroutinesCommands
+ttlSeconds: Long?
+get(key) V?
+put(key, value)
+evict(key)
+clear()
}
CountrySuspendedRepository <|.. DefaultCountrySuspendedRepository
CountrySuspendedRepository <|.. CachedCountrySuspendedRepository
CachedCountrySuspendedRepository --> CountrySuspendedRepository : delegate
CachedCountrySuspendedRepository --> LettuceSuspendedCacheManager : uses
LettuceSuspendedCacheManager --> LettuceSuspendedCache : creates
class LettuceSuspendedCache<K: Any, V: Any>(
val name: String,
val commands: RedisCoroutinesCommands<String, V>, // Lettuce 코루틴 커맨드
private val ttlSeconds: Long? = null,
) {
private fun keyStr(key: K): String = "$name:$key"
suspend fun get(key: K): V? = commands.get(keyStr(key))
suspend fun put(key: K, value: V) {
if (ttlSeconds != null) {
commands.setex(keyStr(key), ttlSeconds, value) // TTL 적용
} else {
commands.set(keyStr(key), value)
}
}
suspend fun evict(key: K) = commands.del(keyStr(key))
suspend fun clear() {
commands.keys("$name:*")
.chunked(100)
.collect { keys -> commands.del(*keys.toTypedArray()) }
}
}class CachedCountrySuspendedRepository(
private val delegate: CountrySuspendedRepository, // DB 접근 구현체
private val cacheManager: LettuceSuspendedCacheManager,
): CountrySuspendedRepository {
companion object {
const val CACHE_NAME = "caches:country:code"
}
private val cache: LettuceSuspendedCache<String, CountryRecord> by lazy {
cacheManager.getOrCreate(name = CACHE_NAME, ttlSeconds = 60)
}
// Cache-Aside 패턴: 캐시 미스 시 delegate 조회 후 캐시 저장
override suspend fun findByCode(code: String): CountryRecord? =
cache.get(code) ?: delegate.findByCode(code)?.apply { cache.put(code, this) }
// Write-Invalidate 패턴: 갱신 전 캐시 무효화
override suspend fun update(countryRecord: CountryRecord): Int {
cache.evict(countryRecord.code)
return delegate.update(countryRecord)
}
override suspend fun evictCacheAll() = cache.clear()
}class DefaultCountrySuspendedRepository: CountrySuspendedRepository {
override suspend fun findByCode(code: String): CountryRecord? =
newSuspendedTransaction {
CountryTable.selectAll()
.where { CountryTable.code eq code }
.singleOrNull()
?.let { CountryRecord(code = it[CountryTable.code], name = it[CountryTable.name]) }
}
override suspend fun update(countryRecord: CountryRecord): Int =
newSuspendedTransaction {
CountryTable.update({ CountryTable.code eq countryRecord.code }) {
it[name] = countryRecord.name
it[description] = countryRecord.description
}
}
override suspend fun evictCacheAll() { /* 캐시 없음, no-op */ }
}flowchart TD
A[suspend findByCode 호출] --> B{LettuceSuspendedCache\ncache.get 히트?}
B -- 예 --> C[CountryRecord 즉시 반환\nDB 쿼리 없음]
B -- 아니오 --> D[delegate.findByCode 호출]
D --> E[newSuspendedTransaction 시작]
E --> F[CountryTable.selectAll WHERE code = ?]
F --> G[DB 결과 CountryRecord]
G --> H[cache.put 으로 Redis에 저장\nTTL 60초]
H --> I[CountryRecord 반환]
J[suspend update 호출] --> K[cache.evict 캐시 즉시 삭제]
K --> L[delegate.update 호출]
L --> M[newSuspendedTransaction]
M --> N[CountryTable.update]
N --> O[갱신 행 수 반환]
@Configuration
class LettuceSuspendedCacheConfig(
private val redisClient: RedisClient,
) {
@Bean
fun lettuceSuspendedCacheManager(): LettuceSuspendedCacheManager =
LettuceSuspendedCacheManager(
redisClient = redisClient,
ttlSeconds = 60L,
codec = LettuceBinaryCodecs.lz4Fory(), // LZ4 압축 + Fory 직렬화
)
}
@Configuration
class SuspendedRepositoryConfig {
@Bean
fun countrySuspendedRepository(
cacheManager: LettuceSuspendedCacheManager,
): CountrySuspendedRepository =
CachedCountrySuspendedRepository(
delegate = DefaultCountrySuspendedRepository(),
cacheManager = cacheManager,
)
}sequenceDiagram
participant Client
participant Cached as CachedCountrySuspendedRepository
participant Cache as LettuceSuspendedCache (Redis)
participant Default as DefaultCountrySuspendedRepository
participant DB
Client->>Cached: suspend findByCode("KR")
Cached->>Cache: suspend get("KR")
alt 캐시 히트
Cache-->>Cached: CountryRecord
Cached-->>Client: CountryRecord (DB 트랜잭션 없음)
else 캐시 미스
Cache-->>Cached: null
Cached->>Default: suspend findByCode("KR")
Default->>DB: newSuspendedTransaction { SELECT WHERE code='KR' }
DB-->>Default: CountryRecord
Default-->>Cached: CountryRecord
Cached->>Cache: suspend put("KR", CountryRecord, ttl=60s)
Cached-->>Client: CountryRecord
end
Client->>Cached: suspend update(countryRecord)
Cached->>Cache: suspend evict("KR")
Cache-->>Cached: 삭제 완료
Cached->>Default: suspend update(countryRecord)
Default->>DB: newSuspendedTransaction { UPDATE ... }
DB-->>Default: 갱신 행 수
Default-->>Cached: 갱신 행 수
Cached-->>Client: 갱신 행 수
| 항목 | Spring Cache (@Cacheable) |
LettuceSuspendedCache |
|---|---|---|
| suspend 함수 지원 | 미지원 (AOP 프록시 제약) | 지원 (코루틴 네이티브) |
| 캐시 제어 방식 | 선언적 어노테이션 | 명시적 코드 |
| TTL 설정 | RedisCacheConfiguration.entryTtl |
생성자 파라미터 |
| 직렬화 | RedisSerializationContext |
Lettuce RedisCodec |
| 트랜잭션 연동 | transactionAware() |
수동 조합 |
# Redis Testcontainer를 자동으로 기동합니다
./gradlew :09-spring:07-spring-suspended-cache:test
# 테스트 로그 요약
./bin/repo-test-summary -- ./gradlew :09-spring:07-spring-suspended-cache:testfindByCode("KR")두 번 연속 호출 시 두 번째에서newSuspendedTransaction이 실행되지 않음을 로그로 확인update()후findByCode()재호출 시cache.get이 null을 반환해 DB 재조회가 일어나는지 검증evictCacheAll()후CACHE_NAME:*패턴 키가 Redis에서 모두 삭제되는지 확인- TTL 60초 만료 후 자동으로 캐시 미스가 발생해 DB 재조회가 이루어지는지 테스트
- 코루틴 취소(cancellation) 발생 시
cache.put이 중단되어도 DB 상태에 영향 없음을 검증
LettuceSuspendedCache.clear()는keys커맨드를 사용하므로 프로덕션 대용량 환경에서는SCAN기반으로 교체 고려- Lettuce 코루틴 커맨드는 이벤트 루프에서 실행되므로 블로킹 코드 혼용 금지
cache.evict와delegate.update사이에 장애 발생 시 캐시만 삭제된 불일치 상태 처리 전략 수립 필요