Spring Boot + MySql(Master + Slave) 구성 (Feat. Mybatis)

최준호·2022년 8월 24일
3

Spring

목록 보기
38/47
post-thumbnail

이전에는 Redis를 사용하여 마스터 슬레이브 구조를 구성해보았다. 이 구조를 본 DB인 MySql에도 적용해보려고 한다.

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

🐳 Docker

🐋 Mysql 서버 생성

🐬 docker-compose 작성

version: "3"
services:
  db-master:
    build: 
      context: ./
      dockerfile: master/Dockerfile
    environment:
      MYSQL_DATABASE: 'db'
      MYSQL_USER: 'user'
      MYSQL_PASSWORD: 'password'
      MYSQL_ROOT_PASSWORD: 'password'
    ports:
      - '3306:3306'
    # Where our data will be persisted
    volumes:
      - my-db-master:/var/lib/mysql
      - my-db-master:/var/lib/mysql-files
    networks:
      - net-mysql
  
  db-slave:
    build: 
      context: ./
      dockerfile: slave/Dockerfile
    environment:
      MYSQL_DATABASE: 'db'
      MYSQL_USER: 'user'
      MYSQL_PASSWORD: 'password'
      MYSQL_ROOT_PASSWORD: 'password'
    ports:
      - '3307:3306'
    # Where our data will be persisted
    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

다음 docker-compose 파일을 먼저 작성해준다. 그 후 Dockerfile과 설정 파일을 작성해주어야한다.

🐬 Master 작성

FROM --platform=linux/x86_64 mysql:8.0
ADD ./master/my.cnf /etc/mysql/my.cnf

Dockerfile은 위와 같이 작성했다.

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

my.cnf Mysql conf 파일은 다음과 같이 작성했다.

🐬 Slave 작성

FROM --platform=linux/x86_64 mysql:8.0
ADD ./slave/my.cnf /etc/mysql/my.cnf

Dockerfile

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

my.cnf

각각 다음과 같이 작성한다.

🐋 Slave 설정하기

docker-compose up -d 명령어를 통해 container를 모두 띄우자

다음과 같이 정상적으로 띄워졌다면 이제 Slave 서버에 설정을 해주어야한다.

이제 Master 서버의 ip 주소값을 가져와야하는데
docker network inspect mysql_net-mysql
명령어를 입력하면

다음과 같이 container에 부여된 ip 값을 확인할 수 있다.

docker exec -it mysql_db-slave_1 /bin/bash

그 후에 slave에 접속하기 위해 다음 명령어를 통해 접속한다

mysql -u root -p
그 후 다음 명령어를 통해 mysql에 접근하면 비밀번호를 물어보는데 우리가 설정한 비밀번호는 password이다.

정상적으로 진행되었다면

해당 화면이 나오면 된다.

stop slave; 이제 mysql에 명령어를 입력해주자.

이렇게 나오면 되고

CHANGE MASTER TO 
MASTER_HOST='{master network ip address}', 
MASTER_USER='root', 
MASTER_PASSWORD='password', 
MASTER_LOG_FILE='mysql-bin.000001', 
MASTER_LOG_POS=0, 
GET_MASTER_PUBLIC_KEY=1;

그 후엔 위 명령어를 쳐줘야하는데 Docker라서 복붙이 안된다...ㅎ 틀리지 않게 잘 쳐보자

이렇게 나오면 된다.

이제 start slave; 명령어를 입력해주고

show slave status\G; 명령어로 slave 상태를 확인하면

다음과 같이 1396 에러로 인해 Slave_SQL_Running 상태값이 No로 표기된다. 이러면 정상적으로 Master query를 Slave가 전달 받지 못하므로 설정을 추가로 해주자.

우선 다시 stop slave; 명령어로 slave를 멈춰준다.

SET GLOBAL SQL_SLAVE_SKIP_COUNTER = 1;

그 후 위 명령어를 입력해주는데. slave 쿼리 에러를 1개 무시하라는 설정이다.

참고 Slave_SQL_Running 수정 방법: 복제 수정 안 함

다음 설정을 모두 마친 후 다시 slave 상태를 조회해보자.

이전에 발생했던 에러를 무시되어지고 slave 상태가 모두 Yes로 변경되었다. 이제 정말 master와 slave로 작동하는지 확인해보자.

🐬 Master에서 Database와 Table, Insert 해보기

이제는 Slave에서가 아닌 Master에서 작성해주어야 한다.

create database juno;
show databases;

명령어를 통해서 database를 만들고 확인해보았다. 이제 table과 데이터까지 다 넣어보자.

use juno;
create table test(
	id bigint,
    name varchar(20)
)

명령어를 통해 table을 생성했다.

