ecsimsw

DataSource 헬스 체크와 동적 라우팅으로 DB 서버 다운 대비하기 본문

DataSource 헬스 체크와 동적 라우팅으로 DB 서버 다운 대비하기

JinHwan Kim 2023. 12. 23. 04:39

만약 DB 서버가 다운된다면?

이전 글 에서 DB 데이터 백업과 부하분산을 목적으로 DB replication 을 적용하고 Transactional readOnly 여부에 따라 DataSource 를 분기했다.

 

이 글에선 Master, Replica 중 하나라도 Connection 에 문제가 생기는 상황을 고민한다. DB 서버를 단순히 쿼리가 readOnly 인지 여부만으로 분기한다면 둘 중 하나라도 문제가 생길 경우 서비스의 읽기가 안되거나 쓰기가 안 되는 심각한 문제가 발생할 것이다. 

 

다른 Stand by Master node 를 만들어두고 Master 가 다운 되었을 때 교체하는 방식, Master 가 죽었을 때 Slave 를 Master 로 승격시키는 방식 등 여러 정책을 고민했다. 그중에서도 아래 세 가지 요구 사항을 만족할 수 있는 것이 새로운 정책에 가장 중요한 포인트였다.

 

1. 인프라 리소스 추가를 가급적 안할 것 / 인프라 수정 사항이 최대한 간단할 것 

2. Down 된 DB 서버를 복구했을 때 DB 복제나 Application 재실행 없이 서비스가 지속될 것

3. 지속적으로 DataSource 상태를 확인하고 문제시 바로 알람을 받을 수 있을 것

 

앞선 문제 사항과 요구 사항으로 구현한 새로운 정책은 다음과 같다.

 

1. Slave 에 문제가 생긴다면 Read 쿼리까지 Master 에서 모두 처리하도록 한다. 마치 scale-in 하는 것처럼 DB 부하 분산의 성능은 줄겠지만 서비스 자체에는 문제가 없도록 하는 것이다.

 

 

 

 

2. Master 에 문제가 생긴다면 서비스에서 발생하는 Update 는 서버 에러를 응답하며 DB 서버 복구를 기다리고, Slave 로 처리할 수 있는 Read 요청만 정상 운영한다. 

 

 

 

 

3. N초마다 한번씩 DB 서버를 헬스 체크하고 이를 로깅 / 알림 한다. 서버 상태를 App 에 저장하고 지속적으로 업데이트해야 쿼리를 분산할 때 서버 상태에 따른 DataSource 를 결정할 수 있고 서버가 복구되었을 때 App 재실행 없이 동적으로 DataSource Routing 이 변경될 수 있다.

 

 

 

 

이 정책을 구현하기 위해 필요한 다섯가지 개념과 이를 어떻게 사용했는지를 소개한다.

 

1. DataSourceHealthIndicator, DataSource 상태 조회

2. ConcurrentHashMap, DB 서버 상태 저장

3. AbstractRoutingDataSource, DataSource routing 규칙 정의

4. LazyConnectionDataSourceProxy, getConnection 지연 

5. ThreadLocal, DataSourceTarget Context 정의

 

1. DataSourceHealthIndicator, DataSource 상태 조회

가장 먼저 N 초에 한번씩 DataSource의 상태를 확인하는 스케줄러를 정의했다. 

 

DB 서버를 확인하려면 어떻게 해야 할까. 우선 DB Connection pool 을 사용하고 있다면 이를 사용해야 할 것이다. 헬스 체크할 때마다 커넥션을 만들면 안 될 테니 말이다. 쿼리는 간단하다. 일반적으로 "Select 1" 으로 무의미한 값을 응답받거나, "Select NOW()"로 서버 현재 시간을 응답받는 방식을 많이 사용하는 것 같다. 

 

내 경우에는 Spring actuator 의 DataSourceHealthIndicator 를 사용했다. 내부는 JdbcTemplate 을 사용해서 Connection pool 을 그대로 사용하면서도, 다른 예외처리나 복잡한 쿼리 수행 코드 없이 DataSource를 인자로 쉽게 서버 상태를 확인할 수 있다.

 

@Scheduled(fixedDelay = 1000)
public void healthCheck(
    @Qualifier(value = DataSourceConfig.DB_SOURCE_BEAN_ALIAS_MASTER)
    DataSource dataSourceMaster
) {
    var indicator = new DataSourceHealthIndicator(dataSourceMaster);
    var healthMaster = indicator.getHealth(false);
    assert healthMaster.getStatus() == Status.UP;
}

 

2. ConcurrentHashMap, DB 서버 상태 저장

