Spring Read/Write 분리 설계

왜 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 폴백 전략 필수

정리

AbstractRoutingDataSourceLazyConnectionDataSourceProxy 조합은 Spring에서 Read/Write 분리의 표준 패턴이다. @Transactional(readOnly = true) 한 줄로 Replica 라우팅이 자동화되며, 기존 코드 변경을 최소화할 수 있다. 트랜잭션 전파·격리 전략과 함께 이해하면 더 견고한 DB 아키텍처를 설계할 수 있다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux