0. 들어가기 전에
추석 연휴 동안 우리 프로젝트의 db가 다운이 되었다.)
다운된 시점 근처에서 해외에서 악의적 요청을 보낸 로그가 있어서 마음을 졸였다. 다행이도 별 문제는 없었지만, DB가 하나인 상황이라 DB가 죽으면 서비스를 운영할 수 없다. 따라서 SPOF문제를 해결하기 위해 replication을 적용하기로 했다.
1. Replication 이란?
하나의 DB 서버를 다른 DB로 복제 하는 것
Replication은 하나의 DB 서버를 다른 서버로 데이터를 동기화하는 것을 의미한다. 예전에는 원본 데이터 서버 - 복제된 데이터 서버
의 관계를 마스터(Master) - 슬레이브(Slave)
라고 했지만 최근에는 소스(Source) - 레플리카(Replica)
라고 말하는 추세이다. 소스 서버에서 데이터 및 스키마의 변경이 시작되고 이런 변경 내역들이 레플리카 서버에 동기화되는 형식으로 동작한다.
그렇다면 Replication은 왜 하는 것일까?
내가 생각하기에 가장 큰 이유는 SPOF 방지를 위한 목적이 큰 것 같다. DB가 하나에서 두 개가 되는 것이기 때문에 그 만큼 가용성이 올라가기 때문이다. 또한, 스케일 아웃, 데이터 백업 면에서도 좋다.
1.1 Replicatoin 방식
1. binary-log 방식
위 그림에서 로그파일 두 개를 볼 수 있다. 바이너리 로그와 릴레이 로그이다.
바이너리 로그를 먼저 설명하자면, 바이너리 로그는 MySQL에서 발생하는 모든 변경 사항(데이터 변경 및 테이블 구조 변경 등)들이 기록된 로그 파일이다. 이러한 바이너리 로그들을 통해 소스 데이터베이스 레플리카 데이터베이스를 동기화하는 것이다.
다음으로 릴레이 로그는 레플리카 서버가 소스 서버의 바이너리 로그를 읽고 저장하는 로그 파일들이다. 이 파일들이 직접적으로 레플리카 서버에서 데이터를 복제할 때 사용된다. 그렇다면 왜 릴레이 로그를 사용하는 것일까?
복제 과정에 대해서 좀 더 자세히 알아보자. 소스 서버의 바이너리 로그가 업데이트되면 소스 서버의 바이너리 로그 덤프 스레드가 바이너리 로그를 레플리카 서버로 전송한다. 레플리케이션 I/O 스레드는 수신한 로그를 릴레이 로그로 저장하고 레플리케이션 SQL 스레드가 복제를 시작한다.
이때 레플리케이션의 I/O 스레드와 SQL 스레드가 독립적으로 작동하는데 이는 변경 사항을 읽는 것과 적용하는 것의 역할을 분리할 수 있다는 장점이 있다. 이를 위해서 중간에 로그들을 저장하는 릴레이 로그를 사용하게 된다.
2. GTID 방식
바이너리 로그 방식은 가장 기본적인 복제 방식이지만, 문제점도 있다. 그건 데이터베이스 복제가 중단되었을 때 복구의 어려움이다. 왜냐하면 바이너리 로그는 로그의 파일명
과 로그가 저장되는 위치
두 개로 식별되기 때문이다. 설명만 읽어도 다른 데이터 서버에서 동일하게 저장되지 않을 것 같다.
그림으로 살펴보자.
위와 같은 방식으로 replication이 진행되고 있고, A에서 B로 바이너리 로그를 성공적으로 복제한다음 C로 복제할 때 A가 다운이 되었다고 하자.
그렇다면 서버의 요청은 어디로 받게 될까? B가 가장 최신의 데이터를 가지고 있으므로 B를 승급해서 사용하는 게 당연할 것이다. 그리고 C에 select 쿼리를 위임을 위임하면 되는데, 안타깝게도 C는 최신의 데이터가 있지 않기 때문에 사용할 수 없다. C는 새로운 데이터를 동기화하고 싶지만, 동기화할 수 없다. B의 릴레이 로그를 통해 복구하는 방법이 있지만 릴레이 로그는 필요하지 않은 시점에 자동으로 삭제가 되므로 그것 또한 하기가 어렵다.
따라서 GTID(Global Transaction ID)가 필요하다는 결론이 나온다. GTID는 바이너리 로그와는 달리 모든 소스와 레플리카 데이터베이스가 같은 값을 가지고 있다. 따라서 데이터베이스가 중간에 복제되다가 중단되어도 중단 시점을 알기 때문에 다른 데이터베이스에서 복제하기 쉽다.
2. 바톤의 Replication 과정
현재 바톤의 서버 구조는 아래와 같다.
이 구조를 다음과 같이 변경할 예정이다.
설정할 건 크게 DB와 스프링 두 개가 있다.
2.1 DB 설정
먼저 운영중인 데이터베이스(이하 Source DB)에서 원래 데이터를 백업을 해준다. 추후에 이 파일로 복제를 진행할 예정이다.
mysqldump -u root -p -v --databases <DATABASE_NAME> \
--quick --single-transaction --routines --set-gtid-purged=ON \
--triggers --extended-insert --source-data=2 > backup.sql
다음으로 docker container에 들어가서 /etc/my.cnf에 들어가 다음과 같이 설정파일들을 바꿔주면 된다.
source
# Replication - Source
server-id=1
log-bin=binlog
gtid-mode=ON # GTID 모드
enforce-gtid-consistency=ON # GTID 모드
log_replica_updates=ON # 복제된 내용을 자신의 바이너리 로그에 저장(추후에 replica 상태에서 승급되면 사용 가능)
replica
# Replication - Replica
server-id=2
log-bin=binlog
gtid-mode=ON # GTID 모드
enforce-gtid-consistency=ON # GTID 모드
relay_log=mysql-relay-bin
relay_log_purge=ON # rela_log 자동 삭제
read_only # 읽기 전용 DB로 사용
super_read_only # Replica에서 root도 dml이나 ddl을 실행할 수 없음
log_replica_updates=ON # 복제된 내용을 자신의 바이너리 로그에 저장(추후에 승급되면 사용 가능)
추가로 source db에는 replication 용 user를 생성하고 해당 유저에게 replication 권한을 줘야한다.
GRANT REPLICATION SLAVE ON *.* TO 'user'@'%';
FLUSH PRIVILEGES;
그 후 backup.sql을 도커 명령어로 ec2로 옮기고 scp로 복제할 데이터베이스(이하 Replica DB)가 위치한 ec2로 복사해주면된다. 그 후 mysql에 들어가 직접 source 명령어를 실행하면 복제가 완료가 된다.
source backup.sql
그 후 위에서 만들었던 계정을 가지고 아래와 같은 명령어를 mysql에서 입력하면 된다.
stop replica;
Change replication source to source_host=‘<SOURCE_DB_IP>’, source_port=3306, source_user=<SOURCE_USER>, source_password=<SOURCE_USER_PASSWORD>, source_auto_position=1, get_source_public_key=1
start replica;
log를 설정해서 정상동작하는지 확인하면 된다.
SET GLOBAL log_output = 'table';
SET GLOBAL general_log = 1;
SELECT user_host, thread_id, server_id, convert(argument using utf8) FROM mysql.general_log where %% argument %% like '%select%';
SET GLOBAL general_log = 0;
SHOW VARIABLES LIKE '%general%';
2.2 Spring 설정
yml 파일을 수정한다.
spring:
datasource:
source:
url: jdbc:mysql://${SOURCE_IP}:${SOURCE_PORT}/${SOURCE_DB_NAME}
driver-class-name: com.mysql.cj.jdbc.Driver
username: ${SOURCE_USERNAME}
password: ${SOURCE_PASSWORD}
replica:
url: jdbc:mysql://${REPLICA_IP}:${REPLICA_PORT}/${REPLICA_DB_NAME}
driver-class-name: com.mysql.cj.jdbc.Driver
username: ${REPLICA_USERNAME}
password: ${REPLICA_PASSWORD}
설정한 DataSource가 두 개이기 때문에 각각의 DataSource를 빈으로 등록해주어야 한다.
public enum DataSourceType {
SOURCE(SOURCE_NAME),
REPLICA(REPLICA_NAME);
private final String name;
DataSourceType(final String name) {
this.name = name;
}
public static class Name {
public static final String ROUTING_NAME = "ROUTING";
public static final String SOURCE_NAME = "SOURCE";
public static final String REPLICA_NAME = "REPLICA";
}
}
@Configuration
public class DataSourceConfig {
@Qualifier(SOURCE_NAME)
@ConfigurationProperties(prefix = "spring.datasource.source")
@Bean
public DataSource sourceDataSource() {
return DataSourceBuilder.create().build();
}
@Qualifier(REPLICA_NAME)
@ConfigurationProperties(prefix = "spring.datasource.replica")
@Bean
public DataSource replicaDataSource() {
return DataSourceBuilder.create().build();
}
@Qualifier(ROUTING_NAME)
@Bean
public DataSource routingDataSource(@Qualifier(SOURCE_NAME) final DataSource sourceDataSource,
@Qualifier(REPLICA_NAME) final DataSource replicaDataSource
) {
return RoutingDataSource.createDefaultSetting(
Map.of(DataSourceType.SOURCE, sourceDataSource,
DataSourceType.REPLICA, replicaDataSource)
);
}
@Bean
@Primary
public DataSource dataSource(@Qualifier(ROUTING_NAME) final DataSource replicationRoutingDataSource) {
return new LazyConnectionDataSourceProxy(replicationRoutingDataSource);
}
}
여기에서 LazyConnectionDataSourceProxy
가 나온다. 이 객체는 실제로 DB의 데이터를 요청할 때까지 Connection을 획득하는 걸 늦춰주는 객체이다. 원래 스프링은 Transaction이 시작되면 바로 Connection을 가지고 오는데 이 객체는 실제 Connection이 필요할 때 DataSource를 가져오므로 우리가 중간에 조작을 할 수가 있다.
다음으로 RoutingDataSource를 만들어준다.
이 클래스는 @Transactional
이 readOnly=true
이면 Replica로 아니면 Source로 DataSource를 바꿔준다.
public class RoutingDataSource extends AbstractRoutingDataSource {
public static RoutingDataSource createDefaultSetting(final Map<Object, Object> dataSources) {
final RoutingDataSource routingDataSource = new RoutingDataSource();
routingDataSource.setDefaultTargetDataSource(dataSources.get(DataSourceType.SOURCE));
routingDataSource.setTargetDataSources(dataSources);
return routingDataSource;
}
@Override
protected Object determineCurrentLookupKey() {
final boolean readOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
if (readOnly) {
return DataSourceType.REPLICA;
}
return DataSourceType.SOURCE;
}
}
이렇게 설정하면 정상적으로 요청을 했을 때, readOnly
의 여부에 따라 Connection을 다르게 들고 올 것이다.
3. 생각해보기
Replication이 성공적으로 마무리되었다. 그러면 우리가 처음에 생각했던 SPOF를 해결했을까? 아니다. 스프링 코드를 보면 readOnly
여부에 따라 두 가지로 나누어서 DataSource를 선택한다. 만약 선택한 DataSource 의 DB가 다운이 되어있다면 스프링은 알 수 있을까? 없을 것이다. 그렇다면 완벽하게 SPOF를 해결했다고 볼 수 없다. 우리는 단순히 트래픽을 분리했고 일종의 백업을 했을 뿐이다.
그렇다면 어떻게 완벽히 SPOF를 해결할 수 있을까? DB의 health check을 계속해야 하는데 어떻게 하면 좋을까? 이 부분에 대해서도 답은 나와 있는데 외부 도구를 사용하는 것이다. MHA(Master High Availability)
라는 오픈소스가 존재하는데, 이 오픈소스를 사용하면 장애 발생 시 자동으로 Fail-Over(Source로 승격)를 시켜준다. DB 문제를 해결했으니 적절히 Spring 코드도 수정해 주면 된다.
하지만 우리 서비스에는 적용하지 않았다. 그 이유는 적용하는 데 시간이 오래 걸릴 것이고, SPOF는 해결하지 못했지만, 쿼리를 분산하는 것만으로도 충분하다고 생각되기 때문이다. 만약 우리 서비스에 적용할 것이라면 Replica를 select만 되는 게 아니라 CRUD 모두 되게 하는 게 좀 더 좋은 해결책이 될 거로 생각한다.
※ 참고
'개발 > [우테코]' 카테고리의 다른 글
Spring에서 Redis Test 하기 (0) | 2024.02.12 |
---|---|
Redis를 활용해서 RefreshToken 최적화하기 (0) | 2024.02.08 |
build 할 때 Rest Docs 파일이 생성이 안되는 문제 트러블 슈팅 (0) | 2023.08.20 |
[Nginx] 하나의 EC2 안에서 React와 Spring 통신하기 (2) | 2023.08.06 |
[Docker] 도커 컨테이너 간 통신 트러블 슈팅 (0) | 2023.07.30 |