[Pet-Hub] MySQL 데이터 분산 처리를 위한 Master-Slave 이중화 구성(Spring과 JPA 설정)

DevSeoRex·2023년 6월 2일
3
post-thumbnail

🤠 Spring의 master-slave 설정

MySQL Replication 설정을 통한 master-slave 구조의 셋팅은 저번 포스팅에서 전부 완료되었습니다.
추가적으로 애플리케이션 내부에서 어떤 DB로 요청을 보낼 지 결정하기 위해서는 Spring 설정이 필수적입니다.
따라서 이번 포스팅은 Spring + JPA 설정을 해보겠습니다.

application.yml 작성

 spring:
  datasource:
    master:
      hikari:
        username: <master의 user 이름>
        password: <master의 비밀번호>
        driver-class-name: com.mysql.cj.jdbc.Driver
        jdbc-url: jdbc:mysql://<master의 IP주소:포트>/db?serverTimezone=Asia/Seoul


    slave:
      hikari:
        username: <slave의 user 이름>
        password: <slave의 비밀번호>
        driver-class-name: com.mysql.cj.jdbc.Driver
        jdbc-url: jdbc:mysql://<slave의 IP주소:포트>/db?serverTimezone=Asia/Seoul

application.yml 파일에 여러개의 datasource를 등록하기 위한 설정정보를 작성했습니다.

DataSourceConfiguration 클래스 작성

@Configuration
public class DataSourceConfiguration {

    @Bean(MASTER_DATASOURCE) // masterDataSource 이름의 Bean을 생성한다.
    @ConfigurationProperties(prefix = "spring.datasource.master.hikari") // 접두사로 시작하는 속성을 사용해서 Bean을 구성한다.
    public DataSource masterDataSource() {
        return DataSourceBuilder.create()
                // HikariDataSource 타입의 DataSource 객체를 생성한다.
                .type(HikariDataSource.class)
                .build();
    }

    @Bean(SLAVE_DATASOURCE) // slaveDataSource 이름의 Bean을 생성한다.
    @ConfigurationProperties(prefix = "spring.datasource.slave.hikari")
    public DataSource slaveDataSource() {
        return DataSourceBuilder.create()
                .type(HikariDataSource.class)
                .build();
    }

    @Bean
    public DataSource routingDataSource(
            // masterDataSource와 slaveDataSource라는 이름을 가진 Bean을 주입받는다.
            @Qualifier(MASTER_DATASOURCE) DataSource masterDataSource,
            @Qualifier(SLAVE_DATASOURCE) DataSource slaveDataSource) {

        RoutingDataSource routingDataSource = new RoutingDataSource();


        Map<Object, Object> datasourceMap = ImmutableMap.<Object, Object>builder()
                .put("master", masterDataSource)
                .put("slave", slaveDataSource)
                .build();

        // RoutingDataSource의 대상 데이터 소스를 위에서 생성한 맵으로 지정한다.
        routingDataSource.setTargetDataSources(datasourceMap);

        // 기본 대상 데이터 소스를 masterDataSource로 설정한다.
        routingDataSource.setDefaultTargetDataSource(masterDataSource);

        return routingDataSource;
    }

    @Primary // 동일한 타입의 여러 Bean 중에서 우선적으로 사용되는 기본 Bean을 설정한다.
    @Bean
    public DataSource dataSource(@Qualifier("routingDataSource") DataSource routingDataSource) {
        // 지연 연결 기능을 제공하기 위해서 사용한다 -> 데이터베이스 연결의 지연 실행을 지원하고, 필요한 시점에서만 연결을 수행하도록 구성한다.
        return new LazyConnectionDataSourceProxy(routingDataSource);
    }
    
}

이제 코드를 한 부분씩 뜯어 보겠습니다.

Master DataSource와 Slave DataSource가 등록되는 부분입니다.
@ConfigurationProperties를 이용해서 yml 파일에서 구성한 정보들로 DataSource가 구성됩니다.

DataSource의 이름들은 따로 상수로 정의해서 사용하고 있습니다.

