인생을 코딩하다.

[MySQL] 트레픽 분산을 위한 Master/Slave DataSource 동적 라우팅 설정 본문

DataBase

[MySQL] 트레픽 분산을 위한 Master/Slave DataSource 동적 라우팅 설정

Hyung1 2021. 8. 10. 20:27
728x90
반응형

안녕하세요. 이번 글은 제가 MySQL Master 서버 이외에 추가적으로 Replication된 Slave 서버를 생성한 이유와 과정에 관해 글을 작성해 보았습니다.

 

현재 진행중인 프로젝트인 Black-postoffice는 사용자가 지속적으로 증가함하며 많은 양의 트래픽이 발생한다는 가정하에 진행중이기 때문에 하나의 DB 서버로 모든 쓰기/읽기 작업이 집중된다면 쉽게 부하가 발생할 수 있다고 생각했습니다.

 

따라서 Master 서버 이외에 추가적으로 Replication된 Slave 서버를 두고 모든 읽기 작업(read-only)은 slave에게 향하게 함으로써 트래픽이 분산될 수 있도록 구현하였습니다.

 

이는 또한 단일 서버일 때 MySQL 서버가 죽게되어, 서비스를 진행할 수 없어 수익을 창출하지 못하게 되는 위험도 사전에 방지할 수 있습니다.

 

저는 현재 금전적으로 여유가 없는 취준생이기 때문에, 다른 클라우드 플랫폼에 비해 저렴하고 10만 무료 크레딧을 제공해주는 네이버 클라우드 플랫폼에서 MySQL Master 서버와 MySQL Slave 서버를 생성하였습니다.

 

  • MySQL Master : [Compact] 1vCPU, 2GB Mem, 50GB Disk
  • MySQL Slave : [Compact] 1vCPU, 2GB Mem, 50GB Disk

네이버 클라우드 플랫폼에서 MySQL master 서버와 slave 서버를 생성하는 방법은 MySQL 서버 이미지 사용 가이드를 참고해주시면 될 것 같고, 서버 생성 후에 리눅스 터미널에 접속하여 master 서버와 slave 서버를 연동해주는 것은 여기를 참고해주시면 도움이 되실 것입니다.

🔍 MySQL Replication이란?

MySQL의 복제는 레플리케이션이라고도 하는데, 복제는 2대 이상의 MySQL 서버가 동일한 데이터를 담도록 실시간으로 동기화 하는 기술이라고 할 수 있습니다.

 

복제에는 INSERT나 UPDATE와 같은 쿼리를 이용해 데이터를 변경할 수 있는 MySQL서버와 SELECT 쿼리로 데이터를 읽기만 할 수 있는 MySQL서버로 나뉩니다. MySQL에서는 쓰기와 읽기 역할로 구분해, 전자를 마스터라고 하고 후자를 슬레이브라고 합니다.

 

서버의 복제에서는 머스터는 반드시 1개이며 슬레이브는 1개 이상으로 구성될 수 있습니다.

 

  • 마스터
    • MySQL의 바이너리 로그가 활성화되면 어떤 MySQL 서버든 마스터가 될 수 있습니다.
    • 애플리케이션의 입장에서 본다면 마스터 장비는 주로 데이터가 생성 및 변경, 삭제되는 주체(시작점)이라고 볼 수 있습니다.
    • 일반적으로 복제에 참여하는 여러 서버 가운데 변경이 허용되는 서버는 마스터로 한정할 때가 많습니다. 그렇지 않은 경우 복제되는 데이터의 일관성을 보장하기 어렵기 때문입니다.
    • 슬레이브 서버에서 변경 내역을 요청하면 마스터 장비 는 그 바이너리 로그를 읽어 슬레이브로 넘깁니다. 마스터 장비의 프로세스 가운데 "Bimlog dump"라는 스레드가 이 일을 전담하는 스레드입니다.
  • 슬레이브
    • 데이터(바이너리 로그)를 받아 올 마스터 장비의 정보(IP주소와 포트 정보 및 접속 계정)을 가지고 있는 경우 슬레이브가 됩니다.(마스터나 슬레이브라고 해서 별도의 빌드 옵션이 필요하거나 프로그램을 별도로 설치해야 하는 것은 아닙니다.)
    • 마스터 서버가 바이너리 로그를 가지고 있다면 슬레이브 서버는 릴레이 로그를 가지고 있습니다. 슬레이브 서버의 I/O 스레드는 마스터 서버에 접속해 변경 내역을 요청하고 받아 온 변경 내역을 릴레이 로그에 기록합니다.
    • 그리고 슬레이브 서버의 SQL 스레드가 릴레이 로그에 기록된 변경 내역을 재실행함으로써 슬레이브의 데이터를 마스터와 동일한 상태로 유지합니다.
    • I/O 스레드와 SQL 스레드는 마스터 MySQL에서는 가동되지 않으며, 복제가 설정된 슬레이브 MySQL 서버에서 자동적으로 가동하는 스레드입니다.

