본문 바로가기
Spring

[Spring] Replication 적용기 - 2

by 에드박 2021. 10. 25.

이전 글에서 MySQL에 Replication 환경을 구축했습니다.

 

MySQL에 적용한 Replication 환경은 단순히 source 서버에 데이터 변경이 일어나면 replica 서버에 복사되도록 구축한 것일 뿐입니다. Application에서 추가/삭제/수정은 source 서버, 조회는 replica 서버를 사용하려면 추가적인 코드 구현이 필요합니다.


Datasource 설정(yml 또는 properties)

기존에 사용하던 Datasource 설정값은 아래와 같습니다.

spring:
  datasource:
    driver-class-name: org.h2.Driver
    url: jdbc:mysql://localhost:3306..생략...
    username: username
    password: password

 

이제 여러개의 DB 서버를 사용해야하니 Datasource 설정값도 여러개가 필요합니다.

이 글에서는 source 서버 1개, replica 서버 2개를 기준으로 설명하겠습니다.

yml을 기준으로 아래와 같이 작성합니다.

 

spring:
    datasource:
        hikari:
            source:
                username: root
                password: sourcepw
                jdbc-url: jdbc:mysql://localhost:33306/...생략...
            replica:
                replica-List:
                    -   name: replica1
                        username: replica1
                        password: replicapw
                        jdbc-url: jdbc:mysql://localhost:33307/...생략...
                    -   name: replica2
                        username: replica2
                        password: replicapw
                        jdbc-url: jdbc:mysql://localhost:33308/...생략...

 

 

이전의 설정에서 약간의 차이점은 url(이전)과 jdbc-url(현재)라는 이름 입니다.

차이가 있는 이유는 SpringBoot 2.0 부터 HikariCP가 기본이므로 HikariDataSource를 만들어줘야 합니다.

HikariDataSource를 만들기 위해서는 url 매핑 이름이 jdbc-url을 기준으로 매핑하기 때문에 위와같이 작성을 합니다.

기존의 yml 설정에서는 이름을 jdbc-url로 하지않아도 잘 적용되는 이유는 자동 설정으로 적용됐기 때문입니다.
Spring에서 데이터베이스를 2개이상 사용하기 위해서는 DataSource를 직접 만들어줘야합니다. 이때 HikariDataSource를 만들어줍니다.

 

다음으로 설정값을 받을 객체를 생성하겠습니다.


환경설정 값을 매핑할 빈 생성

 

source DB는 간단한 코드로 DataSource를 만들수 있으므로 뒤에서 설정합니다.

여기서는 Replica 서버에 대한 설정만 매핑합니다.

Spring 에서 2개 이상의 데이터베이스를 사용하기 위해서는 DataSource를 직접 생성해줘야 합니다.

 

@ConfigurationProperties를 이용해서 prefix 하위 값들을 JavaBeans에 매핑할 수 있습니다.

중첩된 값을 매핑하기 위해서는 static inner class 를 사용합니다.(public static class Replica)

또한 해당 빈의 값들로 DataSource를 반환하는 메서드를 작성합니다. 인자로 받는 type은 DataSource 생성 시 사용할 타입입니다.

DataSource 생성시 DataSource의 구현체 Type을 선택할 수 있는데 HikariDataSource 또한 DataSource의 구현체중 하나입니다.

 

@Component
@ConfigurationProperties(prefix = "spring.datasource.hikari.replica")
public class Replicas {

    List<Replica> replicaList = new ArrayList<>();

    public <D extends DataSource> Map<String, DataSource> replicaDataSources(Class<D> type) {
        return this.replicaList.stream()
                .collect(Collectors.toMap(Replica::getName, replica -> replica.createDataSource(type)));
    }

    public List<Replica> getReplicaList() {
        return replicaList;
    }

    public static class Replica {
        private String name;
        private String username;
        private String password;
        private String jdbcUrl;

        public <D extends DataSource> DataSource createDataSource(Class<D> type) {
            return DataSourceBuilder.create()
                    .type(type)
                    .url(this.getJdbcUrl())
                    .username(this.getUsername())
                    .password(this.getPassword())
                    .build();
        }

        ...Getter, Setter 생략...
    }
}

 

