Datasource 분리 ( Write,Read) With Aurora MySQL

dragonappear·2022년 8월 15일
3

DataSource

목록 보기
3/4

출처

제목: "[Spring] - Database Replication 구성에 따른 Read / Write 분리하기 (feat. AWS RDS Aurora)"
작성자: tistory(김종현)
작성자 수정일: 2022년3월19일
링크: https://kim-jong-hyun.tistory.com/125
작성일: 2022년8월15일

글 작성 계기

  • 요즘 성능에 대해서 공부하고 있는데, WAS에서 날리는 쿼리를 분산하여 DB 인스턴스의 부하를 줄이고 싶었다.
  • 단일 인스턴스에 CRUD 를 다 때려박았다가, RDS 장애가 발생하면 아주 재미진 상황이 발생할 것이다.
  • 개발하고 있는 프로젝트의 운영서버를 Aurora MySQL을 사용하려고 하는데, Write 용도, Read 용도 인스턴스를 구분하여 성능을 개선하고 싶어서 위 출처의 글을 보고 작성하게되었다.

DB Replication

  • DB 복제를 할 때 기준이 되는 서버를 Primary 라 부르고, 복제된 서버는 Secondary 라고 부른다
  • 기준이 되는 Primary는 1대로 구성되며 복제된 Secondary는 N대로 구성된다.

DB Replication 구성하는 이유

  • 트래픽이 급증할 경우 1대의 DB에 쓰기(Insert,Update,Delete)와 읽기(select) 를 모두 처리하게될 시 DB에 많은 부하가 발생하고 그로 인해서 DB 구간에서 병목이 발생할 수 있다.
  • 이러한 부하를 분산하기 위해 Primary에서는 쓰기를 Secondary에서는 읽기를 처리하게끔 구성한다.
  • Secondary를 N대로 구성하는 이유는 어플리케이션에서 데이터베이스에 엑세스할 때 보통 쓰기보다 읽기가 더 많이 발생하고 다중 JOIN으로 인해 처리시간도 더 오래 걸릴 수 있기 때문이다.

설정

build.gradle

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-jdbc'
	implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'mysql:mysql-connector-java'
	annotationProcessor 'org.projectlombok:lombok'
	annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

application.yml

spring:
  datasource:
    replication:
      driver-class-name: com.mysql.cj.jdbc.Driver
      username: 마스터계정명
      password: 마스터계정비밀번호
      write:
        name: write
        url: jdbc:mysql://리전 클러스터(라이터 인스턴스)엔드포인트:3306/example
      reads:
        - name: read1
          url: jdbc:mysql://리전 클러스터(리더 인스턴스)엔드포인트:3306/example
  • application.yml파일의 구성내용이다. 리전 클러스터(리더 인스턴스) 엔드포인트를 적어두면 로드밸런싱을 해주지만 AWS RDS Aurora를 사용하지 않고 Read용 데이터베이스 엔드포인트를 여러개 적어야 할 상황이 있기 때문에 해당 프로퍼티는 List로 구성해두었다.

ReplicationDataSourceProperties

@Getter
@Setter
@Configuration
@ConfigurationProperties(prefix = "spring.datasource.replication")
public class ReplicationDataSourceProperties {
    private String username;
    private String password;
    private String driverClassName;
    private Write write;
    private List<Read> reads;

    @Getter
    @Setter
    public static class Write {
        private String name;
        private String url;
    }

    @Getter
    @Setter
    public static class Read {
        private String name;
        private String url;
    }
}

application.yml에 설정한 내용을 위 클래스와 매핑하도록 구성한다.

package me.dragonappear.replicationdatasource.datasource;