insert into test (id, name) values (1, 'tester1');

명령어를 통해 데이터를 넣어주었다. 이제 Master와 Slave에서 각각 select 해보자!

select * from test;

Master

Slave

Master와 Slave 모두 동일한 데이터를 가지고 있는 것을 확인할 수 있었다.

여기까지 진행했다면 이제 Mysql로 Master + Slave 구성을 완료한 것이다. 이제 Spring Boot에서 이 DB를 이용해서 사용해보자!

단 여기서 진행한 slave 옵션은 readonly이기 때문에 root 권한을 가진 계정으로 접근하여 insert를 날리면 insert가 되어버린다! 만약 이거까지 막아버리고 싶다면 super_read_only='on'; 명령어를 통해 모든 접근을 막아버리거나 slave에서는 root권한을 가지지 않은 새로운 계정으로 접근하여 insert를 날려보는것이 좋다!

그 외 옵션들에 대한 정보를 더 알고 싶다면 참고 링크에서 확인해보자!

📗 Spring Boot에서 사용하기

Redis와 다르게 아쉽게도 Mysql은 따로 Master + Slave 구조에 대한 오픈소스가 존재하지 않는거 같다. 그래서 직접 설정해주어야 하는데 설정을 진행해보자!

📄 yml 설정

spring:
  master:
    datasource:
      driverClassName: com.mysql.cj.jdbc.Driver
      jdbcUrl: jdbc:mysql://localhost:3306/test
      username: juno
      password: password
  slave:
    datasource:
      driverClassName: com.mysql.cj.jdbc.Driver
      jdbcUrl: jdbc:mysql://localhost:3307/test
      username: juno
      password: passwrod

나는 여러 profile을 사용하고 있어서 다음과 같이 profile을 나누어서 작성했다. 그 외에는 datasource 아래에 master와 slave를 구분하여 작성해두었다.

📄 Config 설정

@Configuration
public class DataSourceConfig {
    public static final String MASTER_DATASOURCE = "masterDataSource";
    public static final String SLAVE_DATASOURCE = "slaveDataSource";

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

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

다음과 같이 Config를 설정하여 bean으로 등록해주자. DataSource의 경우 추후에 connection pool을 관리할 때 편하기 위해 일단 Hikari로 등록했다.

📄 Routing 로직 구현하기

이 부분이 중요한데 두개의 DataSource를 가지고 select 할때는 slave, insert/update/delete를 할때는 slave 이런 방식으로 진행해도 되지만 @Transactional(readonly = '{true or false}')를 통해 readonly 옵션이 true인지 false인지에 따라 master와 slave를 자동으로 분배해주는 것을 구현하려고 한다.

import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import org.springframework.transaction.support.TransactionSynchronizationManager;

public class RoutingDataSource extends AbstractRoutingDataSource{

    @Override
    protected Object determineCurrentLookupKey() {
        return TransactionSynchronizationManager.isCurrentTransactionReadOnly() ? "slave" : "master";
    }
}

spring에서 제공하는 RoutingDataSource 추사 class를 상속받아 다음을 구현해준다. Transaction이 readonly가 true인지 false인지 체크하여 값을 반환해준다.

그리고 다시 Config 부분에

import java.util.HashMap;
import java.util.Map;

import javax.sql.DataSource;

import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy;
import org.springframework.transaction.PlatformTransactionManager;

import com.zaxxer.hikari.HikariDataSource;

@Configuration
@MapperScan(value="com.exmaple.test.mapper")
public class DataSourceConfig {
    public static final String MASTER = "masterDataSource";
    public static final String SLAVE = "slaveDataSource";

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

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

    @Bean
    @DependsOn({MASTER, SLAVE})
    public RoutingDataSource routingDataSource(@Qualifier(MASTER) DataSource master, @Qualifier(SLAVE) DataSource slave){
        Map<Object, Object> targetDataSources = new HashMap<>();
        targetDataSources.put(MASTER, master);
        targetDataSources.put(SLAVE, slave);
        
        RoutingDataSource routingDataSource = new RoutingDataSource();
        routingDataSource.setTargetDataSources(targetDataSources);
        routingDataSource.setDefaultTargetDataSource(master);   //기본은 master
        
        return routingDataSource;
    }

    @Bean
    public LazyConnectionDataSourceProxy lazyDataSource(RoutingDataSource routingDataSource){
        return new LazyConnectionDataSourceProxy(routingDataSource);
    }

    @Bean
    public PlatformTransactionManager transactionManager(LazyConnectionDataSourceProxy routingDataSource){
        return new DataSourceTransactionManager(routingDataSource);
    }

