서론
여러 개의 DataSource를 다루고 있을 때, 트랜잭션 매니저를 다룰 수 있는 한 가지 방법인ChainedTransactionManager에 대해 알아보고 어떤 문제점이 있는지 찾아본 후, 이를 해결하기 위한 방법 중 하나인 JtaTransactionManager에 대해 다뤄보고자 합니다.
Chained TransactionManager
ChainedTransactionManager란
ChainedTransactionManager는 말그대로 여러 개의 트랜잭션을 묶어서 사용할 수 있는 TransactionManager 입니다. 그렇기 때문에 여러 개의 DataSource를 다룰때 여러개의 TransactionManager를 활용하여 트랜잭션을 시작하고 끝내는 작업을 하나의 TransactionManager를 사용해 트랜잭션을 손쉽게 다룰 수 있습니다.
동작 원리
ChainedTransactionManager는 API 명세를 확인했을 때 다음과 같이 동작하고 있음을 확인할 수 있습니다.
- 설정된 인스턴스들의 순서대로 트랜잭션이 시작되고, 커밋/롤백은 역순으로 수행됩니다
- 어떤 TransactionManager가 예외를 던지면 나머지 TransactionManager는 커밋 대신 롤백을 수행하도록 합니다.
또한 첫번째 TransactionManager가 커밋에 성공하고 두번째 TransactionManager가 모종의 이유(I/O 오류, 트랜잭션 리소스)로 커밋에 실패하면 부분적으로 커밋되었다는 것을 알리기 위해 HeuristicCompletionException 예외를 발생시킵니다.
문제점
ChainedTransactionManager를 API 명세를 봤을 때 다음과 같은 문제점이 떠오를 수 있습니다.
1. 정상적인 롤백 보장 어려움
직전에 ChainedTransactionManagers는 트랜잭션이 부분적으로 커밋이 되었다면, 예외를 발생시킨다고 말씀드렸습니다. 즉 ChainedTransactionManager는 부분적으로 커밋된 트랜잭션으로 인해 불일치 상태를 허용하거나, 복구할 수 있는 경우에 사용하는 것을 권장하고 있습니다.
그렇기 때문에 완벽한 분산 트랜잭션을 구현해야 한다고 했을 때 ChainedTransactionManager를 사용해서는 안되고, 어느 정도 불일치를 수용할 수 있고, 중요도에 따라 TransactionManager 순서를 지정해 사용하는 것을 권장합니다.
2. DB 커넥션 문제
ChainedTransactionManager로 여러개의 DataSource를 사용할 때, 사용하지 않는 TransactionManager가 있더라도 무조건 커넥션을 획득하기 때문에 Connection Pool 문제가 발생할 수 있습니다. 하지만 이 부분은 LazyConnectionDatasourceProxyFactory로 어느정도 해결이 가능합니다.
LazyConnectionDataSourceProxyFactory 역할에 대해 간략하게 설명하면, 커넥션을 직접적으로 사용하기 전까지는 프록시 객체로 두어 커넥션 획득을 지연할 수 있게 도와주는 API입니다.
테스트 코드로 확인해보기
1. DataSource 설정
@Configuration
@EnableJpaRepositories(
entityManagerFactoryRef = "beerEntityManagerFactory",
transactionManagerRef = "beerTransactionManager",
basePackages = "com.easy.springBoot3.domain.beer.repository"
)
@Profile("chained")
public class ChainedBeerDataSourceConfig {
@Value("${dataSource.username}")
private String username;
@Value("${dataSource.password}")
private String password;
@Value("${dataSource.url.beer}")
private String beerUrl;
@Primary
@Bean
public DataSource beerDataSource() {
return DataSourceBuilder.create()
.type(HikariDataSource.class)
.driverClassName("com.mysql.cj.jdbc.Driver")
.username(username)
.password(password)
.url(beerUrl)
.build();
}
@Primary
@Bean
public LocalContainerEntityManagerFactoryBean beerEntityManagerFactory(
EntityManagerFactoryBuilder builder,
@Qualifier("beerDataSource") DataSource dataSource
) {
return builder
.dataSource(dataSource)
.packages("com.easy.springBoot3.domain.beer")
.persistenceUnit("beerEntityManager")
.properties(setProperties())
.build();
}
@Bean
public PersistenceExceptionTranslationPostProcessor exceptionTranslation() {
return new PersistenceExceptionTranslationPostProcessor();
}
@Primary
@Bean
public PlatformTransactionManager beerTransactionManager(
@Qualifier("beerEntityManagerFactory") LocalContainerEntityManagerFactoryBean beerEntityManagerFactory) {
return new JpaTransactionManager(beerEntityManagerFactory.getObject());
}
private HashMap<String, String> setProperties() {
HashMap<String, String> propertiesMap = new HashMap<>();
propertiesMap.put("hibernate.hbm2ddl.auto", "update");
propertiesMap.put("hibernate.jdbc.time_zone", "Asia/Seoul");
return propertiesMap;
}
}
@Configuration
@EnableJpaRepositories(
entityManagerFactoryRef = "studyEntityManagerFactory",
transactionManagerRef = "studyTransactionManager",
basePackages = "com.easy.springBoot3.domain.study.repository")
@Profile("chained")
public class ChainedStudyDataSourceConfig {
@Value("${dataSource.username}")
private String username;
@Value("${dataSource.password}")
private String password;
@Value("${dataSource.url.study}")
private String studyUrl;
@Bean
public DataSource studyDataSource() {
return DataSourceBuilder.create()
.type(HikariDataSource.class)
.driverClassName("com.mysql.cj.jdbc.Driver")
.username(username)
.password(password)
.url(studyUrl)
.build();
}
@Bean
public LocalContainerEntityManagerFactoryBean studyEntityManagerFactory(
EntityManagerFactoryBuilder builder,
@Qualifier("studyDataSource") DataSource dataSource
) {
return builder
.dataSource(dataSource)
.packages("com.easy.springBoot3.domain.study")
.persistenceUnit("studyEntityManager")
.properties(setProperties())
.build();
}
@Bean
public PlatformTransactionManager studyTransactionManager(
@Qualifier("studyEntityManagerFactory") LocalContainerEntityManagerFactoryBean studyEntityManagerFacotry
) {
return new JpaTransactionManager(studyEntityManagerFacotry.getObject());
}
private HashMap<String, String> setProperties() {
HashMap<String, String> propertiesMap = new HashMap<>();
propertiesMap.put("hibernate.hbm2ddl.auto", "update");
propertiesMap.put("hibernate.jdbc.time_zone", "Asia/Seoul");
return propertiesMap;
}
}
2. ChainedTransactionManger 설정
@Configuration
@EnableTransactionManagement
@Profile("chained")
public class ChainedTransactionManagerConfig {
@Bean(name = "chainedTransactionManager")
public PlatformTransactionManager transactionManager(
@Qualifier("studyTransactionManager") PlatformTransactionManager studyTransactionManager,
@Qualifier("beerTransactionManager") PlatformTransactionManager beerTransactionManager) {
return new ChainedTransactionManager(studyTransactionManager, beerTransactionManager);
}
@Bean
public TransactionTemplate transactionTemplate(
@Qualifier("chainedTransactionManager") PlatformTransactionManager transactionManager) {
return new TransactionTemplate(transactionManager);
}
}
3. 서비스 코드 작성 및 테스트
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ChainedService {
private final BeerRepository beerRepository;
private final StudyRepository studyRepository;
@Transactional
public void createChainedBeerAndStudy() {
Beer beer = Beer.of("Sapporo", 100);
beerRepository.save(beer);
Study study = Study.from("Math");
studyRepository.save(study);
if (1 == 1) {
throw new RuntimeException();
}
}
}
4. 테스트 및 결과
테스트한 결과, 실제로 Study 엔티티는 정상적으로 커밋되었으나, Beer 엔티티는 롤백이 된 것을 확인했습니다.
ChainedTransaction Manager 해결책
여러 해결책 중 JtaTransactionManager를 활용하면 분산 트랜잭션의 원자성을 보장할 수 있습니다. JtaTransactionManager를 알아보기 전에 다음 개념에 대해 이해하고 있어야 합니다.
XA란?
- XA는 분산 트랜잭션 처리를 위해 X/Open이 제정한 표준 스펙
- 2PC(2 phase commit)을 기반으로 분산 트랜잭션을 원자적으로 다룰 수 있습니다.
2PC의 동작원리?
각 데이터베이스에 대한 트랜잭션이 시작되었다고 가정했을 때 다음과 같은 흐름을 가져갑니다.
1. 1번 데이터베이스에 데이터를 씁니다.
2. 2번 데이터베이스에 데이터를 씁니다.
3. 1, 2번 데이터베이스 각각에 commit을 할 수 있는지 확인합니다.
4. 각 준비단계에서 문제가 없는 경우 각 데이터베이스에 커밋을 수행합니다.
XAResource 인터페이스를 확인해보면 다음과 같이 prepare 메서드를 통해 commit 준비를 확인할 수 있습니다.
단 2PC에도 아래와 같은 단점이 존재합니다.
2PC 단점
- 트랜잭션이 길어지고, Commit 전 Prepare 단계 때문에 성능상 비효율적
- Prepare에 성공했다고 하더라도, Commit 단계에서 실패할 가능성이 존재
JtaTransactionManager
JtaTransactionManager란?
- JtaTrnascationManager는 JTA를 사용하여 트랜잭션을 관리하는 Spring의 트랜잭션 매니저
- Jta 트랜잭션 매니저는 분산 트랜잭션을 다루는데 적합
- 구현체마다 일부 다르게 동작할 수 있음
구현체
Jta 구현체로는 아래와 같은 것들이 존재합니다. 그리고 테스트로 사용해볼 구현체는 Atomikos 입니다.
테스트 코드로 확인해보기
1. DataSource 설정
아래와 같이 두개 DataSource를 설정해줬습니다.
@Configuration
@EnableJpaRepositories(
entityManagerFactoryRef = "beerEntityManagerFactory",
transactionManagerRef = "beerTransactionManager",
basePackages = "com.easy.springBoot3.domain.beer.repository"
)
@Profile("atomikos")
public class JtaBeerDataSourceConfig {
@Bean(name = "beerDataSource")
public DataSource beerDataSource(Environment env) {
AtomikosDataSourceBean dataSource = new AtomikosDataSourceBean();
dataSource.setUniqueResourceName("beer");
dataSource.setXaDataSourceClassName("com.mysql.cj.jdbc.MysqlXADataSource");
String prefix = "spring.jta.atomikos.datasource.beer";
Properties properties = new Properties();
properties.put("URL", env.getProperty(prefix + ".xa-properties.url"));
properties.put("user", env.getProperty(prefix + ".xa-properties.user"));
properties.put("password", env.getProperty(prefix + ".xa-properties.password"));
dataSource.setXaProperties(properties);
return dataSource;
}
@Bean(name = "beerEntityManagerFactory")
public LocalContainerEntityManagerFactoryBean beerEntityManagerFactory(
EntityManagerFactoryBuilder entityManagerFactoryBuilder, @Qualifier("beerDataSource") DataSource dataSource
) {
return entityManagerFactoryBuilder
.dataSource(dataSource)
.packages("com.easy.springBoot3.domain.beer")
.persistenceUnit("beerEntityManager")
.properties(hibernateProperties())
.build();
}
private HashMap<String, String> hibernateProperties() {
HashMap<String, String> properties = new HashMap<>();
properties.put("hibernate.dialect", "org.hibernate.dialect.MySQL8Dialect");
properties.put("hibernate.hbm2ddl.auto", "update");
properties.put("hibernate.transaction.jta.platform", AtomikosJtaPlatform.class.getName());
properties.put("javax.persistence.transactionType", "JTA");
return properties;
}
@Bean
public PlatformTransactionManager beerTransactionManager(
@Qualifier("beerEntityManagerFactory") LocalContainerEntityManagerFactoryBean beerEntityManagerFactory) {
return new JpaTransactionManager(beerEntityManagerFactory.getObject());
}
}
@Configuration
@EnableJpaRepositories(
entityManagerFactoryRef = "studyEntityManagerFactory",
transactionManagerRef = "studyTransactionManager",
basePackages = "com.easy.springBoot3.domain.study.repository"
)
@Profile("atomikos")
public class JtaStudyDataSourceConfig {
@Primary
@Bean(name = "studyDataSource")
public DataSource studyDataSource(Environment env) {
AtomikosDataSourceBean dataSource = new AtomikosDataSourceBean();
dataSource.setUniqueResourceName("study");
dataSource.setXaDataSourceClassName("com.mysql.cj.jdbc.MysqlXADataSource");
String prefix = "spring.jta.atomikos.datasource.study";
Properties properties = new Properties();
properties.put("URL", env.getProperty(prefix + ".xa-properties.url"));
properties.put("user", env.getProperty(prefix + ".xa-properties.user"));
properties.put("password", env.getProperty(prefix + ".xa-properties.password"));
dataSource.setXaProperties(properties);
return dataSource;
}
@Primary
@Bean(name = "studyEntityManagerFactory")
public LocalContainerEntityManagerFactoryBean studyEntityManagerFactory(
EntityManagerFactoryBuilder entityManagerFactoryBuilder, @Qualifier("studyDataSource") DataSource dataSource
) {
return entityManagerFactoryBuilder
.dataSource(dataSource)
.packages("com.easy.springBoot3.domain.study")
.persistenceUnit("studyEntityManager")
.properties(hibernateProperties())
.build();
}
private HashMap<String, String> hibernateProperties() {
HashMap<String, String> properties = new HashMap<>();
properties.put("hibernate.dialect", "org.hibernate.dialect.MySQL8Dialect");
properties.put("hibernate.hbm2ddl.auto", "update");
properties.put("hibernate.transaction.jta.platform", AtomikosJtaPlatform.class.getName());
properties.put("javax.persistence.transactionType", "JTA");
return properties;
}
@Bean
public PlatformTransactionManager studyTransactionManager(
@Qualifier("studyEntityManagerFactory") LocalContainerEntityManagerFactoryBean studyEntityManagerFacotry
) {
return new JpaTransactionManager(studyEntityManagerFacotry.getObject());
}
}
2. JtaTransactionManger 설정
그리고 분산트랜잭션을 관리할 트랜잭션 매니저는 아래와 같이 설정해줬습니다.
@Configuration
@EnableTransactionManagement
@Profile("atomikos")
public class JtaTransactionManagerConfig {
@Bean(name = "atomikosTransactionManager")
public PlatformTransactionManager transactionManager() {
UserTransactionManager userTransactionManager = new UserTransactionManager();
UserTransactionImp userTransactionImp = new UserTransactionImp();
return new JtaTransactionManager(userTransactionImp, userTransactionManager);
}
}
3. 서비스 코드 작성 및 테스트
서비스 코드는 단순하게 각각 다른 DataSource에서 Insert를 수행하고 예외를 발생시켜 롤백이 되도록 코드를 작성했습니다.
@Service
@RequiredArgsConstructor
public class JtaService {
private final BeerRepository beerRepository;
private final StudyRepository studyRepository;
@Transactional(transactionManager = "atomikosTransactionManager")
public void saveBeerAndStudy() {
Beer beer = Beer.of("Jta Beer", 10);
beerRepository.save(beer);
Study study = Study.from("Jta");
studyRepository.save(study);
if (1 == 1) {
throw new RuntimeException();
}
}
}
테스트
테스트 코드를 수행한 결과, 롤백이 정상적으로 되었기에 데이터가 비어있는 것을 확인할 수 있습니다.
@SpringBootTest
class JtaServiceTest {
@Autowired
private JtaService jtaService;
@Autowired
private BeerRepository beerRepository;
@Autowired
private StudyRepository studyRepository;
@Test
@DisplayName("예외가 발생하면 모든 트랜잭션을 롤백 시킨다")
void createChainedBeerAndStudy() {
assertThatThrownBy(() -> jtaService.saveBeerAndStudy())
.isInstanceOf(RuntimeException.class);
assertThat(beerRepository.findAll().size()).isEqualTo(0);
assertThat(studyRepository.findAll().size()).isEqualTo(0);
}
}
여기까지 분산 트랜잭션을 처리할 수 있는 방법으로 JtaTransactionManager, Atomikos를 소개시켜 드렸습니다.
이외에도 트랜잭션을 처리할 수 있는 방법으로 Saga 패턴이나, 이벤트라는 키워드에 대해서도 알게 되었고, 이후에 다룰 수 있는 기회가 있다면 다뤄보도록 하겠습니다.
읽어주셔서 감사합니다.
'Spring' 카테고리의 다른 글
@PathVariable 엔드포인트 매핑 원리 (1) | 2024.10.12 |
---|---|
Redis, 그리고 Spring 에서 Redis 활용 (6) | 2024.09.07 |
AWS Presigned URL로 트래픽 낮추기 (4) | 2024.03.28 |