우아한테크코스/레벨4, 레벨5

[팀 프로젝트] DB Replication ~ AutoConfiguration, OSIV

nauni 2021. 12. 2. 15:31

1. DB Replication

한 서버에서 다른 서버로 데이터가 동기화 되는 것이다. 원본 데이터를 가진 서버가 source서버, 복제된 데이터를 가지는 서버를 replica서버라고 한다.

DB Replication을 왜 하는가?

생각할 때, 지금의 입장에서 replication을 하는 가장 큰 목적은 데이터 백업이라고 생각한다. DB는 안정성과 영속성이 중요시 되는데 1개만 존재한다면 단일 장애점으로 DB서버에 문제가 생길 경우, 기존 데이터를 지기키 못하는 문제가 발생할 수 있다. 이 경우 기존의 데이터를 유지하기 위해서 데이터 백업이 있어야 한다.

 

백업은 주기적으로 백업파일(덤프파일)을 만들 수도 있지만 이것은 실제 운영하고 있는 DB 전체에 lock이 걸리기 때문에 운영시 문제가 될 수 있다. 주기적인 백업은 어느 시기로 설정해야하는가? 백업시기와 현재 상황 사이의 데이터 손실은 복구할 수 있는가? 등의 문제도 있다. 또한, 파일로만 구성되는 경우 DB에서 지원하는 온전한 기능을 활용하기 어렵다. 이는 솔직히 말하면 아직 직접 경험해보지 않아 잘 모르지만 생각해본 내용은 DB에는 장애가 발생할 경우에도 undo, redo 로그 등을 활용하여 데이터를 롤백하거나 커밋한다. 이런 기능들을 파일로만 구성할 경우 온전하게 DB의 역할을 해주지 못하기 때문에 발생할 수 있는 문제라고 생각한다.

 

두번째 목적은 부하 분산이다. Create, Update, Delete 와 Read 의 경우를 분리한다. 대부분의 애플리케이션은 데이터를 조작하는 연산(CUD)보다 읽는 연산(R)이 더 많다. CUD를 소스서버, R을 레플리카서버에서 수행하므로써 읽는 연산에 대한 부하를 분산할 수 있다. 목적에 따라 DB를 분산하면 CUD 서버에 문제가 생기더라도 애플리케이션에서 읽는 동작은 수행할 수 있다. 하나의 서버가 장애가 나더라도 모든 서비스에 장애가 발생하지 않을 수 있다.

 

테코톡에서는 데이터 분석에 대한 목적도 있다고 한다. 분석용 쿼리는 대량의 데이터를 조회하는 경우가 많은데 레플리카서버를 분석쿼리 전용으로 사용한다면 이를 분산하고 실 서비스와 분리하여 목적에만 맞게 사용할 수 있다.

 

가용성 증대와 단일장애점 제거라는 목적으로 크게 생각해 볼 수 있겠다.

2. sourceDB, replicaDB 구성하기

sourceDB 1개, replicaDB 1개로 구성할 것이기 때문에 각각의 EC2를 생성하여 DB를 구성한다. 당시에 이슈에 작성했던 내용을 기반으로 기억을 떠올려 작성하는 것이므로 자세한 설정 과정은 공식문서에 잘 나와 있어서 이 글보다는 공식문서를 참고하는 것이 좋다.

 

1. `/etc/mysql/my.cnf`에서 replication 관련 설정

설정 후 DB를 재시작한다.

[mariadb]
server_id              = 1
log_bin                = /var/log/mysql/mysql-bin.log
expire_logs_days        = 10
log-basename            = primary1
binlog-format           = mixed

2. DB내부에서 확인하고 dump 파일을 생성하여 replicaDB에 보냄

-- 설정확인

MariaDB [(none)]> show variables like 'server_id';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| server_id     | 1     |
+---------------+-------+
1 row in set (0.001 sec)

MariaDB [(none)]> FLUSH TABLES WITH READ LOCK;
MariaDB [(none)]> show master status;
+---------------------+----------+--------------+------------------+
| File                | Position | Binlog_Do_DB | Binlog_Ignore_DB |
+---------------------+----------+--------------+------------------+
| primary1-bin.000005 |     4344 |              |                  |
+---------------------+----------+--------------+------------------+
1 row in set (0.000 sec)

dump 파일을 생성해서 EC2 사이에 파일을 보내주면 된다. 이 경우 lock 이 걸려있으면 dump가 생성되지 않으므로 `unlock tables;`를 해주어야 했었다.

 

