[DB, Spring] Replication 적용하기

Junseo Kim·2021년 8월 25일
4

배포 & 인프라

목록 보기
2/2
post-thumbnail

DB replication이란?

가장 단순한 DB는 하나의 서버에 하나의 DB가 연결 되어있는 구조를 가지고 있다. 그러나 사용자가 늘어날수록 하나의 DB가 모든 쿼리를 처리하기가 힘들어진다.

쿼리의 대부분은 select이다. 리플리케이션은 DB를 하나의 Master DB, 여러개의 Slave DB로 나눠 동일한 데이터를 가지고 있게 한 후, select요청은 Master DB의 부하를 막기 위해 Slave DB에서만 담당하게 하고, CUD요청은 Master DB에서 담당하게 한다. Master DB는 CUD를 처리한 후 Slave DB에 데이터를 동기화 시킨다.

이를 처리하기 위한 기준이 @Transactional(readOnly=true)이다. 이 애노테이션이 붙어있다면 요청을 Slave DB로 보내는 식으로 처리한다.

Replication을 적용하기 위해서는 DB와 Spring Boot 모두 설정을 해줘야한다. 이번 프로젝트를 진행하면서 하나의 master DB 인스턴스와 두개의 Slave DB 인스턴스로 replication을 적용해봤다.

MySQL 설정

master db 설정

/etc/mysql/mysql.conf.d/mysqld.cnf를 열어 설정을 수정해준다.

mysql 접속 후 replication 용 계정을 생성하고 권한을 할당한다.

CREATE USER 'username'@'%' IDENTIFIED BY 'password'; 
GRANT REPLICATION SLAVE ON *.* TO 'username'@'%';
flush privileges ;

master db 정보 확인

slave db에 master db 정보를 추가해줄 때 File값과 Position값이 필요하므로 기억해둬야한다.

이렇게 하면 master db 설정이 끝난다.

slave db 설정

/etc/mysql/mysql.conf.d/mysqld.cnf를 열어 설정을 수정해준다.
master db의 server-id를 1로 주었으니, slave1은 2로, slave2는 3으로 주면된다.

master db의 정보를 추가해준다. master db ip, 포트, 유저 이름, 비밀번호, 위에서 기억해둔 File값과 Position값을 적어준다.

slave를 실행시킨다.

아래의 명령어로 상태를 확인할 수 있다.

테스트로 master db에 insert 쿼리를 날리면 slave db에도 반영됨을 볼 수 있다.

SpringBoot 설정

@Transactional(readOnly=true)가 붙어있는 쿼리는 2개의 slave DB를 번갈아서, CUD 작업이 포함된 경우는 Master DB로 연결되도록 설정해줘야한다.

datasource 설정

springboot를 사용할때 application.properties에 아래와 같이 datasource url, username, password만 적어주면 자동설정에 이해 datasource가 등록된다.

spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/dtc?serverTimezone=UTC&characterEncoding=UTF-8
spring.datasource.username=root
spring.datasource.password=root

하지만 replication을 적용할려면 여러개의 datasource를 등록해야하므로, 자동 설정을 사용할 수 없다. 직접 master, slave datasource들을 등록해줘야한다. application.properties에 아래와 같은 형식으로 각 datasource의 url을 받아 등록해주겠다.

datasource.url=${DATASOURCE_URL}
datasource.username=${DATASOURCE_USERNAME}
datasource.password=${DATASOURCE_PASSWORD}

datasource.slave.slave1.name=slave-1
datasource.slave.slave1.url=${SLAVE1_URL}

datasource.slave.slave2.name=slave-2
datasource.slave.slave2.url=${SLAVE2_URL}

위의 형식은 내가 임의로 해준 것이기 때문에, 프로퍼티 파일에 적어준 정보를 꺼내기 위해서 @ConfigurationProperties 어노테이션을 사용해서 객체로 만들어줄 것이다.

