지난 시간에는 두 개의 물리적 데이터베이스 연결하는 방법을 소개하다가 문제점을 발견했습니다.
이번 시간에는 그 문제를 해결하면서 두 개의 데이터베이스를 연결하는 방법을 소개하려고 하는데, 그 전에 문제점에 대해서 잠시만 짚고 넘어가보도록 하겠습니다.

지난 시간에서의 문제점은 각 기능이 하나의 데이터베이스에만 작업하면 문제가 없지만, 한 기능에서 두개의 데이터베이스 작업을 했을 때 트랜잭션 문제가 있었습니다.
이게 만약 물리적 데이터베이스 두개가 아니라, 물리적 데이터베이스 하나에 두개의 Database를 연결하는 것이었다면, Transaction Propagation(트랜잭션 전파)으로 해결할 수 있었을 것입니다.

물리적 데이터베이스 두 개 연결



하나의 데이터베이스에 database 두 개 연결



하지만 우리가 하려는건 물리적 데이터베이스 두개를 연결하는 것이기 때문에 트랜잭션 전파로는 해결이 되지 않습니다.
(이 글과 동일한 주제로 설명과 테스트를 해주신 분이 있는데 트랜잭션 전파로 테스트도 해주셨기 때문에 아래 블로그를 한번 읽어보시는 것을 추천드립니다.)
https://supawer0728.github.io/2018/03/22/spring-multi-transaction/

그래서 아래와 같은 문제점이 있습니다.

예시 - 한 메서드에서 두개의 데이터베이스 작업

public class AService {
        private final ARepository aRepository;
        public void save() {
                aRepository.save();
        }
}

public class BService {
        private final BRepository bRepository;

        public void save() {
                bRepository.save();
                throw new RuntimeException();
        }
}

public class CommonService {

        private final AService aService;
        private final BService bService;

        @Transactional
        public void commonSave() {
                aService.save();
                bService.save();
        }
}

문제 발생 과정

스프링 프레임워크에서 @Transactional 어노테이션을 사용하여 트랜잭션 관리를 하는 경우, 기본 설정은 단일 데이터 소스 및 연결된 단일 트랜잭션 매니저에 최적화되어 있습니다. 이러한 설정은 commonSave 메서드에서 aService.save()만 호출되는 경우와 같이 단일 데이터베이스 작업에는 문제가 없습니다.

그러나 문제는 CommonService 클래스 내 commonSave 메서드에서 A, B 두 데이터베이스에 대한 작업을 동시에 처리할 때 발생합니다. 앞서 얘기한 것 처럼 @Transactional 어노테이션은 하나의 트랜잭션 매니저를 사용하여 트랜잭션을 관리합니다. 특별한 옵션을 지정하지 않았기 때문에, @Primary로 지정된 트랜잭션 매니저가 사용될 것이고, 이 예제에서는 A 데이터베이스가 @Primary 트랜잭션 매니저로 설정되어 있다고 가정하겠습니다.

bService.save() 메소드를 보면, 데이터베이스에 저장한 뒤 예외를 발생시키는 코드가 있습니다. A 데이터베이스를 관리하는 @Primary 트랜잭션 매니저는 B 데이터베이스 작업에서 발생한 예외를 적절히 처리하지 못합니다. 결과적으로, aRepository의 작업은 예외 발생 시 롤백될 수 있지만, bRepository의 작업은 롤백되지 않고 완료됩니다. 이는 A와 B 데이터베이스 간의 데이터 불일치를 발생시키며, 두 개의 독립된 데이터베이스 작업을 관리할 수 있는 새로운 해결책이 필요합니다.

아래에서 JtaTransactionManager를 이용해서 위 문제를 해결해보겠습니다.



Jta 적용하기

JTA(Java Transaction Api)는 자바 표준으로써, 분산 transaction을 관리할 수 있게 해주는 인터페이스를 제공합니다. 인터페이스를 제공하는다는 뜻은, 트랜잭션 처리가 필요한 자원이 특정 벤더 (mysql, oracle …)에 의존할 필요가 없음을 의미합니다.
그러면 트랜잭션 처리를 해줄 수 있는 구현체를 등록해주어야 하는데, 이 글에서는 오픈소스로 제공하는 Atomikos를 사용할 예정입니다.

