catsridingCATSRIDING|OCEANWAVES
Dev

Spring DataSource Replication 구성하기

jynn@catsriding.com
Dec 19, 2023
Published byJynn
999
Spring DataSource Replication 구성하기

Configure DataSource Replication in Spring

현대의 웹 애플리케이션은 고가용성과 지속적인 확장성을 요구합니다. 이 두 가지 요소를 효과적으로 달성하는 방법 중 하나는 읽기와 쓰기 작업이 수행되는 데이터베이스를 별도로 구성하여 데이터베이스 복제 환경을 만드는 것입니다. 이런 환경에서, 읽기 요청은 하나의 데이터베이스에서 처리되고 쓰기 요청은 다른 데이터베이스에서 처리되므로, 부하 분산과 성능 향상을 실현할 수 있습니다.

이 글에서는 이미 구축된 Database Replication 환경을 JPA 기반의 Spring 프로젝트에 어떻게 적용할 수 있는지, 그 과정을 함께 살펴보도록 하겠습니다.

Prerequisite

본 프로젝트는 다음과 같은 환경을 기반으로 구성되어 있습니다:

  • Java 17
  • Spring Boot 3.1.4
  • JPA
  • Gradle 8
  • Database: AWS Aurora RDS

Setting Up DataSource Replication

AWS Aurora RDS는 클러스터 기반의 구조로 되어 있으며, 쓰기 작업과 읽기 작업을 분리할 수 있는 전용 클러스터 엔드포인트를 제공합니다.

이러한 기능을 활용해 Spring Boot 프로젝트에서 쓰기 전용 데이터베이스와 읽기 전용 데이터베이스를 어떻게 설정하는지 알아보겠습니다. 이 설정 과정을 통해 데이터베이스 읽기 및 쓰기 작업을 효율적으로 분산시키고, 그 결과로 애플리케이션의 비즈니스 로직 처리에 더 높은 성능과 확장성을 제공할 수 있게 됩니다.

Defining Properties

데이터 소스의 복제 설정을 위해 가장 먼저 해야 할 일은 󰈮 application.yml 파일에 데이터베이스 접속 정보를 정의하는 것입니다. Writer와 Reader에 대한 각각의 Endpoint, 사용자 이름 및 패스워드 정보가 필요합니다. 이 정보는 AWS Console에서 확인할 수 있습니다.

application.yml
spring:
  datasource:
    writer:
      jdbc-url: jdbc:mysql://{WRITER_ENDPOINT}
      username: {USERNAME_HERE}
      password: {PASSWORD_HERE}
    reader:
      jdbc-url: jdbc:mysql://{READER_ENDPOINT}
      username: {USERNAME_HERE}
      password: {PASSWORD_HERE}

Creating DataSource Beans

다음은, 데이터베이스 연결에 필요한 정보를 담고 있는 DataSource 객체를 만들고 이를 Spring Bean으로 등록합니다. 쓰기 전용과 읽기 전용에 대한 각각의 Bean을 만들어야 합니다.

DataSourceConfig.java
@Slf4j
@Configuration
public class DataSourceConfig {

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.writer")
    public DataSource writerDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.reader")
    public DataSource readerDataSource() {
        return DataSourceBuilder.create().build();
    }

}
  • @ConfigurationProperties:
    • application.yml 구성 파일 내부의 값을 바인딩합니다.
    • prefix를 통해 특정 속성을 명시할 수 있습니다. 여기서는, 앞서 정의한 writer와 reader 데이터베이스 접속 정보(url, username, password)를 가져옵니다.
  • DataSourceBuilder: @ConfigurationProperties 어노테이션을 통해 사전에 구성한 설정 값들을 바탕으로 DataSource 객체를 초기화합니다.

이와 같이 읽기 및 쓰기 작업 별로 각각의 DataSource 객체를 세팅함으로써, 데이터베이스 연결의 세부적인 설정을 보다 유연하게 관리할 수 있게 됩니다.

Dynamic DataSource Routing

다음은, Spring에서 제공하는 AbstractRoutingDataSource를 확장하여, 트랜잭션 모드를 구분할 수 있는 값을 제공합니다.

DataSourceRoutingConfig.java
public class DataSourceRoutingConfig extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
                ? "reader"
                : "writer";
    }
}
  • determineCurrentLookupKey(): 현재 트랜잭션이 읽기용인지 아닌지를 체크하여 그에 맞는 DataSource의 키를 반환합니다. 이 값은 이후에 Map<key, value> 객체의 키 역할을 하게 됩니다.
    • @Transactional(readOnly = true)와 같이 읽기 전용 트랜잭션인 경우, reader 문자열을 반환합니다.
    • @Transactional(readOnly = false)와 같이 쓰기 가능한 트랜잭션인 경우, writer 문자열을 반환합니다.

위에서 확장한 DataSourceRoutingConfig 객체를 활용하여, 트랜잭션 모드에 따라 동적으로 DataSource를 할당하는 Spring Bean을 추가합니다.

DataSourceConfig.java
@Slf4j
@Configuration
public class DataSourceConfig {

    ...

