Post

MySQL 레플리카 서버 구축하고 스프링 부트에 적용하기

프로젝트에 데이터베이스 레플리케이션을 적용하였습니다. 1개의 주DB와 2개의 보조 DB 서버로 구축된 시스템입니다. MySQL에서 레플리카 서버를 어떻게 설정할 수 있는지 살펴봅니다. 또한 스프링 애플리케이션에서 어떻게 여러개의 데이터소스를 설정하고 트랜잭션의 상태에 따라 분기할 수 있는지 살펴봅니다.

복제(Replication)은 1개 이상의 복사본이 원본 저장소와 동기화를 자동으로 유지하는 것을 말한다. 기본적으로 복제는 비동기 방식이고, 설정에 따라서 DB 전체가 아닌 특정 부분만 복제되도록 할 수 있습니다.

장점

  1. 스케일 아웃: 성능을 향상시키기 위해 여러개의 복제본에 트래픽을 분산시킬 수 있습니다. 모든 쓰기 작업은 주 DB에서 수행하고 읽기작업은 보조 DB 서버에서 처리하도록 구성하면 됩니다.
  2. 데이터 보호: 레플리카는 복제 과정을 중단할 수 있기 때문에 원본 데이터를 손상시키지 않고 레플리카에서 백업 서비스를 가동할 수 있다.
  3. 분석: 주 DB 서버의 성능 이슈 없이 레플리카에서 분석작업을 할 수 있다.

MySQL 복사 원리

MySQL의 Master-Slave 복사 원리는 다음과 같다.

master-slave-mysql

MySQL에서는 원본 서버의 모든 데이터 변경사항을 바이너리 로그에 기록합니다.

  1. 레플리카가 초기화되면 2개의 쓰레드 작업을 생성한다.
    • 하나는 I/O쓰레드이다. 주 DB 서버에 연결하고 바이너리 로그를 하나씩 읽어온다. 그리고 레플리카 서버의 Relay 로그에 해당 내용들을 복한다.
    • 두번째는 SQL 쓰레드이다. Relay 로그를 읽고 레플리카 인스턴스에 최대한 빠르게 적용한다.

MySQL 서버 설정