트랜잭션의 readOnly 여부에 따라 사용할 DataSource를 결정하는 메서드입니다.
현재 DataSource 타입의 bean이 2개(Master, Slave)이기 때문에 타입이 아닌 이름으로 Bean을 찾아주는 @Qualifier 애너테이션을 이용해서 Master와 Slave DataSource를 주입받습니다.

설정 클래스를 작성할때 사용되는 Map은 변하지 않는 값이므로, ImmutableMap을 이용해서 만들어주었습니다.
ImmutableMap은 google의 common 라이브러리에서 가져올 수 있습니다.

Java9 부터 불변 Collection을 생성하기 위한 팩토리 메서드가 지원되고 있지만 저는 ImmutableMap builder 메서드가 직관적이고 어떤 원소가 들어가는지 보기 편해서 사용하고 있습니다.

RoutingDataSource

@Slf4j
public class RoutingDataSource  extends AbstractRoutingDataSource {


    @Override
    // 현재 데이터베이스 연결을 결정하기 위해 호출하는 메서드
    protected Object determineCurrentLookupKey() {

       boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();

        if (isReadOnly) {
            log.info("Slave DataSource 호출 => ");
        } else {
            log.info("Master DataSource 호출");
        }

        // 현재 트랜잭션이 읽기 전용인 경우는 slave, 아닐 경우 master를 반환한다 -> 트랜잭션의 속성에 따라 데이터베이스 연결을 결정
        return isReadOnly ? "slave" : "master";
    }
}

현재 트랜잭션이 readOnly인지 boolean 변수에 담아주고 그 값에 따라서 어떤 DataSource를 사용할 것인지 반환해주는 클래스입니다.

실습을 하면서 알맞은 DataSource가 잘 호출되고 있는지 보기 위해서 저렇게 if ~ else문을 사용하였지만 삼항 연산자를 이용해서 한줄로 개선이 가능합니다.


이제 마지막으로 살펴볼 부분은 이 부분입니다.
@Primary 애너테이션을 이용하게 되면, DataSource 타입의 Bean이 필요하다면 dataSource( ) 메서드에서 반환하는 타입의 Bean을 가장 우선적으로 사용하겠다는 의미입니다.

여기서 LazyConnectionDataSourceProxy 타입의 DataSource를 반환하는 이유는 JDBC Connection을 가지고 오는 시점까지 DataSource의 사용을 지연시키기 때문입니다.

Spring은 실질적인 쿼리 실행 여부에 상관없이 트랜잭션이 걸리면 무조건 Connection을 확보하는 단점이 있습니다.
이것을 보완하기 위한 클래스가 LazyConnectionDataSourceProxy입니다.

실제 쿼리가 실행될때 RoutingDataSource에서 정의한 determineLookupKey 메서드를 활용해 어떤 DataSource를 사용할지 결정하게 되는 것입니다.

😎 JPA 설정

저는 프로젝트에서 JPA를 사용할 것이기 때문에 JPA 설정 클래스도 작성해주겠습니다.

@Configuration
@EnableTransactionManagement // 트랜잭션 관리 기능을 활성화하는 애너테이션
public class JpaConfiguration {


    @Bean
    public LocalContainerEntityManagerFactoryBean entityManagerFactory(
            // 이름이 dataSource인 Bean을 주입 받는다.
            @Qualifier("dataSource") DataSource dataSource) {

        LocalContainerEntityManagerFactoryBean entityManagerFactory
                = new LocalContainerEntityManagerFactoryBean();

        // DataSource를 주입받은 dataSource로 설정한다.
        entityManagerFactory.setDataSource(dataSource);
        // JPA 엔티티 클래스가 포함된 패키지를 설정한다.
        entityManagerFactory.setPackagesToScan("com.adoptpet.server");
        // JPA 벤더 어뎁터를 설정한다.
        entityManagerFactory.setJpaVendorAdapter(jpaVendorAdapter());
        // 영속성 유닛의 이름을 entityManager로 설정한다.
        entityManagerFactory.setPersistenceUnitName("entityManager");

        return entityManagerFactory;

    }

