MySQL Read Replica 도입

조용현·2026년 5월 18일

문제 해결

목록 보기
12/13

배경

Scale Out(HAProxy + App 2대)으로 App CPU 병목을 해소했지만, 병목이 MySQL로 이동했다.

단계TPSAvg 응답시간병목
댓글 트리 최적화 후274/s867msApp CPU
Scale Out 후527/s32msMySQL CPU

커뮤니티 서비스 특성상 읽기 트래픽이 90%를 차지하는데, 단일 MySQL이 읽기와 쓰기를 모두 처리하면서 CPU가 포화되고 있었다. 읽기 쿼리를 Replica로 분산하면 Master의 부하가 줄어들고, MySQL 병목을 해소할 수 있을 것으로 판단했다.


아키텍처 변경

Before (Scale Out, 단일 MySQL)

Client → HAProxy ─┬─ App-1 (2cpu/2GB) ─┬─ MySQL (1cpu/1GB)
                  └─ App-2 (2cpu/2GB) ─┘

After (Scale Out + Read Replica)

Client → HAProxy ─┬─ App-1 (2cpu/2GB) ─┬─ Master (1cpu/1GB) ── Write
                  └─ App-2 (2cpu/2GB) ─┤
                                       └─ Replica (1cpu/1GB) ── Read
                                               ↑
                                          GTID Replication

핵심 구현: LazyConnectionDataSourceProxy

Spring 6.1.2+에서 제공하는 LazyConnectionDataSourceProxy를 사용했다. 기존에 많이 쓰이던 AbstractRoutingDataSource 방식과 비교하면 코드량이 크게 줄어든다.

@Configuration
@ConditionalOnProperty(prefix = "app.datasource.write", name = "jdbc-url")
public class DataSourceConfig {

    @Bean
    @ConfigurationProperties(prefix = "app.datasource.write")
    public DataSource writeDataSource() {
        return DataSourceBuilder.create().type(HikariDataSource.class).build();
    }

    @Bean
    @ConfigurationProperties(prefix = "app.datasource.read")
    public DataSource readDataSource() {
        return DataSourceBuilder.create().type(HikariDataSource.class).build();
    }

    @Bean
    @Primary
    public DataSource dataSource(
            @Qualifier("writeDataSource") DataSource writeDataSource,
            @Qualifier("readDataSource") DataSource readDataSource) {
        LazyConnectionDataSourceProxy proxy = new LazyConnectionDataSourceProxy(writeDataSource);
        proxy.setReadOnlyDataSource(readDataSource);
        return proxy;
    }
}

왜 LazyConnectionDataSourceProxy인가?

비교AbstractRoutingDataSourceLazyConnectionDataSourceProxy
커넥션 획득 시점트랜잭션 시작 시 (readOnly 미확정)첫 SQL 실행 시 (readOnly 확정)
ThreadLocal 필요OX
AOP 설정 필요OX
코드량~50줄 (Config + AOP + ThreadLocal)~20줄

AbstractRoutingDataSource는 트랜잭션 시작 시점에 커넥션을 획득하는데, 이때 @Transactional(readOnly=true) 플래그가 아직 설정되지 않았을 수 있어서 별도 AOP + ThreadLocal이 필요하다. LazyConnectionDataSourceProxy는 커넥션 획득을 첫 SQL 실행까지 지연하므로, 그 시점에는 readOnly 플래그가 이미 확정되어 자연스럽게 올바른 DataSource로 라우팅된다.

기존 서비스 코드 변경 없음

서비스 클래스에 이미 @Transactional@Transactional(readOnly = true)이 올바르게 설정되어 있었기 때문에, DataSourceConfig만 추가하면 자동으로 라우팅이 동작했다.

@Service
@Transactional
@RequiredArgsConstructor
public class PostService {

    @Transactional(readOnly = true)  // → Replica
    public PostDetailResponse getPost(Long postId) { ... }

    public void createPost(PostCreateRequest request) { ... }  // → Master
}

CI/운영 호환성

@ConditionalOnProperty를 적용해서, app.datasource.write.jdbc-url 프로퍼티가 없으면 DataSourceConfig 자체가 로딩되지 않는다. Replica가 없는 CI 환경이나 EC2 단일 DB 환경에서도 기존 spring.datasource.* 설정으로 정상 동작한다.


