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

2025. 3. 24. 17:55·BackEnd/Spring & JPA
목차
  1. 1. 시작하며
  2. 2. 목표
  3. 3. Docker Compose로 Master/Slave 구성하기
  4. 3.1 디렉토리 구조
  5. 3.2 Docker Compose 설정
  6. 3.3 Master DB 설정
  7. 3.4 Slave DB 설정
  8. 4. MySQL Replication 설정
  9. 4.1 마스터에서 복제 계정 생성
  10. 4.2 슬레이브에서 복제 설정
  11. 5. Spring Boot에서 Master/Slave DB 구성 및 읽기/쓰기 분기 처리
  12. 5.1 application.yml 설정
  13. 5.2 라우팅 설정
  14. 6. 읽기/쓰기 테스트 및 로깅
  15. 6.1 서비스단 / 컨트롤러단 예제
  16. 6.2. 로그로 Master/Slave 라우팅 확인하기
  17. 1) 쓰기 요청 (Master 라우팅)
  18. 2) 읽기 요청 (Slave 라우팅)
  19. 6.3 HikariCP 풀 이름 구분 설정
  20. 7. 결론

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
  1. 1. 시작하며
  2. 2. 목표
  3. 3. Docker Compose로 Master/Slave 구성하기
  4. 3.1 디렉토리 구조
  5. 3.2 Docker Compose 설정
  6. 3.3 Master DB 설정
  7. 3.4 Slave DB 설정
  8. 4. MySQL Replication 설정
  9. 4.1 마스터에서 복제 계정 생성
  10. 4.2 슬레이브에서 복제 설정
  11. 5. Spring Boot에서 Master/Slave DB 구성 및 읽기/쓰기 분기 처리
  12. 5.1 application.yml 설정
  13. 5.2 라우팅 설정
  14. 6. 읽기/쓰기 테스트 및 로깅
  15. 6.1 서비스단 / 컨트롤러단 예제
  16. 6.2. 로그로 Master/Slave 라우팅 확인하기
  17. 1) 쓰기 요청 (Master 라우팅)
  18. 2) 읽기 요청 (Slave 라우팅)
  19. 6.3 HikariCP 풀 이름 구분 설정
  20. 7. 결론
'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)
  • 인기 글

  • 태그

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

  • 최근 글

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

티스토리툴바

단축키

내 블로그

내 블로그 - 관리자 홈 전환
Q
Q
새 글 쓰기
W
W

블로그 게시글

글 수정 (권한 있는 경우)
E
E
댓글 영역으로 이동
C
C

모든 영역

이 페이지의 URL 복사
S
S
맨 위로 이동
T
T
티스토리 홈 이동
H
H
단축키 안내
Shift + /
⇧ + /

* 단축키는 한글/영문 대소문자로 이용 가능하며, 티스토리 기본 도메인에서만 동작합니다.