Multi Datasource 설정과 JTA를 활용한 분산 트랜잭션 관리

wontaekoh·2024년 6월 21일
1

Kakfa 도입기

목록 보기
3/6
post-thumbnail

Kafka를 통해 데이터를 Spring Boot 프로젝트에서 Consuming 하면서 3개의 데이터베이스를 연결해야했습니다. 이때 여러 데이터베이스의 트랜잭션을 유지하기 위해 JTA를 사용하여 다중 데이터소스를 설정한 과정을 정리하였습니다.

📌 트랜잭션 문제(JTA를 사용하기 전)

처음에 3개의 데이터베이스를 연결하여 사용하였을 때 Spring에서 @Transactional 어노테이션을 통해 트랜잭을 관리하였습니다. 이 때 RuntimeException이 발생하면 롤백이 동작해야하는데 Primary DB를 제외한 나머지 DB는 롤백이 동작하지 않는 문제가 발생하였습니다.

원인은 Spring에서 @Transactional 어노테이션을 사용하면 Primary 데이터베이스의 transactionManager를 사용하게 되어 Primary가 아닌 나머지 데이터베이스는 transaction으로 묶이지 않게되어 롤백이 정상적으로 동작하지 않던 것이었습니다.

📌 JTA를 사용하여 3개의 DB를 하나의 트랜잭션으로 묶기

이 문제를 해결하기 위해 JTA(Java Transaction API)를 사용하여 다중 데이터베이스가 하나의 동일한 TransactionManager를 사용하도록 설정했습니다. 이를 통해 예외가 발생했을 때 3개의 데이터베이스가 모두 롤백되도록 설정할 수 있었습니다.

예제 코드는 github에 올려두었고, DB설정 없이 편하게 사용할 수 있도록 H2데이터베이스를 사용하여 코드를 작성하였습니다.

1. application.yml 설정

프로젝트 설정 파일인 application.yml부터 시작해 보겠습니다.

spring:
  h2:
    console:
      enabled: true
      path: /h2-console
  output:
    ansi:
      enabled: always

  datasource:
    firstdb:
      xa-properties:
        URL: jdbc:h2:mem:first_database;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
        user: sa
        password:
      xa-data-source-class-name: org.h2.jdbcx.JdbcDataSource
      unique-resource-name: 'firstdb'
      min-pool-size: 5
      max-pool-size: 10
      hibernate:
        ddl-auto: update
        dialect: org.hibernate.dialect.H2Dialect
        naming:
          implicit-strategy: org.hibernate.boot.model.naming.ImplicitNamingStrategyJpaCompliantImpl
          physical-strategy: org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy

    seconddb:
      xa-properties:
        URL: jdbc:h2:mem:second_database;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
        user: sa
        password:
      xa-data-source-class-name: org.h2.jdbcx.JdbcDataSource
      unique-resource-name: 'seconddb'
      min-pool-size: 5
      max-pool-size: 10
      hibernate:
        ddl-auto: update
        dialect: org.hibernate.dialect.H2Dialect
        naming:
          implicit-strategy: org.hibernate.boot.model.naming.ImplicitNamingStrategyJpaCompliantImpl
          physical-strategy: org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy

    thirddb:
      xa-properties:
        URL: jdbc:h2:mem:third_database;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
        user: sa
        password:
      xa-data-source-class-name: org.h2.jdbcx.JdbcDataSource
      unique-resource-name: 'thirddb'
      min-pool-size: 5
      max-pool-size: 10
      hibernate:
        ddl-auto: update
        dialect: org.hibernate.dialect.H2Dialect
        naming:
          implicit-strategy: org.hibernate.boot.model.naming.ImplicitNamingStrategyJpaCompliantImpl
          physical-strategy: org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy

2. Database Properties 설정

DatabaseProperties 클래스는 각 데이터베이스의 설정을 매핑하기 위해 사용됩니다.

package com.example.multidatasource.config;

import lombok.Data;
import org.hibernate.engine.transaction.jta.platform.internal.AtomikosJtaPlatform;
import org.springframework.boot.context.properties.ConfigurationProperties;

import java.util.HashMap;
import java.util.Map;

@Data
@ConfigurationProperties(prefix = "spring.datasource.hikari")
public class DatabaseProperties {

