MySQL 레플리카 서버 구축하고 스프링 부트에 적용하기
프로젝트에 데이터베이스 레플리케이션을 적용하였습니다. 1개의 주DB와 2개의 보조 DB 서버로 구축된 시스템입니다. MySQL에서 레플리카 서버를 어떻게 설정할 수 있는지 살펴봅니다. 또한 스프링 애플리케이션에서 어떻게 여러개의 데이터소스를 설정하고 트랜잭션의 상태에 따라 분기할 수 있는지 살펴봅니다.
복제(Replication)은 1개 이상의 복사본이 원본 저장소와 동기화를 자동으로 유지하는 것을 말한다. 기본적으로 복제는 비동기 방식이고, 설정에 따라서 DB 전체가 아닌 특정 부분만 복제되도록 할 수 있습니다.
장점
- 스케일 아웃: 성능을 향상시키기 위해 여러개의 복제본에 트래픽을 분산시킬 수 있습니다. 모든 쓰기 작업은 주 DB에서 수행하고 읽기작업은 보조 DB 서버에서 처리하도록 구성하면 됩니다.
- 데이터 보호: 레플리카는 복제 과정을 중단할 수 있기 때문에 원본 데이터를 손상시키지 않고 레플리카에서 백업 서비스를 가동할 수 있다.
- 분석: 주 DB 서버의 성능 이슈 없이 레플리카에서 분석작업을 할 수 있다.
MySQL 복사 원리
MySQL의 Master-Slave 복사 원리는 다음과 같다.
MySQL에서는 원본 서버의 모든 데이터 변경사항을 바이너리 로그에 기록합니다.
- 레플리카가 초기화되면 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-address
가 127.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-bin
과 server-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
를 묶고 분기해줄 때 사용한다. AbstractRoutingDataSource
의 setTargetDataSources()
메서드를 통해 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;
}
@Qualifier
를 사용해서 아까전에 등록했던DataSource
들을 주입 받습니다.RoutingDataSource
인스턴스를 생성합니다.sourceDataSource
,replicaDataSourceOne
,replicaDataSourceTwo를
각각"source"
,"replica1"
,"replica2"
키로 매핑시킵니다.- 데이터 소스가 담긴 맵을
RoutingDataSource
에 등록합니다. - 기본
DataSource
로sourceDataSource
를 지정합니다.
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.yml
에 test
프로필을 적용하지 않았다. 따라서 test 프로필일때 사용해야하는 DataSource
대신 @Primary
애노테이션이 달린 LazyConnectionDataSourceProxy
를 사용하려 시도하였다. 테스트 환경에서는 데이터베이스에 연결하는데 필요한 환경변수를 설정해주지 않았기 때문에 에러가 발생했다.
테스트에서 메모리 DB 데이터소스가 적용되지 않음
하지만 test 프로필을 적용해도 여전히 에러가 발생했다.
@Profile("test")
애노테이션을 사용했기 때문에 테스트 프로필에서는 testDataSource()
에서 반환되는 h2 메모리 DB 데이터소스를 사용할 것으로 기대하였으나 @Primary
애노테이션이 적용되어있는 dataSource()
의 반환결과가 빈으로 등록되었다.
이 문제를 해결하기 위해서는 테스트 환경에서 사용할 빈 메서드에도 @Primary
애노테이션을 붙여주어야 하며, 운영환경에서 사용한 빈 메서드에도 프로필 정보, 즉 @Profile
을 작성해야 한다. 프로필이 test
가 아닌 환경에서 사용할 빈 메서드에는 @Profile("!test")
프로필을 적용하여 문제를 해결했다.