두 개의 물리적으로 다른 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
https://supawer0728.github.io/2018/03/22/spring-multi-transaction/
'Java > Spring' 카테고리의 다른 글
AttributeConverter class registered multiple times 에러가 발생할 경우 (0) | 2020.12.16 |
---|---|
테스트 코드 작성 시 willReturn 값이 안나오는 경우 (0) | 2020.12.02 |
멀티 DataSource 접속 방법 정리 (0) | 2019.08.13 |
Spring Batch, Migration, 튜닝 및 OOM 해결 후기 (0) | 2019.08.12 |
QueryDSL 사용하기 (0) | 2019.08.08 |