🔍 복제를 사용할 때 주의할 점

  • 슬레이브는 하나의 마스터만 설정 가능합니다.
  • 마스터와 슬레이브의 데이터 동기화를 위해 슬레이브는 읽기 전용으로 설정합니다.
  • 슬레이브 서버용 장비는 마스터와 동일한 사양이 적합합니다.
    • 여러 개의 스레드로 실행된 쿼리가 슬레이브에서 지연되지 않고 하나의 스레드로 처리될 수 있습니다.
    • 데이터 변경은 데이터 조회보다는 10분의 1 수준으로 유지되는 것이 일반적이므로 마스터 서버와 슬레이브 서버를 같은 사양으로 유지할 때가 많습니다.
    • 또한, 슬레이브 서버는 마스터 서버가 다운된 경우 그에 대한 복구 대안으로 사용될 떄도 많기 때문에 사양을 동일하게 맞추는 경우가 대부분입니다.
  • 복제가 불필요한 경우에는 바이너리 로그를 중지합니다.
    • 바이너리 로그를 안정적으로 기록하기 위해 갭 락(Gap lock)을 유지하고, 매번 트랜잭션 이 커밋될 때 마다 데이터를 변경시킨 쿼리 문장을 바이너리 로그에 기록해야 합니다. 바이너리 로그를 기록하는 작업은 AutoCommit이 활성화 된 MySQL 서버에서 심각한 부하로 나타날 때가 많습니다.
  • 바이너리 로그와 트랜잭션 격리 수준(Isolation level)
    • 바이너리 로그 파일은 어떤 내용이 기록되냐느에 따라 STATEMENT 포맷 방식과 ROW 포맷 방식이 있습니다.
    • STATEMENT 방식은 바이너리 로그 파일에 마스터에서 실행되는 쿼리 문장을 기록하는 방식이며, ROW 포맷은 마스터에서 실행된 쿼리에 의해 변경된 레코드 값을 기록하는 방식입니다.

MySQL의 복제는 마스터에서 처리된 내용이 바이너리 로그로 기록되고, 그 내용이 슬레이브 MySQL 서버로 전달되어 재실행 되는 방식으로 처리됩니다.

 

바이너리 로그 파일에 SQL 문장을 기록하는 방식을 문장 기반 복제 라고 하며, 변경된 레코드를 바이너리 로그에 기록하는 방식을 레코드 기반의 복제라고 합니다.

 

문장 기반 복제에는 Mysql Replication시 주의해야할 점이 있습니다. master 서버에 쓰기 작업이 일어나고 slave 서버에 복제가 되기 전에 slave 서버에 읽기 요청이 일어난다면, 일관되지 않는 값을 얻게됩니다. 따라서, SQL 기반의 복제가 정상적으로 작동하려면 REPEATABLE-READ 이상의 트랜잭션 격리 수준을 사용해야 하며, 그로 인해 InnoDB 테이블에서는 레코드 간의 간격을 잠그는 갭락이나 넥스트 키 락이 필요해집니다.

 

반면 레코드 기반의 복제는 마스터 슬레이브 MySQL 서버 간의 네티워크 트레픽을 많이 발생시킬 수 있지만 READ-COMMITTED 트랜잭션 격리 수준에서도 작동할 수 있으며 InnoDB 테이블에서 잠금의 경합은 줄어들게 됩니다.

 

