Java/Spring

Springboot + JPA + JTA (Atomikos) + MySQL 을 이용한 멀티 트랜잭션 구현

체리필터 2019. 12. 5. 17:12
728x90
반응형

 

두 개의 물리적으로 다른 Database에 트랜잭션을 적용하기 위해서 JTA를 사용해야 한다는 것을 듣고 그 중에 Atomikos를 사용하면 된다는 이야기에 구현을 해 보았다.

구글링을 해 보아도 이런말 저런말들이 많고 예전 기준으로 구현된 것도 많고 소스들이 너무나도 달라 뭘 어떻게 따라 해야 할지 막막했는데...

그리고 주로 MyBatis 기준으로 작업이 되어 있어서 답답한 감이 있었는데 JPA 기준으로 작업을 할 수 있도록 4일 정도 삽질 끝에 완성하여 백업 차원에서 기록으로 남긴다.

 

먼저 Legacy Database와 새롭게 사용하는 Database가 있다는 가정하에 아래와 같이 작업을 해야 한다.

먼저 사용한 의존성은 아래와 같다.

 

build.gradle

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter'
    implementation('org.springframework.boot:spring-boot-starter-aop')
    implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation('org.springframework.data:spring-data-envers')
    implementation 'org.springframework.boot:spring-boot-starter-jdbc'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    runtimeOnly 'mysql:mysql-connector-java'
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation('org.springframework.boot:spring-boot-starter-test') {
        exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
    }

    // providedRuntime('org.springframework.boot:spring-boot-starter-tomcat')
    implementation('org.hibernate:hibernate-java8:5.1.0.Final')
    implementation('org.springframework.boot:spring-boot-starter-jta-atomikos')

    compileOnly('org.projectlombok:lombok:1.18.4')
    testCompileOnly('org.projectlombok:lombok:1.18.4')

    runtimeOnly 'mysql:mysql-connector-java'
}

 

그리고 사용하게 된 설정 파일은 아래와 같다.

 

application.yml

spring:
  main:
    allow-bean-definition-overriding: true
  application:
    name: goodchoice-user
    description: "User internal api for goodchoice"
  jackson:
    time-zone: "Asia/Seoul"
  host: host주소값
  # datasource 이하 값들은 Legacy Hikari datasource를 위한 값들
  datasource:
    lw:
      driverClassName: com.mysql.cj.jdbc.Driver
      jdbcUrl: jdbc:mysql://legacy-write-server:3306/databasename?autoReconnect=true&useSSL=false&zeroDateTimeBehavior=convertToNull
      username: id
      password: password
      maximumPoolSize: 30
      minimumIdle: 5
      poolName: write
      readOnly: false
    lr:
      driverClassName: com.mysql.cj.jdbc.Driver
      jdbcUrl: jdbc:mysql://legacy-read-server:3306/databasename?autoReconnect=true&useSSL=false&zeroDateTimeBehavior=convertToNull
      username: id
      password: password
      maximumPoolSize: 30
      minimumIdle: 5
      poolName: read
      readOnly: true
  # userbenefit 이하 값들은 신규 Hikari datasource를 위한 값들
  userbenefit:
    ubw:
      driverClassName: com.mysql.cj.jdbc.Driver
      jdbcUrl: jdbc:mysql://new-write-server:3306/databasename?autoReconnect=true&useSSL=false&zeroDateTimeBehavior=convertToNull
      username: id
      password: password
      maximumPoolSize: 30
      minimumIdle: 5
      poolName: write
      readOnly: false
    ubr:
      driverClassName: com.mysql.cj.jdbc.Driver
      jdbcUrl: jdbc:mysql://new-read-server:3306/databasename?autoReconnect=true&useSSL=false&zeroDateTimeBehavior=convertToNull
      username: id
      password: password
      maximumPoolSize: 30
      minimumIdle: 5
      poolName: read
      readOnly: true
  # jta 이하 값들은 jta atomikos에서 사용하기 위한 값들
  jta:
    enabled: true
    atomikos:
      datasource:
        lgyw:
          unique-resource-name: xaForLegacyDataSource
          xa-data-source-class-name: com.mysql.cj.jdbc.MysqlXADataSource	# class로 사용되는 MysqlXADataSource 클래스는 mysql-connector-java의 버전에 따라 위치가 다르기 때문에 확인 필요
          xa-properties:
            user: id
            password: password
            url: jdbc:mysql://legacy-write-server:3306/databasename?autoReconnect=true&useSSL=false&zeroDateTimeBehavior=convertToNull
        ubgw:
          unique-resource-name: xaForUserbenefitDataSource
          xa-data-source-class-name: com.mysql.cj.jdbc.MysqlXADataSource
          xa-properties:
            user: id
            password: password
            url: jdbc:mysql://new-write-server:3306/databasename?autoReconnect=true&useSSL=false&zeroDateTimeBehavior=convertToNull
  jpa:
    hibernate:
      ddl-auto: none
    properties:
      hibernate:
        show_sql: fase
        use_sql_comments: true
        format_sql: true
        dialect: org.hibernate.dialect.MySQL55Dialect

 

