본문 바로가기

Spring

[Spring] Master, Slave 데이터베이스 구조로 쓰기, 읽기 연산 나누기

원티드에서 제공하는 프리온보딩 챌린지 과정을 수강하면서 데이터베이스 구조를 주 데이터베이스(Master DB)서브 데이터베이스(Slave DB)로 나눠 쓰기 연산(Insert, Update, Delete)과 읽기 연산(Find)을 분기 처리하는 방법을 알게 되어 정리한 글입니다.

서론

대부분의 애플리케이션은 쓰기 연산보다 읽기 연산의 비중이 훨씬 높다. 따라서 더 나은 성능을 위해 데이터베이스 상태를 변경하는 생성, 수정, 삭제는 주 데이터베이스(Master DB)에서 처리하고 읽기 연산은 서브 데이터베이스(Slave DB)에서 처리한다.

 

 

연산 종류에 따라 DB를 구분하는 방법은 @Transactional 어노테이션을 사용한다. @Transactional의 readOnly 속성이 true면 읽기 연산이므로 Slave DB에서 처리하고 false면 Master DB에서 처리한다.

  1. @Transactional(readOnly = true) : 읽기 연산, Slave DB에서 처리
  2. @Transactional(readOnly = false) : 쓰기 연산, Master DB에서 처리

DB  Replication

 

[Spring-boot] Master - Slave 구조에 따른 Read, Write 분기

서론 데이터베이스를 이용한다면 대부분 쓰기보다 읽기 의 행위가 더 많습니다. DB의 부하를 줄이기 위해 다음과 같이 Master - Slave 구조를 많이 사용하는데요. 이러한 구조를 가지고 있을 때 Transe

k3068.tistory.com

코드를 작성하기 전에 먼저 Master DB와 Slave DB를 동기화 시켜주는 작업이 필요하다. (동기화 방법은 위 블로그를 참조했습니다.)

 

docker-compose.yml

version: "3"
services:
  db-master:
    build:
      context: ./
      dockerfile: master/Dockerfile
    restart: always
    environment:
      MYSQL_DATABASE: "db"
      MYSQL_USER: "user"
      MYSQL_PASSWORD: "password"
      MYSQL_ROOT_PASSWORD: "password"
    ports:
      - "3307:3306"
    # Where our data will be persisted
    container_name: "master_db"
    volumes:
      - my-db-master:/var/lib/mysql
      - my-db-master:/var/lib/mysql-files
    networks:
      - net-mysql

  db-slave:
    build:
      context: ./
      dockerfile: slave/Dockerfile
    restart: always
    environment:
      MYSQL_DATABASE: "db"
      MYSQL_USER: "user"
      MYSQL_PASSWORD: "password"
      MYSQL_ROOT_PASSWORD: "password"
    ports:
      - "3308:3306"
    # Where our data will be persisted
    container_name: "slave_db"
    volumes:
      - my-db-slave:/var/lib/mysql
      - my-db-slave:/var/lib/mysql-files
    networks:
      - net-mysql

# Names our volume
volumes:
  my-db-master:
  my-db-slave:

networks:
  net-mysql:
    driver: bridge

master/Dockerfile

FROM mysql:8.0.32
ADD ./master/my.cnf /etc/mysql/my.cnf

master/my.cnf

[mysqld]
log_bin = mysql-bin
server_id = 10
default_authentication_plugin=mysql_native_password

slave/Dockerfile

FROM mysql:8.0.32
ADD ./slave/my.cnf /etc/mysql/my.cnf

slave/my.cnf

[mysqld]
log_bin = mysql-bin
server_id = 11
relay_log = /var/lib/mysql/mysql-relay-bin
log_slave_updates = 1
read_only = 1
default_authentication_plugin=mysql_native_password

 

위와 같이 DB Replication에 필요한 설정 파일을 작성하고 Docker-compose.yml 파일이 있는 폴더 경로로 접근해 아래 순서대로 명령어를 수행한다.

 

docker-compose up -d

 

docker inspect {MASTER DB CONTAINER ID}

 

여기서 IPAddress 값 저장!!

 

docker exec -it {SLAVE DB CONTAINER ID} mysql -u root -p

 

위 명령어로 SLAVE DB CONTAINER에 접속 후 아래 명령어로 SLAVE DB를 세팅해 준다.

이때 'MASTER DB IP Address' 값은 위에서 저장한 IPAddress 값을 적용해 준다.

 

stop slave;

