Notice
Recent Posts
Recent Comments
Link
«   2025/07   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30 31
Archives
Today
Total
관리 메뉴

Develog

코드스테이츠 53일차 본문

코드스테이츠

코드스테이츠 53일차

안형준 2022. 7. 11. 19:10

JTA를 이용한 분산 트랜잭션 적용

 

때때로 서로 다른 데이터소스를 사용하는 한 개 이상의 데이터베이스를 하나의 트랜잭션으로 묶어서 처리해야 할 경우가 있는데 이를 분산 트랜잭션 이라고 한다.

 

백업을 위해 특정 데이터베이스의 데이터를 다른 데이터베이스로 복제하는 방법은 여러가지가 존재한다.

같은 종류의 데이터베이스일 경우, 복제(Replication) 기능을 이용해서 데이터를 백업할 수 있다.

다른 종류의 데이터베이스 간에 사용할 수 있는 방법은 애플리케이션의 스케쥴링 기능을 통해 주기적으로 원본 데이터베이스의 데이터를 다른 데이터베이스로 백업하는 기능을 구현할 수 있으며, 이런 기능들을 기본적으로 지원하는 Apache NiFi 같은 오픈 소스 기술을 사용할 수도 있다.

 

두 개의 데이터베이스 설정

두 개의 데이터베이스에 분산 트랜잭션을 적용하려면 두 개의 데이터베이스에 모두 다음과 같은 설정이 필요하다.

  1. 해당 데이터베이스에 맞는 데이터소스(Datasource)를 생성한다.
  2. 각 데이터소스를 JPA의 EntityManager가 인식하도록 설정한다.
  3. JTA TransactionManager 설정
  4. JTA Platform 설정

 

커피 주문을 위한 데이터베이스(XaCoffeeOrderConfig) 설정

// (1) JpaRepository 활성화

@EnableJpaRepositories(

        basePackages = {"com.codestates.member",

                "com.codestates.stamp",

                "com.codestates.order",

                "com.codestates.coffee"},

        entityManagerFactoryRef = "coffeeOrderEntityManager"

)

@Configuration

public class XaCoffeeOrderConfig {

// (2) 데이터소스 생성

    @Primary

    @Bean

    public DataSource dataSourceCoffeeOrder() {

        MysqlXADataSource mysqlXADataSource = new MysqlXADataSource();

        mysqlXADataSource.setURL("jdbc:mysql://localhost:3306/coffee_order" +

                "?allowPublicKeyRetrieval=true" +

                "&characterEncoding=UTF-8");

        mysqlXADataSource.setUser("guest");

        mysqlXADataSource.setPassword("guest");



        AtomikosDataSourceBean atomikosDataSourceBean = new AtomikosDataSourceBean();

        atomikosDataSourceBean.setXaDataSource(mysqlXADataSource);

        atomikosDataSourceBean.setUniqueResourceName("xaCoffeeOrder");



        return atomikosDataSourceBean;

    }



// (3) EntityManagerFactoryBean 설정

    @Primary

    @Bean

    public LocalContainerEntityManagerFactoryBean coffeeOrderEntityManager() {

        LocalContainerEntityManagerFactoryBean emFactoryBean =

                new LocalContainerEntityManagerFactoryBean();

        HibernateJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();

        vendorAdapter.setDatabase(Database.MYSQL);

        Map<String, Object> properties = new HashMap<>();

        properties.put("hibernate.hbm2ddl.auto", "create");

        properties.put("hibernate.show_sql", "true");

        properties.put("hibernate.format_sql", "true");



// (4)

        properties.put("hibernate.transaction.jta.platform", 

                                             AtomikosJtaPlatform.class.getName());

        properties.put("javax.persistence.transactionType", "JTA");



        emFactoryBean.setDataSource(dataSourceCoffeeOrder());

        emFactoryBean.setPackagesToScan(new String[]{

                "com.codestates.member",

                "com.codestates.stamp",

                "com.codestates.order",

                "com.codestates.coffee"

        });

        emFactoryBean.setJpaVendorAdapter(vendorAdapter);

        emFactoryBean.setPersistenceUnitName("coffeeOrderPersistenceUnit");

        emFactoryBean.setJpaPropertyMap(properties);



        return emFactoryBean;

    }

}

(1)에서는 데이터베이스를 사용하기 위한 JpaReposiroty가 위치한 패키지와 entityManagerFactory 빈에 대한 참조를 설정한다.

  • basePackages

ㄴ기존에 사용하던 JpaRepository를 그대로 사용하도록 해당 Repository가 있는 패키지 경로를 적어준다.

  • entityManagerFactoryRef

ㄴ(3)의 Bean 생성 메서드 명을 적어준다.

(2)에서는 데이터베이스에 대한 데이터소스를 생성하기 위해 데이터베이스 접속 정보들을 설정한다.

