성능 최적화 21편 - Read Replica로 읽기/쓰기 분리하기
지금까지는 하나의 데이터베이스를 기준으로 쿼리, 인덱스, 커넥션 풀, 격리 레벨을 최적화하는 방법을 다뤘습니다. 하지만 서비스 규모가 커지면 단일 데이터베이스만으로는 트래픽을 감당하기 어려운 시점이 옵니다. 이번 글에서는 읽기 전용 복제본(Read Replica)을 두고 읽기와 쓰기를 분리하는 방법을 정리합니다.
1. 읽기/쓰기 분리가 필요한 이유
대부분의 서비스는 쓰기(INSERT/UPDATE/DELETE)보다 읽기(SELECT) 트래픽이 압도적으로 많습니다. 상품 목록 조회, 상세 조회 같은 읽기 요청이 초당 수백 건 들어오는 동안, 실제 주문이나 결제 같은 쓰기 작업은 상대적으로 적은 경우가 대부분입니다.
읽기(SELECT) 요청 : 초당 800건
쓰기(INSERT/UPDATE) 요청 : 초당 50건
이 모든 요청이 하나의 데이터베이스로 몰리면, 읽기 트래픽이 쓰기 트랜잭션의 락 대기에 영향을 주거나, 반대로 무거운 쓰기 작업이 읽기 성능을 끌어내릴 수 있습니다. Read Replica는 데이터베이스를 원본(Primary)과 읽기 전용 복제본(Replica)으로 나눠서, 쓰기는 Primary에만, 읽기는 여러 Replica에 분산시키는 구조입니다.
[ 쓰기 ]
│
▼
┌───────────┐
│ Primary │
└─────┬─────┘
│ 복제(Replication)
┌───────────┼───────────┐
▼ ▼ ▼
┌──────────┐┌──────────┐┌──────────┐
│ Replica1 ││ Replica2 ││ Replica3 │
└──────────┘└──────────┘└──────────┘
▲ ▲ ▲
└───────────┴───────────┘
[ 읽기 ]
2. Spring Boot에서 다중 데이터소스 구성하기
spring:
datasource:
primary:
jdbc-url: jdbc:mysql://primary-db:3306/shop
username: app
password: ${DB_PASSWORD}
replica:
jdbc-url: jdbc:mysql://replica-db:3306/shop
username: app
password: ${DB_PASSWORD}
@Configuration
public class DataSourceConfig {
@Bean
@ConfigurationProperties("spring.datasource.primary")
public DataSource primaryDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
@ConfigurationProperties("spring.datasource.replica")
public DataSource replicaDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
public DataSource routingDataSource(
@Qualifier("primaryDataSource") DataSource primary,
@Qualifier("replicaDataSource") DataSource replica) {
RoutingDataSource routingDataSource = new RoutingDataSource();
Map<Object, Object> dataSourceMap = new HashMap<>();
dataSourceMap.put("primary", primary);
dataSourceMap.put("replica", replica);
routingDataSource.setTargetDataSources(dataSourceMap);
routingDataSource.setDefaultTargetDataSource(primary);
return routingDataSource;
}
}
AbstractRoutingDataSource를 상속받아, 현재 트랜잭션이 읽기 전용인지에 따라 적절한 데이터소스로 요청을 라우팅하는 클래스를 만듭니다.
public class RoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
boolean readOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
return readOnly ? "replica" : "primary";
}
}
3. @Transactional(readOnly = true)로 라우팅하기
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
@Transactional(readOnly = true) // Replica로 라우팅
public List<ProductDto> getProducts() {
return productRepository.findAll().stream()
.map(ProductDto::from)
.toList();
}
@Transactional // 기본값(readOnly = false) -> Primary로 라우팅
public void createProduct(ProductCreateDto dto) {
productRepository.save(Product.from(dto));
}
}
@Transactional(readOnly = true)가 붙은 메서드는 RoutingDataSource가 자동으로 Replica로 연결해줍니다. 이 옵션이 단순히 "읽기 성능에 조금 도움이 되는 힌트" 수준이 아니라, 실제로 어느 서버로 쿼리를 보낼지를 결정하는 기준이 된다는 점이 중요합니다.
4. Replication Lag - 가장 흔히 마주치는 함정
Read Replica를 도입할 때 가장 많이 겪는 문제는 복제 지연(Replication Lag)입니다. Primary에 데이터를 쓰고 나서, 그 변경 사항이 Replica에 반영되기까지는 짧지만 분명한 시간차가 존재합니다.
[문제 상황]
1. 사용자가 주문 생성 (Primary에 저장)
2. 즉시 주문 완료 화면으로 이동, 주문 상세 조회 (Replica로 라우팅)
3. 복제가 아직 안 끝나서 Replica에는 방금 만든 주문이 없음
4. "주문을 찾을 수 없습니다" 에러 발생
이런 문제를 피하기 위한 방법들:
- 쓰기 직후 즉시 읽어야 하는 화면(주문 완료 페이지 등)은 명시적으로 Primary를 사용하도록 예외 처리합니다.
- 하나의 트랜잭션 안에서 쓰기와 읽기가 함께 필요한 로직은 애초에
readOnly = true를 붙이지 않아, 같은 트랜잭션 내에서는 Primary만 사용하도록 합니다. - 복제 지연 자체를 모니터링해서, 지연이 비정상적으로 커지면 알림을 받도록 구성합니다.
@Transactional // readOnly를 붙이지 않아 Primary로 강제
public OrderDetailDto createAndGetOrder(OrderCreateDto dto) {
Order order = orderService.createOrder(dto);
return OrderDetailDto.from(order); // 같은 트랜잭션 내에서 Primary로 즉시 조회
}
5. Replica가 여러 대일 때 - 부하 분산
Replica가 여러 대라면, 읽기 요청을 그중 하나에만 몰아주지 않고 분산시켜야 합니다. RoutingDataSource를 확장해서 라운드 로빈 방식으로 여러 Replica에 순환 배정하거나, 데이터베이스 앞단에 ProxySQL, PgBouncer 같은 프록시를 두어 라우팅과 부하 분산을 위임하는 방식도 실무에서 널리 쓰입니다.
6. 도입 전 고려할 점
- 모든 서비스에 필요한 것은 아닙니다. 읽기 트래픽이 Primary 하나로도 충분히 감당되는 규모라면, 복제 지연이라는 새로운 복잡도를 감수할 이유가 없습니다.
- 강한 일관성이 필요한 로직에는 신중하게 적용해야 합니다. 결제, 재고처럼 데이터 정합성이 조금이라도 어긋나면 안 되는 로직은 Replica 라우팅 대상에서 제외하는 것이 안전합니다.
- 모니터링 지표에 Replication Lag를 반드시 포함시켜야 합니다. 지연이 커지는 순간을 놓치면, 사용자가 방금 한 행동의 결과를 못 보는 상황이 반복될 수 있습니다.
7. 정리
- 읽기 트래픽이 쓰기보다 압도적으로 많은 서비스 구조에서는, Read Replica로 읽기와 쓰기를 물리적으로 분리해 Primary의 부하를 줄일 수 있다
- Spring에서는
AbstractRoutingDataSource와@Transactional(readOnly = true)를 조합해 트랜잭션 단위로 자동 라우팅할 수 있다 - 복제 지연으로 인해 "방금 쓴 데이터가 안 보이는" 문제가 발생할 수 있으므로, 쓰기 직후 즉시 조회가 필요한 로직은 예외적으로 Primary를 사용해야 한다
- 무조건 도입하기보다, 실제 읽기 트래픽 규모와 일관성 요구 수준을 먼저 판단하는 것이 우선이다
7편에서 다룬 커넥션 풀이 "하나의 DB에 얼마나 많은 연결을 유지할 것인가"의 문제였다면, 이번 편의 Read Replica는 "그 DB 자체를 여러 대로 나눌 것인가"라는 한 단계 더 근본적인 확장 전략입니다.
'성능 최적화' 카테고리의 다른 글
| 성능 최적화 STEP 23 - Resilience4j (0) | 2026.07.21 |
|---|---|
| 성능 최적화 STEP 22 - 샤딩(Sharding) (1) | 2026.07.20 |
| 성능 최적화 STEP 20 - 네트워크 비용 줄이기 (0) | 2026.07.15 |
| 성능 최적화 STEP 19 - 트랜잭션 격리 레벨 (0) | 2026.07.15 |
| 성능 최적화 STEP 18 - 번들 사이즈 분석과 축소 (0) | 2026.07.14 |