콘텐츠로 이동

Bluetape4k Exposed Part 5: 실전 예제 - 다양한 캐시 전략, 멀티테넌시

운영 예제 작업대에서 작은 로봇 작업자들이 서비스, 캐시, 메트릭, 데이터 파이프를 연결하는 3D diorama
운영 예제의 목표는 Exposed의 SQL 제어력을 유지하면서 캐시, tenant, outbox를 애플리케이션 기능으로 바로 쓰게 만드는 것입니다.

시리즈 마지막 글에서는 실전 예제를 보겠습니다. 제가 bluetape4k-exposed에서 가장 신경 쓴 부분은 Exposed의 장점인 SQL DSL과 명시적인 transaction 제어를 유지하면서, 서비스 코드에서 반복되는 운영 기능을 쉽게 붙이는 것이었습니다. 캐시 전략, 멀티테넌시, outbox/idempotency는 모두 직접 만들 수도 있습니다. 문제는 직접 만들기 시작하면 repository, service, interceptor, scheduler, metric 코드가 금방 흩어진다는 점입니다.

그래서 저는 이 기능들을 “Exposed를 쓰는 애플리케이션에 자연스럽게 붙일 수 있는 확장”으로 정리했습니다. Spring Boot에서는 설정을 줄이고, cache module에서는 read/write 전략을 repository API로 바꾸며, 멀티테넌시 예제에서는 tenant별 schema/database 전환을 transaction 진입점에 모았습니다. outbox/idempotency도 같은 맥락입니다. 복잡한 운영 기능을 프레임워크처럼 크게 만들기보다, Exposed transaction 위에 얇게 얹어서 바로 테스트하고 벤치마크할 수 있게 만드는 것이 목표였습니다.

HTTP request에서 Spring Boot wiring, cache strategy, tenant context, outbox idempotency, repository layer, observability를 거쳐 measured service로 가는 production boundary diagram
Spring wiring, cache, tenant context, outbox, observability는 Exposed repository와 transaction 경계를 중심으로 조립됩니다.

먼저 Spring Boot 자동 설정입니다. 실제 애플리케이션에서는 Exposed Database, transaction manager, repository scan, Entity wiring을 매번 손으로 맞추게 됩니다. 작은 예제에서는 괜찮지만, 서비스가 늘어나면 같은 설정 코드를 계속 복사하게 됩니다. 제가 Spring Boot 자동 설정을 넣은 이유는 이 반복을 줄이기 위해서입니다.

확장역할
exposed-spring-boot-jdbcExposed DAO Entity 기반 Spring Data JDBC repository
exposed-spring-boot-r2dbcExposed R2DBC DSL 기반 coroutine repository
batch-exposedSpring Batch와 Exposed 연동
exposed-spring-modulithExposed 기반 Spring Modulith JDBC 이벤트 발행

JDBC 쪽은 Spring Data 스타일 repository와 PartTree query를 사용할 수 있게 했습니다.

interface UserRepository : ExposedJdbcRepository<User, Long> {
fun findByName(name: String): List<User>
fun findByAgeGreaterThan(age: Int): List<User>
fun findByEmailContaining(keyword: String): List<User>
}

R2DBC 쪽은 coroutine repository와 Flow streaming을 전면에 둔 형태로 만들었습니다.

interface UserRepository : ExposedR2dbcRepository<User, Long> {
suspend fun findByName(name: String): List<User>
suspend fun countByAge(age: Int): Long
}

자동 설정의 목적은 Spring Data처럼 익숙한 사용성을 제공하되, Exposed DSL과 transaction 제어력을 잃지 않는 것입니다. repository interface는 얇게 유지하고, 실제 쿼리와 transaction 선택은 Exposed의 방식으로 가져갑니다. 이렇게 하면 service 코드는 Spring Boot 애플리케이션답게 단순해지고, 성능이 중요한 지점에서는 SQL DSL로 바로 내려갈 수 있습니다.

캐시 전략: Cache Aside와 CacheWriteMode를 구분한다

섹션 제목: “캐시 전략: Cache Aside와 CacheWriteMode를 구분한다”

캐시는 Exposed repository 위에 얹었을 때 효과가 가장 분명하게 보입니다. service 코드는 같은 repository API를 호출하지만, 아래에서는 local cache, Redis, near cache, read-through, write-through, write-behind 전략을 바꿔볼 수 있습니다. exposed-cache에는 Caffeine, Lettuce, Redisson 기반 캐시 repository가 같은 방식으로 동작하도록 공통 인터페이스와 설정을 두었습니다.

