본문 바로가기
카테고리 없음

부하 분산을 위한 MySQL을 Master/Slave 이중화(Docker)

by 풍댕이 2026. 2. 11.

개인 프로젝트로 책 커뮤니티 서비스를 진행 중 현재는 데이터베이스가 잘 동작하고 별 문제가 없지만, 앞으로 서비스를 진행하면서 사용자가 많아 지면 데이터베이스가 문제 없이 돌아갈까? 하는 고민부터 시작되었다.

Replication은 왜 필요한가요?

Scale-out Solutions(부하분산)

일종의 부하 분산을 의미한다. DB에 접근해서 처리해야 하는 것들이 대부분이다. 읽기, 쓰기, 수정의 모든 연산이 하나의 DB에서 일어난다면 트래픽이 늘어남에 따라 자연스럽게 병목 현상이 생길 수 밖에 없다.

쓰기는 원본 서버에서만 수행하게 하고 읽기 기능은 원본의 복제 서버에서 읽어오게 한다면 쓰기의 기능과 읽기의 기능을 병목없이 모두 향상시킬 수 있게 된다.

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

복제의 대상이 되는 DB 서버를 Master 서버라 하고, 데이터가 복제된 DB 서버를 Slave 서버라 부른다.
따라서 Slave 서버를 여러 대 돌린다면 읽기 연산을 분산시켜 서버의 부하를 상당히 줄일 수 있다.

 

데이터의 보안

Replication을 구성하게 되면 항상 복제를 진행하는게 아닌, 일시중지가 가능하게 된다. 그럼으로써 원천 데이터를 손상시키지 않고 복제본에서 백업 서비스를 작동시키는게 가능하다. Master의 데이터가 날아가더라도 Slave에 데이터가 저장되어 있으니 복구될 수 있다.

1. 클라이언트가 DB서버 중 Master 서버에 데이터를 전달한다.

2. 마스터 서버는 해당 정보를 Binary Log(MySQL)라는 임시(temp) 파일에 저장한다.

3. Slave가 최신 데이터(Read)를 Master에 요청한다.

4. 이때 Master 서버는 최신 정보를 전달하기 위해 아까 Write 작업을 수행한 Binary Log에서 이를 읽어와 Slave 서버에 전달한다.

5. Slave 서버는 이를 Relay Log라는 임시 파일에 적었다가 변경사항을 한번에 DB에 반영한다.

6. 다른 클라이언트에서 동일 데이터에 대한 조회 쿼리가 전달되면 Slave에서 로그에서 꺼내주는 방식으로 동작한다.

Docker로 두 MySQL 서버 이중화하기

version: '3.8'
services:
  mysql-master:
    image: mysql:8.0
   	//터미널에서 'docker exec' 동작할 때 사용하는 이름을 'mysql-master'로 고정
    container_name: mysql-master
    ports:
      - '3306:3306'
    environment:
      MYSQL_ROOT_PASSWORD: 'master비밀번호'
      MYSQL_DATABASE: bookservice
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
      //서버 ID : 복제 그룹 내에서 1번 서버
      - --server-id=1
    restart: always
    volumes:
    	//내 컴퓨터의 설정 파일을 컨테이너 안으로 넣어주기.
      - ~/Docker/mysql/master:/etc/mysql/conf.d
      //도커 볼륨(컨테이너가 삭제되어도 데이터 저장)하기 위한 파일 저장 경로
      - /Users/gimjun-u/Desktop/mysqlVolume/docker-mysql/mysql_data/master:/var/lib/mysql
    healthcheck:
      test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ]
      interval: 10s
      timeout: 5s
      retries: 5
  mysql-slave:
    image: mysql:8.0
    container_name: mysql-slave
    ports:
      - '3307:3306'
    environment:
      MYSQL_ROOT_PASSWORD: 'slave비밀번호'
      MYSQL_DATABASE: bookservice
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
      - --server-id=2
      //읽기 전용, 데이터 수정 불가.
      - --read_only=1
    restart: always
    volumes:
      - ~/Docker/mysql/slave:/etc/mysql/conf.d
      - /Users/gimjun-u/Desktop/mysqlVolume/docker-mysql/mysql_data/slave:/var/lib/mysql
    links:
    //슬레이브 컨테이너 안에서 'mysql-master'라는 이름으로 마스터를 찾을 수 있게 해준다.
      - mysql-master

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