먼저 Atomikos 사용을 위해서 필요한 UserTransation object와 TransactionManager를 담을 수 있는 클래스를 만들어 보자.

 

AtomikosJtaPlatform.java

package kr.co.within.goodchoice.user.jta.infrastructure.config;

import org.hibernate.engine.transaction.jta.platform.internal.AbstractJtaPlatform;

import javax.transaction.TransactionManager;
import javax.transaction.UserTransaction;

public class AtomikosJtaPlatform extends AbstractJtaPlatform {
    private static final long serialVersionUID = 1L;

    static TransactionManager transactionManager;
    static UserTransaction transaction;

    @Override
    protected TransactionManager locateTransactionManager() {
        return transactionManager;
    }

    @Override
    protected UserTransaction locateUserTransaction() {
        return transaction;
    }
}

 

위와 같이 담을 그릇이 준비 되었다면 해당 Object에 값을 담을 수 있는 클래스를 작성하자.

 

XaDataManagerConfig.java

package kr.co.within.goodchoice.user.jta.infrastructure.config;

import com.atomikos.icatch.jta.UserTransactionImp;
import com.atomikos.icatch.jta.UserTransactionManager;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.orm.jpa.JpaVendorAdapter;
import org.springframework.orm.jpa.vendor.Database;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.transaction.jta.JtaTransactionManager;

import javax.transaction.TransactionManager;
import javax.transaction.UserTransaction;

@Slf4j
@Configuration
@ComponentScan
@EnableTransactionManagement
public class XaDataManagerConfig {
    @Bean
    public JpaVendorAdapter jpaVendorAdapter() {
        HibernateJpaVendorAdapter hibernateJpaVendorAdapter = new HibernateJpaVendorAdapter();
        hibernateJpaVendorAdapter.setShowSql(true);
        hibernateJpaVendorAdapter.setGenerateDdl(false);
        hibernateJpaVendorAdapter.setDatabase(Database.MYSQL);

        return hibernateJpaVendorAdapter;
    }

    @Bean(name = "userTransaction")
    public UserTransaction userTransaction() throws Throwable {
        log.info("========= userTransaction =========");
        UserTransactionImp userTransactionImp = new UserTransactionImp();
        userTransactionImp.setTransactionTimeout(10000);

        return userTransactionImp;
    }

    @Bean(name = "atomikosTransactionManager", initMethod = "init", destroyMethod = "close")
    public TransactionManager atomikosTransactionManager() throws Throwable {
        log.info("========= atomikosTransactionManager =========");
        UserTransactionManager userTransactionManager = new UserTransactionManager();
        userTransactionManager.setForceShutdown(false);

        AtomikosJtaPlatform.transactionManager = userTransactionManager;

        return userTransactionManager;
    }

    @Bean(name = "multiTxManager")
    @DependsOn({"userTransaction", "atomikosTransactionManager"})
    public PlatformTransactionManager transactionManager() throws Throwable {
        log.info("========= transactionManager =========");
        UserTransaction userTransaction = userTransaction();

        AtomikosJtaPlatform.transaction = userTransaction;

        JtaTransactionManager manager = new JtaTransactionManager(userTransaction, atomikosTransactionManager());

        return manager;
    }
}

 

위 파일에서 중요한 부분은 AtomikosJtaPlatform.transactionManager와 AtomikosJtaPlatform.transaction에 값을 담는 부분이다.

그리고 multiTxManager bean에서 두 개의 bean (userTransation, atomikosTransactionManager)을 묶어서 JtaTransactionManager로 묶는 것이다.

 