import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import org.springframework.transaction.support.TransactionSynchronizationManager;

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public class ReplicationRoutingDatasource extends AbstractRoutingDataSource {
    private static final String READ = "read";
    private static final String WRITE = "write";
    private final ReadOnlyDataSourceCycle<String> readOnlyDataSourceCycle = new ReadOnlyDataSourceCycle<>();

    @Override
    public void setTargetDataSources(Map<Object, Object> targetDataSources) {
        super.setTargetDataSources(targetDataSources);
        List<String> readOnlyDataSourceLookupKeys = targetDataSources.keySet()
                .stream()
                .map(String::valueOf)
                .filter(lookupKey -> lookupKey.contains(READ)).collect(Collectors.toList());

        readOnlyDataSourceCycle.setReadOnleyDataSourceLookupKeys(readOnlyDataSourceLookupKeys);
    }

    @Override
    protected Object determineCurrentLookupKey() {
        return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
                ? readOnlyDataSourceCycle.getReadOnlyDataSourceLookupKey()
                : WRITE;
    }
}
  • Spring에서는 AbstractRoutingDataSource 라는 클래스를 제공해주는데 이 클래스는 DataSource와 각 DataSource를 조회할 key로 구성할 수 있게끔 해주는데 여기서 말하는 Key는 ReadWRITE 두개를 의미한다.

  • 그러면 Read용과 Write용 데이터베이스 커넥션을 언제 얻어야 할지 구분을 해야하는데,그 구분은 @Transactional 어노테이션으로 구분이 가능하다.

  • @TransactionalreadOnly 속성이 true로 지정되면 Read 데이터베이스 커넥션을 얻고 false(기본값)면 Write 데이터베이스 커넥션을 얻는다.

package me.dragonappear.replicationdatasource.datasource;

import java.util.List;

public class ReadOnlyDataSourceCycle<T> {
    private List<T> readOnlyDataSourceLookupKeys;
    private int index = 0;

    public void setReadOnlyDataSourceLookupKeys(List<T> readOnlyDataSourceLookupKeys) {
        this.readOnlyDataSourceLookupKeys = readOnlyDataSourceLookupKeys;
    }

    public T getReadOnlyDataSourceLookupKey() {
        if (index + 1 >= readOnlyDataSourceLookupKeys.size()) {
            index = -1;
        }
        return readOnlyDataSourceLookupKeys.get(++index);
    }
}
  • 해당 클래스는 AWS RDS Aurora를 사용하고 있다면 필요는 없지만 간략히 설명하자면 어플리케이션에서 N개의 Read용 데이터베이스에 로드밸런싱을 해준다고 생각하면 될 것 같다.
package me.dragonappear.replicationdatasource.config;

import com.zaxxer.hikari.HikariDataSource;
import lombok.RequiredArgsConstructor;
import me.dragonappear.replicationdatasource.datasource.ReplicationRoutingDatasource;
import me.dragonappear.replicationdatasource.props.ReplicationDataSourceProperties;
import me.dragonappear.replicationdatasource.props.ReplicationDataSourceProperties.Write;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import static me.dragonappear.replicationdatasource.props.ReplicationDataSourceProperties.*;

@Configuration
@RequiredArgsConstructor
public class ReplicationDataSourceConfiguration {
    private final ReplicationDataSourceProperties replicationDataSourceProperties;

    @Bean
    public DataSource routingDataSource() {
        ReplicationRoutingDataSource replicationRoutingDataSource = new ReplicationRoutingDataSource();

        Write write = replicationDataSourceProperties.getWrite();
        DataSource writeDataSource = createDataSource(write.getUrl(), "Write Datasource Pool",10,false);

        Map<Object, Object> dataSourceMap = new HashMap<>();
        dataSourceMap.put(write.getName(), writeDataSource);

        List<Read> reads = replicationDataSourceProperties.getReads();
        for (Read read : reads) {
            dataSourceMap.put(read.getName(), createDataSource(read.getUrl(),"Read Datasource Pool",20,true));
        }

        replicationRoutingDataSource.setDefaultTargetDataSource(writeDataSource);
        replicationRoutingDataSource.setTargetDataSources(dataSourceMap);
        replicationRoutingDataSource.afterPropertiesSet();

        return new LazyConnectionDataSourceProxy(replicationRoutingDataSource);
    }