@Getter
@Setter
@ConfigurationProperties(prefix = "datasource")
public class CustomDataSourceProperties {
    private String url;
    private String username;
    private String password;
    private final Map<String, Slave> slave = new HashMap<>();

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

datasource를 등록해주기 위한 config클래스를 만들어준다. 이때 datasource 자동설정을 제외해준다.(DataSourceAutoConfiguration)

@Configuration
@Profile("prod")
@EnableAutoConfiguration(exclude = {DataSourceAutoConfiguration.class}) // DataSource 자동 설정을 제외시킨다.
@EnableConfigurationProperties(CustomDataSourceProperties.class)
public class CustomDataSourceConfig {

    private final CustomDataSourceProperties databaseProperty;
    private final JpaProperties jpaProperties;

    public CustomDataSourceConfig(CustomDataSourceProperties databaseProperty, JpaProperties jpaProperties) {
        this.databaseProperty = databaseProperty;
        this.jpaProperties = jpaProperties;
    }
}

slave db 번갈아 사용

하나의 slave db만 사용하는 일이 없이 번갈아 사용할 수 있도록 CircularList 를 만들어준다.

slave DB 리스트를 주입하여 하나씩 번갈아 사용하도록 한다.

public class CircularList<T> {
    private final List<T> list;
    private Integer counter = 0;

    public CircularList(List<T> list) {
        this.list = list;
    }

    public T getOne() {
        if (counter + 1 >= list.size()) {
            counter = -1;
        }
        return list.get(++counter);
    }
}

@Transactional(readOnly=true)에 따른 분기처리

@Transactional(readOnly=true)인 경우 slave datasource로, 나머지는 master datasource로 분기 처리를 하기위한 ReplicationRoutingDataSource 클래스를 생성한다.

AbstractRoutingDataSource를 상속하여 구현해줘야한다. AbstractRoutingDataSource는 spring-jdbc 모듈에 포함되어 있는 클래스로, 여러 datasource를 등록하고, 상황에 맞게 원하는 datasource를 사용할 수 있도록 추상화한 클래스이다.

determineCurrentLookupKey()메서드는 현재 요청에서 사용할 datasource의 key값을 반환해준다.

public class ReplicationRoutingDataSource extends AbstractRoutingDataSource {

    private CircularList<String> dataSourceNameList;

    @Override
    public void setTargetDataSources(Map<Object, Object> targetDataSources) {
        super.setTargetDataSources(targetDataSources);

        // slave db 정보를 CircularList로 관리
        dataSourceNameList = new CircularList<>(
                targetDataSources.keySet()
                                 .stream()
                                 .map(Object::toString)
                                 .filter(string -> string.contains("slave"))
                                 .collect(Collectors.toList())
        );
    }

    /**
     * 현재 요청에서 사용할 DataSource 결정할 key값 반환
     */
    @Override
    protected Object determineCurrentLookupKey() {
        boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
        if (isReadOnly) {
            logger.info("Connection Slave");
            return dataSourceNameList.getOne();
        } else {
            logger.info("Connection Master");
            return "master";
        }
    }
}

DataSourceConfig

마지막으로 위에서 datasource를 등록해주기 위해 만들어놓았던 config클래스를 완성시켜준다. DataSourceAutoConfiguration 자동설정을 빼주었기 때문에 Hibernate 설정을 직접 주입 해줘야한다. 네이밍 전략도 지정해줘야 한다.

application.properties 파일에 hibernate 설정 추가

spring.jpa.properties.hibernate.show_sql=false
spring.jpa.properties.hibernate.generate-ddl=false
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true
spring.jpa.properties.hibernate.physical_naming_strategy=org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy

application.properties에 적어준 Hibernate 설정 값을 JpaProperties로 주입받아 entityManagerFactory에 직접 주입해준다.

@Configuration
@Profile("prod")
@EnableAutoConfiguration(exclude = {DataSourceAutoConfiguration.class}) // DataSource 자동 설정을 제외시킨다.
@EnableConfigurationProperties(CustomDataSourceProperties.class)
public class CustomDataSourceConfig {

    private final CustomDataSourceProperties databaseProperty;
    private final JpaProperties jpaProperties;

    public CustomDataSourceConfig(CustomDataSourceProperties databaseProperty, JpaProperties jpaProperties) {
        this.databaseProperty = databaseProperty;
        this.jpaProperties = jpaProperties;
    }