3. replicaDB 설정 및 시작한다.

DB를 설치하고 ip를 열어주는 설정도 해주어야 한다. replica DB의 서버아이디는 소스DB와 다르게 설정해야하는데 100번대부터 진행하는 것으로 팀내에서 결정했다.

-- /etc/mysql/mariadb.conf.d/50-server.cnf

# Instead of skip-networking the default is now to listen only on
# localhost which is more compatible and is not less secure.
bind-address            = 127.0.0.1


-- /etc/mysql/my.conf

[mariadb]
server_id=100

다음과 같은 명령어로 replica 설정을 시작한다.

-- mariaDB 공식문서 내용
CHANGE MASTER TO
  MASTER_HOST='master.domain.com',
  MASTER_USER='replication_user',
  MASTER_PASSWORD='bigs3cret',
  MASTER_PORT=3306,
  MASTER_LOG_FILE='master1-bin.000096', -- 이 부분에서 sourceDB와 바이너리로그 파일넘버를 일치하여야 하는 듯 하다
  MASTER_LOG_POS=568,
  MASTER_CONNECT_RETRY=10;

CHANGE MASTER TO MASTER_USE_GTID = slave_pos;
START SLAVE;

3. SpringBoot에서 replication 설정하기

이 부분은 제작근로 배포일정과 겹쳐져서 많이 참여를 못했었다. 많은 부분을 페어로 진행했던 완태, 에드가 해결해 주었다. 스프링부트에서는 기본적인 auto configuration이 되어있다. 이런 기본 설정에 의거하여 쉽게 애플리케이션 세팅을 도와주도록 되어있다. 기본적으로 하나의 DataSource가 있을 경우 동작하게 되어있다. 즉, 여러개의 DataSource를 설정하면 제대로 동작하지 않는다는 것이다.

 

몇몇 DB replication을 설정하는 정보를 찾다보면 아래와 같이 DataSourceAutoConfiguration 설정을 제외시키는 방식으로 설정하기도 한다. 이는 AutoConfiguration을 제외하고 새로운 설정을 해줌으로써 동작하게 한다. SpringBoot가 가지는 AutoConfiguration 설정을 사용하지 않는 것이다. 

@Configuration
@EnableAutoConfiguration(exclude = {DataSourceAutoConfiguration.class})
@EnableTransactionManagement
@EnableJpaRepositories(basePackages = {"com.example.sample"})

다른 설정을 위해 SpringBoot의 기본 설정기능을 제외하는 것은 SpringBoot를 온전히 활용하지 않는 것이라는 느낌을 받기도 했다. 아래와 같이 SpringBoot가 가지는 기본 설정을 활용해도 설정이 가능하다.

@Profile("prod|was1|was2")
@Configuration(proxyBeanMethods = false) // BeanLiteMode로 CGLIB을 사용하지 않아 프록시 모드가 안 된다.
public class DataSourceConfig {

    @Bean
    @FlywayDataSource
    @ConfigurationProperties(prefix = "spring.datasource.hikari.master") // properties 설정내용을 읽어온다.
    public DataSource masterDataSource() {
        return DataSourceBuilder.create().type(HikariDataSource.class).build();
    }

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.hikari.slave") // properties 설정내용을 읽어온다.
    public DataSource slaveDataSource() {
        return DataSourceBuilder.create().type(HikariDataSource.class).build();
    }

    @Bean // @Qualifier는 같은 DataSource 타입에서 특정해줄 수 있다.
    public DataSource routingDataSource(@Qualifier("masterDataSource") DataSource masterDataSource,
                                        @Qualifier("slaveDataSource") DataSource slaveDataSource) {
        ReplicationRoutingDataSource routingDataSource = new ReplicationRoutingDataSource();

        HashMap<Object, Object> sources = new HashMap<>();
        sources.put(DATASOURCE_KEY_MASTER, masterDataSource);
        sources.put(DATASOURCE_KEY_SLAVE, slaveDataSource);

        routingDataSource.setTargetDataSources(sources);
        routingDataSource.setDefaultTargetDataSource(masterDataSource);

        return routingDataSource;
    }