    private DataSource createDataSource(String url,String poolName,Integer poolSize,Boolean readOnly) {
        HikariDataSource hikariDataSource = new HikariDataSource();
        hikariDataSource.setDriverClassName(replicationDataSourceProperties.getDriverClassName());
        hikariDataSource.setUsername(replicationDataSourceProperties.getUsername());
        hikariDataSource.setPassword(replicationDataSourceProperties.getPassword());
        hikariDataSource.setJdbcUrl(url);
        hikariDataSource.setReadOnly(readOnly);
        hikariDataSource.setPoolName(poolName);
        hikariDataSource.setMaximumPoolSize(poolSize);

        return hikariDataSource;
    }
}
  • application.yml에 정의된 데이터베이스 접속정보들을 읽어드려서 Write용 DataSource, Read용 DataSource를 생성 후 Spring에서 라우팅 해줄 수 있게끔 설정한다.
  • LazyConnectionDataSourceProxy에 대한 설명은 여기서 정리하진 않음

LazyConnectionDataSourceProxy 참고: https://sup2is.github.io/2021/07/08/lazy-connection-datasource-proxy.html

appliacation.yml 에 아래 코드 추가

logging:
  level:
    com.zaxxer.hikari.HikariConfig: DEBUG
    com.zaxxer.hikari: TRACE
  • 커넥션 풀 초기화 과정을 보면 Write Datasource Pool이 먼저 디폴트로 잡혀있어서 생성된다.

  • 처음에는 어플리케이션 실행 시점에 DB 커넥션이 Writer 에만 생성되고, Reader 인스턴스에는 생성이 되지 않아서 해맸는데
  • Read 옵션이 달린 트랜잭션을 시작할때 Read Datasource Pool이 초기화된다.

  • 조회를 하는 로직이 포함된 요청을 했을때 02:00 시 쯤에 DB 연결이 잡혀있는것을 볼수있다.

Read / Write 테스트

package me.dragonappear.replicationdatasource.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;

@Slf4j
@Service
@RequiredArgsConstructor
public class ReplicationService {
    private final DataSource lazyDataSource;