(3)에서는 JPA의 EntityManager를 얻기 위해서 LocalContainerEntityManagerFactoryBean을 사용하고 있다.

  • LocalContainerEntityManagerFactoryBean에서 사용하는 어댑터 중에서 우리가 사용하는 HibernateJpaVendorAdapter를 설정해주고, Hibernate에서 필요한 설정 정보를 Map으로 설정해준다.
  • EntityManager가 사용할 Entity 클래스가 위치한 패키지 경로를 지정해준다.

(4)와 같이 JTA Platform의 이름을 추가해주어야 한다.

 

백업용 회원 정보 데이터베이스(XaBackupConfig) 설정

// (1)

@EnableJpaRepositories(

        basePackages = {"com.codestates.backup"},

        entityManagerFactoryRef = "backupEntityManager"

)

@Configuration

public class XaBackupConfig {

    @Bean

    public DataSource dataSourceBackup() {

// (2)

        MysqlXADataSource mysqlXADataSource = new MysqlXADataSource();

        mysqlXADataSource.setURL("jdbc:mysql://localhost:3306/backup_data" +

                "?allowPublicKeyRetrieval=true" +

                "&characterEncoding=UTF-8");

        mysqlXADataSource.setUser("backup");

        mysqlXADataSource.setPassword("backup");



        AtomikosDataSourceBean atomikosDataSourceBean = new AtomikosDataSourceBean();

        atomikosDataSourceBean.setXaDataSource(mysqlXADataSource);

        atomikosDataSourceBean.setUniqueResourceName("xaMySQLBackupMember");



        return atomikosDataSourceBean;

    }



    @Bean

    public LocalContainerEntityManagerFactoryBean backupEntityManager() {

        LocalContainerEntityManagerFactoryBean emFactoryBean =

                new LocalContainerEntityManagerFactoryBean();

        HibernateJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();

        vendorAdapter.setDatabase(Database.MYSQL);

        Map<String, Object> properties = new HashMap<>();

        properties.put("hibernate.hbm2ddl.auto", "create");

        properties.put("hibernate.show_sql", "true");

        properties.put("hibernate.format_sql", "true");

        properties.put("hibernate.transaction.jta.platform",  

                                             AtomikosJtaPlatform.class.getName());

        properties.put("javax.persistence.transactionType", "JTA");



        emFactoryBean.setDataSource(dataSourceBackup());



// (3)

        emFactoryBean.setPackagesToScan(new String[]{"com.codestates.backup"});

        emFactoryBean.setJpaVendorAdapter(vendorAdapter);

        emFactoryBean.setPersistenceUnitName("backupPersistenceUnit");

        emFactoryBean.setJpaPropertyMap(properties);



        return emFactoryBean;

    }

}

데이터베이스에서 사용할 JpaRepository가 위치한 패키지 경로를 (1)과 같이 바꿔줘야한다.

MySQL 접속 정보를 (2)와 같이 입력해주어야 한다.

EntityManager가 사용할 Entity 클래스(여기서는 BackupMember)가 위치한 패키지 경로를 (3)과 같이 변경해준다.

 

JTA TransactionManager 설정

@Configuration

public class JtaConfig {

// (1)

    @Bean(name = "userTransaction")

    public UserTransaction userTransaction() throws Throwable {

        UserTransactionImp userTransactionImp = new UserTransactionImp();

        userTransactionImp.setTransactionTimeout(10000);

        return userTransactionImp;

    }



    @Bean(name = "atomikosTransactionManager")

    public TransactionManager atomikosTransactionManager() throws Throwable {

// (2)

        UserTransactionManager userTransactionManager = new UserTransactionManager();

        userTransactionManager.setForceShutdown(false);



// (3)

        AtomikosJtaPlatform.transactionManager = userTransactionManager;



        return userTransactionManager;

    }



    @Bean(name = "transactionManager")

    @DependsOn({ "userTransaction", "atomikosTransactionManager" })

    public PlatformTransactionManager transactionManager() throws Throwable {

        UserTransaction userTransaction = userTransaction();



        AtomikosJtaPlatform.transaction = userTransaction;



        TransactionManager atomikosTransactionManager = atomikosTransactionManager();



// (4)

        return new JtaTransactionManager(userTransaction, atomikosTransactionManager);

    }

}

Atomikos와 관련된 두 개의 Bean은 userTransaction과 atomikosTransactionManager이며, 이 두 개의 Bean을 (4)와 같이 JtaTransactionManager의 생성자로 넘겨주면 Atomikos의 분산 트랜잭션을 사용할 수 있다.

  • (1)의 UserTransaction은 애플리케이션이 트랜잭션 경계에서 관리되는 것을 명시적으로 정의한다.
  • (2)와 같이 UserTransaction을 관리하는 UserTransactionManager를 생성한 후에 (3)과 같이 AtomikosJtaPlatform의 트랜잭션 매니저로 설정한다.

 

