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

시리즈 마지막 글에서는 실전 예제를 보겠습니다. 제가 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 위에 얇게 얹어서 바로 테스트하고 벤치마크할 수 있게 만드는 것이 목표였습니다.

Spring Boot 자동 설정
섹션 제목: “Spring Boot 자동 설정”먼저 Spring Boot 자동 설정입니다. 실제 애플리케이션에서는 Exposed Database, transaction manager, repository scan,
Entity wiring을 매번 손으로 맞추게 됩니다. 작은 예제에서는 괜찮지만, 서비스가 늘어나면 같은 설정 코드를 계속 복사하게
됩니다. 제가 Spring Boot 자동 설정을 넣은 이유는 이 반복을 줄이기 위해서입니다.
| 확장 | 역할 |
|---|---|
exposed-spring-boot-jdbc | Exposed DAO Entity 기반 Spring Data JDBC repository |
exposed-spring-boot-r2dbc | Exposed R2DBC DSL 기반 coroutine repository |
batch-exposed | Spring Batch와 Exposed 연동 |
exposed-spring-modulith | Exposed 기반 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 | 의미 |
|---|---|
LOCAL | Caffeine 같은 인프로세스 캐시. 빠르지만 JVM 간 공유 없음 |
REMOTE | Redis 같은 원격 캐시. 인스턴스 간 공유 |
NEAR_CACHE | L1 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_ONLY | miss 시 DB에서 읽고 캐싱 | cache만 갱신. DB persistence로 기대하면 안 됨 |
WRITE_THROUGH | read-through | cache에 쓰면 DB에도 동기 쓰기 |
WRITE_BEHIND | read-through | queue 수락 후 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을 비교합니다.

| 프로파일 | 읽기 ops/s | 기준 대비 |
|---|---|---|
| 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 쓰기 + 로컬 캐시 |
| Redis Cache | ~7,300 | DB 쓰기 + Redis SET |
| Near Cache | ~7,200 | DB 쓰기 + near cache update |
| Write-Through | ~5,600 | DB + Redis 동기 쓰기 |
| Write-Behind | ~24,000 | cache에는 즉시 쓰고, 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 방식을 나란히 둡니다.
기본 흐름은 같습니다. 구현 방식을 바꿔가며 이 흐름을 얼마나 적은 코드로 유지할 수 있는지 보면 됩니다.
X-TENANT-ID헤더에서 tenant를 식별합니다.- request context에 tenant를 전파합니다.
- transaction 진입 시 schema 또는 datasource를 선택합니다.
- tenant A 요청에서 tenant B 데이터가 나오지 않는지 테스트합니다.
환경별 컨텍스트 전파 방식은 다릅니다.
| 환경 | 컨텍스트 | 스키마 전환 |
|---|---|---|
| Spring MVC | ThreadLocal | AOP @Before |
| Virtual Threads | ScopedValue | AOP @Before |
| WebFlux + Coroutines | Reactor Context + CoroutineContext.Element | newSuspendedTransactionWithTenant |
| Ktor | coroutine context/plugin | request 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
규칙을 맡습니다. 역할을 이렇게 나누면 멀티테넌시가 애플리케이션 전체에 번지는 일을 줄일 수 있습니다.
운영 통합: Outbox와 멱등성
섹션 제목: “운영 통합: Outbox와 멱등성”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_outbox에 PENDING 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-performance와 12-production-integration은 단순 CRUD 예제에서 한 단계 더 나가도록
구성했습니다.
| 예제 | 코드에서 볼 질문 |
|---|---|
01-cache-strategies | read/write 전략을 repository API 교체 수준으로 바꿔 볼 수 있는가 |
02-cache-strategies-coroutines | suspend 흐름에서도 같은 cache 전략을 유지하는가 |
04-benchmark | cache hit/miss와 write mode별 처리량을 workload 기준으로 비교하는가 |
05/06-cache-strategies-ktor | Ktor에서도 controller/service 코드 과잉 없이 cache 전략을 쓰는가 |
12-production-integration/03-04 | HTTP outbox와 idempotency를 Exposed transaction으로 묶는가 |
12-production-integration/07-08 | realtime outbox를 domain row와 같이 저장하고 publish 단계로 넘기는가 |
12-production-integration/09-10 | observability, 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에서 보여주고 싶은 핵심입니다.
참고 링크
섹션 제목: “참고 링크”- Spring Boot JDBC README: spring-boot/exposed-jdbc/README.ko.md
- Spring Boot R2DBC README: spring-boot/exposed-r2dbc/README.ko.md
- Exposed cache README: exposed-cache/README.ko.md
- Cache benchmark: spring-boot/cache-benchmark/README.ko.md
- Multi-tenant workshop: exposed-workshop/10-multi-tenant
- High performance workshop: exposed-workshop/11-high-performance
- Production integration: exposed-workshop/12-production-integration/README.ko.md
- Spring HTTP outbox/idempotency: 12-production-integration/03-spring-http-outbox-idempotency/README.ko.md
- Ktor HTTP outbox/idempotency: 12-production-integration/04-ktor-http-outbox-idempotency/README.ko.md
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.