MySQL GTID Replication 구성

Docker Compose로 Master/Replica를 구성했다.

Master 설정

mysql-master:
  image: mysql:8
  command:
    - "--server-id=1"
    - "--gtid-mode=ON"
    - "--enforce-gtid-consistency=ON"
    - "--log-bin=mysql-bin"
    - "--binlog-format=ROW"

Replica 자동 설정

별도 one-shot 컨테이너(replica-setup)가 양쪽 DB 기동을 대기한 뒤 Replication을 설정한다.

CHANGE REPLICATION SOURCE TO
  SOURCE_HOST='mysql-master',
  SOURCE_USER='repl',
  SOURCE_PASSWORD='replpassword',
  SOURCE_AUTO_POSITION=1,
  GET_SOURCE_PUBLIC_KEY=1;
START REPLICA;

GTID 기반 복제를 선택한 이유는, Docker 환경에서 컨테이너가 재시작될 때 binlog 포지션이 변경되더라도 GTID가 자동으로 추적해주기 때문이다. Position-based 방식은 재시작 시마다 수동으로 포지션을 재설정해야 한다.


테스트 환경

항목Before (Scale Out)After (+ Replica)
App2대 (각 2cpu / 2GB)2대 (각 2cpu / 2GB)
HAProxy0.5cpu / 128MB0.5cpu / 128MB
MySQL1대 (1cpu / 1GB)Master (1cpu / 1GB) + Replica (1cpu / 1GB)
HikariCP50Write 50 + Read 80
vusers500500
테스트 시간10분10분
테스트 데이터댓글 505만건, 좋아요 735만건
워크로드 비율읽기 90% (비로그인 60% + 로그인 30%) + 쓰기 10%

부하테스트 결과

전체 지표 비교

지표Before (Scale Out)After (+ Replica)변화율
TPS527.0/s538.9/s+2.3%
평균 응답시간32ms10ms↓ 67.1%
p5027ms7ms↓ 74.1%
p9093ms21ms↓ 77.4%
p95125ms36ms↓ 71.2%
p99237ms80ms↓ 66.2%
Max1,338ms469ms↓ 64.9%
에러율0%0%동일
총 요청 수316,041323,024+2.2%

주요 엔드포인트별 평균 응답시간

엔드포인트BeforeAfter개선율
GET /boards (게시판 목록)22ms7ms↓ 68%
GET /boards/slug (게시글 목록, Hot)32ms11ms↓ 66%
GET /boards/slug (게시글 목록, Normal)32ms11ms↓ 66%
GET /boards/slug/postId (게시글 상세, Hot)24ms8ms↓ 67%
GET /boards/slug/postId (게시글 상세, Normal)27ms9ms↓ 67%
GET 댓글 조회 (Hot)31ms10ms↓ 68%
GET 댓글 조회 (Normal)39ms11ms↓ 72%
POST /login82ms84ms동일 (BCrypt 지배적)
POST 좋아요 (Comment, Hot)52ms15ms↓ 71%
POST 좋아요 (Comment, Normal)25ms8ms↓ 68%
POST 게스트 글쓰기 (Hot)179ms110ms↓ 39%

응답시간 분포 (p50 / p95 / p99)

Before (단일 MySQL)

Response Time Before

p99가 100~350ms 범위에서 크게 출렁이며, p95도 간헐적 스파이크가 발생한다. 단일 MySQL에서 읽기/쓰기 쿼리가 경합하면서 tail latency가 불안정하다.

After (+ Read Replica)

Response Time After

p99가 20~80ms로 안정화되고, p50은 거의 일정하게 7ms를 유지한다. 읽기/쓰기 분리로 쿼리 큐잉이 해소되면서 응답시간이 전반적으로 안정되었다.

Container CPU 사용률

Before (단일 MySQL)

Container CPU Before

단일 MySQL(파란색)이 100%에 고정되어 CPU 포화 상태다. App 2대가 120~160% 범위에서 동작하지만, DB가 병목이므로 App CPU가 놀고 있는 구간도 보인다.

After (+ Read Replica)