XA는 데이터베이스 트랜잭션과 같은 자원 관리자 간의 트랜잭션을 도와주는 프로토콜인데, 2PC(Two Phase Commit) - 즉, 2단계 커밋 방식으로 분산 트랜잭션을 처리합니다.
2단계 커밋이란, 분산 트랜잭션에 참여하는 리소스들이 하나의 글로벌 트랜잭션에서 동일한 커밋 또는 롤백을 할 수 있도록 보장하는 과정이고, 준비 단계커밋 단계로 나뉘어집니다.

준비 단계(Prepare Phase)

  • 각 트랜잭션 매니저에게 트랜잭션을 커밋할 준비가 되었는지 묻는 단계 입니다

커밋 단계(Commit Phase)

  • 모든 트랜잭션 매니저가 준비 완료 응답을 보냈을 때, 글로벌 트랜잭션을 실제로 커밋/롤하는 단계입니다.
  • 만약에 어느 하나라도 실패 응답을 보냈다면, 글로벌 트랜잭션은 모든 트랜잭션 매니저에게 롤백 명령을 보냅니다.

이와 같은 방법으로 데이터베이스를 벤더와 관계없이 하나 이상 사용할 때 트랜잭션을 보장할 수 있는데, 매번 2PC 방식으로 트랜잭션을 처리하게 되면 아무래도 단일 트랜잭션을 사용할 때 보다는 성능에 문제가 생긴다는 단점이 존재합니다.

코드 적용

1. dependency 추가

yml부터 자바 코드까지 이전 글과 설정하는 순서는 비슷한데, 의존성이 하나 추가되고, 설정하는 내용이 조금 많이 바뀝니다.
참고로, 스프링 부트 3.0 이전까지는 spring-boot-starter-jta-atomikos를 지원했다가, 3.0 이후로는 지원 종료되었기 때문에 공식문서를 보고 적절한 의존성을 찾아야합니다.
https://www.atomikos.com/Blog/ExtremeTransactions6dot0


build.gradle

...
implementation 'com.atomikos:transactions-spring-boot3-starter:6.0.0'
implementation 'jakarta.transaction:jakarta.transaction-api:2.0.1'
...

 

2. yml 파일 작성

spring:
  jta:
    enabled: true
    atomikos:
      datasource:
        db1:
          unique-resource-name: db1DataSuorce
          xa-data-source-class-name: com.mysql.cj.jdbc.MysqlXADataSource
          xa-properties:
            user: username
            password: password
            url: jdbc:mysql://<DB1_주소>

        db2:
          unique-resource-name: db2DataSource
          xa-data-source-class-name: com.mysql.cj.jdbc.MysqlXADataSource
          xa-properties:
            user: username
            password: password
            url: jdbc:mysql://<DB2_주소>
  jpa:
    open-in-view: false
    properties:
      hibernate:
        show_sql: false
        format_sql: false
        use_sql_comments: false
        dialect: org.hibernate.dialect.MySQL8Dialect
        hbm2ddl:
          auto: none

우리는 두 개의 데이터베이스를 사용하기 때문에 위에 작성한 정보는 자바 코드로 직접 등록해주어야 합니다.
여기서 중요한 부분은 XA 프로토콜을 이용해서 분산 트랜잭션을 사용할 수 있도록 XA 전용 드라이버인 MysqlXADataSource을 지정한 것입니다.

 

3. Java 코드 설정

yml 파일을 읽어 객체에 저장

@Getter
@Setter
@Configuration
@ConfigurationProperties(prefix = "spring.jta.atomikos.datasource")
public class AtomikosDataSourceProperty {

    private AtomikosConfig db1;
    private AtomikosConfig db2;

    @Getter
    @Setter
    public static class AtomikosConfig {
        private String uniqueResourceName;
        private String xaDataSourceClassName;
        private XaProperties xaProperties;
    }

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



DB1 - DataSource 설정

@Configuration
@RequiredArgsConstructor
public class Db1DataSourceConfig {

    private final AtomikosDataSourceProperty jtaProperty;

    @Bean
    public DataSource db1DataSource() {
        var db1 = jtaProperty.getDb1();
        return getAtomikosDataSourceBean(db1);
    }