이 방식의 장점은 단순합니다. 성능을 끌어올리려고 service마다 cache-aside 코드를 복사하지 않아도 됩니다. 읽기 중심 서비스라면 near cache로 DB와 Redis 왕복을 줄이고, 쓰기 burst가 큰 서비스라면 write-behind로 요청 처리 시간을 줄이는 식으로 workload에 맞춰 선택할 수 있습니다.

CacheMode의미
LOCALCaffeine 같은 인프로세스 캐시. 빠르지만 JVM 간 공유 없음
REMOTERedis 같은 원격 캐시. 인스턴스 간 공유
NEAR_CACHEL1 local + L2 Redis. hot read에서 네트워크 왕복 감소

여기서 헷갈리기 쉬운 부분이 Cache Aside와 CacheWriteMode입니다. Cache Aside는 보통 service 코드가 DB와 캐시를 둘 다 직접 다룹니다. 예를 들어 DB에 저장한 뒤 cache invalidate/put을 호출하거나, cache miss 때 service가 DB를 읽고 cache에 채웁니다. 반면 제가 만든 JdbcCacheRepository/R2dbcCacheRepository 계열의 CacheWriteMode는 repository API 안에서 DB 반영까지 맡습니다. 사용하는 쪽에서는 cache repository에 put() 또는 save()를 호출하지만, 모드에 따라 뒷단 DB 작업이 같이 일어납니다.

그래서 쓰기 전략은 이렇게 읽어야 합니다.

CacheWriteMode읽기쓰기
READ_ONLYmiss 시 DB에서 읽고 캐싱cache만 갱신. DB persistence로 기대하면 안 됨
WRITE_THROUGHread-throughcache에 쓰면 DB에도 동기 쓰기
WRITE_BEHINDread-throughqueue 수락 후 cache에 반영하고, DB는 비동기로 flush

구현도 이 모델을 그대로 따릅니다. 단순화하면 WRITE_THROUGH는 cache와 DB를 같은 호출에서 처리하고, WRITE_BEHIND는 DB 쓰기를 queue에 넣은 뒤 cache를 갱신합니다.

when (config.writeMode) {
CacheWriteMode.WRITE_THROUGH -> {
cache.put(key, entity)
writeToDb(id, entity)
}
CacheWriteMode.WRITE_BEHIND -> {
writeBehindQueue.trySend(id to entity).getOrThrow()
cache.put(key, entity)
}
else -> cache.put(key, entity) // READ_ONLY
}

WRITE_THROUGH는 “cache에만 썼으니 DB는 모른다”가 아닙니다. cache repository에 쓴다는 API 모양을 사용하더라도, 그 repository가 persistence 작업을 같이 맡습니다. 이 점을 Cache Aside와 섞어 쓰면 “DB 저장은 서비스가 따로 해야 하나?”라는 오해가 생기기 쉽습니다.

그래서 벤치마크도 같은 기준으로 만들었습니다. bluetape4k-workshop/spring-boot/cache-benchmark에서는 H2와 Redis Testcontainers로 7가지 cache profile을 비교합니다.

Cache read throughput chart showing Caffeine about 490000 ops per second and Near Cache about 465000 ops per second versus No Cache about 8200
캐시가 warm된 읽기 흐름에서는 Caffeine과 Near Cache가 No Cache 대비 약 57-60배 높았습니다.
프로파일읽기 ops/s기준 대비
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

쓰기 흐름은 따로 봐야 합니다. 읽기에서 빠른 전략이 쓰기에서도 항상 좋은 것은 아닙니다.

Cache write throughput chart showing Write Behind about 24000 ops per second versus No Cache about 8200 and Write Through about 5600
쓰기에서는 Write-Behind가 약 24,000 ops/s로 가장 높지만, 최종 일관성을 받아들일 수 있어야 합니다.
프로파일쓰기 ops/s해석
No Cache~8,200DB만
Caffeine~8,100DB 쓰기 + 로컬 캐시
Redis Cache~7,300DB 쓰기 + Redis SET
Near Cache~7,200DB 쓰기 + near cache update
Write-Through~5,600DB + Redis 동기 쓰기
Write-Behind~24,000cache에는 즉시 쓰고, DB에는 비동기로 flush

Write-Behind는 빠릅니다. 이 모드는 요청 경로에서 DB 쓰기 시간을 분리하기 때문에 throughput을 크게 올릴 수 있습니다. 다만 선택 기준은 분명해야 합니다. 주문 확정처럼 즉시 영속성이 필요한 흐름보다는, 조회 캐시 warm-up이나 지연 저장을 허용할 수 있는 흐름에 맞습니다. flush queue, shutdown drain, idempotency를 같이 설계하면 “빠르지만 불안한 캐시”가 아니라 workload에 맞춘 쓰기 최적화가 됩니다.