Container CPU After

Master(초록)가 ~25%로 크게 낮아지고, Replica(파란색)가 ~75%로 읽기 부하를 흡수한다. App CPU가 더 균일하게 활용되고 있다.


분석

응답시간이 크게 줄어든 이유

Before에서는 단일 MySQL(1cpu)이 읽기와 쓰기를 모두 처리했다. 500 vusers가 동시에 요청하면 DB 쿼리가 큐에 쌓여 대기 시간이 발생했다.

Replica 도입 후 읽기 쿼리(전체의 ~90%)가 Replica로 분산되면서:

  • Master: 쓰기 쿼리에만 집중 → 쿼리 큐 대기 감소
  • Replica: 읽기 전용으로 동작 → 1cpu 전체를 읽기에 활용
  • 결과적으로 모든 읽기 엔드포인트에서 66~72% 응답시간 감소

쓰기 성능도 간접 개선된 이유

게스트 글쓰기(179ms → 110ms, ↓39%), 좋아요(52ms → 15ms, ↓71%) 등 쓰기 경로도 개선되었다. Master에서 읽기 쿼리가 사라지면서 쓰기 쿼리의 Lock 경합과 대기 시간이 줄었기 때문이다. POST /login은 BCrypt 해싱이 응답시간의 대부분을 차지하므로 DB 부하 분산의 영향을 거의 받지 않았다(82ms → 84ms).

TPS가 소폭 상승에 그친 이유

응답시간은 67.1% 감소했지만 TPS는 2.3%만 상승했다. 이는 JMeter 테스트 시나리오가 Think Time과 고정 스레드 수(500)로 구성되어 있어, 응답이 빨라져도 다음 요청까지 대기 시간이 있기 때문이다. 실제 사용자 트래픽에서는 응답시간 감소가 사용자 체감 성능 향상으로 직결된다.

Tail Latency 극적 개선

p99가 237ms → 80ms(↓66.2%), Max가 1,338ms → 469ms(↓64.9%)로 크게 줄었다. Before에서는 DB 쿼리 큐잉으로 간헐적으로 긴 대기가 발생했는데, 읽기/쓰기 분리로 큐잉 자체가 해소된 결과다.


트레이드오프

트레이드오프내용대응
Replication LagReplica에서 stale 데이터를 읽을 수 있음커뮤니티 특성상 수초 지연은 허용 가능. 본인 쓰기만 Sticky Primary로 보장
DB 리소스 증가MySQL 1대 → 2대 (총 DB CPU 1.0 → 2.0)읽기 부하 분산으로 CPU당 효율은 향상
운영 복잡도 증가Master/Replica 2대 관리, 복제 상태 모니터링 필요mysqld-exporter + Grafana로 Lag/Threads/QPS 상시 감시
커넥션 풀 리소스 증가Write 50 + Read 80 = 총 130 커넥션App 메모리에 여유가 있고, DB별로 용도에 맞게 풀 크기를 분리 튜닝 가능
장애 포인트 추가Replica 장애 시 읽기 실패 가능Replica 장애 감지 시 Master fallback 전략 필요 (현재 미구현)

정리

단계TPSAvg 응답시간병목
댓글 트리 최적화 후274/s867msApp CPU
Scale Out 후527/s32msMySQL CPU
+ Read Replica539/s10ms— (여유)

Scale Out으로 이동한 MySQL 병목을 Read Replica로 해소했다. 읽기 쿼리가 Replica로 분산되면서 평균 응답시간이 32ms → 10ms로 67.1% 감소했고, p99는 237ms → 80ms로 66.2% 감소했다. 쓰기 경로도 Master 부하 감소로 간접적으로 39~71% 개선되었다.

구현 관점의 핵심은 LazyConnectionDataSourceProxy 하나로 기존 서비스 코드 변경 없이 읽기/쓰기 자동 라우팅을 구현한 것이다. @Transactional(readOnly = true) 어노테이션만으로 Replica로 라우팅되므로, 애플리케이션 레이어의 변경 비용이 거의 없었다.

이후 Replication Lag 완화를 위해 병렬 복제 + Sticky Primary를 추가 적용했다.

profile
백엔드 개발자

0개의 댓글