애플리케이션 전역에서 사용할 수 있는 서버 상태를 저장한다. ConcurrentHashMap 을 사용해서 다중 요청에서도 안전할 수 있도록 하였다. 이 값으로 DB 서버의 상태를 읽어 DataSource Routing Target 을 결정할 때 이 값이 사용된다.

 

WAS가 여러 개인데 공유 메모리를 사용하지 않아도 될까 고민했다. 장단점이 있는 것 같다. 공유 메모리를 사용하면 WAS 별 DataSource 상태의 싱크가 맞춰진다는 장점이 있을 것이다. 다만 각 모든 WAS에서 짧은 시간으로 스케줄링하여 정보를 업데이트하고 있어 싱크가 안 맞더라도 1초도 안 되는 작은 시간 차이일 것인데, 그를 위해 매번 쿼리가 발생할 때마다 이 공유 메모리를 사용함이 비효율적이라고 생각했다. 

 

각 WAS 별 메모리에 관리하기로 하고, 그 데이터 수가 고정적이고 교체나 evict 가 필요없는 상황이니 또 다른 캐시를 위한 라이브러리를 추가하기 보다 ConcurrentHashMap 으로 간단히 관리하고 싶었다.

 

private static final ConcurrentMap<DataSourceType, Status> STATUS_MAP = new ConcurrentHashMap<>();

static {
    Arrays.stream(DataSourceType.values())
        .forEach(it -> STATUS_MAP.put(it, Status.UNKNOWN));
}

 

3. AbstractRoutingDataSource, DataSource routing 규칙 정의

어떤 DataSource 에 라우팅 될지 규칙을 정의한다. TransactionManager와 앞서 정의한 각 서버별 서버 상태 데이터를 사용할 수 있겠다. 

 

예를 들어 두 서버가 모두 Up 상황이라면 현재 수행 중인 트랜잭션의 ReadOnly 여부에 따라 Master, Slave 로 전송할 쿼리를 나눌 수 있을 것이다. 코드가 길어서 예제에는 추가하지 않았지만 각 DataSource의 서버 상태별 라우팅 규칙을 여러 분기로 정의했다.

 

public class DataSourceRoutingRule extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        var isReadOnlyQuery = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
        if (isReadOnlyQuery && !DataSourceHealth.isUp(SLAVE)) {
            return SLAVE;
        }
        if (!isReadOnlyQuery && !DataSourceHealth.isUp(MASTER)) {
            throw new DataSourceConnectionDownException("Server can read only now");
        }
        return MASTER;
    }
}

 

4. LazyConnectionDataSourceProxy, getConnection 지연 

스프링에서 기본적으로는 트랜잭션이 진입한 시점에서 DataSource 로부터 Connection을 받아온다. 이는 DB Connection 주기를 길게 하고, Connection 이 불필요한 상황에서도 Connection을 물고 있게 하며 쿼리 정보로 DataSource를 결정할 수 없도록 한다.

 

LazyConnectionDataSourceProxy 를 사용하여 Connection 사용 필요의 시점에 DataSource 로부터 Connection을 받게 된다. 이에 따라 Connection 주기를 짧게하고 불필요한 상황에서 Connection을 받아오지 않는다. Read 의 DataSource와 Write 의 DataSource를 동적으로 나눌 때처럼 트랜젝션 정보나 외부 상태에 따라 DataSource를 분산할 수 있도록 한다.

 

@Bean
public DataSource routingDataSource(
    @Qualifier(DB_SOURCE_BEAN_ALIAS_MASTER) DataSource masterDataSource,
    @Qualifier(DB_SOURCE_BEAN_ALIAS_SLAVE) DataSource slaveDataSource
) {
    var routingDataSource = new DataSourceRoutingRule();
    routingDataSource.setDefaultTargetDataSource(masterDataSource);
    routingDataSource.setTargetDataSources(Map.of(
        MASTER, masterDataSource,
        SLAVE, slaveDataSource
    ));
    return routingDataSource;
}

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

 

5. ThreadLocal, DataSourceTarget Context 정의

DataSourceHealthIndicator 는 Jdbc template 을 사용하기에 앞서 정의한 Routing dataSource 를 그대로 따른다. 따라서 indicator 의 소스를 어떤 것으로 하는지와 관계없이 Routing rule 에 의해 target DataSource가 결정된다.

 

이를 고려하지 않으면 DataSourceHealthIndicator 에 지정한 DataSource가 아닌 라우터에 의해 결정된 타켓의 상태를 확인하는 엉뚱한 헬스 체크가 된다. 헬스 체크에서 사용하는 쿼리는 ReadOnly 가 아니기 때문에 DataSource를 Slave 로 하더라도 라우팅 룰에 의해 항상 Master 의 상태를 체크하게 된다.

 

