Bluetape4k Exposed Part 5: 실전 예제 - 캐시 전략과 멀티테넌시

이번 글에서는 실전 예제를 살펴봅니다. bluetape4k-exposed가 중점을 둔 부분은 Exposed의 장점인 SQL DSL과
명시적인 트랜잭션 제어를 유지하면서, 서비스 코드에서 반복되는 운영 기능을 쉽게 결합하는 것입니다. 캐시 전략,
멀티테넌시, 아웃박스, 멱등성은 모두 직접 구현할 수 있습니다. 문제는 구현을 시작하면 저장소, 서비스, 인터셉터,
스케줄러, 메트릭 코드가 여러 계층에 흩어지기 쉽다는 점입니다.
그래서 이 기능들을 Exposed 애플리케이션에 필요한 만큼 결합할 수 있는 확장으로 정리했습니다. Spring Boot에서는 반복 설정을 줄이고, 캐시 모듈에서는 읽기/쓰기 전략을 저장소 API로 전환하며, 멀티테넌시 예제에서는 테넌트별 스키마와 DB 전환을 트랜잭션 진입점에 모았습니다. 아웃박스와 멱등성도 같은 원칙을 따릅니다. 운영 기능을 처음부터 거대한 프레임워크로 만들기보다 Exposed 트랜잭션에 필요한 기능만 결합해 곧바로 테스트하고 벤치마크할 수 있게 만드는 것이 목표였습니다.

