콘텐츠로 이동

Bluetape4k Cache Part 3: Near Cache와 Exposed로 캐싱 전략 만들기

Exposed repository cache strategy workbench with robot workers and cache pipelines
DB 앞에 캐시를 놓는 순간, 성능 문제와 일관성 문제는 같은 설계 안으로 들어옵니다.

Exposed repository 앞에 캐시를 붙이는 일은 처음엔 간단해 보입니다.

  1. 먼저 캐시를 본다.
  2. 없으면 DB를 본다.
  3. 저장할 때 캐시도 갱신한다.

여기까지는 모두가 평화롭습니다. 그런데 “쓰기 실패는?”, “삭제는?”, “다른 인스턴스 local cache는?”, “batch update는?”, “읽기 모델과 쓰기 모델이 다르면?” 같은 질문이 나오면 분위기가 바뀝니다. 평화롭던 회의실에 갑자기 화이트보드가 하나 더 필요해집니다.

bluetape4k-exposed는 이 문제를 repository 계약과 cache strategy로 나눠 다룹니다. 캐시를 어디에 둘지, DB write와 어떤 순서로 묶을지, 실패를 어떻게 볼지 분리해서 선택하게 합니다.

JdbcCacheRepository strategy map with Redisson map loader, map writer, and Exposed DB
JdbcCacheRepository의 핵심은 Redisson map에 loader와 writer를 붙여 read/write 계약을 바꾸는 것입니다.

기본 계약은 JdbcCacheRepositoryR2dbcCacheRepository에 있습니다. 애플리케이션 입장에서는 get, put, invalidate를 호출하지만, 실제 동작은 선택한 cache mode와 write mode를 따릅니다.

interface JdbcCacheRepository<ID: Any, E: Serializable>: Closeable {
val table: IdTable<ID>
val cacheName: String
val cacheMode: CacheMode
val cacheWriteMode: CacheWriteMode
fun ResultRow.toEntity(): E
fun extractId(entity: E): ID
fun findByIdFromDb(id: ID): E?
fun get(id: ID): E?
fun getAll(ids: Collection<ID>): Map<ID, E>
fun put(id: ID, entity: E)
fun putAll(entities: Map<ID, E>, batchSize: Int = DEFAULT_BATCH_SIZE)
fun invalidate(id: ID)
fun invalidateAll(ids: Collection<ID>)
fun clear()
}

관련 소스: JdbcCacheRepository.kt, R2dbcCacheRepository.kt

여기서 중요한 점은 getput이 단순한 map operation이 아니라는 것입니다. 둘 다 repository 정책을 따릅니다. 이 정책을 어떻게 잡느냐에 따라 성능, 일관성, 장애 시 동작이 달라집니다.

AbstractJdbcRedissonRepositoryRMap 또는 RLocalCachedMap을 만들 때 Redisson의 loader/writer를 붙입니다.

protected open val mapLoader: EntityMapLoader<ID, E> by lazy {
ExposedEntityMapLoader(table) { toEntity() }
}
protected val mapWriter: EntityMapWriter<ID, E>? by lazy {
when (config.cacheMode) {
RedissonCacheConfig.CacheMode.READ_ONLY -> null
RedissonCacheConfig.CacheMode.READ_WRITE ->
ExposedEntityMapWriter(
entityTable = table,
updateBody = { stmt, entity ->
with(this@AbstractJdbcRedissonRepository) { stmt.updateEntity(entity) }
},
batchInsertBody = { entity ->
val stmt = this
with(this@AbstractJdbcRedissonRepository) { stmt.insertEntity(entity) }
},
writeMode = config.writeMode,
)
}
}

전체 소스: JdbcCacheRepository.kt, AbstractJdbcRedissonRepository.kt

그래서 get은 map lookup처럼 보이지만, cache miss에서는 loader가 DB에서 읽어 cache를 채웁니다. put도 cache put처럼 보이지만, write mode가 WRITE_THROUGH이면 writer가 DB에 즉시 반영하고, WRITE_BEHIND이면 writer queue가 비동기로 반영합니다. 이게 cache-aside와 다른 지점입니다. 애플리케이션 코드가 DB 저장 후 cache.put 순서를 직접 조립하지 않습니다.