    private DataSource getAtomikosDataSourceBean(AtomikosDataSourceProperty.AtomikosConfig db1) {
        AtomikosDataSourceBean dataSource = new AtomikosDataSourceBean();
        dataSource.setUniqueResourceName(db1.getUniqueResourceName());
        dataSource.setXaDataSourceClassName(db1.getXaDataSourceClassName());
        dataSource.setXaDataSource(getMysqlXADataSource(db1));
        dataSource.setMaxPoolSize(10);
        dataSource.setMinPoolSize(2);
        dataSource.setTestQuery("select 1;");
        return dataSource;
    }

    private MysqlXADataSource getMysqlXADataSource(AtomikosDataSourceProperty.AtomikosConfig property) {
        MysqlXADataSource xaDataSource = new MysqlXADataSource();
        xaDataSource.setUser(property.getXaProperties().getUser());
        xaDataSource.setPassword(property.getXaProperties().getPassword());
        xaDataSource.setUrl(property.getXaProperties().getUrl());
        return xaDataSource;
    }
}



DB2 - DataSource 설정

@Configuration
@RequiredArgsConstructor
public class Db2DataSourceConfig {

    private final AtomikosDataSourceProperty jtaProperty;

    @Bean
    public DataSource db2DataSource() {
        var db2 = jtaProperty.getDb2();
        return getAtomikosDataSourceBean(db2);
    }

    private DataSource getAtomikosDataSourceBean(AtomikosDataSourceProperty.AtomikosConfig db2) {
        AtomikosDataSourceBean dataSource = new AtomikosDataSourceBean();
        dataSource.setUniqueResourceName(db2.getUniqueResourceName());
        dataSource.setXaDataSourceClassName(db2.getXaDataSourceClassName());
        dataSource.setXaDataSource(getMysqlXADataSource(db2));
        dataSource.setMaxPoolSize(10);
        dataSource.setMinPoolSize(2);
        dataSource.setTestQuery("select 1;");
        return dataSource;
    }

    private MysqlXADataSource getMysqlXADataSource(AtomikosDataSourceProperty.AtomikosConfig property) {
        MysqlXADataSource xaDataSource = new MysqlXADataSource();
        xaDataSource.setUser(property.getXaProperties().getUser());
        xaDataSource.setPassword(property.getXaProperties().getPassword());
        xaDataSource.setUrl(property.getXaProperties().getUrl());
        return xaDataSource;
    }
}



DB1 - JPA 설정

@Configuration
@EnableJpaRepositories(
        basePackages = {"com.practice.**.infrastructure.repository.jpa.db1"}
        , entityManagerFactoryRef = "db1EntityManagerFactory"
        , transactionManagerRef = "multiTxManager"
)
public class Db1JpaConfig extends CommonDataSourceOption {

    private final JpaConfigProperty jpaConfig;

    public Db1JpaConfig(JpaConfigProperty jpaConfig) {
        this.jpaConfig = jpaConfig;
    }

    @Bean
    @Primary
    public EntityManagerFactory db1EntityManagerFactory(@Qualifier("db1DataSource") DataSource dataSource) {
        LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean();
        factory.setDataSource(dataSource);
        factory.setPackagesToScan("com.practice.**.domain.entity.db1");
        factory.setPersistenceUnitName("db1EntityManager");
        factory.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
        factory.setJpaProperties(hibernateProperties(jpaConfig));
        factory.afterPropertiesSet();
        return factory.getObject();
    }
}



DB2 - JPA 설정

@Configuration
@EnableJpaRepositories(
        basePackages = {"com.practice.**.infrastructure.repository.jpa.db2"}
        , entityManagerFactoryRef = "db2EntityManagerFactory"
        , transactionManagerRef = "multiTxManager"
)
public class Db2JpaConfig extends CommonDataSourceOption {

    private final JpaConfigProperty jpaConfig;

    public Db2JpaConfig(JpaConfigProperty jpaConfig) {
        this.jpaConfig = jpaConfig;
    }

