
오늘은 Replication이 구성된 PostgreSQL 환경에서 Spring Boot가 Master(Write) 와 Slave(Read) 를 자동으로 구분해 사용하는 방법을 설명해볼게요.
Spring Boot는 기본적으로 다중 데이터소스를 자동 구성하지 않으므로, Master/Slave 분리를 위해 다음 작업이 필요합니다.
- Master / Slave 각각 별도의
DataSource정의 - 트랜잭션의
readOnly여부에 따른 DB 라우팅 AbstractRoutingDataSource를 활용한 동적 선택LazyConnectionDataSourceProxy로 실제 커넥션 생성 시점에 데이터소스 결정
이 과정을 완료하면 Spring Boot는 읽기 요청은 Slave, 쓰기 요청은 Master 로 자동 라우팅하여 안정적인 Read/Write 분리 환경을 구성할 수 있습니다.
전체 동작 원리
Spring Boot는 트랜잭션의 readOnly 값에 따라 Master 또는 Slave로 DB 연결을 결정합니다.
- @Transactional(readOnly = true) → Slave로 라우팅
- 기본 트랜잭션(readOnly = false) → Master로 라우팅
라우팅 과정은 다음 구성 요소들이 처리합니다.
- AbstractRoutingDataSource: 트랜잭션 정보를 기반으로 사용할 데이터소스를 동적으로 선택
- LazyConnectionDataSourceProxy: 실제 커넥션을 사용하는 시점에 최종 데이터소스를 결정
시퀀스 흐름은 아래와 같습니다.
Service → @Transactional(readOnly = true)
→ RoutingDataSource(determineKey)
→ Slave DataSource
application.yml 설정
Master와 Slave 데이터소스를 각각 명시합니다.
spring:
datasource:
master:
hikari:
driver-class-name: org.postgresql.Driver
jdbc-url: jdbc:postgresql://MASTER_HOST:5432/DB_NAME
username: master_user
password: master_pw
read-only: false
slave:
hikari:
driver-class-name: org.postgresql.Driver
jdbc-url: jdbc:postgresql://SLAVE_HOST:5433/DB_NAME
username: slave_user
password: slave_pw
read-only: true
Spring Boot는 다중 DataSource를 자동 구성하지 않으므로@ConfigurationProperties+DataSourceBuilder를 사용해 Master와 Slave를 직접 생성해야 합니다.
DataSource Bean 구성
1. Master DataSource 설정
@Configuration
public class MasterDataSourceConfig {
@Primary
@Bean(name = "masterDataSource")
@ConfigurationProperties(prefix = "spring.datasource.master.hikari")
public DataSource masterDataSource() {
return DataSourceBuilder.create()
.type(HikariDataSource.class)
.build();
}
}
@Primary: 기본 DataSource로 Master를 사용하도록 지정- Master는 항상 Write 작업을 담당합니다.
2. Slave DataSource 설정
@Configuration
public class SlaveDataSourceConfig {
@Bean(name = "slaveDataSource")
@ConfigurationProperties(prefix = "spring.datasource.slave.hikari")
public DataSource slaveDataSource() {
return DataSourceBuilder.create()
.type(HikariDataSource.class)
.build();
}
}
- Slave는 읽기 전용(Read Only) 데이터소스로 사용됩니다.
DataSourceType Enum
Master와 Slave 타입을 Enum으로 관리합니다.
public enum DataSourceType {
Master,
Slave
}
두 데이터소스 간 라우팅 시 기준값으로 사용됩니다.
라우팅 DataSource 구현
AbstractRoutingDataSource를 확장해 트랜잭션의 readOnly 여부에 따라 Master 또는 Slave를 선택합니다.
public class ReplicationRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
? DataSourceType.Slave
: DataSourceType.Master;
}
}
동작 방식
readOnly = true→ SlavereadOnly = false→ Master
RoutingDataSourceConfig
Master와 Slave를 실제로 라우팅하는 DataSource 조합 구성을 담당합니다.
@Configuration
public class RoutingDataSourceConfig {
@Bean(name = "routingDataSource")
public DataSource routingDataSource(
@Qualifier("masterDataSource") DataSource masterDataSource,
@Qualifier("slaveDataSource") DataSource slaveDataSource
) {
ReplicationRoutingDataSource routingDataSource = new ReplicationRoutingDataSource();
Map<Object, Object> dataSourceMap = Map.of(
DataSourceType.Master, masterDataSource,
DataSourceType.Slave, slaveDataSource
);
routingDataSource.setTargetDataSources(dataSourceMap);
routingDataSource.setDefaultTargetDataSource(masterDataSource);
return routingDataSource;
}
@Bean(name = "dataSource")
public DataSource dataSource(
@Qualifier("routingDataSource") DataSource routingDataSource
) {
return new LazyConnectionDataSourceProxy(routingDataSource);
}
}
주요 포인트
1. LazyConnectionDataSourceProxy 적용
LazyConnectionDataSourceProxy는 커넥션을 트랜잭션 시작 시점이 아니라 실제 SQL 실행 시점에 가져옵니다.
이를 통해 readOnly 여부가 정확히 반영된 상태에서 라우팅이 이뤄집니다.
2. DefaultTargetDataSource = Master
트랜잭션 정보가 없을 때는 기본적으로 Master를 사용하도록 설정합니다.
이를 통해 Slave로의 의도치 않은 쓰기 요청을 방지할 수 있습니다.
사용 예시
1. Slave로 라우팅 되는 경우
@Transactional(readOnly = true)
public List<User> getUsers() {
return userRepository.findAll();
}
readOnly = true 이므로 해당 요청은 자동으로 Slave로 라우팅되어 읽기 부하가 분산됩니다.
2. Master로 라우팅 되는 경우
@Transactional
public void saveUser(User user) {
userRepository.save(user);
}
쓰기 트랜잭션이므로 Master로 라우팅되어 Write 작업을 수행합니다.
검증 방법
검증은 PostgreSQL에서 연결 상태를 확인하는 방식으로 가능합니다.
Master
SELECT * FROM pg_stat_activity
WHERE backend_type = 'client backend';
Slave
SELECT * FROM pg_stat_activity;
각 요청이 실제로 Slave로 전달되는지 pg_stat_activity를 통해 확인할 수 있습니다.
문제 해결 (Troubleshooting)
Slave가 Write 요청을 받는 경우
@Transactional(readOnly = true)누락 여부 확인LazyConnectionDataSourceProxy가 적용되어 있는지 점검
(적용되지 않으면 트랜잭션 시작 시점에 Master/Slave가 잘못 결정될 수 있음)
Slave에서 시차로 인해 데이터가 즉시 반영되지 않는 경우
- PostgreSQL 물리 복제는 기본적으로 비동기 방식이므로 정상 동작
- 즉시 일관성이 필요하다면 Synchronous Replication 사용을 고려
요청이 Master/Slave로 원하는 대로 라우팅되지 않을 때
determineCurrentLookupKey()에 로그를 추가해 라우팅 키 확인@Transactional이 프록시 기반 AOP 범위 내에서 적용되고 있는지 점검- AOP 제외 대상(프록시 적용 제외 클래스/메서드)인지 확인
이렇게 Spring Boot 환경에서 Master–Slave Replication을 적용하여 읽기 요청과 쓰기 요청을 자동으로 분리하는 방법을 살펴봤습니다.
핵심 구성 요소는 다음과 같아요
- Master / Slave 데이터소스 분리
RoutingDataSource+determineCurrentLookupKey를 통한 동적 라우팅LazyConnectionDataSourceProxy로 커넥션 생성 시점 지연@Transactional(readOnly = true)기반의 라우팅 전략
이 구성을 적용하면 애플리케이션은 복제된 데이터베이스 환경을 효율적으로 활용할 수 있게 되고,
읽기 부하 분산과 전체 시스템 안정성을 크게 향상시킬 수 있을거에요!
'Backend > Spring 🌱' 카테고리의 다른 글
| Spring에서 ServiceImpl, 정말 써야 할까요? (0) | 2024.06.14 |
|---|---|
| Redis 캐싱으로 API 성능 개선하기 (0) | 2024.04.01 |
| Spring SSE와 Redis Pub/Sub으로 구현하는 실시간 알림 (다중 서버 환경까지 스케일링하기) (0) | 2024.03.19 |
| 스프링 컨테이너 이해하기: BeanFactory와 ApplicationContext (0) | 2024.03.12 |
| Spring MVC에서 HandlerMapping과 HandlerAdapter를 나눈 이유 (0) | 2024.02.07 |
안녕하세요, 저는 주니어 개발자 박석희 입니다. 언제든 하단 연락처로 연락주세요 😆