RW/RO 분리 + MySQL Router를 활용한 로드밸런싱 구현

이경헌·2025년 5월 18일
0

이번 글에서는 기존 단일 데이터소스 구성에서 데이터베이스 접근을 쓰기(Write)읽기(Read)로 분리하고, 각각을 MySQL Router의 6446, 6447 포트로 연결해서 로드밸런싱하는 구조를 구현한 과정을 공유합니다.


1. 기존 로직: 단일 DataSource

기존에는 하나의 URL로만 DB에 연결했습니다.

@Configuration
public class DataSourceConfig {

	@Value("${spring.datasource.url}")
	private String url;

	@Value("${spring.datasource.dbcp2.username}")
	private String username;

	@Value("${spring.datasource.dbcp2.password}")
	private String password;

	@Bean
	public BasicDataSource dataSource() {
		BasicDataSource dataSource = new BasicDataSource();
		dataSource.setUrl(url);
		dataSource.setUsername(username);
		dataSource.setPassword(password);
		dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver");

		// 커넥션 풀 최적화
		dataSource.setInitialSize(18);
		dataSource.setMaxTotal(18);
		dataSource.setMaxIdle(18);
		dataSource.setMinIdle(18);

		dataSource.setTestOnBorrow(true);
		dataSource.setValidationQuery("SELECT 1");

		return dataSource;
	}

}
  • 단일 URL에 모든 트래픽이 몰리면서 읽기/쓰기 부하 분산이 어려웠습니다.
  • 읽기 작업이 많은 서비스 특성상 DB 부하가 집중되는 현상이 발생했습니다.

2. 전체 아키텍처 흐름

  • WAS에서 DB에 연결 시 쓰기 요청은 MySQL Router의 6446 포트(Write 전용)로,
  • 읽기 요청은 6447 포트(Read 전용)로 라우팅됩니다.
  • 스프링에서는 AOP(Aspect-Oriented Programming)로 트랜잭션의 읽기/쓰기 여부에 따라 적절한 DataSource를 자동 선택합니다.

3. 핵심 코드 분석

application.yml 설정

spring:
  datasource:
    router:
      url:
        rw: jdbc:mysql://mysql-router:6446/eventor
        ro: jdbc:mysql://mysql-router:6447/eventor
    dbcp2:
      username: ${MYSQL_USERNAME}
      password: ${MYSQL_PASSWORD}
  • RW와 RO에 각각 다른 JDBC URL을 지정합니다.

3.1 DataSourceConfig - 데이터소스 빈 설정

@Bean
@Profile("prod")
public DataSource writeDataSource(@Value("${spring.datasource.router.url.rw}") String url) {
    return createDataSource(url);
}

@Bean
@Profile("prod")
public DataSource readDataSource(@Value("${spring.datasource.router.url.ro}") String url) {
    return createDataSource(url);
}
  • 프로덕션 환경에서 쓰기/읽기 각각의 DataSource를 생성합니다.
  • spring.datasource.router.url.rw는 6446 포트 URL,
  • spring.datasource.router.url.ro는 6447 포트 URL을 가리킵니다.
@Bean
@Profile("prod")
public DataSource routingDataSource(
    @Qualifier("writeDataSource") DataSource writeDataSource,
    @Qualifier("readDataSource") DataSource readDataSource) {
    
    RoutingDataSource routingDataSource = new RoutingDataSource();
    Map<Object, Object> dataSourceMap = new HashMap<>();
    dataSourceMap.put(DataSourceContextHolder.WRITE, writeDataSource);
    dataSourceMap.put(DataSourceContextHolder.READ, readDataSource);
    
    routingDataSource.setTargetDataSources(dataSourceMap);
    routingDataSource.setDefaultTargetDataSource(writeDataSource);
    
    return routingDataSource;
}

@Primary
@Bean
@Profile("prod")
public DataSource dataSource(DataSource routingDataSource) {
    return new LazyConnectionDataSourceProxy(routingDataSource);
}
  • routingDataSource읽기/쓰기용 DataSource를 하나로 묶어 호출 시점에 적절한 DataSource를 선택하도록 합니다.
  • LazyConnectionDataSourceProxy는 실제 커넥션 획득을 지연시켜 트랜잭션 시작 시점에 결정되게 합니다.

3.2 DataSourceContextHolder - 현재 읽기/쓰기 상태 저장

public class DataSourceContextHolder {
    public static final String READ = "READ";
    public static final String WRITE = "WRITE";

    private static final ThreadLocal<String> contextHolder = new ThreadLocal<>();

    public static void setRoutingKey(String key) {
        contextHolder.set(key);
    }

    public static String getRoutingKey() {
        return contextHolder.get();
    }

    public static void clear() {
        contextHolder.remove();
    }
}
  • 현재 쓰기/읽기 상황을 ThreadLocal에 저장해, 요청별로 독립적으로 관리합니다.

3.3 DataSourceRoutingAspect - AOP로 트랜잭션 읽기/쓰기 판단

@Aspect
@Component
public class DataSourceRoutingAspect {

    @Before("@annotation(transactional)")
    public void setDataSourceBasedOnTransaction(Transactional transactional) {
        if (transactional.readOnly()) {
            DataSourceContextHolder.setRoutingKey(DataSourceContextHolder.READ);
        } else {
            DataSourceContextHolder.setRoutingKey(DataSourceContextHolder.WRITE);
        }
    }

    @After("@annotation(transactional)")
    public void clearDataSourceContext(Transactional transactional) {
        DataSourceContextHolder.clear();
    }
}
  • @Transactional 애노테이션에 따라 읽기 전용 트랜잭션인지 확인해,
  • READ 또는 WRITE 키를 DataSourceContextHolder에 세팅합니다.
  • 트랜잭션 종료 후 키는 반드시 삭제합니다.

3.4 RoutingDataSource - AbstractRoutingDataSource 구현

public class RoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return DataSourceContextHolder.getRoutingKey();
    }
}
  • 스프링에서 제공하는 AbstractRoutingDataSource를 상속해,
  • 현재 컨텍스트에 맞는 DataSource를 동적으로 선택합니다.

4. 마치며

이번 구성으로 WAS에서는 개발자는 특별히 데이터 소스를 신경 쓸 필요 없이,
@Transactional(readOnly = true) 만 붙여주면 MySQL Router를 통해 읽기/쓰기 서버가 적절히 분리되어 로드밸런싱 됩니다.

이런 아키텍처는 대규모 트래픽에서 DB 부하를 줄이고, 성능과 안정성을 크게 향상시키는 효과가 있습니다.

0개의 댓글