멀티테넌시: header에서 schema까지

섹션 제목: “멀티테넌시: header에서 schema까지”

멀티테넌시는 다른 ORM이나 plain JDBC로도 구현할 수 있습니다. 다만 실전에서는 tenant 식별, context 전파, schema/database 선택, cache key, 테스트가 서로 다른 계층에 흩어지기 쉽습니다. 저는 이 부분을 Exposed transaction 진입점에 최대한 모으는 편이 낫다고 봅니다. exposed-workshop/10-multi-tenant에서는 schema 기반 tenant 분리, database-per-tenant, Spring MVC/Virtual Threads/WebFlux/Ktor 방식을 나란히 둡니다.

기본 흐름은 같습니다. 구현 방식을 바꿔가며 이 흐름을 얼마나 적은 코드로 유지할 수 있는지 보면 됩니다.

  1. X-TENANT-ID 헤더에서 tenant를 식별합니다.
  2. request context에 tenant를 전파합니다.
  3. transaction 진입 시 schema 또는 datasource를 선택합니다.
  4. tenant A 요청에서 tenant B 데이터가 나오지 않는지 테스트합니다.

환경별 컨텍스트 전파 방식은 다릅니다.

환경컨텍스트스키마 전환
Spring MVCThreadLocalAOP @Before
Virtual ThreadsScopedValueAOP @Before
WebFlux + CoroutinesReactor Context + CoroutineContext.ElementnewSuspendedTransactionWithTenant
Ktorcoroutine context/pluginrequest plugin + transaction helper

여기에 캐시가 붙으면 규칙이 하나 더 생깁니다.

cache key에는 tenant 정보가 들어가야 합니다.

user:42는 단일 tenant 서비스에서는 괜찮을 수 있습니다. 멀티테넌트에서는 tenant:korean:user:42처럼 tenant가 키의 일부여야 합니다. 이 규칙을 repository/cache helper에 모아두면 service 코드에서는 tenant별 캐시 키를 매번 직접 조립하지 않아도 됩니다. Exposed transaction helper는 schema/database 선택을 맡고, cache helper는 tenant key 규칙을 맡습니다. 역할을 이렇게 나누면 멀티테넌시가 애플리케이션 전체에 번지는 일을 줄일 수 있습니다.

12-production-integration에서는 캐시보다 더 애플리케이션 기능에 가까운 예제를 만들었습니다. 대표적인 것이 HTTP client outbox와 idempotency입니다. 결제, 알림, 외부 API 연동은 대부분 “DB 상태 변경”과 “외부 시스템 호출”을 같이 다뤄야 합니다. 이 둘을 service 코드에 흩어놓으면 retry, 중복 요청, 상태 전이가 금방 복잡해집니다.

Spring/Ktor 예제에서는 이 흐름을 Exposed transaction 중심으로 정리했습니다. 먼저 pending outbox record를 저장하고, idempotencyKey에는 unique constraint를 겁니다. 중복 요청이 오면 새 작업을 만들지 않고 기존 record를 반환합니다. 그 다음 gateway를 호출하고 결과에 따라 SUCCEEDED, RETRYABLE_FAILED, PERMANENT_FAILED로 상태를 바꿉니다.

단계코드에서 확인할 구현
요청 검증orderId, amountCents, idempotencyKey normalize/validation
의도 저장spring_payment_outboxPENDING row insert
중복 방지idempotency_key unique index, duplicate이면 기존 row 반환
외부 호출fake gateway로 성공/재시도 가능 실패/영구 실패를 테스트
상태 전이성공은 SUCCEEDED, 재시도 가능 실패는 RETRYABLE_FAILED

여기서 중요한 것은 “outbox table이 있다”가 아닙니다. Exposed transaction 안에서 의도 저장, 중복 방지, 상태 전이를 하나의 애플리케이션 패턴으로 만들 수 있다는 점입니다. JPA event listener나 별도 messaging framework로 크게 시작하지 않아도, 필요한 지점부터 작게 붙일 수 있습니다.

리얼타임 outbox 예제도 같은 방식입니다. 알림 요청은 domain row와 outbox row를 같은 Exposed transaction에 저장하고, 별도 publish 단계가 pending event를 SSE/WebSocket hub로 전달합니다. 전달 성공은 PUBLISHED, 실패는 FAILED로 남깁니다. 예제 안에서는 작게 보이지만, 운영 코드에서는 이 지점에 재시도 worker, claim lease, attempt limit을 붙이면 됩니다. 처음부터 거대한 event platform을 만들 필요는 없습니다.

