Scale Out(HAProxy + App 2대)으로 App CPU 병목을 해소했지만, 병목이 MySQL로 이동했다.
| 단계 | TPS | Avg 응답시간 | 병목 |
|---|---|---|---|
| 댓글 트리 최적화 후 | 274/s | 867ms | App CPU |
| Scale Out 후 | 527/s | 32ms | MySQL CPU |
커뮤니티 서비스 특성상 읽기 트래픽이 90%를 차지하는데, 단일 MySQL이 읽기와 쓰기를 모두 처리하면서 CPU가 포화되고 있었다. 읽기 쿼리를 Replica로 분산하면 Master의 부하가 줄어들고, MySQL 병목을 해소할 수 있을 것으로 판단했다.
Client → HAProxy ─┬─ App-1 (2cpu/2GB) ─┬─ MySQL (1cpu/1GB)
└─ App-2 (2cpu/2GB) ─┘
Client → HAProxy ─┬─ App-1 (2cpu/2GB) ─┬─ Master (1cpu/1GB) ── Write
└─ App-2 (2cpu/2GB) ─┤
└─ Replica (1cpu/1GB) ── Read
↑
GTID Replication
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;
}
}
| 비교 | AbstractRoutingDataSource | LazyConnectionDataSourceProxy |
|---|---|---|
| 커넥션 획득 시점 | 트랜잭션 시작 시 (readOnly 미확정) | 첫 SQL 실행 시 (readOnly 확정) |
| ThreadLocal 필요 | O | X |
| AOP 설정 필요 | O | X |
| 코드량 | ~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
}
@ConditionalOnProperty를 적용해서, app.datasource.write.jdbc-url 프로퍼티가 없으면 DataSourceConfig 자체가 로딩되지 않는다. Replica가 없는 CI 환경이나 EC2 단일 DB 환경에서도 기존 spring.datasource.* 설정으로 정상 동작한다.
Docker Compose로 Master/Replica를 구성했다.
mysql-master:
image: mysql:8
command:
- "--server-id=1"
- "--gtid-mode=ON"
- "--enforce-gtid-consistency=ON"
- "--log-bin=mysql-bin"
- "--binlog-format=ROW"
별도 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) |
|---|---|---|
| App | 2대 (각 2cpu / 2GB) | 2대 (각 2cpu / 2GB) |
| HAProxy | 0.5cpu / 128MB | 0.5cpu / 128MB |
| MySQL | 1대 (1cpu / 1GB) | Master (1cpu / 1GB) + Replica (1cpu / 1GB) |
| HikariCP | 50 | Write 50 + Read 80 |
| vusers | 500 | 500 |
| 테스트 시간 | 10분 | 10분 |
| 테스트 데이터 | 댓글 505만건, 좋아요 735만건 | |
| 워크로드 비율 | 읽기 90% (비로그인 60% + 로그인 30%) + 쓰기 10% |
| 지표 | Before (Scale Out) | After (+ Replica) | 변화율 |
|---|---|---|---|
| TPS | 527.0/s | 538.9/s | +2.3% |
| 평균 응답시간 | 32ms | 10ms | ↓ 67.1% |
| p50 | 27ms | 7ms | ↓ 74.1% |
| p90 | 93ms | 21ms | ↓ 77.4% |
| p95 | 125ms | 36ms | ↓ 71.2% |
| p99 | 237ms | 80ms | ↓ 66.2% |
| Max | 1,338ms | 469ms | ↓ 64.9% |
| 에러율 | 0% | 0% | 동일 |
| 총 요청 수 | 316,041 | 323,024 | +2.2% |
| 엔드포인트 | Before | After | 개선율 |
|---|---|---|---|
| GET /boards (게시판 목록) | 22ms | 7ms | ↓ 68% |
| GET /boards/slug (게시글 목록, Hot) | 32ms | 11ms | ↓ 66% |
| GET /boards/slug (게시글 목록, Normal) | 32ms | 11ms | ↓ 66% |
| GET /boards/slug/postId (게시글 상세, Hot) | 24ms | 8ms | ↓ 67% |
| GET /boards/slug/postId (게시글 상세, Normal) | 27ms | 9ms | ↓ 67% |
| GET 댓글 조회 (Hot) | 31ms | 10ms | ↓ 68% |
| GET 댓글 조회 (Normal) | 39ms | 11ms | ↓ 72% |
| POST /login | 82ms | 84ms | 동일 (BCrypt 지배적) |
| POST 좋아요 (Comment, Hot) | 52ms | 15ms | ↓ 71% |
| POST 좋아요 (Comment, Normal) | 25ms | 8ms | ↓ 68% |
| POST 게스트 글쓰기 (Hot) | 179ms | 110ms | ↓ 39% |
Before (단일 MySQL)

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

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

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

Master(초록)가 ~25%로 크게 낮아지고, Replica(파란색)가 ~75%로 읽기 부하를 흡수한다. App CPU가 더 균일하게 활용되고 있다.
Before에서는 단일 MySQL(1cpu)이 읽기와 쓰기를 모두 처리했다. 500 vusers가 동시에 요청하면 DB 쿼리가 큐에 쌓여 대기 시간이 발생했다.
Replica 도입 후 읽기 쿼리(전체의 ~90%)가 Replica로 분산되면서:
게스트 글쓰기(179ms → 110ms, ↓39%), 좋아요(52ms → 15ms, ↓71%) 등 쓰기 경로도 개선되었다. Master에서 읽기 쿼리가 사라지면서 쓰기 쿼리의 Lock 경합과 대기 시간이 줄었기 때문이다. POST /login은 BCrypt 해싱이 응답시간의 대부분을 차지하므로 DB 부하 분산의 영향을 거의 받지 않았다(82ms → 84ms).
응답시간은 67.1% 감소했지만 TPS는 2.3%만 상승했다. 이는 JMeter 테스트 시나리오가 Think Time과 고정 스레드 수(500)로 구성되어 있어, 응답이 빨라져도 다음 요청까지 대기 시간이 있기 때문이다. 실제 사용자 트래픽에서는 응답시간 감소가 사용자 체감 성능 향상으로 직결된다.
p99가 237ms → 80ms(↓66.2%), Max가 1,338ms → 469ms(↓64.9%)로 크게 줄었다. Before에서는 DB 쿼리 큐잉으로 간헐적으로 긴 대기가 발생했는데, 읽기/쓰기 분리로 큐잉 자체가 해소된 결과다.
| 트레이드오프 | 내용 | 대응 |
|---|---|---|
| Replication Lag | Replica에서 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 전략 필요 (현재 미구현) |
| 단계 | TPS | Avg 응답시간 | 병목 |
|---|---|---|---|
| 댓글 트리 최적화 후 | 274/s | 867ms | App CPU |
| Scale Out 후 | 527/s | 32ms | MySQL CPU |
| + Read Replica | 539/s | 10ms | — (여유) |
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를 추가 적용했다.