지난 포스팅에 이어 오늘은 리플리케이션 실습을 진행해보기로 한다.
1. Replication with Docker
간단한 실습을 위해 docker를 통해 replication을 구성해보기로 한다.

docker-compose.yml
다음과 같이 master 노드 1개와 slave 노드 2개로 구성한다.
services:
mysql-master:
image: mysql:8.0
container_name: mysql-master
restart: always
environment:
MYSQL_ROOT_PASSWORD: 1234
MYSQL_DATABASE: test
MYSQL_USER: user
MYSQL_PASSWORD: 1234
ports:
- "3307:3306"
volumes:
- ./config/master.cnf:/etc/mysql/conf.d/master.cnf:ro
- ./config/master-init.sql:/docker-entrypoint-initdb.d/master-init.sql:ro
networks:
- mysql-network
mysql-slave1:
image: mysql:8.0
container_name: mysql-slave1
restart: always
environment:
MYSQL_ROOT_PASSWORD: 1234
MYSQL_DATABASE: test
MYSQL_USER: user
MYSQL_PASSWORD: 1234
ports:
- "3308:3306"
volumes:
- ./config/slave1.cnf:/etc/mysql/conf.d/slave1.cnf:ro
networks:
- mysql-network
mysql-slave2:
image: mysql:8.0
container_name: mysql-slave2
restart: always
environment:
MYSQL_ROOT_PASSWORD: 1234
MYSQL_DATABASE: test
MYSQL_USER: user
MYSQL_PASSWORD: 1234
ports:
- "3309:3306"
volumes:
- ./config/slave2.cnf:/etc/mysql/conf.d/slave1.cnf:ro
networks:
- mysql-network
networks:
mysql-network:
driver: bridge
master.cnf
마스터 노드에 사용되는 mysql 설정 파일이다.
binlog 포맷은 ROW로 지정하였고, test db의 트랜잭션만 바이너리 로그에 기록하기로 한다.
[mysqld]
server-id = 1
log-bin = mysql-bin
binlog-format = ROW
binlog-do-db = test
bind-address = 0.0.0.0
master-init.sql
다음은 마스터 노드에 실행될 리플리케이션을 위한 전용 사용자 계정 생성 및 권한 부여 SQL 명령어다.
컨테이너가 시작될 때 자동으로 수행될 예정이다.
CREATE USER IF NOT EXISTS 'replicator'@'%' IDENTIFIED WITH mysql_native_password BY 'replicator_password';
GRANT REPLICATION SLAVE ON *.* TO 'replicator'@'%';
FLUSH PRIVILEGES;
slave.cnf
이어서 슬레이브 노드에 사용되는 설정 파일이다.
read-only 옵션을 통해 읽기만 허용되게 한다.
[mysqld]
server-id = 2
relay-log = mysql-relay-bin
read-only = 1
bind-address = 0.0.0.0
설정 파일에서 replication 구성에 포함된 서버들은 각자 고유한 server-id를 갖도록 해줘야 한다!
replication_setup.sh
마지막으로 docker-compose 이후 MySQL Master-Slave 복제 환경을 자동으로 구축하는 셸 스크립트이다.
# 1. Docker Compose 시작
echo "Docker Compose를 시작합니다..."
cd docker
docker-compose up -d
# 2. Master가 준비될 때까지 대기
echo "Master 서버가 준비될 때까지 대기 중..."
sleep 20
# 2. Master 상태 확인 및 로그 파일과 위치 추출
echo "Master 상태를 확인합니다..."
MASTER_STATUS=$(docker exec mysql-master mysql -uroot -p1234 -e "SHOW MASTER STATUS\G")
LOG_FILE=$(echo "$MASTER_STATUS" | grep "File:" | awk '{print $2}')
LOG_POS=$(echo "$MASTER_STATUS" | grep "Position:" | awk '{print $2}')
echo "$MASTER_STATUS"
echo "Master Log File: $LOG_FILE"
echo "Master Log Position: $LOG_POS"
# 4. Slave 복제 설정
echo "Slave1 복제를 설정합니다..."
docker exec mysql-slave1 mysql -uroot -p1234 -e "
STOP SLAVE;
RESET SLAVE ALL;
CHANGE MASTER TO
MASTER_HOST='mysql-master',
MASTER_USER='replicator',
MASTER_PASSWORD='replicator_password',
MASTER_LOG_FILE='$LOG_FILE',
MASTER_LOG_POS=$LOG_POS;
START SLAVE;
"
echo "Slave2 복제를 설정합니다..."
docker exec mysql-slave2 mysql -uroot -p1234 -e "
STOP SLAVE;
RESET SLAVE ALL;
CHANGE MASTER TO
MASTER_HOST='mysql-master',
MASTER_USER='replicator',
MASTER_PASSWORD='replicator_password',
MASTER_LOG_FILE='$LOG_FILE',
MASTER_LOG_POS=$LOG_POS;
START SLAVE;
"
# 5. 복제 상태 확인
sleep 5
echo "복제 상태를 확인합니다..."
echo "=== Slave1 상태 ==="
docker exec mysql-slave1 mysql -uroot -p1234 -e "SHOW SLAVE STATUS\G" | grep -E "(Slave_IO_Running|Slave_SQL_Running|Seconds_Behind_Master|Last_Error)"
echo "=== Slave2 상태 ==="
docker exec mysql-slave2 mysql -uroot -p1234 -e "SHOW SLAVE STATUS\G" | grep -E "(Slave_IO_Running|Slave_SQL_Running|Seconds_Behind_Master|Last_Error)"
테스트

