안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.

이번 포스팅은 이전 포스팅에서 작성한 리플리카 서버를 데이터베이스에 연동하는 과정을 정리하고자 합니다.

 

 

1. 스프링부트 의존성 주입과 application.yml 설정하기

 

 

해당 테스트는 스프링부트 3.1.0, Mysql 8.x로 구성되어 있습니다. 마스터 서버와 슬레이브 서버를 설정하는 과정은 하단 블로그 링크를 첨부하였습니다.

 

https://gose-kose.tistory.com/131

 

[DB] MySQL8.x 리플리카 서버 적용하기(1)

안녕하세요. 기술적 겸손함으로 회사와 함께 성장하고 싶은 KOSE입니다. 이번 포스팅은 리플리카 서버를 적용하는 일련의 과정을 시도해 보는 글을 작성하고자 합니다. 저는 ubuntu22.04, docker 24.0.2,

gose-kose.tistory.com

 

현재 설정된 Mysql은 local의 3307 포트에 연결된 order_master 서버와, 3308 포트에 연결된 order_slave 서버입니다.

 

build.gradle

implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'

// mysql
runtimeOnly 'com.mysql:mysql-connector-j'
implementation 'mysql:mysql-connector-java:8.0.33'

compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'

annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'

 

jpa, mysql을 활용하기 위해 의존성 주입을 적용하였습니다.

 

application.yml

 

server:
  port: 8081

spring:
  datasource:
    source:
      driver-class-name: com.mysql.cj.jdbc.Driver
      jdbc-url: jdbc:mysql://localhost:3307/orders
      username: root
      password: 1234
    replica:
      driver-class-name: com.mysql.cj.jdbc.Driver
      jdbc-url: jdbc:mysql://localhost:3308/orders
      username: root
      password: 1234

  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true
    show-sql: true

  main:
    allow-bean-definition-overriding: true

logging:
  level:
    org:
      hibernate:
        type: trace

 

스프링 부트는 source 서버와 replica 서버의 데이터베이스 커넥션을 각각 설정해야 하므로

driver-class-name, jdbc-url, username, password를 각각 설정하였습니다.

 

참고한 블로그에서 master - slave라는 표현은 윤리적인 문제로 최근에는 Source - Replica를 사용한다고 설명해 주셨습니다.

따라서 저도 master: source, slave: replica로 표현하였습니다.

 

 

2. Configuration 작성하기

 

전체 소스를 먼저 작성한 후, 하나씩 주요 메서드 및 기능을 정리하도록 하겠습니다.

 

@Slf4j
public class RoutingDataSource extends AbstractRoutingDataSource {

    @Nullable
    @Override
    protected Object determineCurrentLookupKey() {
        String lookupKey = TransactionSynchronizationManager.isCurrentTransactionReadOnly() ? "replica" : "source";
        log.info("Current DataSource is {}", lookupKey);
        return lookupKey;
    }
}

 

@Slf4j
@Configuration
public class DataSourceConfig {

    private static final String SOURCE_SERVER = "source";
    private static final String REPLICA_SERVER = "replica";

    @Bean
    @Qualifier(SOURCE_SERVER)
    @ConfigurationProperties("spring.datasource.source")
    public DataSource masterDataSource() {
        log.info("source register");
        return DataSourceBuilder.create().build();
    }

    @Bean
    @Qualifier(REPLICA_SERVER)
    @ConfigurationProperties("spring.datasource.replica")
    public DataSource replicaDataSource() {
        log.info("replica register");
        return DataSourceBuilder.create().build();
    }

    @Bean
    public DataSource routingDataSource(@Qualifier(SOURCE_SERVER) DataSource masterDataSource,
                                        @Qualifier(REPLICA_SERVER) DataSource slaveDataSource) {

        RoutingDataSource routingDataSource = new RoutingDataSource();

        HashMap<Object, Object> dataSourceMap = new HashMap<>();
        dataSourceMap.put(SOURCE_SERVER, masterDataSource);
        dataSourceMap.put(REPLICA_SERVER, slaveDataSource);

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

        return routingDataSource;
    }

    @Bean
    @Primary
    public DataSource dataSource() {
        DataSource determinedDataSource = routingDataSource(masterDataSource(), replicaDataSource());
        return new LazyConnectionDataSourceProxy(determinedDataSource);
    }
}

 