    private FirstDb firstDb;
    private SecondDb secondDb;
    private ThirdDb thirdDb;

    @Data
    public static class FirstDb {
        private XaProperties xaProperties;
        private String xaDataSourceClassName;
        private String uniqueResourceName;
        private int maxPoolSize;
        private Hibernate hibernate;
    }

    @Data
    public static class SecondDb {
        private XaProperties xaProperties;
        private String xaDataSourceClassName;
        private String uniqueResourceName;
        private int maxPoolSize;
        private Hibernate hibernate;
    }

    @Data
    public static class ThirdDb {
        private XaProperties xaProperties;
        private String xaDataSourceClassName;
        private String uniqueResourceName;
        private int maxPoolSize;
        private Hibernate hibernate;
    }

    @Data
    public static class XaProperties {
        private String url;
        private String user;
        private String password;
    }

    @Data
    public static class Hibernate {
        private String ddlAuto;
        private String dialect;
        private Naming naming;

        public static Map<String, Object> propertiesToMap(Hibernate hibernateProperties) {
            Map<String, Object> properties = new HashMap<>();
            properties.put("hibernate.transaction.jta.platform", AtomikosJtaPlatform.class.getName());
            properties.put("javax.persistence.transactionType", "JTA");

            if(hibernateProperties.getDdlAuto() != null) {
                properties.put("hibernate.hbm2ddl.auto", hibernateProperties.getDdlAuto());
            }
            if(hibernateProperties.getDialect() != null) {
                properties.put("hibernate.dialect", hibernateProperties.getDialect());
            }

            DatabaseProperties.Naming hibernateNaming = hibernateProperties.getNaming();
            if(hibernateNaming != null) {
                if (hibernateNaming.getImplicitStrategy() != null) {
                    properties.put("hibernate.implicit_naming_strategy",  hibernateNaming.getImplicitStrategy());
                }
                if (hibernateNaming.getPhysicalStrategy() != null) {
                    properties.put("hibernate.physical_naming_strategy", hibernateNaming.getPhysicalStrategy());
                }
            }

            return properties;
        }
    }

    @Data
    public static class Naming {
        private String implicitStrategy;
        private String physicalStrategy;
    }

}

3. DataSource 설정

각 데이터베이스의 DataSource 및 EntityManagerFactory를 설정합니다.

3-1. FirstDatasourceConfig.java

package com.example.multidatasource.config;

import com.atomikos.jdbc.AtomikosDataSourceBean;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;

import javax.sql.DataSource;

@Configuration
@EnableConfigurationProperties(DatabaseProperties.class)
@EnableJpaRepositories(
        basePackages = "com.example.multidatasource.firstdb",
        entityManagerFactoryRef = FirstDatasourceConfig.ENTITY_MANAGER_BEAN_NAME,
        transactionManagerRef = XaDataSourceConfig.TRANSACTION_MANAGER_BEAN_NAME
)
public class FirstDatasourceConfig {

    public static final String ENTITY_MANAGER_BEAN_NAME = "firstEntityManger";
    private static final String DATASOURCE_BEAN_NAME = "firstDataSource";
    private static final String DATASOURCE_PROPERTIES_PREFIX = "spring.datasource.firstdb";
    private static final String HIBERNATE_PROPERTIES = "firstHibernateProperties";

    @Primary
    @Bean(name = ENTITY_MANAGER_BEAN_NAME)
    public LocalContainerEntityManagerFactoryBean entityManagerFactory(
            @Qualifier(DATASOURCE_BEAN_NAME) DataSource dataSource,
            @Qualifier(HIBERNATE_PROPERTIES) DatabaseProperties.Hibernate hibernateProperties
    ) {
        LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean();
        em.setDataSource(dataSource);
        em.setPackagesToScan("com.example.multidatasource.firstdb");
        em.setJpaPropertyMap(DatabaseProperties.Hibernate.propertiesToMap(hibernateProperties));
        em.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
        return em;
    }

    @Bean(name = HIBERNATE_PROPERTIES)
    @ConfigurationProperties(DATASOURCE_PROPERTIES_PREFIX + ".hibernate")
    public DatabaseProperties.Hibernate hibernateProperties() {
        return new DatabaseProperties.Hibernate();
    }

