Develog
코드스테이츠 53일차 본문
JTA를 이용한 분산 트랜잭션 적용
때때로 서로 다른 데이터소스를 사용하는 한 개 이상의 데이터베이스를 하나의 트랜잭션으로 묶어서 처리해야 할 경우가 있는데 이를 분산 트랜잭션 이라고 한다.
백업을 위해 특정 데이터베이스의 데이터를 다른 데이터베이스로 복제하는 방법은 여러가지가 존재한다.
같은 종류의 데이터베이스일 경우, 복제(Replication) 기능을 이용해서 데이터를 백업할 수 있다.
다른 종류의 데이터베이스 간에 사용할 수 있는 방법은 애플리케이션의 스케쥴링 기능을 통해 주기적으로 원본 데이터베이스의 데이터를 다른 데이터베이스로 백업하는 기능을 구현할 수 있으며, 이런 기능들을 기본적으로 지원하는 Apache NiFi 같은 오픈 소스 기술을 사용할 수도 있다.
두 개의 데이터베이스 설정
두 개의 데이터베이스에 분산 트랜잭션을 적용하려면 두 개의 데이터베이스에 모두 다음과 같은 설정이 필요하다.
- 해당 데이터베이스에 맞는 데이터소스(Datasource)를 생성한다.
- 각 데이터소스를 JPA의 EntityManager가 인식하도록 설정한다.
- JTA TransactionManager 설정
- 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 |