본문 바로가기

interviewPrep 프로젝트

Spring에서 Mysql Replication으로 Master/Slave 이중화하기

이 글에서는 스프링 서버에서 Mysql Replication을 하는 방법을 다루겠습니다. 

 

1) 프로젝트에 Mysql Replication을 적용한 이유

2) Mysql Replication 동작 원리 

3) Docker로 Mysql Replication이 적용된 Master, Slave 컨테이너 띄우기

4) Spring에서 DataSource 설정하기 

5) Spring에서 @Transactional로 쿼리 요청 분기하기 


1) 프로젝트에 Mysql Replication을 적용한 이유


1. 스케일 아웃(scale-out) 
- 제가 개발하는 interviewPrep 프로젝트에서는 사용자가 문제 조회, 답안 작성 등을 통해서 db에 접근합니다.

  이 때, 사용자가 점점 늘어난다면 db에 쿼리를 날리는 일이 더 많아질 것이고,

  이로 인해 db 부하가 늘어날 것이라고 판단하였습니다.
  따라서 db 부하를 줄이기 위해 db를 이중화하여 Master와 Slave로 분리하고,

  Master는 쓰기/수정/삭제 쿼리를 처리하고, Slave는 읽기 쿼리를 처리하는 방식을 도입했습니다 

2. 데이터 백업
- DB 서버에 저장된 데이터가 사용자의 실수로 삭제되면, 서비스 운영에 치명적인 영향을 줄 수 있습니다. 

  따라서 Master의 데이터를 Slave에 백업해둠으로써, DB 서버에 문제가 생겼을 때, 데이터를 복구할 수 있습니다. 

 

2) Mysql Replication 동작 원리

 

 Mysql 서버에서 발생하는 모든 변경 사항은 별도의 로그 파일에 순서대로 기록되는데,

 이를 바이너리 로그(binary log)라고 합니다.

 Mysql Replication은 이 바이너리 로그를 기반으로 구현됐는데,

 Master 서버에서 생성된 바이너리 로그가 Slave 서버로 전송되고, Slave 서버에서는 해당 내용을 로컬 디스크에 저장한뒤 자신이 가진 데이터에 반영함으로써 Master 서버와 Slave 서버 간에 데이터 동기화가 이뤄집니다.
 Slave 서버에서 Master 서버의 바이너리 로그를 읽어 들여 따로 로컬 디스크에 저장해둔 파일을

 릴레이 로그(Relay log)라고 합니다. 

 

 

3) Docker로 Mysql Replication이 적용된 Master, Slave 컨테이너 띄우기

Docker로 Mysql Master, Slave 컨테이너를 띄우는 법은 이 글을 참고해서 진행했습니다.  

https://jupiny.com/2017/11/07/docker-mysql-replicaiton/

 

- Docker로 Mysql Master, Slave 컨테이너를 띄운 이유는,

  (1) db 서버간 이중화를 더 쉽게 관리하고 확장할 수 있으며

  (2) 환경 간 호환성 문제를 최소화할 수 있기 때문입니다. 

 

4) Spring에서 DataSource 설정하기

- 기존에는 Spring 서버의 DataSource가 한 개였지만, 이제는 Master와 Slave로 이중화되었기에, 두 개의 DataSource에 대해 쿼리 요청을 각각 나눠서 보내줘야 합니다. 그 설정 과정을 단계별로 설명드리겠습니다. 

 

(1) application.yml 작성

- application.yml 파일에 master datasource와 slave datasource를 각각 등록해줍니다.

spring:
  datasource:
    master:
       hikari:
         username: <master 유저 네임>
         password: <master 비밀번호>
         driver-class-name: com.mysql.cj.jdbc.Driver
         jdbc-url: jdbc:mysql://mysql-master:3306/interviewPrep?useSSL=false

    slave:
        hikari:
          username: <slave 유저 네임>
          password: <slave 비밀번호> 
          driver-class-name: com.mysql.cj.jdbc.Driver
          jdbc-url: jdbc:mysql://mysql-slave:3306/interviewPrep?useSSL=false

 

(2) DataSourceConfig 클래스 작성 

@Configuration
@EnableAutoConfiguration(
        exclude = {DataSourceAutoConfiguration.class}
)
@EnableTransactionManagement
public class DataSourceConfig {

    public static final String MASTER_DATASOURCE = "masterDataSource";
    public static final String SLAVE_DATASOURCE = "slaveDataSource";

    @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);
    }

}

 

코드를 나눠서 살펴보겠습니다.

 

(2-1) MasterDataSource, SlaveDataSource를 Bean으로 등록 

    public static final String MASTER_DATASOURCE = "masterDataSource";
    public static final String SLAVE_DATASOURCE = "slaveDataSource";

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

- MasterDataSource와 SlaveDataSource를 빈으로 등록합니다.

  이 때, @ConfigurationProperties를 사용하여 application.yml에 정의한 속성값을 사용하여 빈을 구성합니다.   

  반환된 데이터 소스는 HikariCP 타입의 데이터 소스로 설정됩니다. 

 

 

(2-2) routingDataSource 메소드

    @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;
    }