혹시 Isolation level에 관해 잘 모르신다면, 여기를 참고해주시면 될 것 같습니다.

 

[Real MySQL 정리], 4장 트랜잭션과 잠금

트랜잭션과 잠금 트랜잭션 MySQL에서의 트랜잭션 트랜잭션은 작업의 완전성을 보장해주는 것이다. 즉 논리적인 작업 셋을 모두 완벽하게 처리하거나 또는 처리하지 못할 경우에는 원 상태로 복

junghyungil.tistory.com

 

🔍 이제 코드를 작성해보도록 하겠습니다.

Spring의 AbstractRoutingDataSource는 다중 DataSource를 묶고 키를 통해 상황에 따라 동적으로 라우팅할 수 있도록 도와줍니다.

package com.flabedu.blackpostoffice.commom.config

import com.flabedu.blackpostoffice.commom.utils.constants.MASTER
import com.flabedu.blackpostoffice.commom.utils.constants.SLAVE
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource
import org.springframework.transaction.support.TransactionSynchronizationManager

class RoutingDataSourceConfig : AbstractRoutingDataSource() {

    override fun determineCurrentLookupKey() =
        if (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) SLAVE else MASTER
}

Spring에서 제공하는 AbstractRoutingDataSource 클래스가 바로 Datasource 동적으로 사용할 수 있게 하는 핵심 클래스입니다.

 

determineCurrentLookupKey를 이용해 라우팅할 DataSource의 키를 선택하는 isCurrentTransactionReadOnly() 메소드를 통해 트랜잭션의 readOnly 여부를 알아내서 readOnly라면 slave DB로, readOnly가 아니라면 master DB로 라우팅되도록 상수를 반환합니다.

 

package com.flabedu.blackpostoffice.commom.config

import com.flabedu.blackpostoffice.commom.utils.constants.MASTER
import com.flabedu.blackpostoffice.commom.utils.constants.SLAVE
import com.zaxxer.hikari.HikariDataSource
import org.mybatis.spring.SqlSessionFactoryBean
import org.mybatis.spring.SqlSessionTemplate
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.jdbc.DataSourceBuilder
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Primary
import org.springframework.jdbc.datasource.DataSourceTransactionManager
import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy
import org.springframework.transaction.annotation.EnableTransactionManagement
import javax.sql.DataSource

@Configuration
@EnableTransactionManagement
class DatasourceConfig {

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.hikari.master")
    fun masterDataSource() = DataSourceBuilder.create().type(HikariDataSource::class.java).build()

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.hikari.slave")
    fun slaveDataSource() = DataSourceBuilder.create().type(HikariDataSource::class.java).build()

    @Bean
    fun routingDataSource(
        @Qualifier("masterDataSource") masterDataSource: DataSource,
        @Qualifier("slaveDataSource") slaveDataSource: DataSource,

    ) = RoutingDataSourceConfig().apply {

        setTargetDataSources(
            HashMap<Any, Any>().apply {
                this[MASTER] = masterDataSource
                this[SLAVE] = slaveDataSource
            }
        )
        setDefaultTargetDataSource(masterDataSource)
    }

    @Bean
    @Primary
    fun dataSource(@Qualifier("routingDataSource") routingDataSource: DataSource) =
        LazyConnectionDataSourceProxy(routingDataSource)

    @Bean
    fun transactionManager(@Qualifier("dataSource") dataSource: DataSource) = DataSourceTransactionManager(dataSource)

    @Bean
    fun sqlSessionTemplate() = SqlSessionTemplate(sqlSessionFactory())

    @Bean
    fun sqlSessionFactory() = SqlSessionFactoryBean().apply {
        setDataSource(dataSource(routingDataSource(masterDataSource(), slaveDataSource())))
    }.`object`
}

 master 및 slave DataSource는 ConfigurationProperties를 통해 설정값을 바인딩하여 간단하게 만들 수 있습니다. 그리고 아까 만들었던 RoutingDataSourceConfig에 master와 slave DataSource를 타겟으로 추가해줍니다.

 