다음으로 추가/수정/삭제 기능에는 source DataSource, 조회 기능에는 replica DataSource를 사용하도록 Routing해주는 설정을 구현하겠습니다.


 

RoutingDataSource 구현체 생성

DataSource Routing을 할 때 '어떤것을 기준으로 하는가'에 대해서 기준을 세우자면 '구현하기 나름' 이라고 할 수 있습니다.

여기서는 일반적으로 @Transactional의 readOnly 설정을 기준으로 false면 source DataSource, true면 replica Datasource 를 사용하도록 설정하겠습니다.

  • @Transactional(readOnly=false) -> source DataSource 사용
  • @Transactional(readOnly=true) -> replica DataSource 사용
@Slf4j 애노테이션은 Lombok을 사용한 것입니다.
TransactionStnchronizationManager는 현재 요청에 할당된 쓰레드와 매핑되어 있는 트랜잭션을 가져옵니다. 어떻게 동작되는지는 스프링의 트랜잭션 동기화(토비의 스프링 5장 추천), 스레드와의 매핑은 ThreadLocal 을 키워드로 학습해보시면 됩니다.
@Slf4j
public class ReplicationRoutingDataSource extends AbstractRoutingDataSource {

    public static final String DATASOURCE_SOURCE_KEY = "source";

    private ReplicaDataSourceNames replicaDataSourceNames;

    public void setReplicaDataSourceNames(List<String> names) {
        this.replicaDataSourceNames = new ReplicaDataSourceNames(names);
    }

    @Override
    protected Object determineCurrentLookupKey() {
        final boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
        if (isReadOnly) {
            String nextReplicaDataSourceName = replicaDataSourceNames.getNextName();
            log.info("Using Replica DB Name : {}", nextReplicaDataSourceName);
            return nextReplicaDataSourceName;
        }
        log.info("Using Source DB");
        return DATASOURCE_SOURCE_KEY;
    }

    public static class ReplicaDataSourceNames {

        private final List<String> values;
        private int counter = 0;

        public ReplicaDataSourceNames(List<String> values) {
            this.values = values;
        }

        public String getNextName() {
            if (counter == values.size()) {
                counter = 0;
            }
            return values.get(counter++);
        }
    }
}

AbstractRoutingDataSource : 조회 키를 기반으로 다양한 DataSource중 하나로 getConnection() 호출을 라우팅하며 AbstractDataSource 구현체 입니다.

AbstractRoutingDataSource를 상속받아서 반드시 구현해야할 추상 메서드는 determineCurrentLookupKey()입니다.

DataSource를 얻기위한 Key를 반환합니다. 이 메서드에서 어떤 분기로 어떤 Key를 반환해줄것인가가 RoutingDataSource의 핵심입니다.

 

ReplicaDataSourceNames : 이 내부 클래스는 표준이 아닙니다. 개인적으로 만든것인데 여러개의 replica DataSource의 이름을 필드로 가지고 getNextName() 메서드로 Transactional(readOnly=true)인 요청이 올 때마다 replica DataSource를 번갈아가며 사용하기 위해 만들었습니다.

(여기서 replica DataSource의 이름은 yml에서 정의한 name 속성 값 입니다. Replica 객체의 name 필드이기도 합니다.)

 

여기까지가 ReplicationRoutingDataSource 구현입니다.

이제 마지막으로 만든것들을 조립할 시간입니다. DatabaseConfig를 구현하겠습니다.

 


DatabaseConfig 구현

DatabaseConfig에서는 DataSource에 대한 설정을 주로 합니다.

먼저 애노테이션에 대해 설명하겠습니다.

 

@Configuration : 하나 이상의 @Bean 메서드를 선언하고 런타임에 Spring 컨테이너에 의해 빈 정의, 요청등이 처리됨을 나타냅니다.

@EnableAutoConfiguration : 스프링 애플리케이션 컨텍스트의 자동 구성을 활성화합니다. exclude 로 특정 자동 구성 클래스를 제외시킬 수 있습니다.

@EnableTransactionManagement : Spring의 <tx:*> XML 네임스페이스에서 찾을 수 있는 지원과 유사한 Spring의 애너테이션 기반 트랜잭션 관리 기능을 활성화 합니다. @Configuration 클래스에서 트랜잭션 관리를 구성하는데 사용됩니다.

