본문 바로가기
make-delivery 프로젝트

[#8] Mysql Replication - Spring에서 Master/Slave 이중화 with Docker

by sunggook lee 2020. 12. 3.

이글에선 단일서버에서 Mysql Replication을 port를 나누어 하는 방법을 다룹니다.

 

목차

- 내 프로젝트에서 Mysql Replication을 사용해야 하는 이유

- Mysql Replication의 동작 원리

- Docker로 Mysql 컨테이너 두개 띄우기

- Spring 에서 AOP를 이용하여 쿼리 분산 구현

 

구현순서

1. Docker를 이용하여 Mysql 컨테이너를 두개 띄우고 각각 Master와 Slave를 할당합니다.

2. Spring applcation level에서 DataSource를 나누어 읽기는 Slave로 쿼리를 쓰기/삭제는 Master로 쿼리를 보냅니다.

 

Mysql Replication을 사용해야 하는 이유

1. 부하분산 (일종의 Scale out)

제가 개발하는 make-delivery 프로젝트에서는 db에 접근할 일이 많습니다. 사용자가 점점 많아지고

트래픽이 늘어날 경우에 db 쿼리를 날리는 일이 더 많아 질 것이고 DB에서는 쿼리를 모두 처리하기 힘들어 질 것입니다.

따라서 부하를 줄이기 위해 DB를 이중화하여 Master에서는 쓰기/수정/삭제 연산을 처리하고 Slave에서는

읽기 연산만을 처리하여 병목을 줄여줍니다.

 

2. 데이터 백업

Master의 데이터가 날라가더라도 Slave에 데이터가 저장되어 있으니 복구될 수 있습니다.

(Mysql Replication은 비동기 방식이기 때문에 100% 데이터 정합성을 보장해줄 수는 없습니다.)

 

 

 

 

 

Mysql Replication의 동작 원리

Mysql의 Replication은 비동기 복제 방식을 사용하고 있다.

https://thilinamad.medium.com/mysql-db-replication-63786ac8241e

- Master에서 변경이 있으면 이를 바이너리 로그에 저장하고 이를 비동기적으로 Slave에게 전송한다.

- Slave에서는 이를 받아 릴레이 로그에 저장한 후 변경사항을 스토리지 엔진에 반영한다.

cloudrain21.com/mysql-replication

에 아주 매우 잘 설명되어있으니 참고하시면 되겠습니다.

 

 

Docker로 Mysql 컨테이너 두개 띄우기

서버 자체를 여러 개 띄워 Master와 Slave로 나누는 것이 좋은 방법일 것입니다. 하지만 실제 배포 전

테스트 용도로 Docker 컨테이너 두개를 띄워 포트를 분리해 이중화를 적용했습니다.

 

서버 한대에서 이중화를 하는 방법은 포트 자체를 분리하는 방법과 Docker로 Mysql 컨테이너 두개로

띄우는 방법이 있습니다. 포트 자체를 나누는 방법은 리눅스 버전에 따라 OS에 따라 파일시스템 차이가

있기 때문에 도커로 적용하였습니다.

 

도커에 Mysql이미지를 받고 도커 컴포즈를 이용해 두개의 컨테이너를 띄워줍니다.

jupiny.com/2017/11/07/docker-mysql-replicaiton/

이곳에 아주 매우 잘 설명되어있습니다.

 

개인적으로는 Docker run에 옵션을 넣어주는 것이 귀찮아 Docker Compose를 적용하였고 다음과 같습니다.

 

version: '3'
services:
 db:
  image: mysql:8.0.17
  container_name: mysql-master
  ports:
   - '3306:3306'
  environment:
   - MYSQL_ROOT_PASSWORD='비밀번호'
  command:
   - --character-set-server=utf8mb4
   - --collation-server=utf8mb4_unicode_ci
  restart: always
  volumes:
   - /Users/sunggooklee/datadir:/var/lib/mysql
   - /Users/sunggooklee/replication/master:/etc/mysql/conf.d
 db-slave:
  image: mysql:8.0.17
  container_name: mysql-slave
  ports:
   - '3307:3306'
  environment:
   - MYSQL_ROOT_PASSWORD='비밀번호'
  command:
   - --character-set-server=utf8mb4
   - --collation-server=utf8mb4_unicode_ci
  restart: always
  volumes:
   - /Users/sunggooklee/replication/slave:/etc/mysql/conf.d
   - /Users/sunggooklee/slavedatadir:/var/lib/mysql
  links:
   - db

 

호스트의 포트 3306,3307로 각각의 도커 컨테이너에 접속하게 합니다.

 

/Users/sunggooklee/datadir:/var/lib/mysql

 

중요한 점은 volumes를 이용하여 도커 컨테이너 내의 변경된 Mysql 데이터들을 저장해주어야하고

이를 위해 호스트의 디렉토리에 도커내 Mysql을 마운트해줘야 합니다.

 

이제 Mysql Replication은 끝났고 Spring level에서 이중화를 구현해주어야 합니다.

 

Spring 에서 AOP를 이용하여 쿼리 분산 구현

제 프로젝트에서는 Spring을 이용하고 있습니다. 현재는 한개의 DataSource를 이용하고 있지만

이중화를 해줬기 때문에 두개의 DataSource에 각각 쿼리를 보내줘야 합니다.

 

이를 위해 AbstractRoutingDataSource클래스에 determineCurrentLookupKey를 이용하여

Master나 Slave중 사용할 DataSource를 라우팅해줍니다. ThreadLocal 변수를 이용하여

현재 쓰레드에서 사용할 DataSource를 determineCurrentLookupKey 함수에서 정해줍니다.

 

@Bean
public DataSource routingDataSource(
    @Qualifier(value = "masterDataSource") DataSource masterDataSource,
    @Qualifier(value = "slaveDataSource") DataSource slaveDataSource) {

    AbstractRoutingDataSource routingDataSource = new AbstractRoutingDataSource() {
        @Override
        protected Object determineCurrentLookupKey() {
            DataSourceType dataSourceType = RoutingDataSourceManager.getCurrentDataSourceName();

            if (TransactionSynchronizationManager
                .isActualTransactionActive()) {
                boolean readOnly = TransactionSynchronizationManager
                    .isCurrentTransactionReadOnly();
                if (readOnly) {
                    dataSourceType = DataSourceType.SLAVE;
                } else {
                    dataSourceType = DataSourceType.MASTER;
                }
            }

            RoutingDataSourceManager.removeCurrentDataSourceName();
            return dataSourceType;
        }
    };

    Map<Object, Object> targetDataSources = new HashMap<>();

    targetDataSources.put(DataSourceType.MASTER, masterDataSource);
    targetDataSources.put(DataSourceType.SLAVE, slaveDataSource);

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

    return routingDataSource;
}

 

트랜잭션이 걸려있지 않다면 AOP의 Enum값을 보고 Master와 Slave중 정하고

트랜잭션이 걸려있다면 트랜잭션이 readOnly이면 Slave로 아니라면 Master로 보냅니다.

 

이렇게하는 이유는 AOP의 Enum값으로 DataSource를 정하기 때문에 트랜잭션이 시작될 때

AOP가 적용되기 전이므로 Master와 Slave를 정하기 전이기 때문입니다.

 

1. Spring은 기본적으로 트랜잭션을 시작할 때 쿼리가 실행되기도 전에 DataSource를 정해놓습니다.

(DataSourceTransactionManager를 이용한 트랜잭션 처리는 TransactionManager를 식별 -> DataSource에서 Connection 추출 -> Transaction 동기화(커넥션 저장) 순서로 진행됩니다)

2. Transaction이 시작되면 같은 DataSource만을 이용합니다.

 

 

따라서 쿼리 메소드에 AOP로 DataSource를 정하는 로직이 가능하게 하려면 쿼리를 실행할 때 DataSource를 정할 수 있도록

DataSource 연결을 늦춰주도록 구현하여야 합니다. 이는 다음과 같이 LazyConnectionDataSourceProxy를 이용하여 현재 RoutingDataSource를 감싸서 구현 가능합니다.

 

    @Bean
    public DataSource lazyRoutingDataSource(
        @Qualifier(value = "routingDataSource") DataSource routingDataSource) {
        System.out.println("comer here lazy??");
        return new LazyConnectionDataSourceProxy(routingDataSource);
    }

    @Bean
    public PlatformTransactionManager transactionManager(
        @Qualifier(value = "lazyRoutingDataSource") DataSource lazyRoutingDataSource) {
        System.out.println("transactionmanager???");
        DataSourceTransactionManager transactionManager = new DataSourceTransactionManager();
        transactionManager.setDataSource(lazyRoutingDataSource);
        return transactionManager;
    }

 

TransactionManager 선별 -> LazyConnectionDataSourceProxy에서 Connection Proxy 객체 획득 -> Transaction 동기화(Synchronization) -> 실제 쿼리 호출시에 ReplicationRoutingDataSource.getConnection()/determineCurrentLookupKey() 호출 이 순서로 변경되었습니다.

 

 

따라서 트랜잭션이 시작되고 DataSource가 정해지는 것이아니라 @Transactional이 붙은 메소드에서 쿼리가 실제로 실행될 때

DataSource가 정해지도록 LazyConnectionDataSourceProxy가 만들어줬습니다. (애초에 @Transactional이 붙지 않은 메소드에서 DataSource는 쿼리가 실행될 때 정해집니다)

 

이제 determineLookUpKey함수에서 TransactionSynchronizationManager.isActualTransactionActive()로 트랜잭션이 활성화되었는지 확인하고 트랜잭션이 시작되었다면 readOnly인지를 판별해 DataSource를 정해줘야합니다. 

 

 

 

masterDataSource와 slaveDataSource를 빈으로 등록해줍니다.

 

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

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

 

우선 @SetDataSource 어노테이션을 만들고 그 안에 Enum 으로 Master와 Slave를 정해줍니다.

 

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface SetDataSource {

    DataSourceType dataSourceType();

    enum DataSourceType {
        MASTER, SLAVE;
    }

}

 

그 다음은 @Aspect로 Aop 포인트컷등을 정해주는데 이때 @Before를 이용하여 메소드(데이터베이스 mapper)

시작 전에 Master인지 Slave인지를 정해줍니다.

 

@Aspect
@Component
public class SetDataSourceAspect {

    @Before("@annotation(com.flab.makedel.annotation.SetDataSource) && @annotation(target)")
    public void setDataSource(SetDataSource target) throws WrongDataSourceException {

        if (target.dataSourceType() == DataSourceType.MASTER
            || target.dataSourceType() == DataSourceType.SLAVE) {
            RoutingDataSourceManager.setCurrentDataSourceName(target.dataSourceType());
        } else {
            throw new WrongDataSourceException("Wrong DataSource Type : Should Check Exception");
        }

    }
}

 

위 스샷을 보면 RoutingDataSourceManager에서 현재 데이터소스의 이름을 정해주는데 이는 쓰레드 로컬을 이용합니다.

 

public class RoutingDataSourceManager {

    private static final ThreadLocal<DataSourceType> currentDataSourceName = new ThreadLocal<>();

    public static void setCurrentDataSourceName(DataSourceType dataSourceType) {
        currentDataSourceName.set(dataSourceType);
    }

    public static DataSourceType getCurrentDataSourceName() {
        return currentDataSourceName.get();
    }

    public static void removeCurrentDataSourceName() {
        currentDataSourceName.remove();
    }
}

 

데이터베이스에 트랜잭션은 각 쓰레드에서 사용됩니다. 쓰레드간에 데이터베이스 Connection이 같을일이 없습니다.

따라서 이를 이용해 쿼리를 보낼 때 그 쓰레드에서 Master를 사용하겠다고 하면 Master DataSource를 사용하도록

AOP와 쓰레드로컬을 이용하여 구현해줍니다.

 

 

 

    @SetDataSource(dataSourceType = DataSourceType.SLAVE)
    UserDTO selectUserById(String id);

    @SetDataSource(dataSourceType = DataSourceType.MASTER)
    void deleteUser(String id);

 

이 프로젝트는 Mybatis를 사용하기 때문에 Mapper Interface위에 어노테이션으로 AOP를 적용하면

DB 이중화 구현은 완료됩니다.

 

스프링에서 DB 이중화한 과정을 정리하자면

AbstractRoutingDataSource 클래스를 이용하여 TargetDataSources에 slave와 master DataSource를 등록하고


determineCurrentLookupKey 함수를 오버라이드하여 Slave Datasource와 Master DataSource중 선택하여 사용합니다.

 

어떤 데이터소스를 사용할지는 AOP를 사용하여 구현하였습니다.


메소드(Mapper) 시작전에 AOP를 이용하여 쓰레드로컬 변수에 마스터와 슬레이브중 한개를 넣어놓고


determineCurrentLookupKey함수에서 쓰레드로컬 변수를 가져와 어떤 데이터소스를 사용할지 정합니다.

 

determineCurrentLookupKey는 JDBC getConnection()함수에서 호출되어 데이터소스가 정해집니다.

 

이로써 Mysql Replication을 하는 이유와 동작 원리 스프링에서의 구현까지 알아봤습니다.

 

 

프로젝트 url

github.com/f-lab-edu/make-delivery

 

f-lab-edu/make-delivery

구매자에게 음식 배달을 제공하는 서비스입니다. Contribute to f-lab-edu/make-delivery development by creating an account on GitHub.

github.com

참고자료

supawer0728.github.io/2018/05/06/spring-boot-multiple-datasource/

 

(Spring Boot) 다중 DataSource 사용하기 with Sharding

서론Spring Boot를 사용해서 Database Sharding을 처리할 수 있을까?요즘 NoSQL에서는 Sharding과 관련해서 많은 편의를 제공한다. 알아서 Sharding을 제공해주고, 클러스터에 노드가 추가되면 Shard key를 기반

supawer0728.github.io

d2.naver.com/helloworld/5812258

egloos.zum.com/kwon37xi/v/5364167

 

Java 에서 DataBase Replication Master/Slave (write/read) 분기 처리하기

대규모 서비스 개발시에 가장 기본적으로 하는 튜닝은 바로 데이터베이스에서 Write와 Read DB를 Replication(리플리케이션)하고 쓰기 작업은 Master(Write)로 보내고 읽기 작업은 Slave(Read)로 보내어 부하

egloos.zum.com

 

댓글