    @Bean
    public SqlSessionFactory sqlSessionFactory(LazyConnectionDataSourceProxy dataSource) throws Exception{
        SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();
        sessionFactory.setDataSource(dataSource);
        PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
        sessionFactory.setMapperLocations(resolver.getResources("classpath:/db/mapper/**/*.xml"));
        sessionFactory.setConfigLocation(resolver.getResource("classpath:db/mybatis-config.xml "));
        return sessionFactory.getObject();
    }
}

다음과 같이 추가해준다.

여기서 LazyConnectionDataSourceProxy를 사용하지 않으면 쿼리를 발생시켰을 때 모두 Msater로 쏠리게되는 현상이 있다. Proxy로 내부 실행 순서를 바꿔줘야 정상 작동하게 된다. 자세한 내용은 참고글1 참고글2 중요! 참고글3 참고하면 될거같다.

그러면 다음과 같은 에러를 만날 수 있다^^

해당 에러는 local에 바로 mysql을 설치하지 않고 Docker를 사용하여 진행했을 때 나오는 에러인데 에러 문구 그대로 현재 ip에 대한 계정의 권한이 없기 때문이다. mysql에 새로운 계정을 생성해서 권한을 부여해주자!

위 로그를 보면 나의 경우 172.28.0.1 ip 번호를 통해 통신하고 있다. 각자 ip를 잘 확인하고 진행하자!

CREATE USER juno@172.28.0.1 identified by 'password';
grant all privileges on test.* to juno@172.28.0.1 WITH GRANT OPTION;
flush privileges;

다음 3 명령어를 차례대로 입력하여 진행하면 되고 나는 test database에서 진행하기 때문에 test.*인데 테이블 명이 다를 경우 다른 테이블 명 혹은

CREATE USER juno@172.28.0.1 identified by 'password';
grant all privileges on *.* to juno@172.28.0.1 WITH GRANT OPTION;
flush privileges;

로 진행하자!

그럼 다시 실행해보면!

다음과 같이 정상적으로 Connection pool이 등록되어지고 서버가 정상 실행된다.

📄 테스트!

참고 쿼리 실행 이력 확인

그래서 정말 insert는 master로 가고 select는 slave로 가는지 궁금해졌다. 그래서 로그로 찍어보려고 했는데 어떻게 하는 방법을 모르겠어서 mysql 실행 로그를 찍는 방법이 있더라. 그래서 위 방법대로 진행해보았다.

show variables like 'general%';

sql에서 다음 명령어를 사용하면

다음 화면에서 general_log 값이 OFF 일것이다. 그럼

set global general_log=on;

명령어를 통해 각 Master와 Slave 모두 log를 남기도록 설정해준다.

log를 계속 남기면 추후에 로그 분석을 하기엔 좋겠지만 DB 서버에 부하가 생길 수 있습니다!

그 후 아래 file의 위치에서 해당 파일을 cat으로 확인하거나 tail -f 명령어를 통해 파일의 로그를 확인하여 내가 보낸 명령어가 어디서 실행되는지 확인해볼 수 있었다.

다음과 같이 Select는 Slave에서 쿼리가 나왔으며 Insert는 Master에서 쿼리가 나왔다. 또한 Master에서 insert가 진행된 후 Slave에서 해당 내용을 Commit하고 있는 것도 확인할 수 있었다!

😂 Mybatis만 참고!

@Configuration
@MapperScan(value="com.kakaovx.ballmateapitest.mapper")
public class DataSourceConfig {
    
    ...

    @Bean
    @Profile(value = "local")
    public DataSourceInitializer dataSourceInitializer(@Qualifier(MASTER) DataSource master){
        ResourceDatabasePopulator resourceDatabasePopulator = new ResourceDatabasePopulator();
        resourceDatabasePopulator.addScript(new ClassPathResource("db/h2/schema.sql"));
        resourceDatabasePopulator.addScript(new ClassPathResource("db/h2/data.sql"));

        DataSourceInitializer dataSourceInitializer = new DataSourceInitializer();
        dataSourceInitializer.setDataSource(master);
        dataSourceInitializer.setDatabasePopulator(resourceDatabasePopulator);
        return dataSourceInitializer;
    }
}

JPA라면 Entity 구조로 자동으로 table 생성 쿼리를 날려줘서 상관 없지만 Mybatis의 경우 테스트 코드 확인을 위해 다음과 같이 Datasource를 Initializer 해주어야한다. 초기에 데이터 구조는 잡아놔야 테스트 코드가 실행이 가능하니 말이다 ㅜㅜ JPA 부럽다...

profile
코딩을 깔끔하게 하고 싶어하는 초보 개발자 (편하게 글을 쓰기위해 반말체를 사용하고 있습니다! 양해 부탁드려요!) 현재 KakaoVX 근무중입니다!

0개의 댓글