본문 바로가기
Spring

[Spring] Master/Slave DataSource 동적 라우팅 설정하기

by 케로베로 2021. 5. 5.

 현재 로컬에서 진행중이던 프로젝트가 배포 단계로 진입함으로써 DBMS 또한 테스트용 h2에서 RDS에 MySql을 올려 사용하는 방식으로 교체하였다.

 

 현재 진행중인 프로젝트인 shoe-auction은 사용자가 지속적으로 증가함하며 많은 양의 트래픽이 발생한다는 가정하에 진행중이기 때문에 하나의 DB 서버로 모든 쓰기/읽기 작업이 집중된다면 쉽게 부하가 발생할 수 있다고 생각했다.

 

따라서 Master 서버 이외에 추가적으로 Replication된 Slave 서버를 두고 모든 읽기 작업(read-only)은 slave에게 향하게 함으로써 트래픽이 분산될 수 있도록 구현하였다.

 

 

 

 그렇다면 기존에 오직 하나의 Datasource와 통신하던 Spring boot 애플리케이션이 읽기/쓰기 작업 별로 여러 Datasource에게 동적 라우팅하도록 하기 위해서는 어떤 설정을 해야하는지 알아보겠다.


AbstractRoutingDataSource

 Spring의 AbstractRoutingDataSource는 다중 DataSource를 묶고 키를 통해 상황에 따라 동적으로 라우팅할 수 있도록 도와준다.

 

⦁ common/db/DynamicRoutingDataSource.java

public class DynamicRoutingDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        return isCurrentTransactionReadOnly() ? SLAVE : MASTER;
    }
}

 determineCurrentLookupKey를 이용해 라우팅할 DataSource의 키를 선택하는데 isCurrentTransactionReadOnly() 메소드를 통해 트랜잭션의 readOnly 여부를 알아내서 readOnly라면 slave DB로, readOnly가 아니라면 master DB로 라우팅되도록 상수 키를 반환한다.

 

properties 설정

 application.yml 설정은 아래와 같이 master와 slave를 나눠주었다. 만약 slave db가 더 많이 존재할 경우에는 slave-list를 두고 url만 작성해 사용하는 것이 더 간단할 것 같다.

 

⦁ resources/application.yml

spring:
  datasource:
    hikari:
      master:
        jdbc-url: jdbc:mysql://shoeauction.abcd1234.ap-northeast-2.rds.amazonaws.com:3306/shoeauction
        username: username
        password: password
        driver-class-name: com.mysql.cj.jdbc.Driver
      slave:
        jdbc-url: jdbc:mysql://shoeauction-slave.abcd1234.ap-northeast-2.rds.amazonaws.com:3306/shoeauction
        username: username
        password: password
        driver-class-name: com.mysql.cj.jdbc.Driver

 

RoutingDataSourceConfig

⦁ common/config/RoutingDataSourceConfig.java

@RequiredArgsConstructor
@Configuration
@EnableTransactionManagement
public class RoutingDataSourceConfig {

    private final Environment env;

    @ConfigurationProperties(prefix = "spring.datasource.hikari.master")
    @Bean
    public DataSource masterDataSource() {
        return DataSourceBuilder.create().type(HikariDataSource.class).build();
    }

    @ConfigurationProperties(prefix = "spring.datasource.hikari.slave")
    @Bean
    public DataSource slaveDataSource() {
        return DataSourceBuilder.create().type(HikariDataSource.class).build();
    }

    @DependsOn({"masterDataSource", "slaveDataSource"})
    @Bean
    public DataSource routingDataSource(
        @Qualifier("masterDataSource") DataSource master,
        @Qualifier("slaveDataSource") DataSource slave) {
        DynamicRoutingDataSource routingDataSource = new DynamicRoutingDataSource();

        Map<Object, Object> dataSourceMap = new HashMap<>();

        dataSourceMap.put(MASTER, master);
        dataSourceMap.put(SLAVE, slave);

        routingDataSource.setTargetDataSources(dataSourceMap);
        routingDataSource.setDefaultTargetDataSource(master);

        return routingDataSource;
    }

    @DependsOn({"routingDataSource"})
    @Bean
    public DataSource dataSource(DataSource routingDataSource) {
        return new LazyConnectionDataSourceProxy(routingDataSource);
    }
    
    // ...
}

 master와 slave DataSource는 ConfigurationProperties를 통해 설정값을 바인딩하여 간단하게 만들 수 있다. 그리고 아까 만들었던 DynamicRoutingDataSource에 master와 slave DataSource를 타겟으로 추가해준다.

 

 여기서 중요한 것은 spring에서는 원래 Transaction 동기화 이전에 DataSource에서 Connection을 획득한다. 하지만 우리는 Transaction 동기화 이후 실제 쿼리 호출 시 DataSource를 정하고 Connection을 획득해야만 하기 때문에 문제가 발생한다.

 

 따라서 LazyConnectionDataSourceProxy를 통해 routingDataSource를 감싸주어서 Tracnsaction 동기화 이전에는 Connection Proxy 객체를 획득하고 이 후 쿼리가 호출될 때에 DataSource를 정하고 Connection을 획득할 수 있도록 늦춰주었다.

 

 또한 순환 참조가 발생할 수 있기 때문에 DependsOn 어노테이션을 이용해서 Bean들간의 의존 관계를 명시해주었다.

 

 

    @Primary
    @Bean
    public LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource) {
        LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean();
        em.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
        em.setDataSource(dataSource);
        em.setPackagesToScan("com.flab.shoeauction.domain");

        Map<String, Object> properties = new HashMap<>();
        properties.put("hibernate.physical_naming_strategy",
            SpringPhysicalNamingStrategy.class.getName());
        properties.put("hibernate.implicit_naming_strategy",
            SpringImplicitNamingStrategy.class.getName());
        properties.put("hibernate.hbm2ddl.auto", env.getProperty("spring.jpa.hibernate.ddl-auto"));
        em.setJpaPropertyMap(properties);

        return em;
    }

    @Primary
    @Bean
    public PlatformTransactionManager transactionManager(
        EntityManagerFactory entityManagerFactory) {
        JpaTransactionManager transactionManager = new JpaTransactionManager();
        transactionManager.setEntityManagerFactory(entityManagerFactory);

        return transactionManager;
    }

 마지막으로 JPA에서 사용할 EntityManagerFactory와 TransactionManager를 설정해준다.

 

테스트

@Transactional(readOnly = true)
    public Page<ThumbnailResponse> findProducts(SearchCondition condition,
        Pageable pageable) {
        return productRepository.findAllBySearchCondition(condition, pageable);
    }

조회 메소드에 위와 같이 readOnly 옵션이 true인 Transactional 어노테이션을 붙여주면 slave DB로 조회 쿼리가 날아갈 것이다. 

 

jdbcUrl.........................jdbc:mysql://shoeauction-slave.abcd1234.ap-northeast-2.rds.amazonaws.com:3306/shoeauction

 


 

 

f-lab-edu/shoe-auction

개인 간 신발 거래 서비스. Contribute to f-lab-edu/shoe-auction development by creating an account on GitHub.

github.com

 

 

'Spring' 카테고리의 다른 글

[Spring] Slf4j + Log4j2 로거 적용하기  (0) 2021.03.29
[Spring] 프로젝트에 Cache 적용하기  (0) 2021.03.28