    @Primary
    @Bean(name = DATASOURCE_BEAN_NAME)
    @ConfigurationProperties(prefix = DATASOURCE_PROPERTIES_PREFIX)
    public DataSource dataSource() {
        return new AtomikosDataSourceBean();
    }
}

3-2. SecondDatasourceConfig.java

package com.example.multidatasource.config;

import com.atomikos.jdbc.AtomikosDataSourceBean;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;

import javax.sql.DataSource;

@Configuration
@EnableConfigurationProperties(DatabaseProperties.class)
@EnableJpaRepositories(
        basePackages = "com.example.multidatasource.seconddb",
        entityManagerFactoryRef = SecondDatasourceConfig.ENTITY_MANAGER_BEAN_NAME,
        transactionManagerRef = XaDataSourceConfig.TRANSACTION_MANAGER_BEAN_NAME
)
public class SecondDatasourceConfig {

    public static final String ENTITY_MANAGER_BEAN_NAME = "secondEntityManager";
    private static final String DATASOURCE_BEAN_NAME = "secondDataSource";
    private static final String DATASOURCE_PROPERTIES_PREFIX = "spring.datasource.seconddb";
    private static final String HIBERNATE_PROPERTIES = "secondHibernateProperties";

    @Bean(name = ENTITY_MANAGER_BEAN_NAME)
    public LocalContainerEntityManagerFactoryBean entityManagerFactory(
            @Qualifier(DATASOURCE_BEAN_NAME) DataSource dataSource,
            @Qualifier(HIBERNATE_PROPERTIES) DatabaseProperties.Hibernate hibernateProperties
    ) {
        LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean();
        em.setDataSource(dataSource);
        em.setPackagesToScan("com.example.multidatasource.seconddb");
        em.setJpaPropertyMap(DatabaseProperties.Hibernate.propertiesToMap(hibernateProperties));
        em.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
        return em;
    }

    @Bean(name = HIBERNATE_PROPERTIES)
    @ConfigurationProperties(DATASOURCE_PROPERTIES_PREFIX + ".hibernate")
    public DatabaseProperties.Hibernate hibernateProperties() {
        return new DatabaseProperties.Hibernate();
    }

    @Bean(name = DATASOURCE_BEAN_NAME)
    @ConfigurationProperties(prefix = DATASOURCE_PROPERTIES_PREFIX)
    public DataSource dataSource() {
        return new AtomikosDataSourceBean();
    }
}

3-3. ThirdDatasourceConfig.java

package com.example.multidatasource.config;

import com.atomikos.jdbc.AtomikosDataSourceBean;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;

import javax.sql.DataSource;

@Configuration
@EnableConfigurationProperties(DatabaseProperties.class)
@EnableJpaRepositories(
        basePackages = "com.example.multidatasource.thirddb",
        entityManagerFactoryRef = ThirdDatasourceConfig.ENTITY_MANAGER_BEAN_NAME,
        transactionManagerRef = XaDataSourceConfig.TRANSACTION_MANAGER_BEAN_NAME
)
public class ThirdDatasourceConfig {

    public static final String ENTITY_MANAGER_BEAN_NAME = "thirdEntityManager";
    private static final String DATASOURCE_BEAN_NAME = "thirdDataSource";
    private static final String DATASOURCE_PROPERTIES_PREFIX = "spring.datasource.thirddb";
    private static final String HIBERNATE_PROPERTIES = "thirdHibernateProperties";

    @Bean(name = ENTITY_MANAGER_BEAN_NAME)
    public LocalContainerEntityManagerFactoryBean entityManagerFactory(
            @Qualifier(DATASOURCE_BEAN_NAME) DataSource dataSource,
            @Qualifier(HIBERNATE_PROPERTIES) DatabaseProperties.Hibernate hibernateProperties
    ) {
        LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean();
        em.setDataSource(dataSource);
        em.setPackagesToScan("com.example.multidatasource.thirddb");
        em.setJpaPropertyMap(DatabaseProperties.Hibernate.propertiesToMap(hibernateProperties));
        em.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
        return em;
    }

    @Bean(name = HIBERNATE_PROPERTIES)
    @ConfigurationProperties(DATASOURCE_PROPERTIES_PREFIX + ".hibernate")
    public DatabaseProperties.Hibernate hibernateProperties() {
        return new DatabaseProperties.Hibernate();
    }