    @Bean
    @Primary // 이 어노테이션으로 1개의 DataSource 처럼 동작가능한 듯 하다.
    public DataSource dataSource(@Qualifier("routingDataSource") DataSource routingDataSource) {
        return new LazyConnectionDataSourceProxy(routingDataSource);
    }
}
public class ReplicationRoutingDataSource extends AbstractRoutingDataSource {
    private static final Logger logger = LoggerFactory.getLogger(ReplicationRoutingDataSource.class);
    public static final String DATASOURCE_KEY_MASTER = "master";
    public static final String DATASOURCE_KEY_SLAVE = "slave";

    @Override
    protected Object determineCurrentLookupKey() {
        boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();

        logger.info("This Transaction is readOnly=" + isReadOnly);

        if (isReadOnly) {
            return DATASOURCE_KEY_SLAVE;
        }
        return DATASOURCE_KEY_MASTER;
    }
}

`@Transactional(readOnly = true)` 여부로 R, CUD에 대해서 DB를 분리하도록 설정했다.

4. 스프링부트 내부설정 살펴보기

@Primary 어노테이션

내부에 들어가보면 Primary 어노테이션에 대한 설명은 아래와 같이 나와있다. 즉, 여러개의 빈이 존재한다고 하면 우선적으로 적용되게 해주는 어노테이션이다.

it will be injected preferentially over the jdbc-based variant assuming both are present as beans within the same Spring application context, which is often the case when component-scanning is applied liberally

컴포넌트 스캐닝이 자유롭게 적용되는 경우가 많은 동일한 Spring 애플리케이션 컨텍스트 내에서 둘 다 빈으로 존재한다고 가정하면 jdbc 기반 변형보다 우선적으로 주입됩니다.

Primary 설정을 하지 않으면 bean 이 cycle이 돈다는 경고를 받게 된다.

@Qualifier 어노테이션

내부 확인을해보면 역시 아래와 같이 나와있다.

This annotation may be used on a field or parameter as a qualifier for candidate beans when autowiring. It may also be used to annotate other custom annotations that can then in turn be used as qualifiers.

이 주석은 자동 연결 시 후보 빈에 대한 한정자로 필드 또는 매개변수에 사용될 수 있습니다. 또한 다른 사용자 정의 주석에 주석을 달고 차례로 한정자로 사용할 수도 있습니다.

@Configuration(proxyBeanMethods = false)

해당 설정으로 성능이 좀 더 좋아진다고 한다.(프록시를 만들지 않아도 되므로) Configuration은 굳이 프록시로 관리될 필요가 없기 때문에 CGLIB 설정을 끄고 동작해도 괜찮은 듯 하다. 사실 이 부분에 대해서는 깊은 이해는 아직 부족하지만 springframework.boot의 여러 AutoConfiguration 설정에도 해당 설정이 되어있다.

 

이 설정에 대해 공부하다가 헷갈린 점이 몇몇 있었다. 프록시로 동작하지 않고 싱글턴으로 동작하지 않는데 왜 컨테이너 싱글턴 관리 대상에 해당되는 것일까라는 생각이 들었다. Configuration이 있는 클래스 자체가 프록시로 동작하지 않는 것이지 내부의 @Bean 으로 등록하는 메소드는 싱글턴 관리대상에 들어간다. 하지만, 내부에서 메소드를 호출한다면 프록시로 싱글턴 관리가 되지 않기 때문에 그것은 새로운 객체가 생성될 수 있다. 따라서 싱글턴으로 관리되는 객체와 새롭게 생성되는 객체와 다르게 동작할 수 있다. 인자로 자동 주입되지 않고 메소드 자체를 호출한다면 예상치 못한 문제의 원인이 될 수 있을 것 같다는 생각이 들었다.

HibernateJpaConfiguration.java

내부 코드를 확인해보면 @ConditionalOnSingleCandidate(DataSource.class)를 확인할 수 있다. 하나의 DataSource만 있을 때 동작한다는 의미이다.

@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(HibernateProperties.class)
@ConditionalOnSingleCandidate(DataSource.class)
class HibernateJpaConfiguration extends JpaBaseConfiguration {
    // ...
}

ConditionalOnSingleCandidate 에서는 다음과 같이 나와있다. 이 조건 때문에 @Primary 어노테이션 처리가 필요한 것이다.

@Conditional that only matches when a bean of the specified class is already contained in the BeanFactory and a single candidate can be determined.
The condition will also match if multiple matching bean instances are already contained in the BeanFactory but a primary candidate has been defined; essentially, the condition match if auto-wiring a bean with the defined type will succeed.