레플리카로 운영할 MySQL 서버에는 파일을 통해 설정이 필요하기 때문에 도커를 사용하지 않고 mysql-server 를 직접 설치하여 사용하였다. 이미 환경이 준비되었다면 [[#레플리케이션 설정]]으로 바로 넘어가자.

서버 설치

1
sudo apt-get install mysql-server

유저 정보 설정

1
2
3
mysql server start
sudo mysql -u root -p
# [Enter password]  나오면 password 입력하면 된다. 생략도 가능하다.
1
2
use mysql # mysql database 선택
select user, host, plugin from user; # 유저 정보 조회

1
create user '유저이름'@'%' identified by '패스워드';

새롭게 만든 유저에게 권한을 부여한다.

1
2
grant all privileges on *.* to '유저이름'@'%' with grant option;
flush privileges; # 권한 테이블 갱신

특정 사용자에게 모든 데이터베이스와 테이블에 대한 모든 권한을 부여하는 쿼리이다. 다른 사용자에게 권한을 부여할 수 있는 권한도 포함한다.

외부 접속 설정

/etc/mysql/mysql.conf.d 디렉토리의 mysqld.cnf 파일을 확인해보면 bind-address127.0.0.1 로컬로 설정되어 있다. 외부 접속을 허용해야 하므로 임시로 0.0.0.0으로 설정해주자. 임시로만 한 설정이고, 보안문제가 발생할 수 있기 때문에 나중에 특정 컴퓨터나 망에서만 접속할 수 있도록 설정을 변경해 주는 것이 좋다.

readonly 파일이기 때문에 sudo 권한으로 편집기를 열어야한다.

1
sudo vim /etc/mysql/mysql.conf.d/mysqld.cnf

1
2
3
sudo service restart mysql
# 또는
sudo systemctl restart mysql

레플리케이션 설정

주 DB (Master DB)

/etc/mysql/my.cnf 파일에 다음 내용을 추가합니다.

  • log-bin
    • 업데이트되는 모든 쿼리들이 Binary log 파일에 기록됩니다.
    • 기본적으로 바이너리 로그 파일은 MySQL의 data directory인 /var/lib/mysql/<호스트명>-bin.000001과 같은 형식으로 생성됩니다.
    • 이 설정을 변경하면 바이너리 파일의 경로와 파일명의 접두어를 변경할 수 있습니다.
  • server-id
    • 설정에서 서버를 식별하기 위한 고유 ID 값입니다.
    • master, slave 각각 다르게 설정해야합니다.

서버를 재시작 해서 변경 내용을 적용하고, mysql 서버에 접속해서 설정이 제대로 되었는지 확인해보자.

1
2
sudo systemctl restart mysql
sudo -u root -p
1
show master status\G

File은 현재 바이너리 로그 파일명이고, Position은 현재 로그의 위치를 나타낸다.

보조 DB (Slave DB)

/etc/mysql/my.cnf 파일을 수정해줍니다. 여기서도 log-binserver-id를 추가하였습니다.

  • slave1 서버에서는 server-id=2
  • slave2 서버에서는 server-id=3
1
2
3
[mysqld]
log-bin=mysql-bin
server-id=2

데이터 덤프

주 DB에 있는 데이터를 덤프하여 보조 DB에 import 한다.

보조 DB와 주 DB 연결하기

Mysql Replication 구성하기 (tistory.com)

주 DB 서버에서 show master status 쿼리로 현재 로그 파일의 이름과 로그의 위치를 알 수 있었습니다.

1
show master status\G

  • 로그파일의 이름은 mysql-bin.000003
  • 로그파일의 위치는 986690 입니다.

보조 DB 서버에 접속해서 주 DB 서버 정보를 입력하고 레플리케이션을 시작합니다.

1
2
3
4
5
6
7
8
CHANGE MASTER TO 
MASTER_HOST='주DB 호스트', 
MASTER_USER='주DB 접속 유저', 
MASTER_PASSWORD='주DB 패스워드', 
MASTER_LOG_FILE='mysql-bin.000003', 
MASTER_LOG_POS=982386;

START SLAVE
  • MASTER_HOST: 주DB(master server)서버의 호스트명
  • MASTER_USER: 주DB 서버의 mysql에서 REPLICATION SLAVE 권한을 가진 User 계정의 이름
  • MASTER_PASSWORD: 주DB서버의 mysql에서 REPLICATION SLAVE 권한을 가진 User 계정의 비밀번호
  • MASTER_LOG_FILE: 주 DB 서버의 바이너리 로그 파일명
  • MASTER_LOG_POS: 주 DB 서버의 현재 로그의 위치

MASTER_LOG_POS 부터 파이너리 로그파일을 읽어 복사합니다. 레플리케이션을 시작한 후 보조 DB 서버에서 연결정보를 조회해보면 다음과 같습니다.

1
SHOW SLAVE STATUS\G

스프링 부트에서 MySQL 레플리카 사용하기

스프링 부트에서 MySQL 레플리카를 활용하여 읽기전용 트랜잭션은 보조 DB에서 처리하고, 쓰기는 주 DB 에서 처리하도록 설정한다.

  • @Transactional(readOnly=true): 보조 DB
  • @Transactional(readOnly=false): 주 DB 이를 위해서 주 DB, 보조 DB 중 어느 DB 를 선택하는지 설정하는 AbstractRoutingDataSource와 읽기 전용 트랜잭션에 보조 DB가 커넥션 되도록 하는 LazyConnectionDataSourceProxy를 사용합니다.

먼저 하나의 주 DB 와 2개의 보조 DB 에 대한 정보를 application.yml에 적어준다. 하나의 DataSource만 등록하는 것은 스프링 부트가 자동으로 빈을 만들어 관리해주지만 2개 이상의 DataSource를 사용할 때는 직접 빈을 만들어 사용해야한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
spring:  
  config:  
    import: optional:file:.env[.properties]  
  datasource:  
    source:  
      username: username
      password: password
      driver-class-name: com.mysql.cj.jdbc.Driver  
      jdbc-url: jdbc:mysql://XXX.XXX.XXX.XXX:3306/database
    replica1:  
      username: username
      password: password
      driver-class-name: com.mysql.cj.jdbc.Driver  
      jdbc-url: jdbc:mysql://XXX.XXX.XXX.XXX:3306/database
    replica2:  
      username: username
      password: password  
      driver-class-name: com.mysql.cj.jdbc.Driver  
      jdbc-url: jdbc:mysql://XXX.XXX.XXX.XXX:3306/database

그리고 Source와 Replica에 대응하는 DataSource 타입의 빈을 3개등록해주었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Configuration  
@Slf4j  
public class DatasourceConfig {  
  
    private static final String SOURCE_SERVER = "SOURCE";  
    private static final String REPLICA_SERVER1 = "REPLICA1";  
    private static final String REPLICA_SERVER2 = "REPLICA2";  
      
    @Bean  
    @Qualifier(SOURCE_SERVER)  
    @ConfigurationProperties(prefix = "spring.datasource.source")  
    public DataSource sourceDataSource() {  
        return DataSourceBuilder.create()  
            .build();  
    }  
    @Bean  
    @Qualifier(REPLICA_SERVER1)  
    @ConfigurationProperties(prefix = "spring.datasource.replica1")  
    public DataSource replicaDataSourceOne() {  
        return DataSourceBuilder.create()  
            .build();  
    }  
    @Bean  
    @Qualifier(REPLICA_SERVER2)  
    @ConfigurationProperties(prefix = "spring.datasource.replica2")  
    public DataSource replicaDataSourceTwo() {  
        return DataSourceBuilder.create()  
            .build();  
    }  
          
}

@ConfigurationProperties 애노테이션을 사용해서 application.yml에 명시한 설정 중 특정 설정 값만을 자바 빈에 매핑할 수 있다. 서로 다른 DataSource 객체에 다른 설정을 매핑할 수 있도록 하는 것이다.

일반적으로 같은 타입의 빈이 2개 이상 등록된 경우 스프링은 어떤 빈을 주입해야할지 모른다. 따라서 @Qualifier 애노테이션을 사용하여 한정자를 지정하고, 빈을 주입받는 입장에서 한정자를 명시하여 주입받을 빈을 지정할 수 있도록 만든다. @Qualifier를 사용하지 않고 @Bean 의 파라미터로 빈의 이름을 전달하는 방법을 사용해도 된다.

AbstractRoutingDataSource

스프링이 제공하는 AbstractRoutingDataSource라는 추상클래스는 Multi DataSource 환경에서 여러 DataSource를 묶고 분기해줄 때 사용한다. AbstractRoutingDataSourcesetTargetDataSources() 메서드를 통해 Map을 제공한다. key로 는 특정 데이터 소스를 표현하는 String, 그리고 value에는 DataSource가 담긴 Map이다.

determineTargetDataSource() 메서드는 실제로 사용할 DataSource를 결정하는 역할을 합니다. 이때 determineCurrentLookupKey() 메서드를 호출하여 반환된 key 값을 기준으로 데이터 소스를 선택합니다. 반환된 key 값에 따라, setTargetDataSources() 메서드로 설정한 Map에서 해당 key에 해당하는 DataSource를 조회해 사용하게 됩니다.

따라서, AbstractRoutingDataSource 추상 클래스를 상속받아, determineCurrentLookupKey() 메서드를 오버라이드하고, 특정 데이터 소스의 key를 반환하는 로직을 작성해야합니다.

AbstractRoutingDataSource를 상속한 RoutingDataSource를 구현하였습니다. determineCurrentLookupKey() 메서드를 오버라이드 하여 트랜잭션의 readOnly 값에 따라 다른 DataSource 키를 반환하도록 했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Slf4j  
public class RoutingDataSource extends AbstractRoutingDataSource {  
  
    private final List<String> replicaKeys = List.of("replica1", "replica2");  
    private final AtomicInteger counter = new AtomicInteger(0);   
      
    @Override  
    protected Object determineCurrentLookupKey() {  
        boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();  
        if (isReadOnly) {  
            int index = counter.getAndIncrement() % replicaKeys.size();  
            return replicaKeys.get(index);  
        }  
        return "source";  
    }  
}

TransactionSynchronizationManager를 사용하면 현재 트랜잭션이 읽기전용 트랜잭션인지 확인할 수 있습니다. 읽기전용 트랜잭션인지 판단한 내용을 바탕으로 분기해서 레플리카 서버와 마스터 서버중 어떤 DataSource를 선택할 지 결정하게 됩니다.

counter 필드는 두개 이상의 레플리카 서버에서 어떤 것을 선택할지 결정하는데 사용됩니다. 레플리카 서버가 하나만 있다면 바로 그 서버를 선택하면 되지만, 두개의 서버를 라운드 로빈 알고리즘 방식으로 번갈아 가면서 사용하고 싶었습니다. 카운터 값을 하나 두고, mod 연산의 결과에 따라 다른 레플리카 서버가 선택됩니다.

RoutingDataSource에 사용할 Source DataSource와 Replica DataSource 정보를 등록하고 이것을 빈으로 만듭니다. DataSourceConfig에 이어서 작성한 내용입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Bean  
public DataSource routingDataSource(  
    @Qualifier(SOURCE_SERVER)   DataSource sourceDataSource, //(1)
    @Qualifier(REPLICA_SERVER1) DataSource replicaDataSourceOne,  
    @Qualifier(REPLICA_SERVER2) DataSource replicaDataSourceTwo  
) {  
    RoutingDataSource routingDataSource = new RoutingDataSource(); // (2)  
  
    HashMap<Object, Object> dataSourceMap = new HashMap<>();  // (3)
    dataSourceMap.put("source", sourceDataSource);  
    dataSourceMap.put("replica1", replicaDataSourceOne);  
    dataSourceMap.put("replica2", replicaDataSourceTwo);  
      
    routingDataSource.setTargetDataSources(dataSourceMap);  // (4)
    routingDataSource.setDefaultTargetDataSource(sourceDataSource);  // (5)
  
    return routingDataSource;  
}
  1. @Qualifier를 사용해서 아까전에 등록했던 DataSource들을 주입 받습니다.
  2. RoutingDataSource 인스턴스를 생성합니다.
  3. sourceDataSource, replicaDataSourceOne, replicaDataSourceTwo를 각각 "source", "replica1", "replica2" 키로 매핑시킵니다.
  4. 데이터 소스가 담긴 맵을 RoutingDataSource에 등록합니다.
  5. 기본 DataSourcesourceDataSource를 지정합니다.

LazyConnectionDataSourceProxy

스프링이 DataSource를 통해 커넥션을 획득하는 과정에 대해서 이해해야합니다. 스프링은 트랜잭션에 진입하는 순간에 바로 DataSource를 가져오고, 커넥션을 획득합니다. 그리고 다음에 트랜잭션의 현재상태가 저자됩니다. 즉, TransactionSynchronizationManager에 트랜잭션 정보를 동기화하는 작업은 DataSource로부터 커넥션을 얻어온 이후 동작한다는 것입니다.

하지만 커넥션을 얻어오기 이전에 TransactionSynchronizationManager을 통해서 트랜잭션의 정보를 얻어 와야 합니다.

따라서 LazyConnectionDataSourceProxy라는 클래스를 사용해서 트랜잭션에 진입한 시점이 아니라, 실제 쿼리가 시작된 시점에 DataSource가 선택되도록 지연시킬 필요가 있습니다.

1
2
3
4
5
6
7
8
9
10
11
@Bean  
@Primary  
@Profile("!test")  
public DataSource dataSource() {  
    DataSource determinedDataSource = routingDataSource(  
        sourceDataSource(),   
        replicaDataSourceOne(),   
        replicaDataSourceTwo()  
    );  
    return new LazyConnectionDataSourceProxy(determinedDataSource);  
}

발생한 문제

테스트 환경에서 레플리카 데이터소스에 접근

테스트환경에서는 h2 메모리 데이터베이스에서 접근하고, 그 외에는 레플리카 서버에 접근하게 만들 의도였다. 하지만 테스트환경에서도 레플리카 서버에 접근하는 모습을 보였고, 데이터베이스와 연결하지 못해 에러가 발생하는 문제가 있었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Configuration  
@Slf4j  
public class DatasourceConfig {  
  
    private static final String SOURCE_SERVER = "SOURCE";  
    private static final String REPLICA_SERVER1 = "REPLICA1";  
    private static final String REPLICA_SERVER2 = "REPLICA2";  
      
    @Bean  
    @Profile("test") // 프로필이 test인 환경에서 사용
    @Primary // 테스트 환경에서 우선적으로 사용  
    public DataSource testDataSource() {  
        log.info("테스트 데이터소스 초기화");  
        DriverManagerDataSource dataSource = new DriverManagerDataSource();  
        dataSource.setDriverClassName("org.h2.Driver");  
        dataSource.setUrl("jdbc:h2:mem:db;DB_CLOSE_DELAY=-1");  
        dataSource.setUsername("sa");  
        dataSource.setPassword("");  
        return dataSource;  
    }  

	//...
      
    @Bean  
    @Primary    
    @Profile("!test") // 프로필이 test가 아닌 환경에서 사용  
    public DataSource dataSource() {  
        DataSource determinedDataSource = routingDataSource(  
            sourceDataSource(),   
            replicaDataSourceOne(),   
            replicaDataSourceTwo()  
        );  
        return new LazyConnectionDataSourceProxy(determinedDataSource);  
    }  
}

data계층의 설정파일에 프로필을 적용하지 않음.

data계층의 테스트 설정파일인 test/resources/application.ymltest 프로필을 적용하지 않았다. 따라서 test 프로필일때 사용해야하는 DataSource 대신 @Primary 애노테이션이 달린 LazyConnectionDataSourceProxy를 사용하려 시도하였다. 테스트 환경에서는 데이터베이스에 연결하는데 필요한 환경변수를 설정해주지 않았기 때문에 에러가 발생했다.

테스트에서 메모리 DB 데이터소스가 적용되지 않음

하지만 test 프로필을 적용해도 여전히 에러가 발생했다.

@Profile("test") 애노테이션을 사용했기 때문에 테스트 프로필에서는 testDataSource()에서 반환되는 h2 메모리 DB 데이터소스를 사용할 것으로 기대하였으나 @Primary 애노테이션이 적용되어있는 dataSource()의 반환결과가 빈으로 등록되었다.

이 문제를 해결하기 위해서는 테스트 환경에서 사용할 빈 메서드에도 @Primary 애노테이션을 붙여주어야 하며, 운영환경에서 사용한 빈 메서드에도 프로필 정보, 즉 @Profile을 작성해야 한다. 프로필이 test가 아닌 환경에서 사용할 빈 메서드에는 @Profile("!test") 프로필을 적용하여 문제를 해결했다.

This post is licensed under CC BY 4.0 by the author.