    @Bean
    public DataSource routingDataSource(
            @Qualifier("writerDataSource") DataSource writerDataSource,
            @Qualifier("readerDataSource") DataSource readerDataSource) {
        DataSourceRoutingConfig routingDataSource = new DataSourceRoutingConfig();

        Map<Object, Object> dataSourceMap = new HashMap<>();
        dataSourceMap.put("writer", writerDataSource);
        dataSourceMap.put("reader", readerDataSource);

        routingDataSource.setTargetDataSources(dataSourceMap);
        routingDataSource.setDefaultTargetDataSource(writerDataSource);
        return routingDataSource;
    }

    @Bean
    @Primary
    public DataSource dataSource(@Qualifier("routingDataSource") DataSource routingDataSource) {
        return new LazyConnectionDataSourceProxy(routingDataSource);
    }
}
  • routingDataSource(): Spring Bean으로 등록될 DataSource를 동적 라우팅하는 역할을 합니다.
    • setTargetDataSources(): 애플리케이션에서 사용할 DataSource 목록을 Map<key, value> 형태로 등록하여 관리합니다. 여기서, Map의 key는 앞서 AbstractRoutingDataSource를 확장한 DataSourceRoutingConfig에서 명시적으로 설정하였습니다.
    • setDefaultTargetDataSource(): 기본 DataSource로는 writer 데이터베이스를 설정합니다.
  • @Primary: Spring에 동일한 Bean이 여러개 존재하는 경우, 이 어노테이션이 선언된 Bean이 우선권을 지닙니다.
  • LazyConnectionDataSourceProxy: routingDataSource를 프록시로 가지는 DataSource를 생성합니다. 이 구조를 통해 데이터베이스 연결이 필요한 시점에 자동으로 연결이 이루어지는 지연 연결 방식을 구현해 성능 이점을 얻을 수 있습니다.

Playgrounds

읽기 및 쓰기 전용 데이터베이스를 동적으로 할당하기 위해 필요한 작업을 완료하였습니다. 테스트를 통해 트랜잭션 모드에 따라 적절한 DataSource를 가져오는지 확인해 보겠습니다.

먼저, 󰈮 application.yml에 데이터베이스 연결 정보를 추가합니다. 구분을 위해 로컬 머신에 두 개의 데이터베이스를 생성하였습니다:

application.yml
spring:
  datasource:
    writer:
      jdbc-url: jdbc:mysql://localhost:3306/writer
      username: catsriding
      password: 1234
    reader:
      jdbc-url: jdbc:mysql://localhost:3306/reader
      username: catsriding
      password: 1234

그리고 아래와 같이 서비스 객체를 구현하였습니다:

WavesService.java
@Slf4j
@Service
@RequiredArgsConstructor
public class WavesService {

    private final DataSource dataSource;

    @Transactional(readOnly = false)
    public void write() {
        logDataSourceInfo();
    }

    @Transactional(readOnly = true)
    public void read() {
        logDataSourceInfo();
    }

    private void logDataSourceInfo() {
        try {
            Connection connection = dataSource.getConnection();
            DatabaseMetaData metaData = connection.getMetaData();
            log.info("Url: " + metaData.getURL());
            log.info("isReadOnly: {}", TransactionSynchronizationManager.isCurrentTransactionReadOnly());
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }
}
  • write(): @Transactional(readOnly = false)를 통해 쓰기가 가능한 트랜잭션을 시작합니다.
  • read(): @Transactional(readOnly = true)를 통해 읽기 전용 트랜잭션을 시작합니다.
  • logDataSourceInfo(): 할당된 DataSource 정보와 트랜잭션 정보를 로그에 출력합니다.
    • Connection: 데이터베이스 연결 정보를 지니고 있는 객체입니다.
    • TransactionSynchronizationManager: 현재 스레드에 바인딩된 실제 트랜잭션의 정보를 담고 있으며, isCurrentTransactionReadOnly()를 통해 readOnly 속성을 반환합니다.

이제 테스트 코드를 작성하여 위에서 구현한 write() 함수를 호출하고 그 결과를 확인해 보겠습니다:

DataSourceRoutingTest.java
@SpringBootTest
@ActiveProfiles("test")
public class DataSourceRoutingTest {

    @Autowired
    WavesService wavesService;

    @Test
    public void testRouting() {

        wavesService.write();

    }
}

콘솔 로그를 통해, writer 데이터베이스가 할당되었으며 트랜잭션 역시 쓰기가 가능한 모드인 것을 확인할 수 있습니다.

console
Url: jdbc:mysql://localhost:3306/writer
isReadOnly: false

다음은 read() 함수를 호출해 보겠습니다:

DataSourceRoutingTest.java
@SpringBootTest
@ActiveProfiles("test")
public class DataSourceRoutingTest {

    @Autowired
    WavesService wavesService;

    @Test
    public void testRouting() {

-       wavesService.write();
+       wavesService.read();

    }
}

예상한 대로, reader 데이터베이스가 할당되고 읽기 전용 트랜잭션으로 시작된 것을 확인할 수 있습니다.

console
Url: jdbc:mysql://localhost:3306/reader
isReadOnly: true

여기까지, Database Replication 전략을 적용하기 위한 읽기 및 쓰기 전용 DataSource Bean을 등록하고, 트랜잭션 모드에 따라 동적으로 할당하는 환경을 구성해 보았습니다.

  • Spring
  • JPA