[Spring Boot] MySQL Master/Slave 복제 설정과 Read/Write 자동 분기 처리 방법 정리

2025. 3. 24. 17:55·BackEnd/Spring & JPA

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. 목표

이번 포스팅에서는 아래와 같은 구성 및 기능을 목표로 설정합니다.

  1. MySQL Master/Slave 이중화 구성 (Docker Compose 활용)
  2. Spring Boot에서 Master는 쓰기, Slave는 읽기 처리가 자동으로 되도록 설정
  3. @Transactional(readOnly = true) 어노테이션에 따라 자동으로 Slave로 라우팅
  4. 어떤 요청이 Master/Slave 중 어디에서 처리되었는지 로그로 확인 가능
  5. 실서비스 도입 전, 사전 검증을 위한 기초 구성 실습

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 구조와 라우팅 처리 방식이 중요합니다.

  1. Docker Compose로 손쉽게 Master/Slave 이중화 구성 가능
    • MySQL 설정파일(my.cnf)과 Dockerfile을 통해 손쉽게 구성할 수 있음
  2. Spring Boot에서 AbstractRoutingDataSource를 활용해 동적 라우팅 처리
    • 트랜잭션의 readOnly 여부에 따라 Master/Slave로 자동 분기
  3. 읽기/쓰기 트랜잭션 분리로 성능 분산 및 확장성 확보
    • 읽기 부하가 많은 시스템에서 Slave를 늘려 처리 분산 가능
  4. 라우팅 로그와 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
'BackEnd/Spring & JPA' 카테고리의 다른 글
  • [Spring Cloud MSA] Config, Gateway, Eureka까지 MSA 핵심 구조 쉽게 이해하기
  • [Spring] @RequestPart를 활용하여 JSON + MultipartFile 동시 전송하기 (Feat. 게시판에서 게시물 생성과 첨부 파일 업로드 한번에 처리하기)
  • [Spring Data JPA] JPA 엔티티 설계 시 생성자 접근 제한을 PROTECTED로 설정하는 이유
  • [Spring] @ResponseBody VS ResponseEntity<T>
개발자 동긔
개발자 동긔
배우고 느낀점들을 기록합니다. 열정 넘치는 백엔드 개발자로 남고싶습니다.
  • 개발자 동긔
    Donker Dev
    개발자 동긔
  • 전체
    오늘
    어제
    • Category (39)
      • BackEnd (23)
        • JAVA (15)
        • Spring & JPA (7)
      • Database (4)
      • Computer Science (2)
        • Network (0)
        • Security (0)
        • Web (1)
      • DevOps (6)
        • Docker (1)
        • Jenkins (0)
        • Monitoring (2)
        • CICD (1)
      • 트러블 슈팅 (3)
      • 성능 개선 (1)
      • Project (0)
  • 인기 글

  • 태그

    JPA
    restful api
    인터페이스
    interface
    docker compose
    CICD
    restful api 설계
    docker
    master/slave db 이중화 처리
    spring cloud msa
    Jenkins
    와일드카드
    mysql master/slave replication
    Spring
    nginx
    spring boot
    Database
    SSH
    java
    @RequestBody
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
개발자 동긔
[Spring Boot] MySQL Master/Slave 복제 설정과 Read/Write 자동 분기 처리 방법 정리
상단으로

티스토리툴바