@EnableConfigurationProperties : @ConfigurationProperties를 설정한 클래스를 활성화하기 위해 사용됩니다. 여기서는 Replicas.class를 가져옵니다.

@EnableJpaRepositories : JPA Repository를 활성화하기 위한 애너테이션 입니다. 패키지에서 Spring Data Repository에 대한 애너테이션이 달린 클래스를 스캔합니다.

 

@Configuration
@EnableAutoConfiguration(exclude = {DataSourceAutoConfiguration.class})
@EnableTransactionManagement
@EnableConfigurationProperties(Replicas.class)
@EnableJpaRepositories(basePackages = {"com.wooteco.nolto"})
public class DatabaseConfig {

    private final Replicas replicas;

    public DatabaseConfig(Replicas replicas) {
        this.replicas = replicas;
    }

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.hikari.source")
    public DataSource sourceDataSource() {
        return DataSourceBuilder.create().type(HikariDataSource.class).build();
    }

    @Bean
    public Map<String, DataSource> replicaDataSources() {
        return replicas.replicaDataSources(HikariDataSource.class);
    }

    @Bean
    public DataSource routingDataSource(@Qualifier("sourceDataSource") DataSource source,
                                        @Qualifier("replicaDataSources") Map<String, DataSource> replicas) {
        ReplicationRoutingDataSource routingDataSource = new ReplicationRoutingDataSource();

        Map<Object, Object> dataSources = new HashMap<>();
        dataSources.put(DATASOURCE_SOURCE_KEY, source);
        dataSources.putAll(replicas);

        routingDataSource.setTargetDataSources(dataSources);
        routingDataSource.setDefaultTargetDataSource(source);
        List<String> replicaDataSourceNames = new ArrayList<>(replicas.keySet());
        routingDataSource.setReplicaDataSourceNames(replicaDataSourceNames);

        return routingDataSource;
    }

    @Primary
    @Bean
    public DataSource dataSource(@Qualifier("routingDataSource") DataSource routingDataSource) {
        return new LazyConnectionDataSourceProxy(routingDataSource);
    }
}

 

sourceDataSource() : sourceDataSource 빈을 생성합니다. replica와 다르게 @ConfigurationProperties(prefix = "spring.datasource.hikari.source") 설정을 바로 적용해서 사용하는 것을 볼 수 있는데 replica 처럼 List로 구성해야 하는 경우가 아니라면 해당 설정처럼 바로 매핑해서 DataSource로 만드는것이 가능합니다.

replicaSources() : replica DataSource들을 Map<String, DataSource> 형태의 빈을 생성합니다.

routingDataSource() : 앞에서 만들었던 ReplicationRoutingDataSource 클래스의 빈을 생성합니다.

setTargetDataSources(Map<Object, Object> targetDataSource) : 검색 key를 key로 하고 값을 DataSource로 가지는 Map을 설정합니다. Key는 임의 형식일 수 있지만 예제에서는 name을 key로 사용해서 매핑했습니다. 런타임에 특정 DataSource를 찾기위해 사용되는 key는 앞에서 ReplicationRoutingDataSource 클래스에 정의한 determineCurrentLookupKey() 메서드의 반환값에 의해 결정됩니다.

setDefaultTargetDataSource(Datasource datasource) : 기본으로 사용될 DataSource를 지정합니다. determineCurrentLookupKey() 메서드의 반환값인 key가 targetDataSource에 없는 경우 defaultTargetDataSource가 사용됩니다. 여기서는 source DataSource를 기본 DataSource로 설정했습니다.

LazyConnectionDataSourceProxy : DataSource에 대한 프록시를 생성합니다. 실제 JDBC Connection은 느리게 가져옵니다.

이때 auto-commit mode(자동 커밋모드), transaction isolation(트랜잭션 격리), read-only mode(읽기전용 모드) 같은 설정값은 유지한채 실제 연결을 가져오는 즉시 JDBC Connection에 적용됩니다. 실제로 Connection이 필요하지 않는한 Connection Pool에서 JDBC Connection을 가져오지 않습니다. JDBC statement 가 처음 작성될 때 JDBC connection을 가져옵니다.

즉, 실제 쿼리가 실행될 때 Connection Pool 에서 JDBC Connection을 가져옵니다.

 