    @Transactional(readOnly = true)
    public void read() {
        try (Connection connection = lazyDataSource.getConnection() ) {
            log.info("read url : {}", connection.getMetaData().getURL());
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

    @Transactional
    public void write() {
        try (Connection connection = lazyDataSource.getConnection() ) {
            log.info("write url : {}", connection.getMetaData().getURL());
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }
}
@SpringBootTest
public class ReplicationServiceTest {

    @Autowired
    ReplicationService replicationService;

    @Test
    public void read() {
        replicationService.read();
    }

    @Test
    public void write() {
        replicationService.write();
    }
}

  • @Transactional, @Transactional(readOnly = true) 두개의 메서드를 생성 후 데이터베이스 url값을 확인해보니 정상적으로 라우팅이 되었다.

JPA Setting

package me.dragonappear.replicationdatasource.config;

import com.zaxxer.hikari.HikariDataSource;
import lombok.RequiredArgsConstructor;
import me.dragonappear.replicationdatasource.datasource.ReplicationRoutingDataSource;
import me.dragonappear.replicationdatasource.props.ReplicationDataSourceProperties;
import org.springframework.boot.autoconfigure.orm.jpa.JpaProperties;
import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.AbstractJpaVendorAdapter;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.PlatformTransactionManager;


import javax.persistence.EntityManagerFactory;
import javax.sql.DataSource;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import static me.dragonappear.replicationdatasource.props.ReplicationDataSourceProperties.*;

@Configuration
@RequiredArgsConstructor
public class ReplicationDataSourceConfiguration {
    private final ReplicationDataSourceProperties replicationDataSourceProperties;
    private final JpaProperties jpaProperties;

    @Bean
    public PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) {
        return new JpaTransactionManager(entityManagerFactory);
    }

    @Bean
    public LocalContainerEntityManagerFactoryBean entityManagerFactory() {
        EntityManagerFactoryBuilder entityManagerFactoryBuilder = createEntityManagerFactoryBuilder();
        return entityManagerFactoryBuilder.dataSource(routingDataSource()).packages("me.dragonappear.*.entity").build();
    }

    private EntityManagerFactoryBuilder createEntityManagerFactoryBuilder() {
        AbstractJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();
        return new EntityManagerFactoryBuilder(vendorAdapter, jpaProperties.getProperties(), null);
    }

    @Bean
    public DataSource routingDataSource() {
        ReplicationRoutingDataSource replicationRoutingDataSource = new ReplicationRoutingDataSource();

        Write write = replicationDataSourceProperties.getWrite();
        DataSource writeDataSource = createDataSource(write.getUrl(), "Write Datasource Pool",10,false);

        Map<Object, Object> dataSourceMap = new HashMap<>();
        dataSourceMap.put(write.getName(), writeDataSource);

        List<Read> reads = replicationDataSourceProperties.getReads();
        for (Read read : reads) {
            dataSourceMap.put(read.getName(), createDataSource(read.getUrl(),"Read Datasource Pool",20,true));
        }

        replicationRoutingDataSource.setDefaultTargetDataSource(writeDataSource);
        replicationRoutingDataSource.setTargetDataSources(dataSourceMap);
        replicationRoutingDataSource.afterPropertiesSet();

        return new LazyConnectionDataSourceProxy(replicationRoutingDataSource);
    }

    private DataSource createDataSource(String url,String poolName,Integer poolSize,Boolean readOnly) {
        HikariDataSource hikariDataSource = new HikariDataSource();
        hikariDataSource.setDriverClassName(replicationDataSourceProperties.getDriverClassName());
        hikariDataSource.setUsername(replicationDataSourceProperties.getUsername());
        hikariDataSource.setPassword(replicationDataSourceProperties.getPassword());
        hikariDataSource.setJdbcUrl(url);
        hikariDataSource.setReadOnly(readOnly);
        hikariDataSource.setPoolName(poolName);
        hikariDataSource.setMaximumPoolSize(poolSize);

        return hikariDataSource;
    }
}
  • JPA를 사용하고 있다면 위 ReplicationDataSourceConfiguration.class 안에다 해당 코드를 넣어주자.
  • 라우팅 데이터소스 구성 시 EntityManagerFactory를 JAVA 설정으로 잡아줘야 인식을 하는 것 같다.

application.yml

  jpa:
    database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
    hibernate:
      ddl-auto: create

application.yml에 위 코드 추가해준다.

Entity

@NoArgsConstructor(access = PROTECTED)
@Getter
@Entity
public class Account {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    @Column(name = "account_id")
    private Long id;

    private String name;

    public Account(String name) {
        this.name = name;
    }
}

어플리케이션 실행 후 DB에 Insert 쿼리를 날린다

use example;

Insert into account(account_id,name) values(1,'test');

Service

@Slf4j
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class AccountService {
    private final AccountJpaRepository accountJpaRepository;
    private final DataSource lazyDataSource;

    public Account get(Long id) {
        try (Connection connection = lazyDataSource.getConnection() ) {
            log.info("read url : {}", connection.getMetaData().getURL());
            return accountJpaRepository.findById(id).orElseThrow(() -> new RuntimeException("조회 실패"));
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

    @Transactional
    public Long create(String name) {
        try (Connection connection = lazyDataSource.getConnection() ) {
            log.info("write url : {}", connection.getMetaData().getURL());
            return accountJpaRepository.save(new Account(name)).getId();
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

}

Test

@Rollback
@SpringBootTest
public class JpaConnectionTest {

    @Autowired
    AccountService accountService;

    @Test
    void get() {
        Account account = accountService.get(1L);
        Assertions.assertThat(account.getName()).isEqualTo("test");
    }

    @Test
    void create() {
        Long id = accountService.create("test2");
        Account account = accountService.get(id);
        Assertions.assertThat(account.getName()).isEqualTo("test2");
    }

}

  • 로그로 찍히는 URL을 확인해보자

참고

  • 리전 클러스터, 라이터 인스턴스, 리더 인스턴스를 각각 클릭해보면 리전 클러스터는 클러스터 라이터 인스턴스, 클러스터 리더 인스턴스의 엔드포인트가 존재하고 라이터 인스턴스, 리더 인스턴스는 각각 독립적인 엔드포인트가 존재한다.
  • 리전 클러스터의 클러스터 리더 인스턴스의 엔드포인트는 리더 인스턴스들이 N개의 인스턴스로 구성되어 있을 때 로드밸런싱을 해줄 수 있는 엔드포인트 이다. 라이터 인스턴스, 리더 인스턴스의 독립적인 엔드포인트를 요청할 경우 로드밸런싱을 해주지 않는다.

0개의 댓글