이렇게까지 작업을 한 이후 실제 물리적으로 떨어져 있는 각각의 데이터베이스를 이용한 DataSource를 만들어 보자

반응형

먼저 Atomikos에서 사용하기 위한 Legacy DataSource 클래스를 작성해 보자.

파일 이름은 XaForLegacyDataSourceConfig 로 작성했다.

사용된 XA 이름은 아래 참고 링크에서 볼 수 있듯이 분산트랜잭션 처리를 위한 표준 스팩 이름이기 때문에 사용하였다.

 

https://d2.naver.com/helloworld/5812258

 

XaForLegacyDataSourceConfig.java

package kr.co.within.goodchoice.user.jta.infrastructure.config;

import com.atomikos.jdbc.AtomikosDataSourceBean;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaVendorAdapter;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import javax.sql.DataSource;
import java.util.Properties;

@Slf4j
@Configuration
@DependsOn("multiTxManager")
@EnableTransactionManagement
@EnableJpaAuditing
@EnableJpaRepositories(
        basePackages = {"kr.co.within.goodchoice.user.jta.domain.legacy"}
        , entityManagerFactoryRef = "xaEntityManagerFactory"
        , transactionManagerRef = "multiTxManager"
)
@EntityScan("kr.co.within.goodchoice.user.jta.domain.legacy")
public class XaForLegacyDataSourceConfig {
    @Value("${spring.jta.atomikos.datasource.lgyw.unique-resource-name}")
    private String uniqueResourceName;

    @Value("${spring.jta.atomikos.datasource.lgyw.xa-data-source-class-name}")
    private String dataSourceClassName;

    @Value("${spring.jta.atomikos.datasource.lgyw.xa-properties.user}")
    private String user;

    @Value("${spring.jta.atomikos.datasource.lgyw.xa-properties.password}")
    private String password;

    @Value("${spring.jta.atomikos.datasource.lgyw.xa-properties.url}")
    private String url;

    @Autowired
    private JpaVendorAdapter jpaVendorAdapter;

    @Bean(name = "xaForLegacyDataSource")
    public DataSource xaForLegacyDataSource() {
        log.info("==================== xaForLegacyDataSource");
        Properties properties = new Properties();
        properties.setProperty("url", url);
        properties.setProperty("user", user);
        properties.setProperty("password", password);

        AtomikosDataSourceBean dataSource = new AtomikosDataSourceBean();
        dataSource.setUniqueResourceName(uniqueResourceName);
        dataSource.setXaDataSourceClassName(dataSourceClassName);
        dataSource.setXaProperties(properties);

        return dataSource;
    }

    @Bean(name = "xaEntityManagerFactory")
    @DependsOn("multiTxManager")
    public LocalContainerEntityManagerFactoryBean xaEntityManagerFactory() {
        log.info("==================== legacyEntityManagerFactory");
        Properties properties = new Properties();
        properties.put("hibernate.transaction.jta.platform", AtomikosJtaPlatform.class.getName());
        properties.put("javax.persistence.transactionType", "JTA");

        LocalContainerEntityManagerFactoryBean entityManager = new LocalContainerEntityManagerFactoryBean();
        entityManager.setDataSource(xaForLegacyDataSource());
        entityManager.setJpaVendorAdapter(jpaVendorAdapter);
        entityManager.setPackagesToScan("kr.co.within.goodchoice.user.jta.domain.legacy");
        entityManager.setPersistenceUnitName("legacy_write_unit");
        entityManager.setJpaProperties(properties);

        return entityManager;
    }
}

 

필요한 접속 정보를 셋팅하고 해당 값으로 Datasource를 만든 다음 entityManagerFactory를 생성 한다.

접속 정보를 @Value로 가지고 와도 되지만 아래처럼 해도 된다. (해보지는 않았지만 될 듯?)

DataSource를 만드는 메소드 위에 아래와 같은 어노테이션을 붙이면 된다.

 

@ConfigurationProperties(prefix = "spring.jta.atomikos.datasource.lgyw.xa")

 

그리고 나서 중요한 것은 위에 XaDataManagerConfig.java에서 만들어 둔 multiTxManager bean에 대한 의존성을 적용해 줘야 한다.