Read-through는 cache miss에서 EntityMapLoader가 DB를 읽어 cache를 채웁니다. exposed-workshop 11장은 UserCredentialsCacheRepository로 이 경로를 보여줍니다.

@Repository
class UserCredentialsCacheRepository(
redissonClient: RedissonClient,
) : AbstractJdbcRedissonRepository<String, UserCredentialsRecord>(
redissonClient = redissonClient,
config = RedissonCacheConfig.READ_ONLY_WITH_NEAR_CACHE.copy(
name = "exposed:user-credentials",
),
) {
override val table = UserCredentialsTable
override fun ResultRow.toEntity() = toUserCredentialsRecord()
override fun extractId(entity: UserCredentialsRecord): String = entity.id
}

전체 소스: UserCredentialsCacheRepository.kt, UserCredentialsCacheRepositoryTest.kt

읽기가 많은 데이터에 잘 맞습니다. 첫 요청은 DB에서 읽고, 다음 요청은 캐시를 봅니다. 단, write path에서 invalidate를 제대로 해야 합니다. 안 그러면 과거의 캐시와 현재의 DB가 서로 다른 말을 합니다.

Write-through는 put/putAll이 writer-backed Redisson map으로 들어가고, ExposedEntityMapWriter가 DB까지 같은 operation에서 반영합니다. exposed-workshop 11장의 UserCacheRepository는 read-through와 write-through를 함께 사용합니다.

@Repository
class UserCacheRepository(
redissonClient: RedissonClient,
) : AbstractJdbcRedissonRepository<Long, UserRecord>(
redissonClient = redissonClient,
config = RedissonCacheConfig.READ_WRITE_THROUGH_WITH_NEAR_CACHE.copy(
name = "exposed:users",
),
) {
override val table: UserTable = UserTable
override fun ResultRow.toEntity(): UserRecord = toUserRecord()
override fun extractId(entity: UserRecord): Long = entity.id
override fun BatchInsertStatement.insertEntity(entity: UserRecord) {
this[UserTable.username] = entity.username
this[UserTable.firstName] = entity.firstName
this[UserTable.lastName] = entity.lastName
}
override fun UpdateStatement.updateEntity(entity: UserRecord) {
this[UserTable.username] = entity.username
this[UserTable.firstName] = entity.firstName
this[UserTable.lastName] = entity.lastName
}
}

관련 예제: UserCacheRepository.kt, UserCacheRepositoryTest.kt

장점은 쓰기 직후 읽기의 최신성이 좋다는 점입니다. 쓰기가 끝나면 캐시도 최신입니다. 단점은 쓰기 latency에 cache write 비용이 들어온다는 점입니다. 성능 그래프에서 putSingle이 read보다 느린 이유가 여기서도 보입니다. 쓰기는 공짜가 아닙니다. 청구서는 latency로 옵니다.

Write-behind는 같은 writer를 쓰되 Redisson write mode가 WRITE_BEHIND입니다. put은 cache 쪽에 먼저 반영되고, DB write는 큐에서 비동기로 flush됩니다.

@Repository
class UserEventCacheRepository(
redissonClient: RedissonClient,
) : AbstractJdbcRedissonRepository<Long, UserEventRecord>(
redissonClient = redissonClient,
config = RedissonCacheConfig.WRITE_BEHIND_WITH_NEAR_CACHE.copy(
name = "exposed:user-events",
),
) {
override val table: UserEventTable = UserEventTable
override fun ResultRow.toEntity(): UserEventRecord = toUserEventRecord()
override fun extractId(entity: UserEventRecord): Long = entity.id
override fun BatchInsertStatement.insertEntity(entity: UserEventRecord) {
this[UserEventTable.username] = entity.username
this[UserEventTable.eventType] = entity.eventType
this[UserEventTable.eventTime] = entity.eventTime
}
}

