스프링 부트 다중 데이터베이스 트랜잭션 처리 (jta-atomikos)
해당 포스팅은 스프링부트 환경에서 다중 데이터베이스를 연결할 때 'multi datasource에 대한 트랜잭션 처리'에 대해 정리한 내용입니다.
내용은 크게 스프링에서 지원하는 'ChainedTransactionManager'와 Java에서 지원하는 'JTA(Java Transaction API)'에 대한 내용으로 나뉘며, ChainedTransactionManager의 경우에는 현재 deprecated 된 상태이기 때문에 세부적인 구현보다는 동작되는 방식과 deprecated 된 이유에 대해서만 살펴보고 JTA에 대해서는 Atomikos를 사용한 실제 구현 방식과 트랜잭션 테스트 결과까지 확인해 볼 예정입니다.
PlatformTransactionManager
먼저 아래에서 살펴볼 두 방식은 모두 내부적으로 TransactionManager의 최상위 인터페이스인 'PlatformTransactionManager'가 사용되며, 각 환경에 맞는 TransactionManager 클래스가 주입되어 사용됩니다.
ChainedTransactionManger
'ChainedTransactionManager'는 org.springframework.data (Spring Data Commons)에서 공식적으로 지원되었던 기술로 현재는 deprecated 되었습니다.
ChainedTransactionManager의 경우 각각의 Datasource에 대한 TransactionManager를 체인 형태로 등록, 연결하여 트랜잭션을 순차적으로 실행시켜 주는 방식입니다.
이러한 방식으로 인해 '지정된 순서대로 트랜잭션을 시작하고 역순으로 커밋 or 롤백을 한다는 특징'이 있습니다.
때문에 트랜잭션의 순서(체이닝 순서)가 매우 중요하며, 트랜잭션의 순서로 인해 트랜잭션이 보장되지 않는 경우가 발생할 수 있습니다.
T3 (Commit) => T2 (Rollback) => T1 (Rollback)
예를 들어 위 이미지와 같이 체이닝 된 트랜잭션에서 다음과 같이 'T3'이 커밋된 다음 'T2'에서 오류가 발생하여 롤백이 된다면 체이닝 순서 상 'T3'는 이미 커밋된 상태이기 때문에 롤백을 할 수 없게 됩니다.
즉, 하나의 트랜잭션에 대해 커밋이 완료된 후, 다음 트랜잭션 커밋 과정에서 오류가 발생할 경우 이전에 커밋된 내용에 대해서는 롤백을 보장할 수 없다는 문제가 있습니다.
XA와 JTA(Java Transaction API)
먼저 'XA'는 분산 트랜잭션 처리를 위한 사양인 'eXtended Architecture'를 의미하며, XA의 목표는 이기종 구성 요소가 포함된 전역 트랜잭션에 대한 원자성을 제공하는 것입니다.
'JTA(Java Transaction API)'는 자바에서 트랜잭션을 제어하기 위한 목적으로 사용되며, XA 아키텍처를 기반으로 분산 트랜잭션을 수행합니다.
플랫폼마다 상이한 TranscationManager들과 애플리케이션들이 상호작용 할 수 있는 인터페이스가 정의되어 있습니다.
Atomikos를 통한 분산 트랜잭션 구현 및 테스트
* 아래 예시는 spring boot 3.2.0 버전에서 구현되었으며, 2개의 MySQL 데이터베이스에 연결한 예시이기 때문에 DBMS가 다를 경우 DataSourceConfig에 대한 설정이 다를 수 있습니다.
// https://mvnrepository.com/artifact/com.atomikos/transactions-spring-boot3-starter
implementation group: 'com.atomikos', name: 'transactions-spring-boot3-starter', version: '6.0.0'
// https://mvnrepository.com/artifact/jakarta.transaction/jakarta.transaction-api
implementation group: 'jakarta.transaction', name: 'jakarta.transaction-api', version: '2.0.1'
먼저 다음과 같이 'transactions-spring-boot3-starter' 및 'jakarta.transaction-api' 의존성을 추가합니다.
(6.0.0 버전이 spring boot 3.0 이상을 지원하는 버전입니다.)
spring:
jpa:
hibernate:
show-sql: true
ddl-auto: validation
open-in-view: false
show-sql: true
properties:
hibernate:
format_sql: true
dialect: org.hibernate.dialect.MySQL5InnoDBDialect
datasource:
first:
xa-properties:
url: jdbc:mysql://localhost:3306/first_database
user: root
password:
xa-data-source-class-name: com.mysql.cj.jdbc.MysqlXADataSource
unique-resource-name: 'first'
min-pool-size: 5
max-pool-size: 10
hibernate:
dialect: org.hibernate.dialect.MySQLDialect
naming:
implicit-strategy: org.hibernate.boot.model.naming.ImplicitNamingStrategyLegacyJpaImpl
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
second:
xa-properties:
url: jdbc:mysql://localhost:3306/second_database
user: root
password:
xa-data-source-class-name: com.mysql.cj.jdbc.MysqlXADataSource
unique-resource-name: 'second'
min-pool-size: 5
max-pool-size: 10
hibernate:
ddl-auto: none
dialect: org.hibernate.dialect.MySQLDialect
(application.yml)
이어서 'application.yml' 파일을 살펴보면 다음과 같은데요.
datasource 하위에 'first', 'second'를 통해 각각의 데이터베이스에 대한 url, user, password 등의 설정 값을 입력하였습니다.
여기서 조금 중요하게 봐야 할 부분은 JDBC 드라이버로 XA 전용 드라이버인 'MysqlXADataSource'가 사용되었다는 것입니다.
@Data
@ConfigurationProperties(prefix = "spring.datasource.hikari")
public class DatabaseProperties {
private First first;
private Second second;
@Data
public static class First {
private XaProperties xaProperties;
private String xaDataSourceClassName;
private String uniqueResourceName;
private int maxPoolSize;
private Hibernate hibernate;
}
@Data
public static class Second {
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;
}
}
(DatabaseProperties class)
그리고 위 application.yml에 설정된 값을 다음과 같은 DatabaseProperties 클래스를 통해 가져옵니다.
@Configuration
@EnableConfigurationProperties(DatabaseProperties.class)
@EnableJpaRepositories(
basePackages = "com.example.multidatabase.first",
entityManagerFactoryRef = FirstDataSourceConfig.ENTITY_MANAGER_BEAN_NAME,
transactionManagerRef = XaDataSourceConfig.TRANSACTION_MANAGER_BEAN_NAME
)
public class FirstDataSourceConfig {
public static final String ENTITY_MANAGER_BEAN_NAME = "firstEntityManager";
private static final String DATASOURCE_BEAN_NAME = "firstDataSource";
private static final String DATASOURCE_PROPERTIES_PREFIX = "spring.datasource.first";
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.multidatabase.first");
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();
}
}
(FirstDataSourceConfig class)
메인이 되는 데이터베이스에 대한 Config 파일이며, 해당 데이터베이스에 대한 EntityManager를 빈으로 등록합니다.
이때 등록되는 DataSource는 com.atomikos.jdbc 패키지의 'AtomikosDataSourceBean' 클래스가 사용됩니다.
@Configuration
@EnableConfigurationProperties(DatabaseProperties.class)
@EnableJpaRepositories(
basePackages = "com.example.multidatabase.second",
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.second";
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.multidatabase.second");
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();
}
}
(SecondDataSourceConfig class)
나머지 데이터베이스에 대한 Config 파일이며, 마찬가지로 해당 데이터베이스에 대한 EntityManager를 빈으로 등록합니다.
메인이 되는 데이터베이스에 대한 Config 파일에서와 동일하게 'AtomikosDataSourceBean'이 DataSource로 사용됩니다.
@Configuration
@EnableTransactionManagement
public class XaDataSourceConfig {
public static final String TRANSACTION_MANAGER_BEAN_NAME = "jtaTransactionManager";
@Bean
public UserTransactionManager userTransactionManager() throws SystemException {
UserTransactionManager userTransactionManager = new UserTransactionManager();
userTransactionManager.setTransactionTimeout(1000);
userTransactionManager.setForceShutdown(true);
return userTransactionManager;
}
@Bean
public UserTransaction userTransaction() throws SystemException {
UserTransaction userTransaction = new UserTransactionImp();
userTransaction.setTransactionTimeout(60000);
return userTransaction;
}
@Primary
@Bean(name = TRANSACTION_MANAGER_BEAN_NAME)
public JtaTransactionManager jtaTransactionManager(
UserTransactionManager userTransactionManager,
UserTransaction userTransaction
) {
JtaTransactionManager jtaTransactionManager = new JtaTransactionManager();
jtaTransactionManager.setTransactionManager(userTransactionManager);
jtaTransactionManager.setUserTransaction(userTransaction);
return jtaTransactionManager;
}
}
(XaDataSourceConfig class)
이어서 분산 트랜잭션을 적용하기 위한 'JtaTransactaionManager'를 빈으로 등록하는 Config 파일입니다.
'UserTransaction' 인스턴스를 통해 트랜잭션의 경계를 구분하며, 해당 인스턴스를 관리하는 'UserTransactionManager'를 통해 트랜잭션을 시작하거나 복구하는 등의 단계를 자동으로 처리할 수 있습니다.
여기까지의 구현이 정상적으로 되었다면 프로젝트 실행 시 다음과 같이 'Thanks for using Atomikos!'라는 메시지와 함께 atomikos와 관련된 설정 내용이 로그로 찍히는 것을 확인할 수 있습니다.
@Transactional
public void transactionTest() {
// secondDatabase User Entity
User user = User.builder()
.email("transactionTest")
.name("transactionTest")
.build();
userRepository.save(user);
// firstDatabase Product Entity (save시 Product Entity의 필드 값 null로 인해 exception 발생)
Product product = Product.builder()
.code("transactionTest")
.build();
productRepository.save(product);
}
(전역 트랜잭션 적용 테스트 코드 중 일부)
그리고 다음과 같이 두 개의 DataSource에 대한 트랜잭션의 원자성을 테스트해 본 결과 firstDatabase의 Product Entity save 과정에서 exception 발생 시, 먼저 save 된 secondDatabase의 User Entity도 함께 롤백되는 것을 확인할 수 있었습니다.
* Atomikos를 통한 분산 트랜잭션을 적용하지 않았을 때는 해당 코드 동작 시 secondDatabase의 User Entity가 롤백되지 않았습니다.
(이 부분은 해당 트랜잭션에 적용된 TransactionManager에 따라 다를 수 있습니다.)
여기까지 'Atomikos'를 통해 여러 개의 DataSource를 사용하는 환경에서 전역 트랜잭션 처리를 구현해 보았습니다.
세부적인 동작 과정에 대해서는 아직 자세하게 살펴보지 못하여 내용적으로 부족한 부분이 많은 점 양해 부탁드립니다.
추가적으로 이렇게 전역적으로 트랜잭션을 처리했을 때 성능적인 문제도 발생할 것으로 예상되는데, 실제 운영에서 다음과 같은 전역 트랜잭션을 구현할 성능으로 인한 문제도 고려되어야 할 것 같습니다.
< github 소스 >
https://github.com/JianChoi-Kor/multidatabase-atomikos
< 참고 자료 >
https://velog.io/@suhongkim98/Spring-Data-JPA-multi-datasource-%EA%B8%80%EB%A1%9C%EB%B2%8C-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0Atomikos-JtaTransactionManager-2
https://www.baeldung.com/java-atomikos
https://kindloveit.tistory.com/120
'Programming > Spring Boot' 카테고리의 다른 글
스프링 부트 내장 톰캣 제거 방법 (exclude embedded tomcat) (0) | 2024.02.06 |
---|---|
스프링부트 내장 톰캣(서블릿 컨테이너) 실행되는 과정 (0) | 2024.02.06 |
Spring Boot 다중 데이터베이스 구성하는 방법 (0) | 2023.12.05 |
(Spring Data JPA) 데이터베이스 View, Entity 매핑하기 (0) | 2023.11.28 |
BeanUtils.copyProperties를 사용한 객체 필드값 복사 (0) | 2023.11.25 |