클래스 상단과 entityManagerFactory 상단에 @DependsOn("multiTxManager") 를 넣으면 된다.

 

이 처럼 legacy쪽 Datasource 작업이 끝났다면 신규 Datasource쪽을 작업해 주면 된다. 소스는 비슷하다.

 

XaForUserbenefitDataSourceConfig.java

package kr.co.within.goodchoice.user.jta.infrastructure.config;

import com.atomikos.jdbc.AtomikosDataSourceBean;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.context.annotation.Primary;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaVendorAdapter;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import javax.sql.DataSource;
import java.util.Properties;

@Slf4j
@Primary
@Configuration
@DependsOn("multiTxManager")
@EnableTransactionManagement
@EnableJpaAuditing
@EnableJpaRepositories(
        basePackages = {"kr.co.within.goodchoice.user.jta.domain.userbenefit"}
        , entityManagerFactoryRef = "userbenefitEntityManagerFactory"
        , transactionManagerRef = "multiTxManager"
)
@EntityScan("kr.co.within.goodchoice.user.jta.domain.userbenefit")
public class XaForUserbenefitDataSourceConfig {
    @Value("${spring.jta.atomikos.datasource.ubgw.unique-resource-name}")
    private String uniqueResourceName;

    @Value("${spring.jta.atomikos.datasource.ubgw.xa-data-source-class-name}")
    private String dataSourceClassName;

    @Value("${spring.jta.atomikos.datasource.ubgw.xa-properties.user}")
    private String user;

    @Value("${spring.jta.atomikos.datasource.ubgw.xa-properties.password}")
    private String password;

    @Value("${spring.jta.atomikos.datasource.ubgw.xa-properties.url}")
    private String url;

    @Autowired
    private JpaVendorAdapter jpaVendorAdapter;

    @Primary
    @Bean(name = "xaForUserbenefitDataSource")
    public DataSource xaForUserbenefitDataSource() {
        log.info("==================== xaForUserbenefitDataSource");
        Properties properties = new Properties();
        properties.setProperty("url", url);
        properties.setProperty("user", user);
        properties.setProperty("password", password);

        AtomikosDataSourceBean dataSource = new AtomikosDataSourceBean();
        dataSource.setUniqueResourceName(uniqueResourceName);
        dataSource.setXaDataSourceClassName(dataSourceClassName);
        dataSource.setXaProperties(properties);

        return dataSource;
    }

    @Primary
    @Bean(name = "userbenefitEntityManagerFactory")
    @DependsOn("multiTxManager")
    public LocalContainerEntityManagerFactoryBean userbenefitEntityManagerFactory() {
        log.info("==================== userbenefitEntityManagerFactory");
        Properties properties = new Properties();
        properties.put("hibernate.transaction.jta.platform", AtomikosJtaPlatform.class.getName());
        properties.put("javax.persistence.transactionType", "JTA");

        LocalContainerEntityManagerFactoryBean entityManager = new LocalContainerEntityManagerFactoryBean();
        entityManager.setDataSource(xaForUserbenefitDataSource());
        entityManager.setJpaVendorAdapter(jpaVendorAdapter);
        entityManager.setPackagesToScan("kr.co.within.goodchoice.user.jta.domain.userbenefit");
        entityManager.setPersistenceUnitName("write_unit");
        entityManager.setJpaProperties(properties);

        return entityManager;
    }
}

 

legacy쪽 소스와 다른 점은 클래스와 각 메소드 위에 @Primary 를 사용했다는 점이다.

해당 어노테이션을 붙이지 않을 경우 오류로그에서 기대하는 datasource는 1개인데 그 이상이 들어왔다와 비슷한 오류를 보게 될 것이다.

 

이렇게까지 해 놓고 실제 사용을 해 보자.

필요한 것은 legacy에서 사용할 entity, repository, service 그리고 신규에서 사용할 entity, repository, service 이다.

또한 두 개의 서비스를 묶어 줄 또 하나의 상위 개념 service가 필요하다.

각각의 entity, repository, service는 간단하게 save만 하는 것이 필요하므로 생략하고 두 개를 묶어 줄 service의 소스 코드는 아래와 같다.

 

ApiService.java

package kr.co.within.goodchoice.user.jta.domain;