sourceDataSource() 메서드는 spring.datasource.source로 시작하는 설정을 이용하여 DataSource 인스턴스를 생성하고 반환하는 역할을 합니다. 

 

@ConfigurationProperties 어노테이션은 스프링 부트에서 제공하는 기능으로 외부 구성 파일에 정의된 값을 활용하여 특정 클래스의 필드와 자동으로 바인딩하도록 돕습니다.

 

replicaDataSource()는 replica 서버에 대한 DataSource 인스턴스를 생성하고 반환하는 역할을 수행합니다.

 

routingDataSource() 메서드에 각각 정의한 Datasource를 Qualifier를 통해 인자로 주입한 후, RoutingDataSource 객체를 생성합니다.

 

RoutingDataSource는 위에서 추상클래스인 AbstractRoutingDataSource를 확장한 클래스입니다.

 

AbStractRoutingDataSource데이터 소스의 라우팅을 처리하는 Spring의 클래스로서, 다중 데이터베이스에 대한 접근을 동적으로 제어하는 역할을 수행합니다. 이를 활용하여 트랜잭션 당 하나의 데이터베이스만 사용하도록 설정할 수 있습니다.

이 클래스를 활용함으로써 읽기와 쓰기를 분리할 수 있습니다.

 

 

 

구현해야 하는 추상 메서드는 determineCurrentLookupKey()입니다. 설명에 따르면 현재 데이터베이스의 키 타입에 따라 매칭시킬 수 있도록 하는 역할을 수행합니다. 현재 데이터베이스 연결이 읽기 전용인지 아닌지 판단하여 source와 replica를 반환합니다.

 

서비스 로직에서 자주 사용하는 @Transactional 어노테이션에 readOnly를 설정할 수 있습니다. 이 값의 설정 여부에 따라, 호출된 현재 트랜잭션이 읽기 전용인지 아닌지 판단할 수 있는 로직을 구현한 것입니다.

 

TransactionSynchronizationManager.isCurrentTransactionReadOnly() ? "replica" : "source";

 

다시 routingDataSource로 돌아가서, 이렇게 키 값에 따라 다른 데이터소스를 적용할 수 있도록 돕는 routingDataSource 객체를 생성한 후, HashMap으로 키와, 소스를 매핑하여 setTargetDayaSource에 맵을 주입 합니다.

 

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

setDefaultTargetDataSource는 기본 데이터소스를 설정할 수 있습니다. 일반적인 쓰기 작업 등은 source 서버에서 진행해야 하므로 기존 값을 설정하였습니다.

 

마지막으로 datasource() 메서드에서 LazyConnectionDataSourceProxy를 반환하는 것을 확인할 수 있습니다. 이 프록시는 실제 데이터베이스 연결이 필요할 때까지 실제 데이터베이스 연결의 생성을 지연시킵니다. 이 부분에 대해 이해가 되지 않아서 지연 연결 생성 방식에 대한 정리를 해보았습니다.

 

제가 기존에 알고 있는 DBCP는 미리 데이터베이스의 연결을 생성한 후 필요에 따라 그 연결을 제공합니다. 하지만 LazyConnectionDataSourceProxy는 데이터베이스 연결이 필요할 때까지 실제 데이터베이스 연결의 생성을 지연시킨다는 것에 혼란이 왔습니다.

 

두 기능은 기본적으로 차이가 존재합니다. DBCP는 연결 생성의 오버헤드를 줄이기 위한 역할이라면, LazyConnectionDayaSourceProxy는 연결 사용 시점을 지연하는 것입니다. 즉, DBCP로 미리 연결을 만들어 놓았지만 실제 SQL 작업이 필요한 시점까지 그 연결을 사용하지 않도록 하는 것이 LazyConnectionDataSourceProxy입니다.

 

예를 들어 methodA()가 있다고 가정하겠습니다.

void methodA() {

// for 문 등 기타 로직
// 문자열 파싱 등 기타 로직
	memberRepository.findById(id);
}

 