Spring Boot 자동 구성
섹션 제목: “Spring Boot 자동 구성”먼저 Spring Boot 자동 구성입니다. 실제 애플리케이션에서는 Exposed Database, 트랜잭션 관리자, 저장소 스캔,
엔티티 연결 구성을 매번 맞춰야 합니다. 작은 예제에서는 문제가 없지만, 서비스가 늘어나면 같은 설정 코드가 반복됩니다.
Spring Boot 자동 구성은 이 반복을 줄입니다.
| 모듈 | 역할 |
|---|---|
exposed-spring-boot-jdbc | Exposed DAO 엔티티 기반 Spring Data JDBC 저장소 |
exposed-spring-boot-r2dbc | Exposed R2DBC DSL 기반 코루틴 저장소 |
batch-exposed | Spring Batch와 Exposed 연동 |
exposed-spring-modulith | Exposed 기반 Spring Modulith JDBC 이벤트 발행 |
JDBC 모듈은 Spring Data 방식의 저장소와 PartTree 쿼리를 지원합니다.
interface UserRepository : ExposedJdbcRepository<User, Long> { fun findByName(name: String): List<User> fun findByAgeGreaterThan(age: Int): List<User> fun findByEmailContaining(keyword: String): List<User>}R2DBC 모듈은 코루틴 저장소와 Flow 스트리밍을 중심으로 구성했습니다.
interface UserRepository : ExposedR2dbcRepository<User, Long> { suspend fun findByName(name: String): List<User> suspend fun countByAge(age: Int): Long}자동 구성의 목적은 Spring Data처럼 익숙한 사용성을 제공하면서 Exposed DSL과 트랜잭션 제어력을 유지하는 것입니다. 저장소 인터페이스는 얇게 유지하고, 실제 쿼리와 트랜잭션 선택은 Exposed 방식으로 처리합니다. 이렇게 하면 서비스 코드는 단순해지고, 성능이 중요한 지점에서는 SQL DSL로 바로 내려갈 수 있습니다.
캐시 전략: 캐시 어사이드와 CacheWriteMode 구분
섹션 제목: “캐시 전략: 캐시 어사이드와 CacheWriteMode 구분”캐시는 Exposed 저장소에 결합했을 때 효과가 가장 분명하게 드러납니다. 서비스 코드는 같은 저장소 API를 호출하지만,
하위 계층에서는 로컬 캐시, Redis, 니어 캐시, Read-Through, Write-Through, Write-Behind 전략을 선택할 수 있습니다.
exposed-cache에는 Caffeine, Lettuce, Redisson 기반 캐시 저장소가 같은 방식으로 동작하도록
공통 인터페이스와 설정을 두었습니다.
서비스마다 캐시 어사이드 코드를 복사하지 않고도 캐시 전략을 바꿀 수 있다는 것이 이 방식의 장점입니다. 읽기 중심 서비스라면 니어 캐시로 DB와 Redis 왕복을 줄이고, 쓰기 요청이 급증하는 서비스라면 Write-Behind로 요청 처리 시간을 줄이는 식으로 워크로드에 맞는 전략을 선택할 수 있습니다.
| CacheMode | 의미 |
|---|---|
LOCAL | Caffeine 같은 프로세스 내부 캐시. 빠르지만 JVM 간에는 공유되지 않음 |
REMOTE | Redis 같은 원격 캐시. 인스턴스 간 공유 |
NEAR_CACHE | L1 로컬 캐시 + L2 Redis. 자주 조회하는 데이터의 네트워크 왕복 감소 |
여기서 혼동하기 쉬운 개념이 캐시 어사이드와 CacheWriteMode입니다. 캐시 어사이드는 보통 서비스 코드가 DB와
캐시를 직접 관리합니다. 예를 들어 DB에 저장한 뒤 캐시를 무효화하거나 갱신하고, 캐시 미스가 발생하면 서비스가
DB에서 읽어 캐시에 저장합니다. 반면 JdbcCacheRepository와 R2dbcCacheRepository 계열의 CacheWriteMode는
저장소 API 안에서 DB 반영까지 담당합니다. 호출자는 캐시 저장소의 put() 또는 save()를 사용하지만, 모드에 따라
DB 작업도 함께 수행됩니다.
그래서 쓰기 전략은 이렇게 읽어야 합니다.
| CacheWriteMode | 읽기 | 쓰기 |
|---|---|---|
READ_ONLY | 미스가 발생하면 DB에서 읽어 캐싱 | 캐시만 갱신하며 DB에는 영속화하지 않음 |
WRITE_THROUGH | Read-Through | 캐시에 쓰면 DB에도 동기 방식으로 반영 |
WRITE_BEHIND | Read-Through | 큐가 요청을 수락하면 캐시에 반영하고 DB에는 비동기로 플러시 |
구현도 이 모델을 그대로 따릅니다. 단순화하면 WRITE_THROUGH는 캐시와 DB 쓰기를 같은 호출에서 처리하고,
WRITE_BEHIND는 DB 쓰기를 큐에 넣은 뒤 캐시를 갱신합니다.
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는 캐시에만 쓰는 전략이 아닙니다. 캐시 저장소에 쓰는 형태의 API를 사용하더라도 해당 저장소가
DB 영속화까지 담당합니다. 이 동작을 캐시 어사이드와 혼동하면 DB 저장을 서비스가 별도로 수행해야 하는지 판단하기
어렵습니다.
벤치마크도 같은 기준으로 구성했습니다. bluetape4k-workshop/spring-boot/cache-benchmark에서는 H2와
Redis Testcontainers를 사용해 일곱 가지 캐시 프로필을 비교합니다. 아래 값은 2026-07-27에 Apple M4 Pro,
Java 21.0.12 환경에서 JMH 방식으로 다시 측정한 결과입니다.

| 프로파일 | 읽기 ops/s | 기준 대비 |
|---|---|---|
| No Cache | 320,764 | 1x |
| Caffeine | 3,058,548 | 9.5x |
| Redis Cache | 4,031 | 0.013x |
| Near Cache | 3,287,505 | 10.3x |
| Read-Through | 4,090 | 0.013x |
| Write-Through | 4,099 | 0.013x |
| Write-Behind | 4,194 | 0.013x |
이 수치는 캐시 적중 자체의 비용뿐 아니라 로컬 H2와 Redis Testcontainers의 왕복 비용도 함께 반영합니다. 따라서 운영 환경의 절대 처리량으로 해석해서는 안 되며, 실제 네트워크와 데이터베이스 조건에서 다시 측정해야 합니다.
쓰기 흐름은 처리량만 비교하면 안 됩니다. Write-Behind의 요청 수락, Write-Through의 DB 반영 완료, Write-Behind의 큐 비우기 완료는 서로 다른 시점에 측정한 값입니다.

