이 글은 우아한테크코스 백엔드 6기 러쉬에 의해 작성되었습니다.


기존 운영환경에 배포되어 있던 크루루 서비스는 단 하나의 데이터베이스 인스턴스를 사용하고 있었습니다. 하나의 데이터베이스 인스턴스만 사용했을 경우 SPOF라는 치명적인 단점이 존재합니다. 이를 해결하기 위해 기존 데이터베이스를 스케일-아웃 했습니다.

기존 데이터베이스 구조

운영환경 데이터베이스가 하나만 존재했습니다. 따라서 read/write 작업이 하나의 데이터베이스에서 이루어지고 있었습니다.

운영 DB를 하나로 운영하면 다음 문제들이 있다고 느꼈습니다.

  • 가용성 확보 불가

기존 운영 환경에서 단 하나의 데이터베이스 인스턴스만 사용했을 때는 데이터베이스가 SPOF가 되는 치명적인 단점이 존재합니다. SPOF란 시스템의 한 구성 요소가 장애를 일으킬 경우 전체 시스템이 중단되는 요소를 의미합니다.

  • 트래픽 부하

모든 데이터베이스 트래픽이 한곳에 쏠리게 됩니다. 많은 사용자가 동시에 요청을 보내게 된다면, 데이터베이스에 많은 부하가 걸립니다. 따라서 문제가 생길 확률이 증가합니다. 이를 해결하기 위해서는 트래픽을 분산시켜야 한다고 생각했습니다.

개선된 데이터베이스 구조

개선된 구조에서는 데이터베이스가 두 개가 추가로 생성되었습니다. 두 개는 READ용 데이터베이스로 WRITE용 데이터베이스의 레플리카입니다. 즉, WRITE 데이터베이스에 추가되는 데이터가 READ 데이터베이스에 동기화됩니다.

데이터베이스 분산을 통해서 가용성을 확보할 수 있게 되었습니다. 기존 데이터베이스 구조에서는 모든 read/write 작업이 하나의 데이터베이스에서 이루어졌습니다. 이에 따라 트래픽 부하가 집중되어 성능 문제가 발생할 수 있었습니다. 개선된 구조에서는 read 작업과 write 작업을 별도의 데이터베이스로 분리해 트래픽을 분산시켰습니다. 특히 read 작업이 빈번하게 발생하므로 read 전용 데이터베이스를 두 개로 설정했습니다.

스프링에서 데이터베이스 분산처리 하기

위 구조로 변경하기 위해서 프로덕션 코드 레벨에서 데이터베이스를 분산 처리해야 합니다.

분산처리를 위해 처리돼야 할 작업을 요약하면 다음과 같습니다.

  1. (Reader/ Writer) Datasource 추가
  2. ReadOnly 옵션 Transaction을 Reader Datasource로 라우팅

Reader/Writer Datasource 추가

각 Datasource를 등록하기 위해서 application.yml에 정보를 기입합니다. 이때 reader와 writer datasource를 구분하기 위해 각각 read write 라는 prefix를 두었습니다.

    datasource:
    read:
      jdbc-url: ${DB_URL}
      driver-class-name: com.mysql.cj.jdbc.Driver
      username: ${DB_USER}
      password: ${DB_PASSWORD}
    write:
      jdbc-url: ${DB_URL}
      driver-class-name: com.mysql.cj.jdbc.Driver
      username: ${DB_USER}
      password: ${DB_PASSWORD}
  

이제 해당 Datasource를 Bean으로 등록하는 과정이 필요합니다. Bean으로 등록하기 위해서 Configuration class를 작성했습니다.

  @Configuration
public class DataSourceConfig {

    private static final String READ_DATASOURCE = "readDataSource";
    private static final String WRITE_DATASOURCE = "writeDataSource";
    private static final String ROUTE_DATASOURCE = "routeDataSource";

    @Bean(name = READ_DATASOURCE)
    @ConfigurationProperties(prefix = "spring.datasource.read")
    public DataSource readDataSource() {
        return DataSourceBuilder.create()
                .type(HikariDataSource.class)
                .build();
    }

    @Bean(name = WRITE_DATASOURCE)
    @ConfigurationProperties(prefix = "spring.datasource.write")
    public DataSource writeDataSource() {
        return DataSourceBuilder.create()
                .type(HikariDataSource.class)
                .build();
    }
}
  

@ConfigurationProperties은 application.yml 파일에 정의된 프로퍼티를 Bean에 바인딩해주는 역할을 합니다. 이를 통해 read와 write 데이터 소스를 각각 설정합니다.

ReadOnly 옵션 Transaction을 Reader Datasource로 라우팅

두개의 datasource를 bean으로 등록했으니, 작업의 유형에 따라 어떤 datasource로 라우팅할 지 정하면 됩니다. Transaction의 ReadOnly 옵션이 true인 경우 reader datasource로 라우팅하기로 했습니다. 다음은 라우팅을 수행하는 부분입니다.

  public class DataSourceRouter extends AbstractRoutingDataSource {

    public static final String READ_DATASOURCE_KEY = "read";
    public static final String WRITE_DATASOURCE_KEY = "write";

    @Override
    protected Object determineCurrentLookupKey() {
        if (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) {
            return READ_DATASOURCE_KEY;
        }
        return WRITE_DATASOURCE_KEY;
    }
}
  