제대로 세팅이 완료되었는지 확인을 위해 master 노드에 order 테이블을 만들고 레코드 한 개를 insert 해보도록 한다.

다음으로 slave 노드에서 order 테이블의 레코드를 확인해보도록 하자.

다음과 같이 master 노드에서 삽입한 레코드가 정상적으로 slave 노드에도 저장된 것을 확인할 수 있다.
2. Spring 실습
이제 Spring에서 replication 구성이 완료된 DB 노드들을 데이터 소스로 등록하고 이용해보도록 하자!
application.yml
spring:
datasource:
master:
jdbc-url: jdbc:mysql://localhost:3307/test?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul
username: user
password: 1234
driver-class-name: com.mysql.cj.jdbc.Driver
slaves:
slave1:
name: slave-1
jdbc-url: jdbc:mysql://localhost:3308/test?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul
username: user
password: 1234
driver-class-name: com.mysql.cj.jdbc.Driver
slave2:
name: slave-2
jdbc-url: jdbc:mysql://localhost:3309/test?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul
username: user
password: 1234
driver-class-name: com.mysql.cj.jdbc.Driver
DataSourceConfig
여러 데이터베이스의 연결을 설정하려면 기본 DataSource 설정을 비활성화하고 다음과 같이 커스텀 설정을 해줘야한다. master 노드와 salve 노드 2대를 map에 저장하고 타겟 데이터소스로 등록해주었다.
@RequiredArgsConstructor
@EnableAutoConfiguration(exclude = {DataSourceAutoConfiguration.class})
@Configuration
public class DataSourceConfig {
private final DataSourceProperties dataSourceProperties;
@Bean
public DataSource routingDataSource() {
Map<Object, Object> dataSourceMap = new HashMap<>();
DataSourceProperties.Master masterProperty = dataSourceProperties.getMaster();
DataSource masterDataSource = createDataSource(
masterProperty.getJdbcUrl(),
masterProperty.getUsername(),
masterProperty.getPassword(),
masterProperty.getDriverClassName()
);
dataSourceMap.put("master", masterDataSource);
dataSourceProperties.getSlaves()
.forEach((key, value) -> dataSourceMap.put(value.getName(), createDataSource(
value.getJdbcUrl(),
value.getUsername(),
value.getPassword(),
value.getDriverClassName()
)));
ReplicationRoutingDataSource routingDataSource = new ReplicationRoutingDataSource();
routingDataSource.setTargetDataSources(dataSourceMap);
routingDataSource.setDefaultTargetDataSource(masterDataSource);
return routingDataSource;
}
public DataSource createDataSource(String url, String username, String password, String driverClassName) {
return DataSourceBuilder.create()
.type(HikariDataSource.class)
.url(url)
.username(username)
.password(password)
.driverClassName(driverClassName)
.build();
}
@Primary
@Bean
public DataSource dataSource(@Qualifier("routingDataSource") DataSource routingDataSource) {
return new LazyConnectionDataSourceProxy(routingDataSource);
}
}
ReplicationRoutingDataSource
다음으로 읽기/쓰기 요청에 따라 적절한 데이터베이스로 라우팅시킬 수 있는 설정이다.
설정에 따라 CUD 요청은 master 노드로, READ 요청은 slave 노드로 라우팅 된다.
@Slf4j
public class ReplicationRoutingDataSource extends AbstractRoutingDataSource {
private CircularList<String> slaveDataSourceNameList;
@Override
public void setTargetDataSources(Map<Object, Object> targetDataSources) {
super.setTargetDataSources(targetDataSources);
slaveDataSourceNameList = new CircularList<>(
targetDataSources.keySet()
.stream()
.map(Object::toString)
.filter(string -> string.contains("slave"))
.collect(Collectors.toList())
);
}
@Override
protected Object determineCurrentLookupKey() {
String dataSourceKey;
// 읽기 전용 트랜잭션인 경우 슬레이브 DB로 라우팅
if (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) {
dataSourceKey = slaveDataSourceNameList.getOne();
log.info("[READ] Routing to: {}", dataSourceKey);
} else {
dataSourceKey = "master";
log.info("[WRITE] Routing to: {}", dataSourceKey);
}
return dataSourceKey;
}
}
읽기 요청의 경우 순환 리스트를 통해 라운드 로빈 방식으로 slave 노드들에 보내지게 된다.
OrderService
@Transaction 어노테이션에 readOnly=true 설정을 하면 읽기 전용 트랜잭션으로 지정되고, 이에 따라 slave 노드로 요청이 도달하게 된다.
@RequiredArgsConstructor
@Service
public class OrderService {
private static final Random RANDOM = new Random();
private final OrderRepository orderRepository;
@Transactional
public Order createOrder() {
BigDecimal randomValue = BigDecimal.valueOf(RANDOM.nextInt(10001))
.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
Order order = new Order(randomValue);
return orderRepository.save(order);
}
@Transactional(readOnly = true)
public List<Order> getAllOrders() {
return orderRepository.findAll();
}
}
테스트
이제 API 요청을 통해 쓰기 요청과 읽기 요청이 어떻게 처리되는지 확인해 보자.


'CS > 데이터베이스' 카테고리의 다른 글
| Materialized View (3) | 2025.08.03 |
|---|---|
| 리플리케이션 (0) | 2025.06.29 |
| 유일키 생성 전략 (1) | 2025.06.15 |
| 샤딩 실습 (ShardingSphere) (0) | 2025.06.15 |
| 파티셔닝과 샤딩 (0) | 2025.06.15 |