    /**
     * datasource 생성
     */
    public DataSource createDataSource(String url) {
        return DataSourceBuilder.create()
                                .type(HikariDataSource.class)
                                .url(url)
                                .driverClassName("com.mysql.cj.jdbc.Driver")
                                .username(databaseProperty.getUsername())
                                .password(databaseProperty.getPassword())
                                .build();
    }

    /**
     * 실제 쿼리가 실행될 때 Connection을 가져오기
     */
    @Bean
    public DataSource dataSource() {
        return new LazyConnectionDataSourceProxy(routingDataSource());
    }

    /**
     * CustomDataSourceProperties를 통해 master, slave Datasource 생성 후
     * ReplicationRoutingDataSource에 등록
     */
    @Bean
    public DataSource routingDataSource() {
        DataSource master = createDataSource(databaseProperty.getUrl());

        Map<Object, Object> dataSourceMap = new LinkedHashMap<>();
        dataSourceMap.put("master", master);
        databaseProperty.getSlave()
                        .forEach((key, value) -> dataSourceMap.put(value.getName(), createDataSource(value.getUrl())));

        ReplicationRoutingDataSource replicationRoutingDataSource = new ReplicationRoutingDataSource();
        replicationRoutingDataSource.setDefaultTargetDataSource(master);
        replicationRoutingDataSource.setTargetDataSources(dataSourceMap);
        return replicationRoutingDataSource;
    }

    /**
     * JPA에서 사용할 EntityManagerFactory 설정
     * hibernate 설정 직접 주입
     */
    @Bean
    public LocalContainerEntityManagerFactoryBean entityManagerFactory() {
        EntityManagerFactoryBuilder entityManagerFactoryBuilder = createEntityManagerFactoryBuilder(jpaProperties);
        return entityManagerFactoryBuilder.dataSource(dataSource()).packages("com.wootech.dropthecode").build();
    }

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

    /**
     * JPA에서 사용할 TransactionManager 설정
     */
    @Bean
    public PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) {
        JpaTransactionManager tm = new JpaTransactionManager();
        tm.setEntityManagerFactory(entityManagerFactory);
        return tm;
    }
}

LazyConnectionDataSourceProxy를 사용하는 이유
Spring은 기본적으로 트랜잭션을 시작할 때 쿼리 실행 전에 DataSource를 정해놓는다. (TransactionManager 식별 -> DataSource에서 Connection 가져오고 -> Transaction 동기화(Synchronization))

따라서 Transaction이 시작되면 같은 DataSource만을 이용한다. 우리는 쿼리를 실행할 때 DataSource를 결정해줘야하기 때문에 미리 DataSource를 정하지 않도록 LazyConnectionDataSourceProxy를 사용하여 실제 쿼리가 실행될 때 Connection을 가져오도록 한 것이다.

네이밍 전략 이슈

DataSourceAutoConfiguration 자동설정을 빼주고, 따로 네이밍 전략을 추가해주지 않았을 때, 에러가 발생한 경우가 있었다.

테이블은 teacher_profile과 같이 언더바로 생성되어 있었는데, 자동설정을 빼주고나니 조회를 teacherProfile로 하려고하여 테이블을 찾지 못해 발생한 에러였다.

application.properties에 적어준 Hibernate 설정 값을 JpaProperties로 주입받아 entityManagerFactory에 직접 주입해주니 해결되었다.

자동설정을 빼고 커스텀하는 경우는 자동설정시 기본값이 뭔지 파악하는게 중요할 것 같다.

OSIV 이슈

마찬가지로 DataSourceAutoConfiguration 자동설정을 빼주었을 때 발생한 문제였다. 자동 설정 사용시 OSIV 기본값이 true인데, 직접 설정을 해줬을 때, OSIV값을 주지 않아서 발생한 문제였다.

참고 -> [JPA] failed to lazily initialize a collection

reference

2개의 댓글

comment-user-thumbnail
2021년 10월 20일

잘 읽었다!!!!!

1개의 답글