LazyConnectionDataSourceProxy

LazyConnectionDataSourceProxy는 왜 필요한걸까요? 단순히 커넥션을 가져오는걸 미뤄서 성능 향상을 이루려는 걸까요?

 

물론 실제 커넥션을 가져오기 전에 예외 발생으로 요청이 중단되면 실제 커넥션을 불러오지 않았는다. 비교적 커넥션에 여유가 생길것이다.

이런 장점도 있지만 DB Replication 설정에는 더 중요한 부분이 있다.

 

Spring 은 @Transactional 애너테이션을 만나면 다음과 같이 동작합니다.

TransactionManager 선택 -> Connection 획득 -> 트랜잭션 동기화

 

이 순서는 AbstractRoutingDataSource 에서 데이터소스를 획득하는 과정에 문제가 있습니다.

앞서 코드에서 DataSource 선택에  TransactionSynchronizationManager.isCurrentTransactionReadOnly() 를 활용합니다.

 

하지만 Connection을 획득하고 -> 트랜잭션을 동기화 한다면, 커넥션 획득 시점에 트랜잭션이 동기화되지 않은 상태입니다.

즉, 현재 스레드에 매핑된 트랜잭션의 정보를 불러올 수 없습니다. 이는 AbstractRoutingDataSource 구현체에서 데이터소스를 찾기위한 분기처리가 불가능합니다.

 

이 문제를 LazyConnectionDataSourceProxy를 통해서 극복할 수 있습니다.

해당 커넥션 프록시를 활용해서 다음과 같이 동작하도록 합니다.

TransactionManager 선택 -> Proxy Connetion 획득 -> 트랜잭션 동기화 -> 실제 쿼리를 활용할 때 Proxy Connection의 getConnection() 호출

(이 때 Proxy Connection가 감싸고있는 실제 데이터소스에서 getConnection()을 호출합니다.)

 

위에서도 언급했지만 트랜잭션 동기화와 어떻게 동기화하는지가 궁금하시다면

스프링의 트랜잭션 동기화(토비의 스프링 5장 추천), 스레드와의 매핑은 ThreadLocal 을 키워드로 학습해보시면 됩니다.


테스트 작성

다음과 같은 상황에 대해 테스트를 진행합니다. (DB 서버는 Replication 환경이 구축된 상태에서 진행합니다.)

  • 저장(insert) 시 -> source DB를 사용한다.
  • 조회(find) 시 -> replica DB를 사용한다.
  • 여러번 조회 시 -> replica DB들을 번갈아가며 사용한다.
@SpringBootTest
class UserServiceTest {

    @Autowired
    UserService userService;

    @Autowired
    EntityManager entityManager;

    private User user1;

    @BeforeEach
    void setUp() {
        user1 = new User("charlie");
    }

    @DisplayName("유저를 저장한다. - source DB를 사용한다.")
    @Test
    void save() {
        userService.save(user1);
    }

    @DisplayName("유저 전체를 조회한다. - source DB를 사용한다.")
    @Test
    void findAll() {
        List<User> users = userService.findAll();
        System.out.println("User count = " + users.size());
        users.forEach(user -> System.out.println(user.getName()));
    }

    @DisplayName("여러번 조회시 - N개의 Replica 데이터베이스를 번갈아가며 사용한다.")
    @Test
    void switchReplicaDatabase() {
        User newUser = userService.save(user1);

        User findUser1 = userService.findById(newUser.getId());
        User findUser2 = userService.findById(newUser.getId());
        User findUser3 = userService.findById(newUser.getId());
        User findUser4 = userService.findById(newUser.getId());
        User findUser5 = userService.findById(newUser.getId());

        assertThat(findUser1.getName()).isEqualTo(user1.getName());
    }

저장 테스트 결과

전체 조회 테스트 결과

여러번 조회 시 테스트 결과


참고자료

- https://velog.io/@kingcjy/Spring-Boot-JPA-DB-Replication-%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0

- https://velog.io/@max9106/Spring-Boot-%EC%99%B8%EB%B6%80%EC%84%A4%EC%A0%95-4xk69h8o50

- http://tech.pick-git.com/db-replication/

- https://jojoldu.tistory.com/296

- http://kwon37xi.egloos.com/5364167

 

댓글