    @Bean
    public EntityManagerFactory db2EntityManagerFactory(@Qualifier("db2DataSource") DataSource dataSource) {
        LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean();
        factory.setDataSource(dataSource);
        factory.setPackagesToScan("com.practice.**.domain.entity.db2");
        factory.setPersistenceUnitName("db2EntityManager");
        factory.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
        factory.setJpaProperties(hibernateProperties(jpaConfig));
        factory.afterPropertiesSet();
        return factory.getObject();
    }
}



JtaTransactionManager Config

@Configuration
@RequiredArgsConstructor
public class JtaTransactionManagerConfig {

    public static final String USER_TRANSACTION = "userTransaction";
    public static final String ATOMIKOS_TRANSACTION_MANAGER = "atomikosTransactionManager";
    public static final String MULTI_TX_MANAGER = "multiTxManager";

    @Bean
    @Qualifier(USER_TRANSACTION)
    public UserTransaction userTransaction() throws SystemException {
        UserTransactionImp userTransactionImp = new UserTransactionImp();
        userTransactionImp.setTransactionTimeout(1000);
        return userTransactionImp;
    }

    @Bean(initMethod = "init", destroyMethod = "close")
    @Qualifier(ATOMIKOS_TRANSACTION_MANAGER)
    public UserTransactionManager atomikosTransactionManager() {
        UserTransactionManager utm = new UserTransactionManager();
        utm.setForceShutdown(true);
        return utm;
    }

    @Bean(MULTI_TX_MANAGER)
    @DependsOn({USER_TRANSACTION, ATOMIKOS_TRANSACTION_MANAGER})
    public PlatformTransactionManager jtaTransactionManager() throws SystemException {
        return new JtaTransactionManager(userTransaction(), atomikosTransactionManager());
    }
}

 

분산 트랜잭션을 적용하기 위한 JtaTransactionManager config 파일입니다.

  • userTransaction()
    • 개별 트랜잭션을 처리하는데 사용니다.
    • userTransaction - 개별 트랜잭션 관리
    • userTransactionManager - 글로벌 트랜잭션 관리
  • atomikosTransactionManager()
    • 글로벌 트랜잭션을 처리하는데 사용2단계 커밋 프로토콜을 구현하고 있어, 다중 데이터 소스에서 트랜잭션을 안정적으로 처리할 수 있습니다.
    • begin(), commit(), rollback() 메서드들을 처리하면서 데이터의 정합성 및 격리(Isolation)를 보장합니다.
  • jtaTransactionManager()
    • 트랜잭션을 시작하거나 커밋/롤백하는데 사용되며, 글로벌 트랜잭션에 참여할 수 있도록 도와줍니다.
    • 특히 여러 DataSource나 JMS와 같은 분산 리소스에서 트랜잭션을 처리하는데 유용한 기능을 제공합니다.
    • 아래 두가지 트랜잭션 매니저를 PlatformTransactionManager 하나로 묶어줍니다.
      • 글로벌 트랜잭션 - userTransactionManager
      • 개별 트랜잭션 - userTransaction

여기까지 구현한 뒤 프로젝트를 실행하면, 아래와 같은 Atomikos에서 찍어주는 로그를 확인할 수 있습니다.

 


 

여기까지 atomikos를 이용해서 여러개의 데이터베이스를 연결한 뒤 트랜잭션을 보장하는 방법을 알아보았습니다.
여기까지 하면 트랜잭션은 보장되지만, 이대로만 사용하게 되면, DB1이나 DB2 단일 작업을 할 때도 multiTxManager를 사용하기 때문에 성능에 이슈가 생길 수 있습니다.

실제 운영환경에서 사용할 때에는 분산 트랜잭션 매니저는 정말 분산 트랜잭션이 필요할 때만 사용할 수 있게끔 설정을 하거나 혹은 다른 방법을 고려해야 할 것 같습니다.

자바 코드를 설정하는 부분에서 클래스가 생각보다 많아졌는데, 어떻게 보면 과하다고 생각할 수 있을지 모르겠으나, 저는 이렇게 이름에 맞게 분리하는게 보기도 편하고 관리하기도 편해서 저만의 스타일로 클래스를 나눠봤습니다.
혹시라도 이 글을 참고하시는 분들께서는 각자에 맞는 방법으로 작성해주시길 바랍니다.

+ Recent posts