트러블슈팅

Spring: 다중 DB 연결 이슈와 아키텍처 설계

Sfer7 2025. 2. 18. 15:37
1. 배경
  • Master/Slave로 이중화된 DB에 Spring 서버에서 연결을 해야 하는 상태
  • Patroni로 이루어진 Cluster는 Etcd를 통해 Failover가 이루어질 수 있어 Master와 Slave는 고정되지 않음
  • Spring 서버에서 API 호출 시 성격에 따라 적절한 노드로 가도록 조치가 필요

 

2. 해결 시도 과정 및 문제 상황
  • Spring Scheduler + Redis를 통한 주기적 캐싱 (1)
    - 30초 간격으로 Spring Scheduler를 호출해 Patroni API를 호출하여 Master와 Slave 노드의 주소를 획득
    - 획득한 주소를 Redis에 저장하여 API 호출 시 캐싱된 데이터를 빠르게 가져올 수 있도록 함
    - 30초 사이에 Failover가 일어나 변경되었을 때를 고려해 API 호출 시 검증 및 재처리 로직을 추가

    처음에는 이러한 방식을 생각했다가 금방 생각을 바꾸게 되었어요
    현재는 1:1로 DB 이중화가 이루어져 있지만, 추후에는 1:N 형태로 Scale-Out이 필요하게 되고,
    Scale-Out이 된 노드에 대해서도 항시 관리가 이루어져야 하니 굉장히 비효율적이라고 생각했습니다 🥲
    때문에 더 나은 방법이 없는지 조사를 진행하게 되었어요

  • HAProxy를 이용한 노드 주소 고정 - 2 Port 기반 (2)
    - HAProxy는 Patroni Cluster와 연동해 자동으로 Master/Slave로의 라우팅 지원
    - 별도로 Master/Slave에 대한 정보를 관리할 필요가 없어짐

    두 번째로는 HAProxy라는 것이 존재한다는 것을 알았습니다
    이걸 사용하면 보다 편리하게 관리할 수 있겠구나 ! 생각했어요
    이 방법을 사용하면 Failover가 일어나더라도 알아서 Master/Slave로의 라우팅이 되니까요

    하지만 이렇게 구성하게 되는 경우 DataSource가 2개로 나뉘게 됩니다

    DataSource가 여러 개인 경우, 이에 맞춰 EntityManagerFactory와 TransactionManager를 동적으로 바꿔줘야 했습니다
    물론 처음에는 무조건 이렇게 해야 하는 줄 알고 여러 삽질을 했지만, 나중엔 더 쉬운 해결법이 있긴 했어요 😭😭 
    어쨋든, 해당 방식은 내부적으로 어떻게 돌아가는지 정확히 이해하지 못하면 문제가 생겼을 때 대처가 어렵다고 생각했습니다

    그렇다면 Port를 1개로 줄일 수 있다면 DataSource를 하나만 사용하는 것처럼 관리할 수 있지 않을까 싶었어요

  • HAProxy - 1 Port 라우팅 시도 (3)
    - HAProxy와 JDBC 연결 시 적절한 값을 보내 HAProxy 내에서 Payload 검출로 찾아내는 방식 시도
    - 검출된 Payload에 따라 적절히 Master/Slave로 라우팅되도록 설정

    이 방법을 시도해봤지만, JDBC URL로 보낸 Payload값은 검출할 수 없었습니다 🥲
    아무리 옵션값을 보내도 Payload에는 아무 것도 전달되지 않았어요
    이때까지만 해도 저는 이게 단순히 HAProxy 자체에서 지원하지 않는 것이라고 생각했습니다

    그럼 HAProxy 앞단에 프록시 서버를 하나 더 두고 해당 서버에서 검출시켜 HAProxy에 라우팅하는건 어떨까 생각했습니다

  • HAProxy + Nginx (4)
    - JDBC URL 또는 Header를 통해 값을 전달하여 라우팅 시도

    이것 역시 실패했습니다
    당연한 것이었지만, JDBC는 TCP를 사용하기 때문에 연결 시도를 위해서는 TCP를 사용해야 했고
    Payload를 검출하지 못하는 것은 TCP의 패킷 특성 상 URL로 데이터를 전달하는 것이 어려웠기 때문이었습니다
    그리고 Header와 같은 경우는 HTTP를 통해 사용할 수 있는 기능이므로, 이 역시 JDBC 연결에는 사용할 수 없었습니다

  • 2 Port + 단일 DataSource (5)
    - AbstractRoutingDataSource 클래스를 이용해 단일 DataSource로 관리
    - AOP를 이용해 적절히 라우팅
    - DefaultDataSource를 Master로 지정해 ddl-auto가 Master Node에 대응하도록 구성

    EntityManagerFactory, TransactionManager를 DataSource에 따라 분리해야 하는 이런 상황을 대비하여
    Spring에서는 다중 DataSource를 단일처럼 이용할 수 있게 지원해 주고 있었습니다

    이와 같은 패키지로 구성하게 되었습니다

    처음에는 @Transactional(readOnly = true)에 따라서 라우팅을 해주려고 생각했지만 여러 문제들이 있었습니다

    1) Connection Pool 문제
      노드 분리를 위해 모든 Service에 대해 @Transactional을 달아야 함
      이는 Connection Pool 고갈 문제로 이어질 수 있음
    2) @Transactional 실행 순서 문제
      @Transactional을 실행한 이후에 readOnly 여부에 따라 DB를 정하려고 생각
      하지만 Transactional은 실행 시점에 DB를 지정
      때문에 readOnly 옵션을 보고 DB를 지정할 수는 없음 (연결 이후에는 DB를 바꿀 수 없기 때문)
      실제로 잠시 후에 보게 될  determineLookupKey 라는 메소드에서는 이를 인식하지 못함
      때문에 @Transactional 실행 이전에 DB를 정해주는 로직이 필요함

    위의 두 가지를 고려하여, 별도의 AOP를 생성하여 이를 해결하기로 했습니다 😊

    // RoutingDataSource.java
    
    @Slf4j
    public class RoutingDataSource extends AbstractRoutingDataSource {
    
        private static final ThreadLocal<String> DATA_SOURCE_CONTEXT_HOLDER = new ThreadLocal<>();
    
        public static void setDataSourceType(String key) {
            DATA_SOURCE_CONTEXT_HOLDER.set(key);
        }
    
        public static String getDataSourceType() {
            return DATA_SOURCE_CONTEXT_HOLDER.get();
        }
    
        public static void clear() {
            DATA_SOURCE_CONTEXT_HOLDER.remove();
        }
    
        @Override
        protected Object determineCurrentLookupKey() {
            String currentDataSource = DATA_SOURCE_CONTEXT_HOLDER.get();
    
            if(DATA_SOURCE_CONTEXT_HOLDER.get() == null || DATA_SOURCE_CONTEXT_HOLDER.get().equals(MASTER.getDescription())) {
                currentDataSource = MASTER.getDescription();
                log.info("Current DataSource: {}", currentDataSource);
                return currentDataSource;
            } else if (DATA_SOURCE_CONTEXT_HOLDER.get().equals(SLAVE.getDescription())) {
                log.info("Current DataSource: {}", currentDataSource);
                return currentDataSource;
            }
    
            throw new RuntimeException();
        }
    }
    
    // PatroniDataSourceType.java
    
    @Getter
    @RequiredArgsConstructor
    public enum PatroniDataSourceType {
        MASTER("master"),
        SLAVE("slave");
    
        private final String description;
    }

    RoutingDataSource는 앞서 말한 AbstractRoutingDataSource에게 상속받습니다
    이때 determineCurrentLookupKey 메소드는 이후 DataSource를 정할 때 사용되는 메소드입니다
    Default일 때, 즉 null일 때는 Master 노드로 연결되도록 지정합니다
    그리고 명시적으로 Slave로 가라고 되어있는 경우 Slave에 연결되도록 합니다

    // DataSourceConfig.java
    
    @Configuration
    public class DataSourceConfig {
    
        @Bean
        @Primary
        public DataSource dataSource(
                @Qualifier("masterDataSource") DataSource masterDataSource,
                @Qualifier("slaveDataSource") DataSource slaveDataSource) {
            Map<Object, Object> targetDataSources = new HashMap<>();
            targetDataSources.put(MASTER.getDescription(), masterDataSource);
            targetDataSources.put(SLAVE.getDescription(), slaveDataSource);
    
            RoutingDataSource routingDataSource = new RoutingDataSource();
            routingDataSource.setTargetDataSources(targetDataSources);
            routingDataSource.setDefaultTargetDataSource(masterDataSource);
    
            return routingDataSource;
        }
    
        @Bean(name = "masterDataSource")
        @ConfigurationProperties(prefix = "spring.datasource.patroni-master")
        public DataSource masterDataSource() {
            return DataSourceBuilder.create().build();
        }
    
        @Bean(name = "slaveDataSource")
        @ConfigurationProperties(prefix = "spring.datasource.patroni-slave")
        public DataSource slaveDataSource() {
            return DataSourceBuilder.create().build();
        }
    }
     
    작성된 RoutingDataSource는 Config단에서 사용하게 될텐데, 이곳에서 Default는 Master로 가도록 지정해줍니다
    초회 연결에서는 이와 같이 설정해둬야 ddl-auto가 Master 노드에 적용됩니다

    그럼 Slave를 어떻게 명시적으로 지정하느냐? 이 부분에서 AOP를 사용하게 됩니다 😀

    // UseSlave.java
    
    @Target({ElementType.METHOD, ElementType.TYPE})
    @Retention(RetentionPolicy.RUNTIME)
    public @interface UseSlave {
    }
    
    // DataSourceRoutingAspect
    
    @Component
    @Aspect
    @Order(Ordered.HIGHEST_PRECEDENCE)
    @Slf4j
    public class DataSourceRoutingAspect {
    
        @Around("@annotation(패키지_주소.annotation.UseSlave)")
        public Object routeToSlave(ProceedingJoinPoint joinPoint) throws Throwable {
            RoutingDataSource.setDataSourceType(SLAVE.getDescription());
    
            Object result;
    
            try {
                result = joinPoint.proceed();
            } finally {
                RoutingDataSource.clear();
            }
    
            return result;
        }
    }

    일단 @UseSlave라는 커스텀 어노테이션을 생성해줍니다
    그리고 @UseSlave가 어떻게 작동할 것인지 Aspect로 설정해줍니다

    UseSlave는 RoutingDataSource에서의 ThreadLocal에 Slave를 지정합니다
    그리고 해당 요청이 종료되게 되면 ThreadLocal에 지정된 데이터를 지워줍니다 (메모리 누수 방지)

    또한 @Order(Ordered.HIGHEST_PRECEDENCE)를 통해 @UseSlave가 가장 먼저 실행되게 지정합니다
    이를 통해 @Transactional보다 @UseSlave가 먼저 지정되어 트랜잭션이 시작되기 전에 DB가 적절히 선택됩니다

    이와 같은 과정을 통해 해결할 수 있었고, 적절히 라우팅되는지 테스트를 해보면 !

    Insert가 있는 API의 경우 Master로, Select만 있는 API의 경우 Slave로 잘 가도록 설정된 것을 볼 수 있습니다 🥰

 

3. 마치며

 

Spring에서 다중 DB에 대해 단일 DataSource를 지원해주는 클래스가 있음을 알았다면 더 빨리 해결할 수 있었을텐데
엄청난 삽질을 해가며 해결하게 됐더랬죠.. 😭

그래도 이러한 시도들을 하면서 모르는 부분들을 더욱 알아갈 수 있었고,
Spring 뿐만이 아니라 TCP, JDBC, HAProxy 등 네트워크나 DevOps 분야에 대한 이해도도 많이 높일 수 있는 작업이었습니다

그럼 다음엔 더 유익한 트러블슈팅 내용으로 찾아오도록 할게요 🥰