여기서 중요한 것은 spring에서는 원래 Transaction 동기화 이전에 DataSource에서 Connection을 획득합니다. 하지만 우리는 Transaction 동기화 이후 실제 쿼리 호출 시 DataSource를 정하고 Connection을 획득해야만 하기 때문에 문제가 발생합니다.

 

따라서, LazyConnectionDataSourceProxy를 통해 routingDataSource를 주고 Tracnsaction 동기화 이전 Connection Proxy 객체를 획득한 후 이 후 쿼리가 호출될 때에 DataSource를 정하고 Connection을 획득할 수 있도록 늦춰주었습니다.

 

또한 스프링 컨테이너가 여러개의 빈을 찾았을 때, 추가적으로 판단할 수 있는 정보를 주기 위해 @Qualifier("찾는이름")을 작성해주었습니다.

 

spring:
	datasource:
		hikari:
		  master:
			jdbc-url: jdbc:mysql://{공인 IP 주소}:3307/black_postoffice?autoReconnect=True&useSSL=false&characterEncoding=UTF-8&serverTimezone=UTC
			username: root
			password: 1234
			driver-class-name: com.mysql.cj.jdbc.Driver
		  slave:
			jdbc-url: jdbc:mysql://{공인 IP 주소}:3307/black_postoffice?autoReconnect=True&useSSL=false&characterEncoding=UTF-8&serverTimezone=UTC
			username: root
			password: 1234
			driver-class-name: com.mysql.cj.jdbc.Driver

 application-local.yml 설정은 아래와 같이 master와 slave를 나눠주었습니다. 만약 slave db가 더 많이 존재할 경우에는 slave-list를 두고 url만 작성해 사용하면 될 것 같습니다.

 

    @Transactional(readOnly = true)
    fun getPosts(email: String, pageNo: Int, pageSize: Int) = Posts(
        nickName = userMapper.getNickName(email),
        profileImagePath = userMapper.getProfileImage(email),
        posts = postMapper.getPosts(email, pageNo, pageSize)
            .map { Post(title = it.title, content = it.content) }
    )

조회 메소드에 위와 같이 readOnly 옵션이 true인 Transactional 어노테이션을 붙여주면 slave DB로 조회 쿼리가 날아가게 됩니다.

 

이는 MySQL 서버에 접속하여 master 서버에 쓰기 요청을 보낸 후, 다시 slave 서버에 접속하여 데이터를 조회해보면 실제 master 서버와 slave 서버의 동기화가 이루어진 것을 확인할 수 있습니다.

 

위는 코틀린 기반이기 때문에 자바 언어로 코드를 보고싶으신 분은 인텔리제이에서 "Kotlin Bytecode"을 이용해 자바 코드로 변환해서 읽어보시면 될 것 같습니다.

 

🔍 모니터링을 통해 master 서버와 slave 서버간의 동기화 확인

마지막으로 master 서버와 slave 서버간의 실제 동기화가 이루어지는 것을 모니터링을 통해 실시간으로 확인할 수 있는 방법이 있는데요, 여기 MySQL 서버 이미지 사용 가이드에서 맨 아래의 SQLSTATUS 목차를 읽어보시면 될 것 같습니다. 우리는 CLI(Command Line Interface) 형태의 모니터링을 통하여 MySQL의 내부의 1초간의 MySQL Status를 Display를 하여 Real-Time으로 DB의 상태 변화를 볼 수 있는데요,

위의 사진과 같이 master 서버와 slave 서버에서 각각 접속하여 각각의 모니터링 화면을 띄운 후에, 실제 I/O를 발생시켜 확인해보시는 것까지가 Master/Slave DataSource 동적 라우팅 설정의 마지막 절차라고 할 수 있습니다.

 

긴 글 읽어주셔서 감사합니다. 더욱 자세한 내용을 보시려면

https://github.com/f-lab-edu/black-postoffice

 

GitHub - f-lab-edu/black-postoffice: 익명으로 편하게 고민, 일상을 공유하는 소셜 네트워크 서비스입니

익명으로 편하게 고민, 일상을 공유하는 소셜 네트워크 서비스입니다. Contribute to f-lab-edu/black-postoffice development by creating an account on GitHub.

github.com

참고해주시면 될 것 같습니다.

728x90
반응형
Comments