@Transactional만으로 DB 읽기/쓰기 분산하기 (Writer/Reader Datasource 자동 라우팅)
들어가며
트래픽이 증가하면서 데이터베이스 부하 관리는 모든 백엔드 시스템이 직면하는 과제입니다. 특히 읽기와 쓰기 작업의 비율이 불균형할 때, 단일 데이터베이스 구조는 병목 지점이 됩니다.
이 글에서는 Writer/Reader Multi-DataSource 라우팅 아키텍처를 통해 이러한 문제를 해결한 경험을 공유합니다. Spring Boot 환경에서 @Transactional(readOnly=true) 어노테이션만으로 자동으로 읽기/쓰기 데이터베이스를 분산하는 방법을 소개합니다.
문제 상황
기존 시스템은 단일 Writer DataSource만 사용하여 모든 조회/변경 쿼리를 처리했습니다
// 기존 구조: 모든 쿼리가 Writer DB로
@Transactional(readOnly = true)
fun findProducts(): List<Product> {
return productRepository.findAll() // Writer DB 사용
}
@Transactional
fun createProduct(dto: CreateDto): Product {
return productRepository.save(entity) // Writer DB 사용
}
발생한 문제들
- 읽기 부하 집중: 모든 조회 쿼리가 Writer DB에 집중되어 쓰기 성능 저하 가능성
- 확장성 제약: DB 부하 분산을 위한 Read Replica 활용 불가
- 리소스 비효율: 읽기 전용 쿼리도 Writer 커넥션 풀을 소비하여 쓰기 트랜잭션 대기 발생 가능
해결 방법: Multi-DataSource 라우팅 아키텍처
아키텍처 개요
Spring의 AbstractRoutingDataSource와 LazyConnectionDataSourceProxy를 활용하여 트랜잭션 메타데이터 기반 자동 라우팅을 구현했습니다