RoutingKey 를 찾을 때 TransactionManager 로 현재 트랜젝션의 ReadOnly 여부를 확인한 것처럼 Routing 시에 사용된 스레드에서 라우팅하길 바라는 DataSource가 있는지 확인한다. 그리고 만약 있다면 이 source 로 쿼리를 분산할 것이다. 

 

protected Object determineCurrentLookupKey() {
    var hasRoutingTarget = DataSourceTargetContext.getTarget();
    if(hasRoutingTarget.hasValue()) {
        return hasRoutingTarget.get();
    }
    var isReadOnlyQuery = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
    ...
}

 

ThreadLocal 로 스레드 별 TargetDataSource 를 따로 관리하는 것으로 이 값이 공유되는 상황을 피할 수 있었다. 아래는 그 예시이다.

 

public class DataSourceTargetContextHolder {

    private static final ThreadLocal<DataSourceType> dataSourceTargetContext = new ThreadLocal<>();

    public static void setContext(DataSourceType dataSourceType) {
        dataSourceTargetContext.set(dataSourceType);
    }

    public static Optional<DataSourceType> getTargetContext(){
        final DataSourceType targetSource = dataSourceTargetContext.get();
        if(targetSource == null) {
            return Optional.empty();
        }
        return Optional.of(targetSource);
    }

    public static void clearContext(){
        dataSourceTargetContext.remove();
    }
}

 

이 Context에서 DataSourceKey 를 가져오는 것으로 현재 스레드에서 사용하고자 하는 DataSource 를 구체적으로 정할 수 있다. 

 

앞서 설정한 DataSourceRoutingRule 에 구체적인 DataSource 가 있으면 다른 규칙보다 앞서 해당 DataSource로 routing 하는 규칙을 추가한다. 

 

public class DataSourceRoutingRule extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        var dsTarget = DataSourceTargetContextHolder.getTargetContext();
        if (dsTarget.isPresent()) {
            return dsTarget.get();
        }
        ...
    }
}

 

헬스 체크를 위한 쿼리 전/후에 이 Context 값을 지정하는 것으로, 이제는 ReadOnly 여부와 상관없이 지정된 DataSource 에 쿼리를 날려 서버 상태를 확인할 수 있게 되었다.

 

@Scheduled(fixedDelay = 1000)
public void healthCheck() {
    DataSourceTargetContextHolder.setContext(MASTER);
    var healthMaster = indicatorMaster.getHealth(false);
    STATUS_MAP.put(MASTER, healthMaster.getStatus());
    DataSourceTargetContextHolder.clearContext();
}

 

 ThreadLocal 을 사용할 땐 Thread 가 재사용될 때 이 값이 남아있는 경우를 항상 주의해야 한다. 

 

마무리

이렇게 LazyConnectionDataSourceProxy 를 사용한 Replication 환경에서 DataSource target 을 구체화하고 헬스 체크와 DB 문제시 라우팅 방향을 동적으로 바꾸는 방법을 실습했다. 

 

Replication / DB 쿼리 분산은 서버 개발에서 매우 중요한 개념과 규칙이라고 생각한다. 서버를 관리하는데 있어 데이터 백업은 빼놓을 수 없이 필수적이라고 생각하고 많은 사용자 요청을 처리하려면 DB 부하분산이 꼭 필요할테니 말이다.

 

이 글은 이렇게 중요한 DB 쿼리 분산 구조에서 각 DB 서버가 다운되었을 때의 처리가 부족하다면, 백업과 부하 분산을 위해 서비스 전체가 다운될 위험 있는 오히려 위험한 구조가 될 수 있음을 보이고 싶단 생각에서 시작한 글이다. 그리고 분산의 원리를 이해하면 여러 정책으로 서버가 다운되었을 때에 대한 처리도 쉽게 구현할 수 있음을 실습으로 보여드리고 싶었다. 

 

설정 코드가 길고 반복되어 예제 코드를 많이 축소하였다. 아래 전체 코드를 확인할 수 있다.

끝까지 읽어주셔서 감사합니다.

 

전체 코드 보기

더보기

 

1. DataSourceTargetContextHolder, ThreadLocal 로 직접 지정할 라우팅 DataSource 를 저장

 

public class DataSourceTargetContextHolder {

    private static final ThreadLocal<DataSourceType> dataSourceTargetContext = new ThreadLocal<>();

    public static void setContext(DataSourceType dataSourceType) {
        dataSourceTargetContext.set(dataSourceType);
    }

    public static Optional<DataSourceType> getTargetContext(){
        final DataSourceType targetSource = dataSourceTargetContext.get();
        if(targetSource == null) {
            return Optional.empty();
        }
        return Optional.of(targetSource);
    }

