| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- helm
- Kubernetes
- redis
- RDBMS
- 성능최적화
- 데이터베이스
- mysql
- prometheus
- Kotlin
- monitoring
- SpringBoot
- CloudNative
- spring boot
- netty
- webflux
- kafka
- DevOps
- jvm
- NIO
- Java
- grafana
- GitOps
- 성능 최적화
- 트랜잭션
- docker
- 동시성제어
- selector
- 백엔드
- JPA
- 백엔드개발
- Today
- Total
유성
Spring Boot에서 MySQL Master-Slave (Replication) 효율적인 연결 본문
지난 글에서는 Kubernetes 환경에서 로컬 디스크와 StatefulSet을 활용해 MySQL 마스터-슬레이브 복제 구조를 구축해 보았다.
이번 글에서는 이렇게 구축된 DB 인프라를 Spring Boot 애플리케이션에서 어떻게 효율적으로 분기하여 연결하는지 알아보자.
Kubernetes에서 MySQL 마스터-슬레이브(Replication) 구축하기
쿠버네티스에서 데이터베이스를 운영할 때 가장 큰 고민은 "데이터의 영속성과 성능"일 것이다.이번 글에서는 로컬 디스크를 활용해 성능을 최적화하고, StatefulSet을 이용해 마스터-슬레이브 복
youseong.tistory.com
1. Master-Slave는 각각 어떤 역할을 할까?
DB 레플리케이션은 데이터의 정합성을 유지하면서 부하를 분산하기 위해 사용된다.
각 서버의 역할은 다음과 같다.
- Master: 데이터의 원본을 관리하며 쓰기 작업 수행 (INSERT, UPDATE, DELETE + SELECT)
- Slave: 마스터의 데이터를 복제하여 읽기 작업 수행 (SELECT)
동작 원리와 주의점
Master에서 쓰기 작업이 발생하면 변경 사항이 Binary Log(binlog)에 기록된다.
Slave는 이 로그를 가져와 자신의 DB에 반영하며 정합성을 맞춘다. (결과적 정합성)
이 과정은 비동기 방식으로 진행되기 때문에, 마스터에 데이터가 반영된 직후 슬레이브에서 조회하면 아직 데이터가 존재하지 않는 '복제 지연'이 발생할 수 있다.
따라서 실시간성이 매우 중요한 조회 작업의 경우, 의도적으로 Master에서 읽도록 설정하는 전략이 필요하다.
(예를 들어, UPDATE 수행을 위한 검증용 SELECT 등)
2. 애플리케이션 단의 DB 분배 설정
DB 앞단에 ProxySQL 같은 프록시 서버를 두어 쿼리를 분석해 분배할 수도 있지만, 이번에는 애플리케이션 레벨에서 직접 DB를 선택하는 방식을 사용해본다.
인프라 복잡도를 낮추면서 개발자가 쿼리 제어권을 가질 수 있다는 장점이 있다.
Step 1: application.yml 설정
Kubernetes 내부의 Headless Service 도메인(mysql-0.mysql, mysql-1.mysql)을 활용하여 접속 정보를 설정한다.
spring:
datasource:
master:
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://${MASTER_DB_HOST:mysql-0.mysql}:3306/my_db?serverTimezone=Asia/Seoul
username: root
password: password
slave:
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://${SLAVE_DB_HOST:mysql-1.mysql}:3306/my_db?serverTimezone=Asia/Seoul
username: root
password: password
Step 2: RoutingDataSource 구현
현재 트랜잭션이 읽기 전용인지 확인하여 어떤 DB를 사용할지 결정하는 로직을 작성한다.
@Slf4j
public class ReplicationRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
// 트랜잭션이 readOnly이면 slave, 아니면 master를 반환
boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
String key = isReadOnly ? "slave" : "master";
log.info("Current Query Routing Target: {}", key);
return key;
}
}
Step 3: DataSourceConfig 설정
이제 여러 개의 데이터소스를 하나로 묶고, Spring이 상황에 맞게 꺼내 쓸 수 있도록 Bean을 등록한다.
@Configuration
public class DataSourceConfig {
@ConfigurationProperties(prefix = "spring.datasource.master")
@Bean
public DataSource masterDataSource() {
return DataSourceBuilder.create().build();
}
@ConfigurationProperties(prefix = "spring.datasource.slave")
@Bean
public DataSource slaveDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
public DataSource routingDataSource(
@Qualifier("masterDataSource") DataSource masterDataSource,
@Qualifier("slaveDataSource") DataSource slaveDataSource) {
ReplicationRoutingDataSource routingDataSource = new ReplicationRoutingDataSource();
Map<Object, Object> dataSourceMap = new HashMap<>();
dataSourceMap.put("master", masterDataSource);
dataSourceMap.put("slave", slaveDataSource);
routingDataSource.setTargetDataSources(dataSourceMap);
routingDataSource.setDefaultTargetDataSource(masterDataSource);
return routingDataSource;
}
@Primary
@Bean
public DataSource dataSource(@Qualifier("routingDataSource") DataSource routingDataSource) {
// 쿼리 실행 시점에 커넥션을 획득하도록 지연시킨다.
return new LazyConnectionDataSourceProxy(routingDataSource);
}
}
3. 핵심: 왜 LazyConnectionDataSourceProxy인가?
이 설정에서 가장 중요한 부분은 LazyConnectionDataSourceProxy이다.
보통 Spring은 트랜잭션이 시작되는 시점에 바로 커넥션을 확보하려 한다.
하지만 이 시점에서는 트랜잭션의 readOnly 여부가 완전히 세팅되지 않아 RoutingDataSource가 제대로 분기를 할 수 없다.
LazyConnectionDataSourceProxy를 사용하면 실제 쿼리가 수행되는 시점까지 커넥션 획득을 지연시켜준다.
덕분에 쿼리 실행 직전에 readOnly 속성을 확인하고 정확하게 Master 혹은 Slave DB에 연결할 수 있게 된다.
4. 테스트 수행해보자
Kubernetes에서 Deployment 버전을 올리고 아래 Service로 요청을 보내보자.
@Transactional
public List<User> findAll() {
return userRepository.findAll();
}
@Transactional(readOnly = true)
public List<User> findAllReadOnly() {
return userRepository.findAll();
}
findAll 수행 결과 (Master로 연결)
2026-01-22 11:00:15.199 [http-nio-8080-exec-8] INFO c.e.d.w.ReplicationRoutingDataSource - Current Query Routing Target: master
예상한 것과 동일하게 Master DB로 연결이 수행되었다.
findAllReadOnly 수행 결과 (Slave로 연결)
2026-01-22 11:01:15.110 [http-nio-8080-exec-9] INFO c.e.d.w.ReplicationRoutingDataSource - Current Query Routing Target: slave
이또한 Slave로 연결이 수행되었다.
구축한 시스템이 실제 장애 상황에서 어떻게 동작하는지 검증하기 위해 Master Pod를 의도적으로 중지시키고 테스트를 진행했다.
장애 상황 테스트 결과
기본적인 RoutingDataSource 설정만 있는 경우 Master가 다운된 상태에서 readOnly 요청이 정상 수행하는 것을 확인했다.
추가로 지금 상태에서는 Slave가 다운된 경우 readOnly 요청이 Master로 전달되지는 않는다.
애플리케이션 단에서 Slave의 상태를 주기적으로 체크하는 HeadthCheck 로직을 도입하거나, Resilience4j 서킷 브레이커를 활용해볼 수도 있다.
마치며
이번 프로젝트를 통해 서비스 레이어에서 @Transactional(readOnly = true) 어노테이션 하나만으로도 읽기 부하를 Slave로 분산하는 구조를 구현해 보았다.
단순히 소스 코드를 나누는 것을 넘어, 인프라 레벨(Kubernetes)과 애플리케이션 레벨(Spring Boot)이 유기적으로 맞물려 동작하는 과정을 경험할 수 있었다. 이상적인 환경이라면 DB 커넥션 가용량을 2배 이상 확보한 셈이며, 이는 대규모 트래픽을 견딜 수 있는 확장성 있는 아키텍처의 밑거름이 될 것이다.
데이터베이스 복제 구조는 단순한 부하 분산을 넘어 고가용성(High Availability) 확보를 위한 필수적인 선택이다. 복제 지연이나 정합성 문제와 같은 트레이드오프를 잘 이해하고 적용한다면 더욱 견고한 백엔드 시스템을 구축할 수 있을 것이다.
'Spring Data' 카테고리의 다른 글
| @Transactional 파라미터 해부: 소스 코드로 보는 8가지 속성 (0) | 2026.02.03 |
|---|---|
| JPA의 진짜 설계 의도: 왜 N+1 문제를 방치하였나? (4) | 2025.07.26 |
| [JPA] fetchJoin 과 Paging 처리의 한계 및 해결 방안 (0) | 2025.03.12 |
| RDBMS 격리 수준 (0) | 2024.11.25 |
| 예외 발생 시 롤백 방지하기: Spring에서 독립적인 트랜잭션 설정 방법 (3) | 2024.09.07 |