@Conditional은 지정된 클래스의 Bean이 이미 BeanFactory에 포함되어 있고 단일 후보를 결정할 수 있는 경우에만 일치합니다. 여러 개의 일치하는 빈 인스턴스가 이미 BeanFactory에 포함되어 있지만 기본 후보가 정의된 경우 조건도 일치합니다. 기본적으로 정의된 유형의 빈 자동 연결이 성공하면 조건이 일치합니다.

5. OSIV (Open Session In View)

DB replication을 적용하면서 OSIV 개념에 대해 처음 알게 되었다. 예를들어 R,C,R 로직이 일어나는 http요청일 경우 처음 요청된 Read DB(즉, replicaDB)에서만 요청이 처리되었다. 중간에 SourceDB에 요청될 것이라고 생각했지만, 처음 커넥션을 맺은 DB에 요청이 일어났다. OSIV가 이것의 원인이다. OpenSessionInView에 대한 설명은 다음과 같이 나와있다.

Binds a JPA EntityManager to the thread for the entire processing of the request.

요청의 전체 처리를 위해 JPA EntityManager를 스레드에 바인딩합니다.

영속성 컨텍스트의 단위가 Service에서 @Transactional 단위로 적용되는 것이 아니라 요청 전체에 걸쳐서 동작하는 것이었다. 기본 설정은 true 였고, yml 설정에서 jpa.open-in-view=false로 설정해주었다. 이렇게 OSIV 설정을 꺼주어야 서비스의 트랜잭션 단위로 영속성 컨텍스트가 관리된다. @Transactional(readOnly=true)인 경우 replicaDB에서 아닌 경우 sourceDB에서 각 트랜잭션 단위로 DB에 연결이 가능하다.

 

처음에 OSIV가 true라는 것이 이해가 잘 되지 않았다. JPA를 공부하면서 Transaction 단위로 영속성 컨텍스트를 관리한다고 믿고 있었는데 그 사실이 아니었기 때문이다. 꽤나 충격적이었다.

 

OSIV 라는 설정에 대해 알게되면서 dto의 의미와 lazy loading의 의미에 대해서도 다시 생각해보게 되었다.

 

OSIV설정이 true 라면, View, Resolver, Controller 등에서 엔티티를 사용한다면 이것이 영속성 컨텍스트 범위에 있기 때문에 실제 데이터에 영향이 가는 것이다. 이것을 dto를 사용하여 레이어 사이에서 엔티티를 보호해주는 역할을 한다는 생각을 하였다. 다시한번 dto를 사용하여 참조를 끊어주는 의미에 대해 생각해보게 된 것이다.

 

OSIV 설정이 false 라면, Resolver를 사용해서 Controller에 엔티티 객체를 넘기고 해당 내용을 Service에서 사용한다고 가정하자. 이경우 객체에 lazy 로딩되는 내용들이 있다면 당시에는 로드되어 있지 않았던 내용을 Service에서 사용하게 될 때, 문제가 발생하게 된다. 아마 이런 경우를 방지하기 위해서 jpa에서는 OSIV 설정을 기본적으로 true로 하여 사용하고 있다고 생각했다. lazy 로딩은 자원을 아낄수도 있지만, 꽤나 예상치 못한 문제가 일어날 수 있는 요소라는 생각이 들었다.

6. 결론

현재 프로젝트에서는 단순하게 sourceDB, replicaDB 1개씩을 설정하였다. 영이의 테코톡을 보면 다양한 방식의 DB Replication 도 설명되어 있어 필요에 따라 다양하게 설정하면 될 듯 싶다.

DB replication 과 관련된 내용 뿐만 아니라 SpringBoot에서 autoConfiguration이 가지는 의미와 설정을 확인하고 활용하는 방법, OSIV 개념에 대해서 새롭게 알게 되었다. 아직 온전한 설정 정보에 대한 깊은 이해는 어렵지만, 이런 내용들을 확인해보고 공부해볼 수 있는 시작이 되었다는 것에 의의가 있다고 생각한다. 프레임워크와 라이브러리의 내부 코드를 살펴보는게 아직 쉽지 않지만 완태를 통해서 내부 코드를 살펴보는 방식을 많이 배웠다.

7. 참고

- 영이의 테코톡 replication

- GPU내껀데 DBreplication 이슈

- GPU내껀데 DB 마이그레이션 이슈

- MariaDB replication

- 완태의 테코블: SpringBoot AutoConfiguration을 대하는 자세

- proxyBeanMode 참고 블로그