Spring

ChainedTransactionManager, JtaTransactionManager

recent0 2024. 7. 17. 23:16

서론

여러 개의 DataSource를 다루고 있을 때, 트랜잭션 매니저를 다룰 수 있는 한 가지 방법인ChainedTransactionManager에 대해 알아보고 어떤 문제점이 있는지 찾아본 후, 이를 해결하기 위한 방법 중 하나인 JtaTransactionManager에 대해 다뤄보고자 합니다.

Chained TransactionManager

ChainedTransactionManager란

ChainedTransactionManager는 말그대로 여러 개의 트랜잭션을 묶어서 사용할 수 있는 TransactionManager 입니다. 그렇기 때문에 여러 개의 DataSource를 다룰때 여러개의 TransactionManager를 활용하여 트랜잭션을 시작하고 끝내는 작업을 하나의 TransactionManager를 사용해 트랜잭션을 손쉽게 다룰 수 있습니다.

동작 원리

ChainedTransactionManager는 API 명세를 확인했을 때 다음과 같이 동작하고 있음을 확인할 수 있습니다.

ChainedTransactionManager API 명세 일부

  • 설정된 인스턴스들의 순서대로 트랜잭션이 시작되고, 커밋/롤백은 역순으로 수행됩니다
  • 어떤 TransactionManager가 예외를 던지면 나머지 TransactionManager는 커밋 대신 롤백을 수행하도록 합니다.

또한 첫번째 TransactionManager가 커밋에 성공하고 두번째 TransactionManager가 모종의 이유(I/O 오류, 트랜잭션 리소스)로 커밋에 실패하면 부분적으로 커밋되었다는 것을 알리기 위해 HeuristicCompletionException 예외를 발생시킵니다.

문제점

ChainedTransactionManager를 API 명세를 봤을 때 다음과 같은 문제점이 떠오를 수 있습니다.

1. 정상적인 롤백 보장 어려움

직전에 ChainedTransactionManagers는 트랜잭션이 부분적으로 커밋이 되었다면, 예외를 발생시킨다고 말씀드렸습니다. 즉 ChainedTransactionManager는 부분적으로 커밋된 트랜잭션으로 인해 불일치 상태를 허용하거나, 복구할 수 있는 경우에 사용하는 것을 권장하고 있습니다.

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의 동작원리?

출처 : https://ebrary.net/64872/computer_science/introduction_phase_commit

각 데이터베이스에 대한 트랜잭션이 시작되었다고 가정했을 때 다음과 같은 흐름을 가져갑니다.

1. 1번 데이터베이스에 데이터를 씁니다.

2. 2번 데이터베이스에 데이터를 씁니다.

3. 1, 2번 데이터베이스 각각에 commit을 할 수 있는지 확인합니다.

4. 각 준비단계에서 문제가 없는 경우 각 데이터베이스에 커밋을 수행합니다.

 

XAResource 인터페이스를 확인해보면 다음과 같이 prepare 메서드를 통해 commit 준비를 확인할 수 있습니다.

XAResource 인터페이스 API

 

단 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