import kr.co.within.goodchoice.user.jta.domain.legacy.entity.LegacyUser;
import kr.co.within.goodchoice.user.jta.domain.legacy.service.LegacyUserService;
//import kr.co.within.goodchoice.user.jta.domain.userbenefit.entity.UserAddInfo;
//import kr.co.within.goodchoice.user.jta.domain.userbenefit.service.UserAddInfoService;
import kr.co.within.goodchoice.user.jta.domain.userbenefit.entity.UserAddInfo;
import kr.co.within.goodchoice.user.jta.domain.userbenefit.service.UserAddInfoService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;

@Slf4j
@Service
public class ApiService {
    @Autowired
    private LegacyUserService legacyUserService;

    @Autowired
    private UserAddInfoService userAddInfoService;

    @Transactional(propagation = Propagation.REQUIRED, transactionManager = "multiTxManager")
    public void addUser() {
        // legacy DB에서 사용되는 entity객체
        LegacyUser legacyUser = LegacyUser.builder()....build();

        // 신규에서 사용될 entity 객체
        UserAddInfo userAddInfo = UserAddInfo.builder()....build();

        legacyUserService.addUser(legacyUser);
        userAddInfoService.addUserInfo(userAddInfo);
    }
}

 

atomikos를 통해 만든 multiTxManager를 사용한다고 메소드 상단에 명시하고 각가 다른 두 개의 서비스를 호출해 보자.

userAddInfoService.addUserInfo 메소드에서는 다음과 같이 작업을 한다.

 

UserAddInfoService.java

package kr.co.within.goodchoice.user.jta.domain.userbenefit.service;

import kr.co.within.goodchoice.user.jta.domain.userbenefit.entity.UserAddInfo;
import kr.co.within.goodchoice.user.jta.domain.userbenefit.repository.UserAddInfoRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Slf4j
@Service
public class UserAddInfoService {
    @Autowired
    private UserAddInfoRepository userAddInfoRepository;

    public void addUserInfo(UserAddInfo userAddInfo) {
        userAddInfoRepository.save(userAddInfo);
        throw new RuntimeException("add UserInfo fail...");
    }
}

 

일부러 Exception을 일으켜 보면 legacy와 신규 DB 모두에 값이 안들어 가게 될 것이고 exception 부분을 지우고 테스트 하면 잘 들어가게 될 것이다.

개인적으로 테스트 시 이상 없이 동작함을 볼 수 있었다.

 

참고로 테스트를 unitTest를 사용하지 않은 이유는 unitTest를 사용하게 될 경우 자동으로 rollback이 되기 때문이다.

 

이와는 별도로 Hikari Pool을 이용하여 Datasource와 TransactionManager를 만들어 두고 ApiService.java 파일에서 해당 transactionManager를 명시하게 된다면 원하는대로 동작하지 않고 두 Database 모두에 커밋 되거나 하나만 커밋 되는 경우를 볼 수 있다.

 

Hikari Pool을 이용하여 다수의 Datasource를 사요하는 예제는 아래의 포스트를 참고하기 바란다.

2019/08/13 - [Java/Spring] - 멀티 DataSource 접속 방법 정리

 

이상으로 삽질을 마치며 아래는 구글링 하면서 알게 된 참조 사이트 들이다. 훨씬 더 많지만 기억이 안나기 때문에 아래 사이트만 남긴다.

 

https://d2.naver.com/helloworld/5812258

https://bigzero37.tistory.com/65

 

Atomikos를 이용한 이기종 DB 트랜잭션(Springboot + Mybatis) - 2. Application 환경구성 및 샘플코드

Atomikos를 이용한 이기종 DB 트랜잭션(Springboot + Mybatis) - 2. Application 환경구성 및 샘플코드 Springboot 및 Mybatis 를 위한 Config 설정 application.properties 정의 spring.jta.enabled=true # DATAS..

bigzero37.tistory.com

https://supawer0728.github.io/2018/03/22/spring-multi-transaction/

 

(Spring)다중 DataSource 처리

서론Spring Application을 만들면서 여러 DataSource와 transaction이 존재하고 하나의 transaction 내에 commit과 rollback이 잘 동작하도록 하려면 어떻게 설정해야 할까? 실제로 구현을 해본 적은 없지만 세 가지 방법이 머릿속에 떠올랐다. @Transactional의 propagation을 이용 spring-

supawer0728.github.io

 

728x90
반응형