DataSourceRouter는 AbstractRoutingDataSource를 상속받아 구현합니다. 해당 클래스는 Spring Framework에서 제공하는 DataSource의 추상 클래스입니다. 이 클래스는 동적으로 datasource를 라우팅할 수 있는 기능을 제공합니다. 즉, 여러 개의 데이터 소스를 사용하는 경우, 애플리케이션에서 특정 기준에 따라 적절한 데이터 소스를 자동으로 선택해 사용할 수 있도록 해줍니다. determineCurrentLookupKey() 메서드를 구현해야 하는데, 이 메서드가 현재 어떤 데이터 소스를 사용할지 결정하는 핵심 메서드입니다. 이 메서드에서 반환하는 값은 라우팅 키로, 해당 키에 맞는 데이터 소스가 설정됩니다.

TransactionSynchronizationManager는 Spring 프레임워크에서 transaction의 상태와 관련된 여러 정보를 관리하는 클래스입니다. transaction 관련된 상태와 자원을 ThreadLocal에 저장하고 관리하는 방식으로 이루어집니다. transaction이 시작되면 관련된 정보와 자원을 현재 스레드에 바인딩하고, transaction이 종료되면 이를 해제하는 방식입니다. 이 과정에서 transaction의 상태(읽기 전용 여부, 이름, 고립 수준 등)와 자원을 관리합니다. 해당 클래스를 통해서, 현재 실행되는 transaction에 readOnly 옵션이 있는지 확인했습니다.

datasource 라우팅 테스트

위 설정들이 잘 적용되었는지 테스트를 진행해야합니다. 운영서버는 실서비스의 데이터가 관리되는 곳이므로, 운영 환경과 비슷한 환경을 구축한 뒤 테스트해야 한다고 판단했습니다. 따라서 개발 환경에서 두 개의 데이터베이스를 구축하고 테스트했습니다.

Writer DB, Reader DB replica 설정

*(Master = Writer, Slave = Reader 라고 이해하면 되겠습니다.)

  1. WriterDB에서 복제 사용자 생성
  CREATE USER 'replica_user'@'%' IDENTIFIED BY 'replica_password';
GRANT REPLICATION SLAVE ON *.* TO 'replica_user'@'%';
FLUSH PRIVILEGES;
  
  1. Writer DB 상태 확인

  SHOW MASTER STATUS;
  

binlog는 MySQL의 바이너리 로그로 서버에서 발생한 모든 데이터 변경 작업(예: INSERT, UPDATE, DELETE 등)을 기록하는 로그 파일입니다.

Position은 로그 파일에서 현재 복제를 시작할 위치. 이 위치는 Slave 서버가 데이터를 어디까지 복제했는지 추적하는 데 사용됩니다.

  1. Reader DB에 Master 설정

  CHANGE MASTER TO
MASTER_HOST='master_master',
MASTER_USER='replica_user',
MASTER_PASSWORD='replica_password',
MASTER_LOG_FILE='mysql-bin.000004',   # Master에서 SHOW MASTER STATUS의 결과
MASTER_LOG_POS=28474;                  # Master에서 SHOW MASTER STATUS의 결과
  
  1. Reader 복제 시작 & 설정 확인

Slave_IO_Running과 Slave_SQL_Running이 Yes로 표시되면 복제가 제대로 설정된 것입니다.


이제 데이터베이스 인프라가 구축되었으므로, 실제 api 호출을 통해 확인해보겠습니다.

dataSource 라우팅을 확인을 위해 로그를 찍었습니다.

  @Slf4j
public class DataSourceRouter extends AbstractRoutingDataSource {

    public static final String READ_DATASOURCE_KEY = "read";
    public static final String WRITE_DATASOURCE_KEY = "write";

    @Override
    protected Object determineCurrentLookupKey() {
        if (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) {
            log.info("read");
            return READ_DATASOURCE_KEY;
        }
        log.info("write");
        return WRITE_DATASOURCE_KEY;
    }
}
  
  1. 회원 가입 API

회원가입은 데이터를 write하는 작업입니다. 로그가 write 가 찍히는 것을 확인할 수 있고, insert 쿼리가 실행되는 것을 확인할 수 있습니다.

  1. 로그인 API

로그인은 데이터를 read하는 작업입니다. 로그가 read 가 찍히는 것을 확인할 수 있고, select 쿼리문이 실행되는 것을 확인할 수 있습니다.

테스트 결과 transaction의 readOnly 옵션에 따라 datasource 라우팅이 정상적으로 작동하는 것을 확인했습니다.

정리

데이터베이스는 애플리케이션의 데이터가 관리되는 곳인 만큼, 안전하게 관리되어야 하는 대상입니다. 데이터베이스의 고가용성을 확보하는 것이 중요하다고 생각했습니다. 따라서 데이터베이스를 여러 개 두고, 서로 간에 동기화하는 방법을 선택했습니다. 데이터베이스의 복제는 데이터 안정성과 성능을 모두 향상할 수 있게 되었습니다.

데이터베이스 복제는 위와 같은 효과를 주지만 데이터 일관성 문제를 주의해야 하고, 복제 지연으로 인한 문제가 발생할 수 있습니다. 해당 문제들이 발생한다면 어떻게 해결해야 할지 고민할 예정입니다.