1. 시작하며
사이드 프로젝트를 운영하면서 현재는 단일 데이터베이스만 사용 중이었습니다. 단순한 개인 프로젝트 수준이라면 큰 무리는 없겠지만, 운영 환경에서 안정성과 확장성을 확보하려면 최소한의 DB 이중화 구조는 반드시 필요하다는 생각이 들었습니다.
특히, 현재 운영 중인 프로젝트는 전체 쿼리 중 약 80~90%가 읽기(Read) 작업으로 이루어져 있기 때문에, 읽기/쓰기 분리 구조를 미리 연습해보는 것만으로도 큰 도움이 되겠다는 판단이 들었습니다.
물론 단일 DB로도 운영은 가능하지만, 실무에서는 장애 대응, 확장성, 성능 분산 등의 이유로 Master/Slave 구조(Master-Replica)를 도입하는 경우가 많습니다.
이에 따라, MySQL Master/Slave Replication 구조를 설정하고, Spring Boot 환경에서 이를 읽기/쓰기 분리 처리(read/write routing)까지 적용하는 연습을 해보았습니다.
이에 따라, MySQL Master/Slave 복제 환경을 Docker로 구성하고, Spring Boot에서 이를 트랜잭션의 readOnly 여부에 따라 자동 라우팅되도록 설정하는 실습을 진행했습니다.
이번 글에서는 다음과 같은 내용을 정리해보려 합니다.
- Docker로 MySQL Master/Slave 환경을 구성
- Spring Boot에서 트랜잭션에 따라 Master/Slave를 자동 분기
- 실제 어떤 요청이 어떤 DB로 갔는지 로그로 확인하는 방법
- 실무에서도 충분히 적용 가능한 구조를 미리 연습해보는 과정 공유
2. 목표
이번 포스팅에서는 아래와 같은 구성 및 기능을 목표로 설정합니다.
- MySQL Master/Slave 이중화 구성 (Docker Compose 활용)
- Spring Boot에서 Master는 쓰기, Slave는 읽기 처리가 자동으로 되도록 설정
@Transactional(readOnly = true)
어노테이션에 따라 자동으로 Slave로 라우팅- 어떤 요청이 Master/Slave 중 어디에서 처리되었는지 로그로 확인 가능
- 실서비스 도입 전, 사전 검증을 위한 기초 구성 실습
3. Docker Compose로 Master/Slave 구성하기
3.1 디렉토리 구조
먼저, 프로젝트 디렉토리는 다음과 같이 구성했습니다.
Spring-DB-Master-Slave
├─BackEnd
└─MySQL
├─master
│ └─mysql-master.Dockerfile
│ └─my.cnf
└─slave
│ └─mysql-slave.Dockerfile
│ └─my.cnf
└─docker-compose-mysql.yml
BackEnd
는 Spring Boot 프로젝트, MySQL
디렉토리는 DB 관련 Docker 구성입니다.
3.2 Docker Compose 설정
📌 docker-compose-mysql.yml
services:
mysql-master:
container_name: mysql-master
build:
context: ./
dockerfile: master/mysql-master.Dockerfile
restart: always
environment:
MYSQL_DATABASE: example
MYSQL_USER: user
MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: root
ports:
- '3306:3306'
volumes:
- db-master:/var/lib/mysql
- db-master:/var/lib/mysql-files
networks:
- mysql-net
mysql-slave:
container_name: mysql-slave
build:
context: ./
dockerfile: slave/mysql-slave.Dockerfile
restart: always
environment:
MYSQL_DATABASE: example
MYSQL_USER: user
MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: root
ports:
- '3307:3306'
volumes:
- db-slave:/var/lib/mysql
- db-slave:/var/lib/mysql-files
networks:
- mysql-net
volumes:
db-master:
db-slave:
networks:
mysql-net:
driver: bridge
mysql-master
: Master DB 역할, 기본 포트3306
사용mysql-slave
: Slave(DB 복제본), 포트3307
사용- 두 컨테이너는
mysql-net
이라는 내부 네트워크로 연결되어 직접 통신 가능.
3.3 Master DB 설정
📌 MySQL/master/my.cnf
[mysqld]
server-id=1 # 마스터는 server-id를 1로 설정
log_bin=mysql-bin # 바이너리 로그 활성화 → 복제를 위해 필수
default_authentication_plugin=mysql_native_password
log_bin
은 복제를 위한 필수 설정, 이 로그를 기반으로 Slave가 동기화합니다.default_authentication_plugin
은 MySQL 8 이상에서 기본 인증 방식이 변경되어 하위 호환을 위해 명시적으로 설정합니다.
📌 MySQL/master/mysql-master.Dockerfile
FROM mysql:8.0
COPY ./master/my.cnf /etc/mysql/my.cnf
3.4 Slave DB 설정
📌 MySQL/slave/my.cnf
[mysqld]
server-id=2 # Slave는 서로 다른 server-id를 가져야 함
relay_log=/var/lib/mysql/mysql-relay-bin
log_slave_updates=1
read_only=1 # 쓰기 금지
default_authentication_plugin=mysql_native_password
read_only
: Slave는 데이터를 수정하지 못하도록 설정relay_log
: 마스터에서 전달받은 로그를 저장하는 위치
📌 MySQL/slave/mysql-slave.Dockerfile
FROM mysql:8.0
COPY ./slave/my.cnf /etc/mysql/my.cnf
4. MySQL Replication 설정
4.1 마스터에서 복제 계정 생성
-- 1) 마스터에서 복제용 계정 명시적으로 생성
CREATE USER 'repl'@'%' IDENTIFIED BY 'replpass';
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%';
FLUSH PRIVILEGES;
-- 2) 마스터 상태 확인
SHOW MASTER STATUS;
SHOW MASTER STATUS;
를 실행하면 다음과 같은 예시 결과가 나옵니다.
→ 이 정보를 Slave에서 설정해야 합니다. (File
, Position
)
4.2 슬레이브에서 복제 설정
-- 1) 기존 복제 중지
STOP REPLICA;
-- 2) 마스터에서 확인한 File, Position 입력
CHANGE REPLICATION SOURCE TO
SOURCE_HOST='mysql-master', -- master DB 서버의 호스트명 (IP)
SOURCE_PORT=3306, -- master DB 서버의 MySQL DB 포트
SOURCE_LOG_FILE='mysql-bin.000004', -- master DB 상태 확인에서 확인한 File 부분
SOURCE_LOG_POS=3271, -- master DB 상태 확인에서 확인한 Position 부분
GET_SOURCE_PUBLIC_KEY=1;
-- 3) 복제 시작 시 계정 명시
START REPLICA USER='repl' PASSWORD='replpass';
복제가 성공하면 SHOW REPLICA STATUS;
명령으로 확인할 수 있습니다.
Replica_IO_Running: Yes
Replica_SQL_Running: Yes
→ 두 항목이 모두 Yes면 복제가 정상 작동 중입니다.
5. Spring Boot에서 Master/Slave DB 구성 및 읽기/쓰기 분기 처리
5.1 application.yml
설정
📌 application.yml
spring:
datasource:
master:
hikari:
pool-name: Hikari-MASTER
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://localhost:3306/example
slave:
hikari:
pool-name: Hikari-SLAVE
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://localhost:3307/example
jpa:
database-platform: org.hibernate.dialect.MySQLDialect
show-sql: true
hibernate:
ddl-auto: update
properties:
hibernate:
format_sql: true
- Master와 Slave를 각각 설정
- HikariCP 커넥션 풀 이름을 명시해 구분 가능 (
Hikari-MASTER
,Hikari-SLAVE
) - JPA 설정을 통해 SQL 로그 출력, 포맷팅 설정
5.2 라우팅 설정
📌 RoutingDataSource.java
@Slf4j
public class RoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
String target = isReadOnly ? "slave" : "master";
log.info("Routing to DataSource: {}", target);
return target;
}
}
@Transactional(readOnly = true)
가 붙으면 자동으로 Slave로 연결됩니다
📌 DataSourceConfig.java
@Configuration
public class DataSourceConfig {
@Bean
@ConfigurationProperties("spring.datasource.master.hikari")
public DataSource masterDataSource() {
return DataSourceBuilder.create().type(HikariDataSource.class).build();
}
@Bean
@ConfigurationProperties("spring.datasource.slave.hikari")
public DataSource slaveDataSource() {
return DataSourceBuilder.create().type(HikariDataSource.class).build();
}
@Bean
public DataSource routingDataSource(
@Qualifier("masterDataSource") DataSource master,
@Qualifier("slaveDataSource") DataSource slave
) {
RoutingDataSource routing = new RoutingDataSource();
Map<Object, Object> dataSources = new HashMap<>();
dataSources.put("master", master);
dataSources.put("slave", slave);
routing.setTargetDataSources(dataSources);
routing.setDefaultTargetDataSource(master);
return routing;
}
@Bean
@Primary
public DataSource dataSource(DataSource routingDataSource) {
return new LazyConnectionDataSourceProxy(routingDataSource);
}
}
LazyConnectionDataSourceProxy
를 통해 트랜잭션 시작 시점까지 라우팅을 미룰 수 있어 정확한 분기가 가능합니다.
6. 읽기/쓰기 테스트 및 로깅
6.1 서비스단 / 컨트롤러단 예제
📌 MemberServiceImpl.java
@Service
@RequiredArgsConstructor
public class MemberServiceImpl implements MemberService {
private final MemberRepository memberRepository;
@Override
public Member createMember(MemberCreateRequest createRequest) {
Member member = Member.builder()
.email(createRequest.email())
.name(createRequest.name())
.build();
return memberRepository.save(member);
}
@Override
@Transactional(readOnly = true)
public List<Member> getAllMembers() {
return memberRepository.findAll();
}
@Override
@Transactional(readOnly = true)
public Member getMemberByEmail(String email) {
return memberRepository.findByEmail(email).orElse(null);
}
}
📌 MemberController.java
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/members")
public class MemberController {
private final MemberService memberService;
@GetMapping("/slave")
public List<Member> getAllMembers() {
return memberService.getAllMembers();
}
@GetMapping("/slave/{email}")
public Member getMemberByEmail(@PathVariable String email) {
return memberService.getMemberByEmail(email);
}
@PostMapping("/master")
public Member createMember(@RequestBody MemberCreateRequest createRequest) {
return memberService.createMember(createRequest);
}
}
6.2. 로그로 Master/Slave 라우팅 확인하기
실제로 읽기/쓰기 요청이 각각 어떤 DB(Master/Slave)로 라우팅되었는지 확인해보려면, 로그 출력 결과를 확인하면 됩니다.RoutingDataSource
클래스에서 로그를 출력해주고 있으므로, 트랜잭션의 readOnly 여부에 따라 어떤 DB로 요청이 전달되었는지 쉽게 추적할 수 있습니다.
1) 쓰기 요청 (Master 라우팅)
다음은 회원 생성 API 요청 시 발생한 로그입니다.
- Hibernate에서 INSERT 쿼리가 실행되는 것이 보입니다.
Routing to DataSource: master
로그를 통해 해당 요청이 Master DB로 라우팅되었음을 확인할 수 있습니다.- 즉, 쓰기 작업은 명확하게 Master로 전달되고 있다는 것을 확인할 수 있습니다.
- 아래쪽에 보이는
"Hikari-MASTER"
풀 이름 로그는 HikariCP 커넥션 풀이 Master DB를 대상으로 동작하고 있다는 것을 의미합니다.
2) 읽기 요청 (Slave 라우팅)
이번에는 @Transactional(readOnly = true)
가 설정된 회원 조회 API 호출 시의 로그입니다.
- Hibernate에서
select * from member
쿼리가 수행된 것을 확인할 수 있습니다. Routing to DataSource: slave
로그를 통해 Slave DB로 라우팅된 것이 확인됩니다.- HikariCP에서도
"Hikari-SLAVE"
풀 이름으로 커넥션이 연결되는 로그가 출력되며, 읽기 요청이 정상적으로 Slave로 분기됨을 검증할 수 있습니다.
6.3 HikariCP 풀 이름 구분 설정
위 로그처럼 Master/Slave 구분이 명확히 되도록 하기 위해, 아래와 같이 application.yml
에서 HikariCP 풀 이름을 직접 지정해주는 것이 좋습니다.
spring:
datasource:
master.hikari.pool-name: Hikari-MASTER
slave.hikari.pool-name: Hikari-SLAVE
이를 설정하면 로그 상에서도 Hikari-MASTER
, Hikari-SLAVE
로 커넥션 구분이 가능하여 운영 환경에서 디버깅 및 모니터링 시 매우 유용합니다.
7. 결론
이번 실습을 통해 Spring Boot에서 MySQL을 Master/Slave 구조로 구성하고, 읽기와 쓰기를 분리하여 처리하는 방법을 실제로 테스트해보았습니다.
사이드 프로젝트 수준에서는 단일 DB만으로도 충분할 수 있지만, 실무에서는 다음과 같은 이유로 Master/Slave 구조와 라우팅 처리 방식이 중요합니다.
- Docker Compose로 손쉽게 Master/Slave 이중화 구성 가능
- MySQL 설정파일(my.cnf)과 Dockerfile을 통해 손쉽게 구성할 수 있음
- Spring Boot에서
AbstractRoutingDataSource
를 활용해 동적 라우팅 처리- 트랜잭션의
readOnly
여부에 따라 Master/Slave로 자동 분기
- 트랜잭션의
- 읽기/쓰기 트랜잭션 분리로 성능 분산 및 확장성 확보
- 읽기 부하가 많은 시스템에서 Slave를 늘려 처리 분산 가능
- 라우팅 로그와 HikariCP 풀 이름 설정으로 운영 중에도 분기 확인 가능
- 로깅만으로도 쿼리 흐름을 확인하고, 문제 발생 시 추적 가능
이번 구성은 사이드 프로젝트에 실제 적용하기 전 사전 테스트로 진행한 연습이었지만, 실제 운영 환경에서도 충분히 활용 가능한 구조임을 확인할 수 있었습니다.
특히, @Transactional(readOnly = true)
하나로 라우팅이 자동으로 분기된다는 점은 Spring의 추상화된 트랜잭션 처리 방식의 강력함을 다시 한 번 체감할 수 있는 부분이었습니다.
다음 글에서는 ProxySQL이나 Orchestrator를 활용해 장애 발생 시 자동 승격( 및 라우팅 전환까지 자동화하는 방법을 다뤄보겠습니다.
'BackEnd > Spring & JPA' 카테고리의 다른 글
[Spring Cloud MSA] Config, Gateway, Eureka까지 MSA 핵심 구조 쉽게 이해하기 (1) | 2025.04.15 |
---|---|
[Spring] @RequestPart를 활용하여 JSON + MultipartFile 동시 전송하기 (Feat. 게시판에서 게시물 생성과 첨부 파일 업로드 한번에 처리하기) (0) | 2025.02.13 |
[Spring Data JPA] JPA 엔티티 설계 시 생성자 접근 제한을 PROTECTED로 설정하는 이유 (0) | 2025.01.21 |
[Spring] @ResponseBody VS ResponseEntity<T> (0) | 2025.01.06 |
[Spring] @Controller VS @RestController (0) | 2025.01.06 |