    private JpaVendorAdapter jpaVendorAdapter() {
        HibernateJpaVendorAdapter hibernateJpaVendorAdapter = new HibernateJpaVendorAdapter();
        // DDL 생성 기능을 비활성화
        hibernateJpaVendorAdapter.setGenerateDdl(false);
        // SQL 쿼리를 로깅하지 않도록 설정
        hibernateJpaVendorAdapter.setShowSql(false);
        // SQL 방언을 MySQL 5 Inno DB 방언으로 설정
        hibernateJpaVendorAdapter.setDatabasePlatform("org.hibernate.dialect.MySQL5InnoDBDialect");
        return hibernateJpaVendorAdapter;
    }

    @Bean
    public PlatformTransactionManager transactionManager (
            // 이름이 entityManager인 Bean을 주입받는다.
            @Qualifier("entityManagerFactory") LocalContainerEntityManagerFactoryBean entityManagerFactory) {
        JpaTransactionManager jpaTransactionManager = new JpaTransactionManager();
        // 주입받은 entityManagerFactory의 객체를 설정한다 -> 트랜잭션 매니저가 올바른 엔티티 매니저 팩토리를 사용하여 트랜잭션을 관리할 수 있다.
        jpaTransactionManager.setEntityManagerFactory(entityManagerFactory.getObject());
        return jpaTransactionManager;
    }
}

하나씩 살펴보겠습니다.


이 부분은 JPA에서 사용할 EntityManager를 등록하는 부분입니다.
JPA를 사용할때 EntityManager를 따로 등록해줄 필요는 없었지만 현재 어떤 DataSource를 사용해야 하는지 설정해줘야 하기 때문에 EntityManager를 직접 Bean으로 등록해줍니다.


이 부분은 JPA와 관련된 설정을 해주는 부분입니다. 저는 DDL 생성과 쿼리 로깅을 비활성화하고, MySQL을 사용중이므로 MySQL 방언을 셋팅해주었습니다.


Spring에서 트랜잭션 관리를 수행하는 PlatformTransactionManager를 직접 등록해줍니다.

이제 모든 설정이 끝났습니다.
그렇다면 제대로 동작하는지 지금부터 확인해보겠습니다.

👻 진짜 분산처리가 되고 있는가?

현재 진행중인 프로젝트의 서비스 클래스 코드의 일부입니다.

기본적으로 Transation을 read-only로 설정하고, 쓰기 작업을 하는 메서드에서는 read-only를 false로 설정합니다.


회원의 정보를 찾는 서비스 메서드와 회원의 정보를 수정하는 서비스 메서드를 실행했을때 서로 다른 DB를 호출하는지 확인해보겠습니다.


먼저 회원의 정보를 조회하는 API를 호출해보겠습니다.

Slave DataSource가 정상적으로 호출되었습니다.


회원 정보를 변경하는 API, 즉 쓰기 작업을 하는 트랜잭션을 실행해보겠습니다.

Master DataSource를 호출해서 쓰기작업을 진행하는 것을 확인할 수 있습니다.

👽 약간의 트러블 슈팅(p6spy 와의 충돌)

쿼리 안에 들어간 파라미터의 값들을 보려고 p6spy를 사용중에 replication 분기 작업을 하였는데 설정도 제대로 되고 모든 것이 잘 되었음에도 Master DB만 호출하는 현상을 겪었습니다.

p6spy는 connection을 직접 사용하기 때문에 LazyConnectionDataSourceProxy를 사용하더라도 WriteOrReadOnlyRoutingDataSource(AbstractRoutingDataSource)의 Write DB인 Master DataSource를 가져오게 되는 것입니다.

따라서 p6spy를 사용하는 대신 AOP로 로그를 찍는 것으로 방향을 틀게 되었습니다.

드디어 두개의 포스팅을 거쳐서 MySQL을 이용한 Replication 설정과 테스트까지 완료해보았습니다.

오늘도 읽어주셔서 감사합니다.

🙇

참고한 레퍼런스

https://devs0n.tistory.com/61

http://kwon37xi.egloos.com/m/5364167

2개의 댓글

comment-user-thumbnail
2024년 2월 29일

맛있게 먹고 갑니다

1개의 답글