JTA Platform(AtomikosJtaPlatform) 설정

public class AtomikosJtaPlatform  extends AbstractJtaPlatform {

    static TransactionManager transactionManager;

    static UserTransaction transaction;



    @Override

    protected TransactionManager locateTransactionManager() {

        return transactionManager;

    }



    @Override

    protected UserTransaction locateUserTransaction() {

        return transaction;

    }

}

AbstractJtaPlatform을 상속한 후에 트랜잭션 매니저의 위치와 UserTransaction의 위치를 지정만 해주면 되는데, 이 작업은 JTA TransactionManager(JtaConfig) 설정에서 이루어진다.

 

회원 백업 정보 엔티티 클래스 정의

// 추가된 부분

    @Enumerated(value = EnumType.STRING)

    @Column(length = 20, nullable = false)

    private MemberStatus memberStatus = MemberStatus.MEMBER_ACTIVE;



    public BackupMember(String email) {

        this.email = email;

    }



    public BackupMember(String email, String name, String phone) {

        this.email = email;

        this.name = name;

        this.phone = phone;

    }



    public enum MemberStatus {

        MEMBER_ACTIVE("활동중"),

        MEMBER_SLEEP("휴면 상태"),

        MEMBER_QUIT("탈퇴 상태");



        @Getter

        private String status;



        MemberStatus(String status) {

           this.status = status;

        }

    }

회원 백업 정보 엔티티(BackupMember.java)

다른 엔티티 클래스와의 연관 관계를 제거했으며, 나머지 코드는 Member 클래스와 동일하다.

 

회원 백업 정보 저장을 위한 Repository 인터페이스

import com.codestates.backup.entity.BackupMember;

import org.springframework.data.jpa.repository.JpaRepository;



public interface BackupMemberRepository extends JpaRepository<BackupMember, Long> {

}

회원 백업 정보 저장을 위한 BackupMemberRepository는 Member가 BackupMember로 변경되었다는 것 외에 MemberRepository와 동일하다.

 

MemberService에서 회원 정보와 회원 백업 정보 등록

public MemberService(BackupMemberService backupMemberService, // (1)

                         MemberRepository memberRepository) {

        this.backupMemberService = backupMemberService;

        this.memberRepository = memberRepository;

        this.backupMemberRepository = backupMemberRepository;

    }



    @Transactional

    public Member createMember(Member member) {

        verifyExistsEmail(member.getEmail());

        Member savedMember = memberRepository.save(member);



// (2)

        backupMemberService.createBackupMember(new BackupMember(member.getEmail(),

                member.getName(), member.getPhone()));



        return savedMember;

    }



...

...

}
  • (1)에서 백업용 회원 정보를 저장할 BackupMemberService를 DI 받는다.
  • 회원 정보를 등록한 후, (2)에서 백업용 회원 정보를 저장한다.

 

MemberService에서 회원 정보와 회원 백업 정보 등록

@Service

public class BackupMemberService {

    private final BackupMemberRepository backupMemberRepository;



    public BackupMemberService(BackupMemberRepository backupMemberRepository) {

        this.backupMemberRepository = backupMemberRepository;

    }



    @Transactional

    public void createBackupMember(BackupMember backupMember) {

        backupMemberRepository.save(backupMember);



// (1)

        throw new RuntimeException("multi datasource rollback test");

    }

}

위 코드는 실질적으로 백업용 회원 정보 저장 작업을 수행하는데, 회원 정보 저장 중에 예외 발생을 시뮬레이션하기 위해 (1)과 같이 RuntimeException을 발생시켰다.

 

테스트 결과

이제 애플리케이션을 실행시키고, Postman으로 postMember()에 요청을 전송하면 두 개의 데이터베이스에 각각 회원 정보를 저장하기 위한 작업을 하다가 BackupMemberService에서 예외가 발생하기 때문에 두 개의 데이터베이스에는 모두 데이터가 저장되지 않는다.

이처럼 분산 트랜잭션을 적용하면 서로 다른 리소스에 대한 작업을 수행하더라도 하나의 작업처럼 관리하기 때문에 원자성을 보장할 수 있다는 사실을 기억하자

 

총정리

  • 서로 다른 데이터소스를 사용하는 한 개 이상의 데이터베이스를 하나의 트랜잭션으로 묶어서 처리해야 하는 것을 분산 트랜잭션이라고 한다.
  • Atomikos는 Spring Boot에서 스타터로 지원하는 가장 인기있는 오픈 소스 트랜잭션 관리자이다.

'코드스테이츠' 카테고리의 다른 글

코드스테이츠 55일차  (0) 2022.07.13
코드스테이츠 54일차  (0) 2022.07.12
코드스테이츠 52일차  (0) 2022.07.09
코드스테이츠 51일차  (0) 2022.07.07
코드스테이츠 50일차  (0) 2022.07.06