전체 소스: UserEventCacheRepository.kt, UserEventCacheRepositoryTest.kt

Write-behind는 쓰기 latency를 낮출 수 있습니다. 대신 설계해야 할 것이 늘어납니다.

  • queue overflow 시 어떻게 할지
  • close 시 pending write를 flush할지
  • 같은 key의 연속 update를 어떻게 병합할지
  • remove 후 stale read를 어떻게 막을지
  • retry 중복이 DB upsert와 충돌하지 않는지

여기서부터 캐시는 “빠른 Map”이 아니라 작은 분산 시스템입니다. 작은 분산 시스템은 작게 실패해야 합니다. 크게 실패하면 이름만 작습니다.

bluetape4k-exposed는 local/remote/near cache와 write strategy를 분리해서 표현합니다.

관련 소스: CacheMode.kt, CacheWriteMode.kt, LocalCacheConfig.kt

이 분리는 꼭 필요합니다. “어디에 캐시할 것인가”와 “언제 쓸 것인가”는 다른 질문입니다. 같은 Redis를 쓰더라도 read-through, write-through, write-behind는 운영 의미가 다릅니다.

질문
cache locationlocal Caffeine, remote Redis, near cache
cache read pathread-through loader
write modeREAD_ONLY, WRITE_THROUGH, WRITE_BEHIND
API shapeJDBC, suspended JDBC, R2DBC
consistency target상대적으로 강한 일관성, eventual consistency, best effort fallback

exposed-workshop 11장의 04-benchmark는 cache strategy를 실제 수치로 비교합니다. AverageTime 기준이라 낮을수록 좋습니다.

Exposed cache benchmark chart comparing no cache, read-through, and write-through latency
읽기 중심 워크로드에서는 cache strategy 차이가 큽니다. 쓰기 중심에서는 DB write 자체가 다시 주인공이 됩니다.

관련 소스: CacheStrategyComparisonBenchmark.kt, ReadThroughCacheBenchmark.kt, BENCHMARK-REPORT.md

전략워크로드payload평균
NO_CACHEREAD_HEAVY256B517.520 us/op
READ_THROUGHREAD_HEAVY256B94.320 us/op
WRITE_THROUGHREAD_HEAVY256B52.103 us/op
NO_CACHEWRITE_HEAVY256B507.766 us/op
READ_THROUGHWRITE_HEAVY256B468.735 us/op
WRITE_THROUGHWRITE_HEAVY256B445.363 us/op

Read-heavy에서는 READ_THROUGH가 baseline보다 약 5.5배, WRITE_THROUGH가 약 9.9배 빠릅니다. 반대로 write-heavy에서는 차이가 1.1배 수준으로 줄어듭니다. 캐시는 read path에서 돈을 벌고, write path에서 영수증을 받습니다.

Read-through 단건 benchmark도 같이 봐야 합니다.

벤치마크payload평균
dbOnlyRead256B0.001 us/op
readThroughCacheHit256B0.003 us/op
readThroughCacheMiss256B0.119 us/op

hit은 싸고, miss는 invalidate와 reload 때문에 훨씬 비쌉니다. 그래서 Near Cache의 핵심 지표는 여전히 hit ratio입니다. 캐시가 맞히면 영웅이고, 자주 틀리면 아주 부지런한 장식품입니다.

전략잘 맞는 경우조심할 점
Read-through읽기가 많고 write가 적음write 후 invalidate 누락
Write-through쓰기 직후 읽기 최신성이 중요write latency 증가
Write-behindwrite burst를 흡수해야 함queue, retry, flush, stale read
NearCache + read-throughhot read가 명확함L1 hit ratio와 invalidation
NearCache + write-behindlocal 반응성을 크게 원함장애 시 의미론을 문서화해야 함

다음 글에서는 이론을 잠깐 내려놓고 bluetape4k-workshop의 실전 예제로 갑니다. 코드는 늘 친절합니다. 단지 우리가 급하게 읽을 뿐입니다.

댓글

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