    public static void clearContext(){
        dataSourceTargetContext.remove();
    }
}

 

2. DataSourceRoutingRule, 프로젝트에서 사용한 DataSource 라우팅 규칙

 

public class DataSourceRoutingRule extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        if (DataSourceTargetContextHolder.getTargetContext().isPresent()) {
            return DataSourceTargetContextHolder.getTargetContext().get();
        }
        var isReadOnlyQuery = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
        if (isReadOnlyQuery && !DataSourceHealth.isUp(SLAVE)) {
            return SLAVE;
        }
        if (!isReadOnlyQuery && !DataSourceHealth.isUp(MASTER)) {
            throw new DataSourceConnectionDownException("Server can read only now");
        }
        return MASTER;
    }
}

 

3. DataSourceHealth, DataSource 헬스 체크 스케줄러와 상태 저장 

 

@Component
public class DataSourceHealth {

    private static final ConcurrentMap<DataSourceType, Status> STATUS_MAP = new ConcurrentHashMap<>();

    static {
        Arrays.stream(DataSourceType.values())
            .forEach(it -> STATUS_MAP.put(it, Status.UNKNOWN));
    }

    private final DataSourceHealthIndicator indicatorMaster = new DataSourceHealthIndicator();
    private final DataSourceHealthIndicator indicatorSlave = new DataSourceHealthIndicator();

    public DataSourceHealth(
        @Qualifier(value = DataSourceConfig.DB_SOURCE_BEAN_ALIAS_MASTER)
        DataSource dataSourceMaster,
        @Qualifier(value = DataSourceConfig.DB_SOURCE_BEAN_ALIAS_SLAVE)
        DataSource dataSourceSlave
    ) {
        indicatorMaster.setDataSource(dataSourceMaster);
        indicatorSlave.setDataSource(dataSourceSlave);
    }

    @Scheduled(fixedDelay = 1000)
    public void healthCheck() {
        DataSourceTargetContextHolder.setContext(MASTER);
        var healthMaster = indicatorMaster.getHealth(false);
        if (healthMaster.getStatus() != Status.UP) {
            throw new DataSourceConnectionDownException(MASTER + " is down, " + healthMaster.getStatus());
        }
        STATUS_MAP.put(MASTER, healthMaster.getStatus());

        DataSourceTargetContextHolder.setContext(SLAVE);
        var healthSlave = indicatorSlave.getHealth(false);
        if (healthSlave.getStatus() != Status.UP) {
            throw new DataSourceConnectionDownException(SLAVE + " is down, " + healthSlave.getStatus());
        }
        STATUS_MAP.put(SLAVE, healthSlave.getStatus());

        DataSourceTargetContextHolder.clearContext();
    }

    public static boolean isUp(DataSourceType dataSourceType) {
        return STATUS_MAP.get(dataSourceType) == Status.UP;
    }
}

 

4. DataSourceConfig, DataSource 설정

 

@Configuration
public class DataSourceConfig {

    public static final String DB_SOURCE_BEAN_ALIAS_MASTER = "MASTER_DB_SOURCE";
    public static final String DB_SOURCE_BEAN_ALIAS_SLAVE = "SLAVE_DB_SOURCE";

    @Bean(value = DB_SOURCE_BEAN_ALIAS_MASTER)
    @ConfigurationProperties(prefix = "spring.datasource.master")
    public DataSource masterDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Bean(value = DB_SOURCE_BEAN_ALIAS_SLAVE)
    @ConfigurationProperties(prefix = "spring.datasource.slave")
    public DataSource slaveDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Bean
    public DataSource routingDataSource(
        @Qualifier(DB_SOURCE_BEAN_ALIAS_MASTER) DataSource masterDataSource,
        @Qualifier(DB_SOURCE_BEAN_ALIAS_SLAVE) DataSource slaveDataSource
    ) {
        var routingDataSource = new DataSourceRoutingRule();
        routingDataSource.setDefaultTargetDataSource(masterDataSource);
        routingDataSource.setTargetDataSources(Map.of(
            MASTER, masterDataSource,
            SLAVE, slaveDataSource
        ));
        return routingDataSource;
    }

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

 

5. DB 연결 정보

 

# datasource - mysql dev

## master
spring.datasource.master.username=root
spring.datasource.master.password=password
spring.datasource.master.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.master.jdbc-url=jdbc:mysql://localhost:13301/picup?useSSL=false&allowPublicKeyRetrieval=true

## slave
spring.datasource.slave.username=root
spring.datasource.slave.password=password
spring.datasource.slave.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.slave.jdbc-url=jdbc:mysql://localhost:13302/picup?useSSL=false&allowPublicKeyRetrieval=true
Comments