이제 코드를 볼 때는 “기능이 몇 개 들어 있는가”보다 “Exposed 위에 운영 기능을 어느 정도까지 쉽게 얹을 수 있는가”를 보면 됩니다. exposed-workshop/11-high-performance12-production-integration은 단순 CRUD 예제에서 한 단계 더 나가도록 구성했습니다.

예제코드에서 볼 질문
01-cache-strategiesread/write 전략을 repository API 교체 수준으로 바꿔 볼 수 있는가
02-cache-strategies-coroutinessuspend 흐름에서도 같은 cache 전략을 유지하는가
04-benchmarkcache hit/miss와 write mode별 처리량을 workload 기준으로 비교하는가
05/06-cache-strategies-ktorKtor에서도 controller/service 코드 과잉 없이 cache 전략을 쓰는가
12-production-integration/03-04HTTP outbox와 idempotency를 Exposed transaction으로 묶는가
12-production-integration/07-08realtime outbox를 domain row와 같이 저장하고 publish 단계로 넘기는가
12-production-integration/09-10observability, readiness, structured error를 애플리케이션 예제 안에서 확인하는가

11-high-performance/04-benchmark의 smoke 결과를 보면 READ_HEAVY workload에서는 WRITE_THROUGH가 NoCache 대비 약 9.9배, READ_THROUGH가 약 5.5배 빠릅니다. WRITE_HEAVY에서는 개선이 약 1.1배로 줄어듭니다. 그래서 저는 캐시 전략을 설명할 때 전략 이름보다 읽기/쓰기 비율을 먼저 봅니다.

  • 읽기 중심 workload에서는 Caffeine/Near Cache/Read-Through의 처리량을 실제 요청 비율로 비교하는가.
  • 쓰기 burst가 큰 workload에서는 Write-Behind를 쓸 수 있는 일관성 조건인지 먼저 확인하는가.
  • tenant id가 schema/database 선택, cache key, log, metric tag에 같은 규칙으로 들어가는가.
  • Spring MVC, Virtual Threads, WebFlux/Coroutines, Ktor 중 어떤 실행 모델을 쓸지 먼저 정했는가.
  • outbox/idempotency를 별도 framework로 키우기 전에 Exposed transaction 안에서 해결할 수 있는지 확인했는가.
  • cache miss, hit, invalidation, DB fallback metric을 benchmark와 운영 metric에서 같이 볼 수 있는가.
  • production 예제를 복사하기보다, service의 read/write 비율과 tenant 분리 방식에 맞춰 필요한 조합만 가져왔는가.

bluetape4k-exposed의 운영 경로는 “Exposed를 Spring Boot에 붙였다”에서 끝나지 않습니다. 제가 이 예제들을 나눠 만든 이유도 여기에 있습니다.

  • Spring Boot 자동 설정은 익숙한 repository 사용성을 제공하면서 Exposed DSL 제어력을 남겨둡니다.
  • cache module은 read/write 전략을 repository API 안에서 바꿔 성능을 끌어올릴 수 있게 합니다.
  • multi-tenancy 예제는 tenant별 schema/database 전환을 transaction 진입점에 모아 구현 부담을 줄입니다.
  • production integration 예제는 outbox, idempotency, readiness를 작은 애플리케이션 패턴으로 붙일 수 있게 합니다.
  • benchmark는 전략 이름이 아니라 workload 기준으로 선택하게 해줍니다.

이 시리즈의 결론은 단순합니다. Exposed는 SQL을 Kotlin답게 다루는 좋은 출발점입니다. bluetape4k-exposed는 그 출발점 위에 성능 최적화와 운영 기능을 얇게 얹기 위한 확장입니다.

“얇다”는 말은 기능이 적다는 뜻이 아닙니다. service 코드가 직접 cache-aside를 반복하거나, tenant routing을 controller마다 붙이거나, outbox worker를 매번 새로 짜지 않아도 된다는 뜻입니다. 필요한 기능은 repository와 transaction 경계에 모으고, 선택 기준은 benchmark와 예제로 확인합니다.

저는 이 방식이 Exposed와 잘 맞는다고 봅니다. Exposed는 SQL을 숨기지 않고, transaction도 명시적으로 다룹니다. 그 장점을 유지하면서 캐시, 멀티테넌시, outbox/idempotency처럼 직접 구현하기 번거로운 부분을 작게 붙이면, 애플리케이션 코드는 단순해지고 성능을 조정할 수 있는 지점은 더 분명해집니다. 운영 기능을 크게 시작하지 않아도 된다는 점이 Part 5에서 보여주고 싶은 핵심입니다.

댓글

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