반응형

들어가며

트래픽이 증가하면서 데이터베이스 부하 관리는 모든 백엔드 시스템이 직면하는 과제입니다. 특히 읽기와 쓰기 작업의 비율이 불균형할 때, 단일 데이터베이스 구조는 병목 지점이 됩니다.

이 글에서는 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 사용
}

발생한 문제들

  1. 읽기 부하 집중: 모든 조회 쿼리가 Writer DB에 집중되어 쓰기 성능 저하 가능성
  2. 확장성 제약: DB 부하 분산을 위한 Read Replica 활용 불가
  3. 리소스 비효율: 읽기 전용 쿼리도 Writer 커넥션 풀을 소비하여 쓰기 트랜잭션 대기 발생 가능

해결 방법: Multi-DataSource 라우팅 아키텍처

아키텍처 개요

Spring의 AbstractRoutingDataSourceLazyConnectionDataSourceProxy를 활용하여 트랜잭션 메타데이터 기반 자동 라우팅을 구현했습니다

핵심 구현 코드

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()
}

핵심 메커니즘

  1. TransactionSynchronizationManager.isCurrentTransactionReadOnly()로 현재 트랜잭션이 읽기 전용인지 판단
  2. readOnly=true면 READER, 그렇지 않으면 WRITER DataSource 선택
  3. 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

장점

  1. 변경 지점 최소화: HikariCP 타임아웃 변경 시 Domain 설정 파일 1곳만 수정
  2. 모듈별 특화: Pool Size는 각 모듈의 트래픽 특성에 맞게 독립 설정
  3. 설정 누락 방지: 공통 기본값이 자동 적용
  4. DRY 원칙 준수: ~100줄의 중복 코드 제거
  5. 독립 배포: 각 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 사용
}

대응 방안

  1. 모든 Service 메서드에 @Transactional 명시
  2. ArchUnit 테스트로 누락 검출 자동화
  3. 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의 AbstractRoutingDataSourceLazyConnectionDataSourceProxy를 활용하면 @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 선택
}

핵심 포인트

  1. LazyConnectionDataSourceProxy: 트랜잭션 컨텍스트 완전 설정 후 Connection 획득
  2. AbstractRoutingDataSource: isCurrentTransactionReadOnly() 기반 자동 라우팅
  3. HikariCP 표준화: Fixed-Size Pool, auto-commit=false 등 최적화 설정
  4. 계층적 설정: 공통 설정 + 모듈별 Pool Size 특화
  5. Zero-Touch: 개발자는 @Transactional 어노테이션만 신경 쓰면 됨

이 아키텍처는 실제 프로덕션 환경에서 검증된 패턴이며, 개발자의 실수를 시스템적으로 방지하면서 트래픽 증가에 대비한 안정적인 DB 부하 분산 솔루션입니다.


참고 자료

반응형