ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Spring Multi Datasource(다중 데이터소스) 설정
    Spring 2022. 2. 10. 15:17
    반응형

    믿기지 않지만 본 글의 작성을 위한 초석(?)으로

    을 선행 작성 하였다.

    먼저 Datasource와 Transaction을 구성하는 여러 가지 방법이 있지만 대략적으로 두 가지를 소개 하자면

    단일 Datasource + Transaction

    하나의 Transaction에 DataSoruce를 하나 포함 하는 기본적인 구성이다.

    복수 Datasoruce + 단일 Transaction

    하나의 Transaction에 복수 의 DatasSource를 포함하는 구성이다.

    해당 구성이 이번 글에서 구성할 설정이다. 이와 같이 구성하는 목적은 위 초석(?) 글의 구성 처럼

    • Master : Insert, Update, Delete

    • Slave : Select

    같이 DB 작업이 분산되어 있는 경우다.

    본 글에서는 DataSource를 HikariDataSource 외에

    • ReplicationRoutingDataSource 복수의 DataSource를 제어하게끔 도와주는 DataSource 구현체이다.

    • LazyConnectionDataSourceProxy 클래스명에서 알 수 있듯이 게으른(?) 연결이다.

      트랜잭션이 발생 될때 마다 어떤 DataSource를 사용해야 하는지 실시간으로 처리 하게끔 도와주는 DataSource 구현체이다.

    사용하였다.

    위 구성은 아마도 같은 종류의 DB를 사용하는 경우일텐데 서로 다른 DB를 사용해야 하는 경우라면

    • ChainedTransactionManager - Spring

    • JTA(Java Transaction API) - Java

    중 하나를 택일 하면 될텐데 ChainedTransactionManager가 deprecated(혹은 제거?) 상태이기 때문에 JTA를 사용하는걸 추천.

    환경

    본 글에 Database는 위 초석(?)글을 이용하여 생성 및 구성하였다.

    Server

    Application

    • Java 11

    • Spring boot 2.6.3

      • mybatis-spring-boot-starter 2.2.1

      • postgresql 42.3.1

    DB

    예제 테이블 구조

    예제로 매우 간단한 테이블이다. 아래 스키마와 예제 데이터 쿼리는 본 글 저장소 예제 프로젝트에 data.sql, schema.sql 파일로 있다.

    스키마

    ALTER TABLE goods DROP CONSTRAINT goods_fk;
    DROP TABLE IF EXISTS member;
    DROP INDEX IF EXISTS member_pk;
    DROP TABLE IF EXISTS goods;
    
    CREATE TABLE public."member" (
        member_seq bigserial NOT NULL,
        id varchar(128) NOT NULL,
        "name" varchar(20) NOT NULL,
        age int4 NOT NULL,
        reg_dtm timestamp(0) NOT NULL DEFAULT now(),
        CONSTRAINT member_pk PRIMARY KEY (member_seq)
    );
    
    CREATE TABLE public.goods (
        goods_seq bigserial NOT NULL,
        member_seq int4 NOT NULL,
        "name" varchar(20) NOT NULL,
        price float8 NOT NULL,
        reg_dtm timestamp NOT NULL DEFAULT now(),
        CONSTRAINT goods_pk PRIMARY KEY (goods_seq)
    );
    
    ALTER TABLE public.goods ADD CONSTRAINT goods_fk FOREIGN KEY (member_seq) REFERENCES public."member"(member_seq);

    예제 데이터

    INSERT INTO member(id, name, age) VALUES('data_id_1', 'data_name_1', 39);
    INSERT INTO member(id, name, age) VALUES('data_id_2', 'data_name_2', 40);
    INSERT INTO member(id, name, age) VALUES('data_id_3', 'data_name_3', 41);

    Spring

    application.yml

    단순 DataSource 접근 정보만 있다.

    spring:
      datasource:
        postgres:
          master:
            driver-class-name: org.postgresql.Driver
            jdbc-url: jdbc:postgresql://localhost:5445/postgres?stringtype=unspecified
            username: postgres
            password: password
          slave:
            driver-class-name: org.postgresql.Driver
            jdbc-url: jdbc:postgresql://localhost:5446/postgres?stringtype=unspecified
            username: postgres
            password: password

    Java Config

    DataSource 설정은 아래와 같은 패키지 구조를 가지고 있다.

    com
     └── datasource     
            └── spring
                └── config
                    └── db
                        └── postgresql
                            ├── master
                            │   └── PostgresqlMasterConfig.java
                            ├── routing
                            │   ├── PostgresqlRoutingConfig.java
                            │   └── ReplicationRoutingDataSource.java
                            └── slave
                                └── PostgresqlSlaveConfig.java

    master의 DataSource를 관리하는 PostgresqlMasterConfig 클래스는 아래와 같다.

    package com.datasource.spring.config.db.postgresql.master;
    
    import com.zaxxer.hikari.HikariDataSource;
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.core.io.ClassPathResource;
    import org.springframework.core.io.Resource;
    import org.springframework.jdbc.datasource.init.DatabasePopulator;
    import org.springframework.jdbc.datasource.init.DatabasePopulatorUtils;
    import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
    
    import javax.sql.DataSource;
    
    /**
     * <pre>
     * postgresql master DB 접속 클래스(등록, 수정, 삭제 전용)
     * </pre>
     *
     */
    @Configuration
    public class PostgresqlMasterConfig {
        private final String driverClassName;
        private final String jdbcUrl;
        private final String username;
        private final String password;
    
        public PostgresqlMasterConfig(@Value("${spring.datasource.postgres.master.driver-class-name}") String driverClassName
                , @Value("${spring.datasource.postgres.master.jdbc-url}") String jdbcUrl
                , @Value("${spring.datasource.postgres.master.username}") String username
                , @Value("${spring.datasource.postgres.master.password}") String password) {
            this.driverClassName = driverClassName;
            this.jdbcUrl = jdbcUrl;
            this.username = username;
            this.password = password;
        }
    
        /**
         * <pre>
         * master DB datasource 반환 메서드
         * </pre>
         *
         * @return master db 접속 datasource 객체
         */
        @Bean("postgresqlMasterDataSource")
        public DataSource postgresqlMasterDataSource() {
            HikariDataSource hikariDataSource = new HikariDataSource();
            hikariDataSource.setPoolName("pg-master");
            hikariDataSource.setDriverClassName(this.driverClassName);
            hikariDataSource.setUsername(this.username);
            hikariDataSource.setJdbcUrl(this.jdbcUrl);
            hikariDataSource.setPassword(this.password);
    
            Resource initSchema = new ClassPathResource("schema.sql");
            Resource initData = new ClassPathResource("data.sql");
            DatabasePopulator databasePopulator = new ResourceDatabasePopulator(initSchema, initData);
    
            DatabasePopulatorUtils.execute(databasePopulator, hikariDataSource);
    
            return hikariDataSource;
        }
    }

    postgresqlMasterDataSource 이름으로 master의 DataSource를 Bean으로 등록한다.

    테이블 생성 및 CUD 작업은 master에서 밖에 할수 없음으로 아래와 같이 테스트를 위한 스키마 생성 및 예제 데이터를 저장한다.

    Resource initSchema = new ClassPathResource("schema.sql");
    Resource initData = new ClassPathResource("data.sql");
    DatabasePopulator databasePopulator = new ResourceDatabasePopulator(initSchema, initData);
    
    DatabasePopulatorUtils.execute(databasePopulator, hikariDataSource);

    slave의 DataSource를 관리하는 PostgresqlSlaveConfig 클래스 아래와 같다.

    @Configuration
    public class PostgresqlSlaveConfig {
        private final String driverClassName;
        private final String jdbcUrl;
        private final String username;
        private final String password;
    
        public PostgresqlSlaveConfig(@Value("${spring.datasource.postgres.slave.driver-class-name}") String driverClassName
                , @Value("${spring.datasource.postgres.slave.jdbc-url}") String jdbcUrl
                , @Value("${spring.datasource.postgres.slave.username}") String username
                , @Value("${spring.datasource.postgres.slave.password}") String password) {
            this.driverClassName = driverClassName;
            this.jdbcUrl = jdbcUrl;
            this.username = username;
            this.password = password;
        }
    
        /**
         * <pre>
         * slave DB datasource 반환 메서드
         * </pre>
         *
         * @return slave db 접속 datasource 객체
         */
        @Bean("postgresqlSlaveDataSource")
        public DataSource postgresqlSlaveDataSource() {
            HikariDataSource hikariDataSource = new HikariDataSource();
            hikariDataSource.setPoolName("pg-slave");
            hikariDataSource.setDriverClassName(this.driverClassName);
            hikariDataSource.setUsername(this.username);
            hikariDataSource.setJdbcUrl(this.jdbcUrl);
            hikariDataSource.setPassword(this.password);
    
            return hikariDataSource;
        }
    }

    AbstractRoutingDataSource 를 상속 받아 어떤 DataSource를 사용할지 처리하는 클래스이다.

    @TransactionalreadOnly의 속성 값(true, false) 기준으로 master, slave 분기 처리를 한다.

    public class ReplicationRoutingDataSource extends AbstractRoutingDataSource {
        private final Logger logger = LoggerFactory.getLogger(this.getClass());
    
        @Override
        protected Object determineCurrentLookupKey() {
            logger.info("isCurrentTransactionReadOnly : {}", TransactionSynchronizationManager.isCurrentTransactionReadOnly() ? "slave" : "master");
    
            return TransactionSynchronizationManager.isCurrentTransactionReadOnly() ? "read" : "write";
        }
    }

    아래 코드가 최종 DataSoruce를 처리하는 클래스이다.

    /**
     * <pre>
     * postgresql routing 설정 클래스
     * </pre>
     */
    @Configuration
    @EnableTransactionManagement
    @MapperScan(basePackages = {"com.datasource.repo.mybatis"}, sqlSessionFactoryRef = "postgresqlSessionFactory")
    public class PostgresqlRoutingConfig {
        private final DataSource postgresqlMasterDataSource;
        private final DataSource postgresqlSlaveDataSource;
    
        public PostgresqlRoutingConfig(DataSource postgresqlMasterDataSource
                , DataSource postgresqlSlaveDataSource) {
            this.postgresqlMasterDataSource = postgresqlMasterDataSource;
            this.postgresqlSlaveDataSource = postgresqlSlaveDataSource;
        }
    
        /**
         * <pre>
         * postgresql db routing 설정 메서드
         * </pre>
         *
         * @return 분기 db 정보가 포함되어 있는 routing datasource 객체
         */
        @Bean
        public DataSource routingDataSource() {
            ReplicationRoutingDataSource routingDataSource = new ReplicationRoutingDataSource();
    
            Map<Object, Object> dataSourceMap = new HashMap<>();
            dataSourceMap.put("write", postgresqlMasterDataSource);
            dataSourceMap.put("read", postgresqlSlaveDataSource);
    
            routingDataSource.setTargetDataSources(dataSourceMap);
            routingDataSource.setDefaultTargetDataSource(postgresqlMasterDataSource);
    
            return routingDataSource;
        }
    
        /**
         * <pre>
         * routing datasoruce를 LazyConnectionDataSourceProxy로 반환하는 메서드
         * 
         * routing datasource에 분기 db를 사용하기 위해선 db 연결 시 마다 어떤 db에 접속 할지 판단해야 하는데
         * 이를 위해 LazyConnectionDataSourceProxy를 사용하여 lazy loading를 사용한다.
         * </pre>
         *
         * @param routingDataSource 분기 db 정보가 포함되어 있는 routing datasource
         * @return lazy loading을 사용하는 LazyConnectionDataSourceProxy 객체
         */
        @Bean
        public DataSource dataSource(DataSource routingDataSource) {
            return new LazyConnectionDataSourceProxy(routingDataSource);
        }
    
        /**
         * <pre>
         * db와 mybatis를 연결하기 위한 SqlSessionFactory 객체 생성 메서드
         * 
         * 이때 사용되는 datasource는 LazyConnectionDataSourceProxy를 통해 생성된 datasource를 사용하였다.
         * </pre>
         *
         * @return 
         * @throws Exception
         */
        @Bean
        public SqlSessionFactory postgresqlSessionFactory(DataSource dataSource) throws Exception {
            SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
            sqlSessionFactoryBean.setDataSource(dataSource);
            sqlSessionFactoryBean.setTypeAliasesPackage("com.datasource.repo.mybatis");
    
            sqlSessionFactoryBean.getObject().getConfiguration().setMapUnderscoreToCamelCase(true);
    
            return sqlSessionFactoryBean.getObject();
        }
    
        /**
         * <pre>
         * SqlSessionTemplate 객체를 반환하는 메서드
         * </pre>
         * 
         * @param postgresqlSessionFactory
         * @return
         * @throws Exception
         */
        @Bean
        public SqlSessionTemplate postgresSessionTemplate(SqlSessionFactory postgresqlSessionFactory) throws Exception {
            return new SqlSessionTemplate(postgresqlSessionFactory);
        }
    
        /**
         * <pre>
         * 트랜잭션 객체 반환
         * 
         * 이때 사용되는 datasource는 LazyConnectionDataSourceProxy를 통해 생성된 datasource를 사용하였다.
         * </pre>
         * 
         * @return DataSourceTransactionManager 객체 반환
         */
        @Bean
        public PlatformTransactionManager transactionManager(DataSource dataSource) {
            DataSourceTransactionManager transactionManager = new DataSourceTransactionManager();
            transactionManager.setDataSource(dataSource);
    
            return transactionManager;
        }    
    }

    RoutingDataSource에 대한 간략 설명으로 아래 코드 참고.

    // - PostgresqlRoutingConfig Class
    @Bean
    public DataSource routingDataSource() {
        ReplicationRoutingDataSource routingDataSource = new ReplicationRoutingDataSource();
    
        /*
        복수로 사용할 DataSource를 Map에 설정한다.
        이때 Map의 Key가 어떤 DataSource를 사용할지 분기 기준 값이 된다.
        */
        Map<Object, Object> dataSourceMap = new HashMap<>();
        dataSourceMap.put("write", postgresqlMasterDataSource);
        dataSourceMap.put("read", postgresqlSlaveDataSource);
    
        routingDataSource.setTargetDataSources(dataSourceMap);
        routingDataSource.setDefaultTargetDataSource(postgresqlMasterDataSource);
    
        return routingDataSource;
    }
    
    // - ReplicationRoutingDataSource Class
    @Override
    protected Object determineCurrentLookupKey() {
        logger.info("isCurrentTransactionReadOnly : {}", TransactionSynchronizationManager.isCurrentTransactionReadOnly() ? "slave" : "master");
    
        // @Transactional의 현재 readOnly 속성 값 기준으로 Map에 Key를 반환해서 Key에 해당 되는 DataSource를 사용한다.
        return TransactionSynchronizationManager.isCurrentTransactionReadOnly() ? "read" : "write";
    }

    그 후 트랜잭션이 발생해서 Connection이 필요한 시점마다 DataSource를 가지고 올 수 있게 LazyConnectionDataSourceProxy 객체에 해당 RoutingDataSource 객체를 설정한다.

    // - PostgresqlRoutingConfig Class
    @Bean
    public DataSource dataSource(DataSource routingDataSource) {
        return new LazyConnectionDataSourceProxy(routingDataSource);
    }

    여기까지 했으면 다중 DataSource 설정은 완료되었다.

    여기서 심심풀이로 본 글에서 만든 ReplicationRoutingDataSource 객체가 어떤 식으로 호출 되고 처리 되는지 살짝 설명을 위해 먼저 LazyConnectionDataSourceProxy 객체를 살펴보면

    public class LazyConnectionDataSourceProxy extends DelegatingDataSource {
    
        .
        .
        .
    
        /*
        생성자로 전달 받은 ReplicationRoutingDataSource 객체를 부모인 setTargetDataSource()를 이용하여 부모에 설정한다.
         */    
        public LazyConnectionDataSourceProxy(DataSource targetDataSource) {
            setTargetDataSource(targetDataSource);
            afterPropertiesSet();
        }
    
        .
        .
        .
    
        /*
        Connenction 정보를 반환하는데 LazyConnectionInvocationHandler 내부 클래스를 사용한다.
        */
        @Override
        public Connection getConnection() throws SQLException {
            return (Connection) Proxy.newProxyInstance(
                    ConnectionProxy.class.getClassLoader(),
                    new Class<?>[] {ConnectionProxy.class},
                    new LazyConnectionInvocationHandler());
        }
    
        .
        .
        .
    
        /*
        해당 내부 클래스가 핵심부분이며 여기서 Connection 관련 프로세스들을 처리한다.
        */
        private class LazyConnectionInvocationHandler implements InvocationHandler {
    
            .
            .
            .
    
            // 해당 메서드가 Connection 정보를 반환한다.
            private Connection getTargetConnection(Method operation) throws SQLException {
                if (this.target == null) {
    
                    .
                    .
                    .
    
                    /*
                    바깥 클래스 LazyConnectionDataSourceProxy의 부모인 DelegatingDataSource에서 
                    obtainTargetDataSource()를 호출해서 본 글에서 만든 ReplicationRoutingDataSource 객체를 반환한다.
                    */
                    this.target = (this.username != null) ?
                            obtainTargetDataSource().getConnection(this.username, this.password) :
                            obtainTargetDataSource().getConnection();
    
                    .
                    .
                    .
    
                return this.target;
            }
        }
    }

    여기까지가 LazyConnectionDataSourceProxy에 대한 설명이다.

    그러면 본 글에서 생성한 ReplicationRoutingDataSource 설명을 이어서 하자면 위 LazyConnectionInvocationHandle 클래스에서 obtainTargetDataSource()를 호출하면 ReplicationRoutingDataSource 객체가 반환 된다.

    그리고 getConnection() 메서드를 호출하면 ReplicationRoutingDataSource의 부모 인 AbstractRoutingDataSource#getConnection()를 호출한다.

    public abstract class AbstractRoutingDataSource extends AbstractDataSource implements InitializingBean {
    
        .
        .
        .
    
        @Override
        public Connection getConnection() throws SQLException {
            return determineTargetDataSource().getConnection();
        }
    
        protected DataSource determineTargetDataSource() {
            Assert.notNull(this.resolvedDataSources, "DataSource router not initialized");
            /*
            Override한 determineCurrentLookupKey() 호출.
    
            - ReplicationRoutingDataSource Class
            @Override
            protected Object determineCurrentLookupKey() {
                logger.info("isCurrentTransactionReadOnly : {}", TransactionSynchronizationManager.isCurrentTransactionReadOnly() ? "slave" : "master");
    
                return TransactionSynchronizationManager.isCurrentTransactionReadOnly() ? "read" : "write";
            }
            */
            Object lookupKey = determineCurrentLookupKey();
    
            /*
            반환된 lookupKey값을 가지고 Map에서 DataSource 추출        
            PostgresqlRoutingConfig#routingDataSource()에서 설정한 값.
    
            - PostgresqlRoutingConfig Class
            @Bean
            public DataSource routingDataSource() {
                ReplicationRoutingDataSource routingDataSource = new ReplicationRoutingDataSource();
    
                Map<Object, Object> dataSourceMap = new HashMap<>();
                dataSourceMap.put("write", postgresqlMasterDataSource);
                dataSourceMap.put("read", postgresqlSlaveDataSource);
    
                routingDataSource.setTargetDataSources(dataSourceMap);
                routingDataSource.setDefaultTargetDataSource(postgresqlMasterDataSource);
    
                return routingDataSource;
            }        
            */
            DataSource dataSource = this.resolvedDataSources.get(lookupKey);
            if (dataSource == null && (this.lenientFallback || lookupKey == null)) {
                dataSource = this.resolvedDefaultDataSource;
            }
            if (dataSource == null) {
                throw new IllegalStateException("Cannot determine target DataSource for lookup key [" + lookupKey + "]");
            }
            return dataSource;
        }
    
    
        .
        .
        .
    
    
        /*
        본 글의 ReplicationRoutingDataSource에서 Override한 메서드
        */
        @Nullable
        protected abstract Object determineCurrentLookupKey();
    
    }

    간단한 메소드 호출 흐름을 그림으로 아래를 참고.

    이로서 다중 DataSource에 대한 설명이 마무리 되었습니다.(갑자기 급 마무리 & 존댓말??)

    원래는 다중 DataSource를 이용한 Service까지 작성이 목표 였는데 한번에(?) 너무 많이 하면 내용이 너무 길어지고 복잡(?)해질것 같아서 다중 DataSource를 이용한 Service는 다음 글에 작성하도록 하겠습니다.

    그럼 지금까지 읽어주셔서 감사합니다. 다음 기회에 또 뵙도록 하겠습니다.

    반응형

    댓글

Designed by Tistory.