핵심 구현 코드
1. DataSource 설정
@Configuration
class DataSourceConfig {
@Bean
@Primary
fun dataSource(): DataSource =
LazyConnectionDataSourceProxy(routingDataSource())
@Bean
fun routingDataSource(): RoutingDataSource =
RoutingDataSource(
writerDataSource = Pair("WRITER", writerDataSource()),
readerDataSource = Pair("READER", readerDataSource()),
)
@Bean
@ConfigurationProperties("spring.datasource.hikari")
fun writerDataSource(): HikariDataSource = HikariDataSource()
@Bean
@ConfigurationProperties("spring.datasource.hikari.slaves.first")
fun readerDataSource(): HikariDataSource =
HikariDataSource().apply {
isReadOnly = true // PostgreSQL 최적화 활성화
}
}
주요 포인트
LazyConnectionDataSourceProxy로 Primary DataSource를 래핑하여 트랜잭션 컨텍스트가 완전히 설정된 후 Connection을 획득- Reader DataSource에
isReadOnly=true설정으로 PostgreSQL JDBC 드라이버 최적화 활성화 - 잘못된 라우팅으로 Reader에서 쓰기 쿼리 실행 시 DB 레벨에서 즉시 거부 (안전장치)
- 이 글에서는 Reader Replica가 1개라고 가정합니다. AWS Aurora DB를 사용하는 경우 Reader Endpoint로 자동으로 여러 Reader 인스턴스에 로드 밸런싱을 제공하므로, 어플리케이션에서는 하나의 Reader DataSource만 설정합니다.
2. 라우팅 로직
class RoutingDataSource(
writerDataSource: Pair<String, DataSource>,
readerDataSource: Pair<String, DataSource>,
) : AbstractRoutingDataSource() {
private val writerDataSourceId = writerDataSource.first
private val readerDataSourceId = readerDataSource.first
init {
val writer = writerDataSource.second
super.setDefaultTargetDataSource(writer)
val targetDataSources: MutableMap<Any, Any> = mutableMapOf(
writerDataSourceId to writer,
readerDataSourceId to readerDataSource.second,
)
super.setTargetDataSources(targetDataSources)
}
override fun determineCurrentLookupKey(): String {
val isReadOnlyTransaction =
TransactionSynchronizationManager.isCurrentTransactionReadOnly()
val selectedDataSource = if (isReadOnlyTransaction) {
readerDataSourceId
} else {
writerDataSourceId
}
logger.debug("DataSource routing: readOnly=$isReadOnlyTransaction → $selectedDataSource")
return selectedDataSource
}
// 런타임 수정 방지 (안정성 보장)
override fun setDefaultTargetDataSource(defaultTargetDataSource: Any) =
throw UnsupportedOperationException()
override fun setTargetDataSources(targetDataSources: MutableMap<Any, Any>) =
throw UnsupportedOperationException()
}
핵심 메커니즘
TransactionSynchronizationManager.isCurrentTransactionReadOnly()로 현재 트랜잭션이 읽기 전용인지 판단readOnly=true면 READER, 그렇지 않으면 WRITER DataSource 선택- DEBUG 로그로 실시간 라우팅 확인 가능
3. HikariCP 설정 표준화
spring:
datasource:
hikari:
jdbc-url: ${app.db.writer.jdbc-url}
username: ${app.db.username}
password: ${app.db.password}
driver-class-name: org.postgresql.Driver
# 핵심 설정값 및 근거
connection-timeout: 30000 # 30초: DB 장애 시 빠른 실패 (Circuit Breaker 효과)
idle-timeout: 600000 # 10분: 불필요한 커넥션 유지 방지
max-lifetime: 1800000 # 30분: DB max connection lifetime과 동기화
maximum-pool-size: ${app.db.writer.max-pool-size}
minimum-idle: ${app.db.writer.max-pool-size} # Fixed-Size Pool
auto-commit: false # Spring @Transactional이 트랜잭션 제어
slaves:
first:
jdbc-url: ${app.db.reader.jdbc-url}
# ... 동일한 설정 ...
maximum-pool-size: ${app.db.reader.max-pool-size}
jpa:
properties:
hibernate:
connection.provider_disables_autocommit: true # Hibernate autocommit 확인 쿼리 생략
설정 값 선정 근거
| 설정 | 값 | 근거 |
|---|---|---|
connection-timeout |
30초 | DB 장애 시 빠른 실패로 Circuit Breaker 효과 |
minimum-idle = maximum-pool-size |
Fixed-Size Pool | 트래픽 급증 시 커넥션 생성 지연 없이 즉시 대응, 성능 예측 가능 |
auto-commit |
false | Spring @Transactional이 트랜잭션 경계 제어 (true면 각 쿼리마다 즉시 커밋되어 원자성 깨짐) |
provider_disables_autocommit |
true | Hibernate가 autocommit 상태 확인 쿼리 생략하여 성능 개선 |
계층적 설정 구조 (멀티모듈 패턴)
참고: Multi-DataSource 라우팅 자체는 모놀리식 애플리케이션에서도 동일하게 구현 가능합니다. 이 글에서는 멀티모듈 MSA 구조를 예시로 설명하지만, 모놀리식의 경우 단일 application.yml에 모든 설정을 통합하여 더 간단하게 구성할 수 있습니다.
모듈 아키텍처
이 패턴은 MSA(Microservice Architecture) 환경 또는 멀티모듈 구조에서 특히 유용합니다
┌─────────────────────────────────────────────┐
│ Application Layer │
├──────────────────┬──────────────────────────┤
│ admin-api │ public-api │ batch │ ← 각 서비스별 독립 배포
│ (내부 관리용) │ (외부 API) │ │
├──────────────────┴──────────────────────────┤
│ Domain Layer │ ← 공통 비즈니스 로직 + DataSource 설정
│ (JPA Entities, Services) │
└─────────────────────────────────────────────┘
핵심 개념
- Domain 모듈: DataSource 설정을 포함한 공통 인프라 제공
- Application 모듈: Domain을 의존하며, 각자의 트래픽 특성에 맞는 Pool Size 설정
설정 파일 계층
각 Application 모듈은 공통 설정을 import하고 Pool Size만 재정의:
1. Domain 모듈 (공통 설정)
# domain/src/main/resources/config-db-common.yml
spring:
datasource:
hikari:
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
auto-commit: false
# ... 공통 HikariCP 설정
2. Admin API 모듈 (내부 사용자용, 낮은 동시성)
# admin-api/src/main/resources/application.yml
spring:
config:
import:
- classpath:config-db-common.yml # 공통 설정 import
app:
db:
writer:
max-pool-size: 10
reader:
max-pool-size: 15
3. Public API 모듈 (외부 트래픽, 높은 동시성)
# public-api/src/main/resources/application.yml
spring:
config:
import:
- classpath:config-db-common.yml
app:
db:
writer:
max-pool-size: 20
reader:
max-pool-size: 40 # 조회 위주 트래픽
4. Batch 모듈 (순차 처리, 최소 Pool)
# batch/src/main/resources/application.yml
spring:
config:
import:
- classpath:config-db-common.yml
app:
db:
writer:
max-pool-size: 5
reader:
max-pool-size: 5
장점
- 변경 지점 최소화: HikariCP 타임아웃 변경 시 Domain 설정 파일 1곳만 수정
- 모듈별 특화: Pool Size는 각 모듈의 트래픽 특성에 맞게 독립 설정
- 설정 누락 방지: 공통 기본값이 자동 적용
- DRY 원칙 준수: ~100줄의 중복 코드 제거
- 독립 배포: 각 Application 모듈은 독립적으로 배포 및 확장 가능
사용 방법
개발자 입장에서의 사용법
변경 전
// 모든 쿼리가 Writer DB로
@Service
class ProductService(
private val productRepository: ProductRepository
) {
fun findProducts(): List<Product> {
return productRepository.findAll() // Writer DB
}
}
변경 후
// @Transactional 어노테이션만 추가하면 자동 라우팅
@Service
class ProductService(
private val productRepository: ProductRepository
) {
@Transactional(readOnly = true) // ← READER DataSource 자동 선택
fun findProducts(): List<Product> {
return productRepository.findAll() // Reader DB로 자동 라우팅
}
@Transactional // ← readOnly 미지정 시 기본값 false → WRITER
fun createProduct(dto: CreateDto): Product {
return productRepository.save(entity) // Writer DB
}
}
핵심 개발자는 @Transactional 어노테이션만 적절히 선택하면 되고, 뒷단에서 자동으로 라우팅됩니다.
주의사항
1. @Transactional 어노테이션 누락 시 라우팅 실패
@Transactional 어노테이션이 없으면 isCurrentTransactionReadOnly()가 false를 반환하여 무조건 WRITER로 라우팅됩니다.
// ❌ 안티패턴: @Transactional 누락
fun findProducts(): List<Product> {
return productRepository.findAll() // Writer DB 사용 (의도와 다름)
}
// ✅ 올바른 사용
@Transactional(readOnly = true)
fun findProducts(): List<Product> {
return productRepository.findAll() // Reader DB 사용
}
대응 방안
- 모든 Service 메서드에
@Transactional명시 - ArchUnit 테스트로 누락 검출 자동화
- DEBUG 로그 활성화로 라우팅 동작 확인
2. Reader DB Replication Lag
Reader DB가 Writer DB와 동기화되지 않은 경우 조회 데이터 최신성 문제 발생 가능:
// 시나리오: 데이터 생성 직후 조회
@Transactional
fun create(dto: CreateDto): Product {
return productRepository.save(entity) // Writer DB에 저장
}
@Transactional(readOnly = true)
fun findById(id: Long): Product {
// Replication Lag로 인해 방금 생성한 데이터가 보이지 않을 수 있음
return productRepository.findById(id).orElseThrow()
}
해결 방법
// Critical Path에서는 Writer 직접 사용
@Transactional // readOnly=false → Writer 사용
fun createAndGet(dto: CreateDto): Product {
val created = productRepository.save(entity)
return productRepository.findById(created.id).orElseThrow() // Writer에서 조회
}
테스트 전략
통합 테스트 (MultiDataSourceRoutingTest)
@SpringBootTest
@DisplayName("Multi-DataSource 라우팅 테스트")
class MultiDataSourceRoutingTest : BaseRepositoryTest() {
@Test
@DisplayName("DataSource Bean이 LazyConnectionDataSourceProxy로 래핑되어 있어야 한다")
fun `should wrap routing datasource with lazy proxy`() {
assertThat(dataSource).isInstanceOf(LazyConnectionDataSourceProxy::class.java)
}
@Test
@DisplayName("Reader DataSource는 readOnly로 설정되어 있어야 한다")
fun `should configure reader datasource as readonly`() {
assertThat(readerDataSource.isReadOnly).isTrue()
}
@Test
@Transactional(readOnly = true)
@DisplayName("읽기 전용 트랜잭션에서 JDBC로 테이블 조회가 가능해야 한다")
fun `should execute jdbc query in readonly transaction`() {
val count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM products",
Long::class.java
) ?: 0L
assertThat(count).isGreaterThanOrEqualTo(0L)
}
}
검증 항목
- LazyConnectionDataSourceProxy 래핑 확인
- Writer/Reader DataSource Bean 등록 확인
- Reader DataSource readOnly 설정 검증
- 쓰기/읽기 트랜잭션에서 JDBC 쿼리 실행 성공
TestContainers 설정 (Optional)
object PostgresTestContainer {
fun configureProperties(registry: DynamicPropertyRegistry) {
val jdbcUrl = instance.jdbcUrl
val username = instance.username
val password = instance.password
// Writer DataSource
registry.add("spring.datasource.hikari.jdbc-url") { jdbcUrl }
registry.add("spring.datasource.hikari.username") { username }
registry.add("spring.datasource.hikari.password") { password }
// Reader DataSource (테스트에서는 Writer와 동일)
registry.add("spring.datasource.hikari.slaves.first.jdbc-url") { jdbcUrl }
registry.add("spring.datasource.hikari.slaves.first.username") { username }
registry.add("spring.datasource.hikari.slaves.first.password") { password }
}
}
테스트 환경에서도 프로덕션과 동일한 Multi-DataSource 구조를 유지하되, Writer/Reader가 동일한 PostgreSQL 컨테이너를 사용합니다.
결론
Spring Boot의 AbstractRoutingDataSource와 LazyConnectionDataSourceProxy를 활용하면 @Transactional(readOnly=true) 어노테이션만으로 읽기/쓰기 데이터베이스를 자동 분산할 수 있습니다.
개발자 친화적인 아키텍처
이 패턴의 가장 큰 장점은 개발자의 추가 노력이나 복잡한 구현 없이 즉시 사용 가능하다는 점입니다:
- 기존 코드 수정 최소화: 이미 사용 중인
@Transactional어노테이션에readOnly=true만 추가 - 휴먼 에러 방지: 개발자가 수동으로 DataSource를 선택할 필요 없음
- 자동화된 라우팅: 트랜잭션 메타데이터 기반으로 시스템이 자동 판단
- 일관성 보장: 모든 팀원이 동일한 패턴으로 DB 접근
잘못된 접근 방식 (수동 관리):
// ❌ 개발자가 매번 DataSource를 명시 (휴먼 에러 가능성)
@Autowired
@Qualifier("readerDataSource")
private lateinit var readerDataSource: DataSource
fun findProducts(): List<Product> {
val connection = readerDataSource.connection // 실수로 writerDataSource 사용 가능
// ...
}
올바른 접근 방식 (자동 라우팅):
// ✅ 어노테이션만으로 자동 라우팅 (실수 불가능)
@Transactional(readOnly = true)
fun findProducts(): List<Product> {
return productRepository.findAll() // 시스템이 자동으로 Reader DB 선택
}
핵심 포인트
- LazyConnectionDataSourceProxy: 트랜잭션 컨텍스트 완전 설정 후 Connection 획득
- AbstractRoutingDataSource:
isCurrentTransactionReadOnly()기반 자동 라우팅 - HikariCP 표준화: Fixed-Size Pool, auto-commit=false 등 최적화 설정
- 계층적 설정: 공통 설정 + 모듈별 Pool Size 특화
- Zero-Touch: 개발자는
@Transactional어노테이션만 신경 쓰면 됨
이 아키텍처는 실제 프로덕션 환경에서 검증된 패턴이며, 개발자의 실수를 시스템적으로 방지하면서 트래픽 증가에 대비한 안정적인 DB 부하 분산 솔루션입니다.
참고 자료
'Programming > SpringBoot' 카테고리의 다른 글
| 10배 빠른 분산 시스템 디버깅: Micrometer Tracing으로 Trace ID 통합하기 (0) | 2025.11.19 |
|---|---|
| Spring Logging (2) : Console Log? AWS CloudWatch? 실행환경별로 로그 남기기 (0) | 2021.10.31 |
| Spring Logging (1) : HTTP Request/Response 로그 남기기 (5) | 2021.10.29 |
| Filter, Interceptor, AOP의 간단 용도 (0) | 2021.10.23 |
| Spring JSR 380 Validation 적용과 테스트 코드 작성 (0) | 2020.11.01 |
댓글
이 글 공유하기
다른 글
-
10배 빠른 분산 시스템 디버깅: Micrometer Tracing으로 Trace ID 통합하기
10배 빠른 분산 시스템 디버깅: Micrometer Tracing으로 Trace ID 통합하기
2025.11.19 -
Spring Logging (2) : Console Log? AWS CloudWatch? 실행환경별로 로그 남기기
Spring Logging (2) : Console Log? AWS CloudWatch? 실행환경별로 로그 남기기
2021.10.31 -
Spring Logging (1) : HTTP Request/Response 로그 남기기
Spring Logging (1) : HTTP Request/Response 로그 남기기
2021.10.29 -
Filter, Interceptor, AOP의 간단 용도
Filter, Interceptor, AOP의 간단 용도
2021.10.23