- 트랜잭션의 readOnly에 따라 사용할 DataSource를 결정하는 메소드입니다. 

  현재 DataSource 타입의 빈이 2개이므로, @Qualifier를 활용해서 타입이 아닌 이름으로

  MasterDataSource와 SlaveDataSource를 주입 받습니다. 

  그리고 RoutingDataSource 클래스의 객체를 생성합니다.

 

- 설정 클래스를 작성할 때 사용하는 Map은 변하지 않는 값이므로, ImmutableMap을 사용합니다. 

  그리고 작성한 dataSourceMap을 routingDataSource의 targetDataSource로 지정하고, 

  기본 데이터 소스를 masterDataSource로 지정한 후, routingDataSource를 반환합니다. 

 

(2-3) 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";
    }

}

- RoutingDataSource 클래스에서는 determineCurrentLookupKey 메소드를 오버라이딩해줍니다. 

  determineCurrentLookupKey 메소드에서는 현재 트랜잭션이 readOnly인지 검사하여, 

  readOnly라면 "slave"를, 아니라면 "master"를 반환합니다. 

 

 

(2-4) dataSource 메소드

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

- dataSource 메소드는 @Primary를 이용하는데, 이를 통해 DataSource 빈이 필요할 때, 해당 빈을
  가장 우선적으로 사용합니다.

  그리고 dataSource 메소드는 LazyConnectionDataSourceProxy 타입의 DataSource 빈을 반환하는데,

   이를 통해 JDBC Connection을 실제로 가져오는 시점까지 DataSource의 사용을 지연시킵니다. 

 

- 그리고 실제 쿼리가 실행될 때, RoutingDataSource 클래스에 정의된 detemineLookupKey 메서드로 

   어떤 DataSource가 사용될지 결정됩니다.

 

 

(3) JpaConfig 클래스 작성

- 프로젝트에 JPA를 적용했으므로 JPA 설정 클래스도 작성했습니다. 

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

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

        LocalContainerEntityManagerFactoryBean entityManagerFactory
                = new LocalContainerEntityManagerFactoryBean();

        // DataSource를 주입받은 dataSource로 설정한다.
        entityManagerFactory.setDataSource(dataSource);
        // JPA 엔티티 클래스가 포함된 패키지를 설정한다.
        entityManagerFactory.setPackagesToScan("com.example.interviewPrep.quiz");
        // 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;
    }
}

- 코드를 나눠서 살펴보겠습니다.

 

(3-1) entityManagerFactory 메소드

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

        LocalContainerEntityManagerFactoryBean entityManagerFactory
                = new LocalContainerEntityManagerFactoryBean();

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

        return entityManagerFactory;
    }

- 이 메소드는 JPA에서 사용할 entityManagerFactory를 등록하는 메소드입니다. 

  이 메소드가 필요한 이유는 현재 어떤 DataSource를 사용할지 주입해줘야 하기 때문입니다.  

 

 

(3-2) jpaVendorAdapter 메소드

    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;
    }

- 이 메소드는 JPA와 관련된 설정(DDL 생성, 쿼리 로깅, 데이터베이스 방언)을 해주는 메소드입니다. 

 

(3-3) transactionManager 메소드

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

- 이 메소드는 트랜잭션 관리를 위한 PlatformTransactionManager를 직접 등록하는 메소드입니다. 

 


5) Spring에서 @Transactional로 쿼리 요청 분기하기 

- 다음은 Spring에서 @Transactional로 실제 쿼리 요청을 분기하는 것에 대해 설명드리겠습니다. 

 

(1) QuestionService 클래스

- QuestionService 클래스에서 클래스 레벨에 @Transactonal(readOnly= true)를 붙여줍니다.

  이렇게 하면 클래스 레벨에서 readOnly가 true로 적용됩니다.

 

- 그리고 createQuestion 메소드에 @Transactional를 붙여줍니다.

  @Transaction의 default readOnly는 false이기 때문에, 

  이렇게 되면 createQuestion은 readOnly=false가 설정됩니다. 

@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class QuestionService {

    private final QuestionRepository questionRepository;
	private final AnswerRepository answerRepository;

    public QuestionDTO getQuestion(Long id) {
        Question question = findQuestion(id);

        return QuestionDTO.builder()
                .id(question.getId())
                .title(question.getTitle())
                .type(question.getType())
                .build();
    }

    @Transactional
    public Question createQuestion(QuestionDTO questionDTO){
        Question question = Question.builder()
                .id(questionDTO.getId())
                .title(questionDTO.getTitle())
                .type(questionDTO.getType())
                .build();
        questionRepository.save(question);
        return question;
    }
}

 

- 다음은 서버를 실행시키고, Question 엔티티를 생성하는 요청을 Postman으로 보내면

 

- 다음과 같이 Master DataSource를 통해, Question 엔티티가 insert됨을 확인할 수 있습니다. 

 

- 반면, Question 엔티티를 읽는 요청을 Postman으로 보내면,

 

- Slave DataSource를 통해, Question 엔티티를 읽어옴을 확인할 수 있습니다.  

 

참고 자료

Real MySQL 2권

[#8] Mysql Replication - Spring에서 Master/Slave 이중화 with Docker (tistory.com)

Docker를 이용하여 MySQL Replication 구성해보기 (jupiny.com)

https://mudchobo.github.io/posts/spring-boot-jpa-master-slave

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