    @Bean(name = DATASOURCE_BEAN_NAME)
    @ConfigurationProperties(prefix = DATASOURCE_PROPERTIES_PREFIX)
    public DataSource dataSource() {
        return new AtomikosDataSourceBean();
    }
}

4. XaDataSourceConfig.java

마지막으로 여러 DB를 하나의 트랜잭션으로 관리하기 위한 JTA 트랜잭션 관리를 위한 설정을 추가합니다.

package com.example.multidatasource.config;

import com.atomikos.icatch.config.UserTransactionService;
import com.atomikos.icatch.config.UserTransactionServiceImp;
import com.atomikos.icatch.jta.UserTransactionImp;
import com.atomikos.icatch.jta.UserTransactionManager;
import jakarta.annotation.PostConstruct;
import jakarta.transaction.SystemException;
import jakarta.transaction.UserTransaction;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.transaction.jta.JtaTransactionManager;

@Configuration
@EnableTransactionManagement
public class XaDataSourceConfig {
    public static final String TRANSACTION_MANAGER_BEAN_NAME = "jtaTransactionManager";

    @Bean(name = "atomikosUserTransactionManager")
    public UserTransactionManager userTransactionManager() throws SystemException {
        UserTransactionManager userTransactionManager = new UserTransactionManager();
        userTransactionManager.setTransactionTimeout(1000);
        userTransactionManager.setForceShutdown(true);

        return userTransactionManager;
    }

    @Bean(name = "atomikosUserTransaction")
    public UserTransaction userTransaction() throws SystemException {
        UserTransaction userTransaction = new UserTransactionImp();
        userTransaction.setTransactionTimeout(60000);

        return userTransaction;
    }

    @Primary
    @Bean(name = TRANSACTION_MANAGER_BEAN_NAME)
    public JtaTransactionManager jtaTransactionManager(
            @Qualifier("atomikosUserTransactionManager") UserTransactionManager userTransactionManager,
            @Qualifier("atomikosUserTransaction") UserTransaction userTransaction
    ) {
        JtaTransactionManager jtaTransactionManager = new JtaTransactionManager();
        jtaTransactionManager.setTransactionManager(userTransactionManager);
        jtaTransactionManager.setUserTransaction(userTransaction);

        return jtaTransactionManager;
    }
}

📌 롤백 동작 테스트

롤백이 정상적으로 동작하는지 확인하기 위해 3가지 API를 개발하였습니다.

  1. 3가지 DB의 데이터를 조회할 수 있는 GET요청
  2. 3가지 DB의 데이터에 데이터를 저장할 수 있는 POST요청
  3. 3가지 DB의 데이터를 모두 삭제하는 DELETE요청

이 때 POST요청을 통해 데이터를 저장할 때 sequence를 통해 저장 순서를 조절할 수 있고, throwError라는 속성을 통해 데이터를 저장하고나서 예외를 던질지 말지 선택할 수 있게 개발하였습니다.

시나리오1. 정상로직(예외X)

throwError 속성을 모두 false로 설정하여 예외를 던지지 않도록 하였습니다.

GET요청을 통해 데이터를 확인하면 조회하면 데이터가 저장된 것을 확인할 수 있습니다.

시나리오2. 마지막 DB를 저장하고나서 예외를 던지기

thirdDb의 throwError 속성을 true로 설정하여 예외를 던지도록 하였습니다.

롤백이 정상적으로 동작하여 3가지 DB모두 하나의 트랜잭션으로 관리가 됨을 확인할 수 있습니다

✨ 마무리

Spring에서 @Transactional을 사용할 때 어떤식으로 Bean을 관리하는지 동작방식에 대해 알 수 있고, JTA를 통해 여러 데이터베이스를 하나의 트랜잭션으로 관리할 수 있는 방법에 대해 알 수 있었습니다.

JTA를 알기전 다중 DB설정했을 때 롤백이 동작하지 않아서 왜 동작하지 않는거지?했던게 지금 생각해보면 당연한건데... 많이 부족함을 느꼈습니다.

profile
주니어 백엔드 개발자, 오원택입니다!

0개의 댓글

관련 채용 정보