왜 DataSource 라우팅이 필요한가?
트래픽이 늘어나면 단일 DB로는 한계가 온다. MySQL Read Replica를 두고 쓰기는 Primary, 읽기는 Replica로 분산하면 DB 부하를 극적으로 줄일 수 있다. 문제는 애플리케이션에서 이를 어떻게 자동 라우팅하느냐다. Spring Boot는 AbstractRoutingDataSource를 통해 트랜잭션 속성에 따라 DataSource를 동적으로 전환할 수 있다.
아키텍처 개요
| 트랜잭션 | DataSource | 용도 |
|---|---|---|
@Transactional |
Primary (Writer) | INSERT, UPDATE, DELETE |
@Transactional(readOnly=true) |
Replica (Reader) | SELECT |
| 트랜잭션 없음 | Primary (기본값) | 안전 우선 |
핵심 원리: readOnly = true인 트랜잭션은 자동으로 Replica로 라우팅된다. 개발자는 기존 코드에 readOnly만 명시하면 된다.
DataSource 설정
# application.yml
spring:
datasource:
writer:
url: jdbc:mysql://primary-db:3306/myapp
username: app_writer
password: ${DB_WRITER_PASSWORD}
hikari:
pool-name: writer-pool
maximum-pool-size: 20
reader:
url: jdbc:mysql://replica-db:3306/myapp
username: app_reader
password: ${DB_READER_PASSWORD}
hikari:
pool-name: reader-pool
maximum-pool-size: 30
read-only: true
Reader 풀은 Writer보다 크게 잡는다. 대부분의 API는 읽기 비중이 70~90%이기 때문이다. read-only: true를 HikariCP에도 설정하면 JDBC 드라이버 레벨에서 추가 최적화가 적용된다.
RoutingDataSource 구현
public class ReplicationRoutingDataSource extends AbstractRoutingDataSource {
private static final String WRITER = "writer";
private static final String READER = "reader";
@Override
protected Object determineCurrentLookupKey() {
boolean isReadOnly = TransactionSynchronizationManager
.isCurrentTransactionReadOnly();
return isReadOnly ? READER : WRITER;
}
}
AbstractRoutingDataSource는 Spring이 제공하는 추상 클래스로, determineCurrentLookupKey()가 반환하는 키에 따라 실제 DataSource를 선택한다. TransactionSynchronizationManager에서 현재 트랜잭션의 readOnly 속성을 읽어 분기한다.
DataSource Bean 설정
@Configuration
public class DataSourceConfig {
@Bean
@ConfigurationProperties("spring.datasource.writer")
public DataSource writerDataSource() {
return DataSourceBuilder.create().type(HikariDataSource.class).build();
}
@Bean
@ConfigurationProperties("spring.datasource.reader")
public DataSource readerDataSource() {
return DataSourceBuilder.create().type(HikariDataSource.class).build();
}
@Bean
public DataSource routingDataSource(
@Qualifier("writerDataSource") DataSource writer,
@Qualifier("readerDataSource") DataSource reader) {
ReplicationRoutingDataSource routing = new ReplicationRoutingDataSource();
Map<Object, Object> targetDataSources = Map.of(
"writer", writer,
"reader", reader
);
routing.setTargetDataSources(targetDataSources);
routing.setDefaultTargetDataSource(writer); // 기본값은 Writer
return routing;
}
@Primary
@Bean
public DataSource dataSource(
@Qualifier("routingDataSource") DataSource routing) {
// LazyConnectionDataSourceProxy로 감싸야 트랜잭션 시작 후 DataSource 결정
return new LazyConnectionDataSourceProxy(routing);
}
}
핵심: LazyConnectionDataSourceProxy가 반드시 필요하다. 이 래퍼 없이는 트랜잭션 시작 시점에 DataSource가 결정되는데, 그 시점에는 아직 readOnly 플래그가 설정되지 않았을 수 있다. LazyConnectionDataSourceProxy는 실제 쿼리가 실행될 때까지 커넥션 획득을 지연시켜 이 문제를 해결한다.
서비스 레이어 적용
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
// Writer로 라우팅
@Transactional
public User create(CreateUserDto dto) {
return userRepository.save(dto.toEntity());
}
// Writer로 라우팅
@Transactional
public User update(Long id, UpdateUserDto dto) {
User user = userRepository.findById(id)
.orElseThrow(() -> new NotFoundException("User not found"));
user.update(dto);
return user;
}
// Replica로 라우팅
@Transactional(readOnly = true)
public User findById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new NotFoundException("User not found"));
}
// Replica로 라우팅
@Transactional(readOnly = true)
public Page<User> findAll(Pageable pageable) {
return userRepository.findAll(pageable);
}
}
기존 코드를 거의 수정하지 않아도 된다. @Transactional(readOnly = true)만 정확히 달아주면 자동으로 Replica를 사용한다.
다중 Replica 로드밸런싱
Replica가 여러 대라면 라운드 로빈으로 분산할 수 있다.
public class MultiReplicaRoutingDataSource extends AbstractRoutingDataSource {
private static final String WRITER = "writer";
private final AtomicInteger counter = new AtomicInteger(0);
private final List<String> readerKeys;
public MultiReplicaRoutingDataSource(int replicaCount) {
this.readerKeys = IntStream.range(0, replicaCount)
.mapToObj(i -> "reader-" + i)
.toList();
}
@Override
protected Object determineCurrentLookupKey() {
if (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) {
int index = Math.abs(counter.getAndIncrement() % readerKeys.size());
return readerKeys.get(index);
}
return WRITER;
}
}
// Bean 설정
@Bean
public DataSource routingDataSource(
@Qualifier("writerDataSource") DataSource writer,
@Qualifier("reader1DataSource") DataSource reader1,
@Qualifier("reader2DataSource") DataSource reader2) {
MultiReplicaRoutingDataSource routing =
new MultiReplicaRoutingDataSource(2);
Map<Object, Object> targets = Map.of(
"writer", writer,
"reader-0", reader1,
"reader-1", reader2
);
routing.setTargetDataSources(targets);
routing.setDefaultTargetDataSource(writer);
return routing;
}
Replication Lag 대응
Replica는 Primary와 동기화 지연(lag)이 발생한다. 데이터를 쓴 직후 읽으면 이전 데이터가 반환될 수 있다. 이를 Read-after-Write 일관성 문제라 한다.
// 방법 1: 쓰기 직후 읽기는 Writer 강제 사용
@Transactional // readOnly 아님 → Writer
public User createAndReturn(CreateUserDto dto) {
User user = userRepository.save(dto.toEntity());
// 같은 트랜잭션 내이므로 Writer에서 읽음
return userRepository.findById(user.getId()).orElseThrow();
}
// 방법 2: 커스텀 어노테이션으로 강제 Writer 읽기
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Transactional(readOnly = false) // Writer 강제
public @interface ForceWriter {}
// 방법 3: AOP로 특정 시간 내 Writer 고정
@Aspect
@Component
public class ReplicationLagAspect {
private static final ThreadLocal<Long> lastWriteTime = new ThreadLocal<>();
private static final long LAG_THRESHOLD_MS = 1000; // 1초
@AfterReturning("@annotation(org.springframework.transaction.annotation.Transactional) " +
"&& !@annotation(readOnlyTx)")
public void afterWrite(JoinPoint jp) {
lastWriteTime.set(System.currentTimeMillis());
}
}
모니터링
// HikariCP 메트릭으로 풀별 상태 확인
@Bean
public MeterBinder writerPoolMetrics(@Qualifier("writerDataSource") HikariDataSource ds) {
return new HikariCPMetricsTrackerFactory(ds);
}
// Actuator에서 확인
// GET /actuator/metrics/hikaricp.connections.active?tag=pool:writer-pool
// GET /actuator/metrics/hikaricp.connections.active?tag=pool:reader-pool
HikariCP 커넥션 풀 메트릭을 Writer/Reader 별로 분리 모니터링하면 어느 쪽이 병목인지 즉시 파악할 수 있다.
주의사항
| 항목 | 주의점 |
|---|---|
| LazyConnectionDataSourceProxy | 없으면 readOnly 감지 전에 커넥션 획득 → 항상 Writer 사용 |
| readOnly 누락 | SELECT도 Writer로 가서 분산 효과 없음 |
| Replication Lag | 쓰기 직후 읽기는 Writer에서 해야 함 |
| 중첩 트랜잭션 | 외부가 readOnly=false면 내부 readOnly=true도 Writer 사용 |
| Replica 장애 | 헬스체크 + Writer 폴백 전략 필수 |
정리
AbstractRoutingDataSource와 LazyConnectionDataSourceProxy 조합은 Spring에서 Read/Write 분리의 표준 패턴이다. @Transactional(readOnly = true) 한 줄로 Replica 라우팅이 자동화되며, 기존 코드 변경을 최소화할 수 있다. 트랜잭션 전파·격리 전략과 함께 이해하면 더 견고한 DB 아키텍처를 설계할 수 있다.