| 프로파일과 측정 지점 | 완료 경계 | 쓰기 ops/s |
|---|---|---|
| Write-Behind 기존 ID 갱신 | 캐시가 요청을 수락하고 DB 쓰기는 큐에서 대기 | 3,551.936 |
| Write-Through 기존 ID 갱신 | 반환 전에 DB 반영 완료 | 3,034.323 |
| Write-Behind 갱신 후 대기 | 큐의 쓰기가 끝나고 DB 값 확인 | 0.988 |
Write-Behind는 요청 경로에서 DB 쓰기를 분리하므로 호출자에게 빠르게 반환할 수 있습니다. 그러나 큐를 비우는 측정에는 설정된 1초 지연도 포함되므로, 요청 수락 처리량을 완료된 DB 쓰기 처리량으로 표현하면 안 됩니다. 주문 확정처럼 즉시 영속화해야 하는 흐름보다는 지연 저장을 허용할 수 있는 흐름에 적합하며, 플러시 큐 관측, 종료 시 큐 비우기, 재시도, 멱등성을 함께 설계해야 합니다.
멀티테넌시: 헤더에서 스키마까지
섹션 제목: “멀티테넌시: 헤더에서 스키마까지”멀티테넌시는 다른 ORM이나 일반 JDBC로도 구현할 수 있습니다. 다만 실전에서는 테넌트 식별, 컨텍스트 전파,
스키마 또는 DB 선택, 캐시 키, 테스트가 서로 다른 계층에 흩어지기 쉽습니다. 이 책임은 Exposed 트랜잭션 진입점에
최대한 모으는 편이 안전합니다. exposed-workshop/10-multi-tenant에서는 스키마 기반 테넌트 분리, 테넌트별 DB,
Spring MVC, 가상 스레드, WebFlux, Ktor 방식을 나란히 비교합니다.
기본 흐름은 같습니다. 실행 환경이 달라져도 이 흐름을 얼마나 일관되게 유지하는지가 중요합니다.
X-TENANT-ID헤더에서 테넌트를 식별합니다.- 요청 컨텍스트에 테넌트 정보를 전파합니다.
- 트랜잭션 진입 시 스키마 또는 데이터 소스를 선택합니다.
- 테넌트 A 요청에서 테넌트 B 데이터가 조회되지 않는지 검증합니다.
환경별 컨텍스트 전파 방식은 다릅니다.
| 환경 | 컨텍스트 | 스키마 전환 |
|---|---|---|
| Spring MVC | ThreadLocal | AOP @Before |
| 가상 스레드 | ScopedValue | AOP @Before |
| WebFlux + 코루틴 | Reactor Context + CoroutineContext.Element | newSuspendedTransactionWithTenant |
| Ktor | 코루틴 컨텍스트/플러그인 | 요청 플러그인 + 트랜잭션 도우미 |
여기에 캐시가 붙으면 규칙이 하나 더 생깁니다.
캐시 키에는 테넌트 정보가 포함되어야 합니다.
user:42는 단일 테넌트 서비스에서는 사용할 수 있습니다. 멀티테넌트 환경에서는
tenant:korean:user:42처럼 테넌트 식별자가 키의 일부여야 합니다. 이 규칙을 저장소와 캐시 도우미에 모아두면 서비스
코드에서 테넌트별 캐시 키를 매번 조립하지 않아도 됩니다. Exposed 트랜잭션 도우미는 스키마 또는 DB 선택을 맡고,
캐시 도우미는 테넌트 키 규칙을 맡습니다. 역할을 나누면 멀티테넌시 관련 코드가 애플리케이션 전체로 퍼지는 것을
줄일 수 있습니다.
운영 통합: 아웃박스와 멱등성
섹션 제목: “운영 통합: 아웃박스와 멱등성”12-production-integration에서는 캐시보다 애플리케이션 기능에 가까운 예제를 다룹니다. 대표적인 기능은 HTTP
클라이언트 아웃박스와 멱등성입니다. 결제, 알림, 외부 API 연동은 대부분 DB 상태 변경과 외부 시스템 호출을 함께
처리해야 합니다. 이 두 책임이 서비스 코드에 흩어지면 재시도, 중복 요청, 상태 전이 처리가 복잡해집니다.
Spring과 Ktor 예제에서는 이 흐름을 Exposed 트랜잭션 중심으로 정리했습니다. 먼저 PENDING 아웃박스 레코드를
저장하고 idempotencyKey에 고유 제약 조건을 적용합니다. 중복 요청이 들어오면 새 작업을 만들지 않고 기존 레코드를
반환합니다. 이후 게이트웨이를 호출하고 결과에 따라 상태를 SUCCEEDED, RETRYABLE_FAILED,
PERMANENT_FAILED로 변경합니다.
| 단계 | 코드에서 확인할 구현 |
|---|---|
| 요청 검증 | orderId, amountCents, idempotencyKey 정규화 및 검증 |
| 의도 저장 | spring_payment_outbox에 PENDING 레코드 삽입 |
| 중복 방지 | idempotency_key 고유 인덱스로 중복을 감지하고 기존 레코드 반환 |
| 외부 호출 | 가짜 게이트웨이로 성공, 재시도 가능 실패, 영구 실패 검증 |
| 상태 전이 | 성공은 SUCCEEDED, 재시도 가능 실패는 RETRYABLE_FAILED |
핵심은 아웃박스 테이블의 존재가 아니라 Exposed 트랜잭션 안에서 의도 저장, 중복 방지, 상태 전이를 하나의 애플리케이션 패턴으로 구성하는 것입니다. JPA 이벤트 리스너나 별도 메시징 프레임워크를 먼저 도입하지 않고도 필요한 지점부터 적용할 수 있습니다.
실시간 아웃박스 예제도 같은 방식을 사용합니다. 알림 요청의 도메인 레코드와 아웃박스 레코드를 같은 Exposed
트랜잭션에 저장하고, 별도의 발행 단계에서 대기 중인 이벤트를 SSE/WebSocket 허브로 전달합니다. 전달에 성공하면
PUBLISHED, 실패하면 FAILED로 기록합니다. 운영 코드에서는 이 지점에 재시도 작업자, 작업 선점 임대, 최대 시도
횟수를 추가할 수 있습니다. 처음부터 대규모 이벤트 플랫폼을 만들 필요는 없습니다.
운영 예제에서 봐야 할 것
섹션 제목: “운영 예제에서 봐야 할 것”코드를 검토할 때는 기능의 개수보다 Exposed 트랜잭션과 저장소 경계에 운영 기능을 얼마나 일관되게 결합했는지 확인해야 합니다.
exposed-workshop/11-high-performance와 12-production-integration은 단순 CRUD 예제에서 한 단계 더 나가도록
구성했습니다.
| 예제 | 코드에서 볼 질문 |
|---|---|
01-cache-strategies | 읽기/쓰기 전략을 저장소 API 교체만으로 전환할 수 있는가 |
02-cache-strategies-coroutines | suspend 흐름에서도 같은 캐시 전략을 유지하는가 |
04-benchmark | 캐시 히트·미스와 쓰기 모드별 처리량을 워크로드 기준으로 비교하는가 |
05/06-cache-strategies-ktor | Ktor에서도 컨트롤러와 서비스에 캐시 관리 코드를 흩뜨리지 않는가 |
12-production-integration/03-04 | HTTP 아웃박스와 멱등성을 Exposed 트랜잭션으로 묶는가 |
12-production-integration/07-08 | 실시간 아웃박스를 도메인 레코드와 함께 저장한 뒤 발행 단계로 넘기는가 |
12-production-integration/09-10 | 관측성, 준비 상태, 구조화된 오류를 애플리케이션 예제에서 확인하는가 |
11-high-performance/04-benchmark의 스모크 테스트 결과에서 READ_HEAVY 워크로드의 WRITE_THROUGH는
NO_CACHE보다 약 9.9배, READ_THROUGH는 약 5.5배 빠릅니다. WRITE_HEAVY에서는 개선 폭이 약 1.1배로
줄어듭니다. 따라서 캐시 전략은 전략 이름보다 읽기/쓰기 비율을 먼저 확인하고 선택해야 합니다.
적용 체크리스트
섹션 제목: “적용 체크리스트”- 읽기 중심 워크로드에서는 Caffeine, Near Cache, Read-Through의 처리량을 실제 요청 비율로 비교하는가.
- 쓰기 요청이 급증하는 워크로드에서는 Write-Behind가 요구하는 일관성 조건을 수용할 수 있는가.
- 테넌트 ID가 스키마 또는 DB 선택, 캐시 키, 로그, 메트릭 태그에 같은 규칙으로 반영되는가.
- Spring MVC, 가상 스레드, WebFlux와 코루틴, Ktor 중 어떤 실행 모델을 사용할지 먼저 정했는가.
- 아웃박스와 멱등성을 별도 프레임워크로 확장하기 전에 Exposed 트랜잭션 안에서 해결할 수 있는가.
- 캐시 미스, 히트, 무효화, DB 대체 경로 메트릭을 벤치마크와 운영 메트릭에서 함께 확인할 수 있는가.
- 운영 예제를 그대로 복사하지 않고 서비스의 읽기/쓰기 비율과 테넌트 분리 방식에 필요한 기능만 선택했는가.
맺음말
섹션 제목: “맺음말”bluetape4k-exposed의 운영 경로는 Exposed와 Spring Boot를 연결하는 데서 끝나지 않습니다. 각 예제는 다음 책임을
분리해 보여줍니다.
- Spring Boot 자동 구성은 익숙한 저장소 사용성을 제공하면서 Exposed DSL 제어력을 유지합니다.
- 캐시 모듈은 읽기/쓰기 전략을 저장소 API 안에서 전환할 수 있게 합니다.
- 멀티테넌시 예제는 테넌트별 스키마 또는 DB 전환을 트랜잭션 진입점에 모읍니다.
- 운영 통합 예제는 아웃박스, 멱등성, 준비 상태를 작은 애플리케이션 패턴으로 결합합니다.
- 벤치마크는 전략 이름이 아니라 워크로드를 기준으로 선택하게 합니다.
Exposed는 SQL을 Kotlin 코드로 명시적으로 다루는 출발점입니다. bluetape4k-exposed는 이 기반에 성능 최적화와
운영 기능을 필요한 만큼 결합하는 확장입니다.
기능을 필요한 만큼 결합한다는 것은 기능이 적다는 뜻이 아닙니다. 서비스 코드에서 캐시 어사이드를 반복하고, 컨트롤러마다 테넌트 라우팅을 구현하며, 아웃박스 작업자를 매번 새로 작성하는 중복을 줄인다는 뜻입니다. 공통 책임은 저장소와 트랜잭션 경계에 모으고, 선택 기준은 벤치마크와 예제로 검증합니다.
Exposed는 SQL을 숨기지 않고 트랜잭션도 명시적으로 다룹니다. 이 장점을 유지하면서 캐시, 멀티테넌시, 아웃박스, 멱등성처럼 반복 구현하기 쉬운 기능을 저장소와 트랜잭션 경계에 모으면 애플리케이션 코드는 단순해지고 성능 조정 지점도 분명해집니다. Part 5의 핵심은 운영 기능의 적용 범위를 작게 시작하고, 워크로드와 격리 요구에 따라 확장하는 것입니다.
참고 링크
섹션 제목: “참고 링크”- Spring Boot JDBC 저장소: spring-boot/jdbc/README.ko.md
- Spring Boot R2DBC 저장소: spring-boot/r2dbc/README.ko.md
- Exposed 캐시 모듈: exposed/cache/README.ko.md
- 캐시 벤치마크: spring-boot/cache-benchmark/README.ko.md
- 멀티테넌트 워크숍: exposed-workshop/10-multi-tenant
- 고성능 처리 워크숍: exposed-workshop/11-high-performance
- 운영 통합 예제: exposed-workshop/12-production-integration/README.ko.md
- Spring HTTP 아웃박스와 멱등성: 12-production-integration/03-spring-http-outbox-idempotency/README.ko.md
- Ktor HTTP 아웃박스와 멱등성: 12-production-integration/04-ktor-http-outbox-idempotency/README.ko.md
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.