중요한 점은 volumes를 이용하여 도커 컨테이너 내의 변경된 MySQL 데이터들을 저장해주어야 하고 이를 위해 호스트의 디렉터리에 도커내 MySQL을 마운트해줘야 한다.

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

Spring Boot 환경에서 Master(Write)와 Slave(Read) 데이터베이스로 쿼리를 분산시키는(Replication Routing) 구현 방법

아키텍처 순서

  1. 요청 진입 : Service 메서드 호출
  2. RoutingDataSource
    1. readOnly = true -> Slave 선택
    2. readOnly = false -> Master 선택
  3. Transaction Manager : 트랜잭션 시작(이때 LazyConnectionDataSourceProxy 덕분에 실제 커넥션은 아직 안 맺음)
  4. Query 실행 시점 : RoutingDataSource가 설정된 Key(Master/Slave)를 확인하여 실제 DB 커넥션 연결

RoutingDataSource


DataSource Key 정의(Enum)

가독성을 위해 데이터소스 타입을 Enum으로 정의한다.

public enum DataSourceType {
	MASTER, SLAVE
}

동적 라우팅 구현(RoutingDataSource)

Spring의 AbstractRoutingDataSource를 상속받아, 현재 스레드 컨텍스트(ThreadLocal)에 저장된 Key를 반환하도록 구현한다.

public class ReplicationRoutingDataSource extends AbstractRoutingDataSource {

	@Override
	protected Object determineCurrentLookupKey() {
		//현재 트랜잭션이 read-only인지 확인
		boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
		//readOnly이면 SLAVE 반환, 아니면 MASTER 반환
		return isReadOnly ? DataSourceType.SLAVE : DataSourceType.MASTER;
	}
}

만약 Slave가 여러 대라면, 여기서 라운드 로빈 로직을 추가하여 부하를 분산시킬 수 있습니다.

DataSource 설정 (Configuration) - 가장 중요⭐️

쿼리 분산이 제대로 동작하려면 LazyConnectionDataSourceProxy가 필수적이다. 이 프록시가 없으면 트랜잭션이 시작되지마자(쿼리 실행 전) 기본 DataSource(보통 Master)의 커넥션을 확보해버리기 때문에 라우팅이 무시됩니다.

@Configuration
public class DataSourceConfig {
    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.master")
    public DataSourceProperties masterDataSourceProperties() {
       return new DataSourceProperties();
    }

    @Bean
    public DataSource masterDataSource(@Qualifier("masterDataSourceProperties") DataSourceProperties properties) {
       return properties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
    }

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.slave")
    public DataSourceProperties slaveDataSourceProperties() {
       return new DataSourceProperties();
    }

    @Bean
    public DataSource slaveDataSource(@Qualifier("slaveDataSourceProperties") DataSourceProperties properties) {
       return properties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
    }

    //Routing DataSource 설정(Master/Slave 매핑)
    @Bean
    public DataSource routingDataSource(
          @Qualifier("masterDataSource") DataSource masterDataSource,
          @Qualifier("slaveDataSource") DataSource slaveDataSource){
       ReplicationRoutingDataSource routingDataSource = new ReplicationRoutingDataSource();

       HashMap<Object, Object> dataSourceMap = new HashMap<>();
       dataSourceMap.put(DataSourceType.MASTER, masterDataSource);
       dataSourceMap.put(DataSourceType.SLAVE, slaveDataSource);

       routingDataSource.setTargetDataSources(dataSourceMap);
       routingDataSource.setDefaultTargetDataSource(masterDataSource); //기본은 master

       return routingDataSource;
    }