여기서 for문이나 문자열 파싱 작업은 SQL 작업을 요구하지 않기 때문에 즉각적으로 커넥션을 여는 것이 아니라 memberRepsitory.findyId와 같이 실제 SQL 작업을 요하는 작업이 도달할 때까지 지연하는 것이 LazyConnectionDayaSourceProxy의 역할입니다.

 

이 객체를 활용함으로써 다음의 장점을 얻을 수 있습니다.

 

1. 자원 최적화: 데이터베이스 연결은 자원을 많이 요구하는 작업이다. 모든 요청에 대해 연결을 즉시 만들지 않고, 필요한 상황에만 연결을 하도록 최대한 지연시키는 것은 자원 사용률을 줄이는데 도움을 줍니다.

 

2. 트랜잭션 관리의 향상: 트랜잭션 범위 내에서 실제 SQL 작업이 발생할 때 까지 데이터베이스 연결을 얻지 않음으로써 트랜잭션 시간을 줄일 수 있습니다.

 

3. 읽기/쓰기 분산 시나리오 지원: 읽기와 쓰기 작업을 분리하는 시나리오에 도움을 주는 역할을 수행합니다. 예를 들어 트랜잭션이 시작되는 지점에는 해당 트랜잭션이 읽기 혹은 쓰기 인지 알 수 없습니다. LazyConnectionDataSourceProxy로 연결을 지연한다면 SQL 요청이 요하는 시점에 어떤 연결이 필요한지 파악할 수 있습니다.

 

 

3. 테스트로 읽기 쓰기 테스트 확인하기

 

@Service
@Transactional
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;

    public Long save(MemberDto memberDto) {
        return memberRepository.save(Member.builder().name(memberDto.getName()).build()).getId();
    }

    @Transactional(readOnly = true)
    public Member findById(Long id) {
        return memberRepository.findById(id).orElseThrow();
    }

    @Transactional(readOnly = true)
    public Member findByName(String name) {
        return memberRepository.findByName(name).orElseThrow();
    }
}

 

@SpringBootTest
class MemberServiceTest {

    @Autowired MemberService memberService;
    @Autowired
    private MemberRepository memberRepository;

    @AfterEach
    void clear() {
        memberRepository.deleteAll();
    }

    @Test
    @DisplayName("저장 및 조회하기")
    public void save_find() throws Exception {
        //given
        String memberName = "gose";

        //when
        Long memberId = memberService.save(new MemberDto(memberName));
        Member member = memberService.findById(memberId);

        //then
        Assertions.assertThat(member.getName()).isEqualTo(memberName);
    }
}

 

 

 

테스트 과정에서 중요한 사항은 @Transactional 어노테이션을 사용하지 않고 @AfterEach를 사용하여 데이터를 지우는 작업을 수행하였습니다. 테스트에서 Transactional 어노테이션이 메서드 단위를 하나의 트랜잭션으로 묶어버리는 역할을 수행할 수 있기 때문에 readOnly가 적용되지 않을 수도 있습니다. 따라서, @AfterEach를 활용하여 롤백하는 작업을 처리하였습니다.

 

Hibernate: 
    insert 
    into
        member
        (name,member_id) 
    values
        (?,?)
2023-06-11T00:04:31.111+09:00  INFO 108103 --- : Current DataSource is master
Hibernate: 
    select
        m1_0.member_id,
        m1_0.name 
    from
        member m1_0 
    where
        m1_0.member_id=?
2023-06-11T00:04:31.157+09:00  INFO 108103 --- : Current DataSource is slave

 

로그를 확인해 보면 insert 로직은 source 서버에서, select는 replica 서버에서 진행하는 것을 확인할 수 있습니다.

 

이상으로 스프링부트에 리플리카 서버를 연동하는 방법을 마치도록 하겠습니다.! 

감사합니다.!!!

 

자료 출처: https://hudi.blog/database-replication-with-springboot-and-mysql/

 

데이터베이스 레플리케이션을 통한 쿼리 성능 개선 (feat. Mysql, SpringBoot)

레플리케이션에 대한 이론적인 내용은 데이터베이스의 확장성과 가용성을 위한 MySQL Replication 를 참고하자. 실습 환경 Ubuntu 22.04 LTS MySQL 8.0 Docker Spring Data JPA 레플리케이션 아키텍처 레플리케이

hudi.blog

 

+ Recent posts