CHANGE MASTER TO 
MASTER_HOST='MASTER DB IP Address', 
MASTER_USER='root', 
MASTER_PASSWORD='password', 
MASTER_LOG_FILE='mysql-bin.000001', 
MASTER_LOG_POS=0, 
GET_MASTER_PUBLIC_KEY=1;

start slave;

 

SLAVE DB STATUS 확인

 

show slave status\G;

 

상태를 확인했을 때 Slave_IO_Running과 Slave_SQL_Running 속성이 Yes로 되있다면 DB 동기화 성공!!

만약 둘중 하나가 NO라면 Last_Errno를 확인 후 적절한 조취를 취하면 된다.

 

application.yml

spring:
  datasource:
    master:
      hikari:
        #mysql setting
        driver-class-name: com.mysql.cj.jdbc.Driver
        jdbc-url: jdbc:mysql://localhost:3307/db?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul
        username: root
        password: password
    slave:
      hikari:
        #mysql setting
        driver-class-name: com.mysql.cj.jdbc.Driver
        jdbc-url: jdbc:mysql://localhost:3308/db?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul
        username: root
        password: password

 

Master DB와 Slave DB를 나누는 설정은 SpringBoot의 application.yml 파일에서 쉽게 할 수 있다.

설정은 Docker-compose.yml 파일에서 생성한대로 Master DB는 3307 port로 Slave DB는 3308 port로 연결했다.

 

하나의 데이터소스를 사용하는 경우 스프링에서 자동으로 데이터소스를 생성하지만 위와 같이 2개 이상의 데이터소스를 사용하면 추가 코드를 작성하여 데이터소스를 직접 작성해야 한다.

AbstractRoutingDataSource.class

public class RoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) ? "slave" : "master";
    }
}

 

AbstractRoutingDataSource.class를 상속받아 determineCurrentLookupKey() 메서드를 재정의 했다.

현재 트랜잭션의 readOnly 속성true면 "slave", false면 "master" 문자열을 리턴

DataSourceConfig.class

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

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

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

    @Bean
    @DependsOn({"masterDataSource", "slaveDataSource"})
    public DataSource routingDataSource(
            @Qualifier("masterDataSource") DataSource masterDataSource,
            @Qualifier("slaveDataSource") DataSource slaveDataSource) {

        RoutingDataSource routingDataSource = new RoutingDataSource();

        Map<Object, Object> datasourceMap = new HashMap<>();
        datasourceMap.put("master", masterDataSource);
        datasourceMap.put("slave", slaveDataSource);

        routingDataSource.setTargetDataSources(datasourceMap);
        routingDataSource.setDefaultTargetDataSource(masterDataSource);

        return routingDataSource;
    }

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

 

DataSourceConfig.class 파일을 작성하여 Master DB와 Slave DB DataSource를 직접 빈으로 등록해 주었다.

  • @EnableAutoConfiguration(exclude = {DataSourceAutoConfiguration.class}) : JPA에서 default로 설정하는 DataSource Config 파일을 제외
  • @EnableTransactionManagement : TransactionManager에 data source를 넣는 config를 호출
  • masterDataSource() : application.yml 파일에서 설정한 spring.datasource.master.hikari 프로퍼티를 사용하여 MASTER DB에 대한 데이터 소스를 설정
  • slaveDataSource() : application.yml 파일에서 설정한 spring.datasource.slave.hikari 프로퍼티를 사용하여 SLAVE DB에 대한 데이터 소스를 설정
  • routingDataSource() : masterDataSource와 slaveDataSource 빈을 인자로 받아 라우팅 데이터 소스를 생성
  • dataSource() : 트랜잭션 동기화 시점에 커넥션을 얻기 위한 설정, 만약 LazyConnectionDataSourceProxy 설정을 하지 않으면 트랜잭션 진입 전에 DataSource가 정해지고 커넥션을 연결하기 때문에 readOnly 속성으로 DataSource를 선택하는 것이 불가능하다.

테스트

왼쪽은 MASTER DB의 sql 로그를 실시간으로 출력한 이미지이고 오른쪽은 SLAVE DB의 sql 로그이다.

 

save 로직을 수행했을 때 MASTER DB 로그에서 insert query가 찍힌 것을 확인할 수 있었다.

 

반대로 select 로직을 수행했을 때는 SLAVE DB에 select query가 찍혀 readOnly 값에 따라 DataSource가 다르게 적용된 것을 확인할 수 있었다.