    //LazyConnectionDataSourceProxy 설정(핵심!)
    //쿼리 실행될 때 데이터 가져오기
    @Bean
    @Primary
    public DataSource dataSource(@Qualifier("routingDataSource") DataSource routingDataSource){
       return new LazyConnectionDataSourceProxy(routingDataSource);
    }

    //TransactionManager가 위에서 만든 LazyProxy DataSource를 바라보게 설정
    @Bean
    public PlatformTransactionManager transactionManager(@Qualifier("dataSource") DataSource dataSource){
       return new DataSourceTransactionManager(dataSource);
    }

Spring Boot 3.4를 사용 중인데 application.yml의 DB url을 HikariDataSource에 제대로 주입하지 못하는 문제 발생
-> Spring Boot는 기본적으로 url을 찾는데, Hikari는 jdbcUrl을 원하기 때문이다.

이로 인해 Master DataSource가 제대로 생성되지 않아 초기화 과정 null 상태가 발생되는 문제가 발생했다.

✅ 해결책: DataSourceProperties 사용 (표준 방식)

설정값(application.yml)을 먼저 자바 객체(properties)로 확실하게 읽어온 뒤, 그걸 이용해 DataSource를 만드는 방식 사용을 통해 바인딩 오류를 방지하였다.

application.yml

spring:
    application:
        name: bookservice
    datasource:
        master:
            driverClassName: com.mysql.cj.jdbc.Driver
            username: root
            password: [master패스워드]
            url: jdbc:mysql://mysql-master:3306/bookservice
        slave:
            driverClassName: com.mysql.cj.jdbc.Driver
            username: root
            password: [slave패스워드]
            url: jdbc:mysql://mysql-slave:3306/bookservice

Master/Slave 서버가 제대로 이중화되었는지 DB에서 테스트

# slave 서버를 master 서버와 연결 해제
mysql> STOP SLAVE;

# master 서버에서 테스트로 생성한 replication_test1 생성이 안되는게 확인
mysql> show tables;
+-----------------------+
| Tables_in_bookservice |
+-----------------------+
| author                |
| book                  |
| book_hash_tag         |
| coupon                |
| hash_tag              |
| member                |
| member_coupon         |
| ordered_book          |
| orders                |
| payment               |
| replication_test      |
| review                |
+-----------------------+

# 다시 slave 서버를 master 서버와 연결
mysql> START SLAVE;

# master 서버에 접속
% docker exec -it mysql-master mysql -u root -p

# 테스트 테이블 replication_test2 생성
mysql> CREATE TABLE replication_test2 (id INT);

# replication_test2 테이블 생성 확인
mysql> show tables;
+-----------------------+
| Tables_in_bookservice |
+-----------------------+
| author                |
| book                  |
| book_hash_tag         |
| coupon                |
| hash_tag              |
| member                |
| member_coupon         |
| ordered_book          |
| orders                |
| payment               |
| replication_test      |
| replication_test1     |
| replication_test2     |
| review                |
+-----------------------+

# slave 서버에 다시 접속
% docker exec -it mysql-slave mysql -u root -p

# slave 서버에서도 master 서버에서 생성한 replication_test2 테스트 테이블 확인
mysql> show tables;
+-----------------------+
| Tables_in_bookservice |
+-----------------------+
| author                |
| book                  |
| book_hash_tag         |
| coupon                |
| hash_tag              |
| member                |
| member_coupon         |
| ordered_book          |
| orders                |
| payment               |
| replication_test      |
| replication_test1     |
| replication_test2     |
| review                |
+-----------------------+

 

참고

https://tjdrnr05571.tistory.com/14?category=876333

 

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

이글에선 단일서버에서 Mysql Replication을 port를 나누어 하는 방법을 다룹니다. 목차 - 내 프로젝트에서 Mysql Replication을 사용해야 하는 이유 - Mysql Replication의 동작 원리 - Docker로 Mysql 컨테이너 두개

tjdrnr05571.tistory.com

https://terrys-tech-log.tistory.com/11