

회사에서 프로젝트를 진행 중에 기존 회사의 데이터가 있는 스키마, 새로운 고객에 대한 정보가 있는 스키마
하나의 물리 DB에 2개의 스키마를 연결해서 개발을 진행해야만 했다.
사용하는 ORM 이나 DSL에 맞춰 잘 설정해준다면 그렇게 어렵지 않게 데이터 핸들링 및 트랜잭션을 처리할 수 있었다.
여기서 순수하게 궁금해졌다. 물리적으로 IP가 다른 DB를 2개 연결하여 데이터 핸들링과 트랜잭션을 잘 처리할 수 있을까,,? 바로 간단한 게시판 프로젝트를 만들었다.
결론부터 말하자면 물론 가능한 일이다
(https://github.com/Hyuk0816/mulitiTransactional)
DB1:

DB2:

서로 IP 주소가 다른 GCP VM에 도커로 MYSQL 컨테이너를 띄어 스키마와 테이블을 만들어줬다.
물리적으로 같은 DB에 2개의 스키마만 연결해서 사용하는 것이라면 Transaction Propagation(트랜잭션 전파)로 해결이 가능했을 것이다. 허나 물리적으로 서로 주소가 다르다면,,??

Atomikos 가 그 문제를 해결해 줄 것이니,, 오늘은 Atomikos 를 이용해 JTA를 적용하여 글로벌 트랜잭션으로 물리적으로 다른 DB를 연결하여 데이터 핸들링을 해보겠다.
JTA(Java Transaction Api)는 자바 표준으로 애플리케이션의 분산 트랜잭션을 지원해주며 DB,메세징 시스템 등 다양한 리소스에 대한 트랜잭션 처리가 가능하다. JTA는 또한, 트랜잭션 시작, 커밋, 롤백 등의 기능을 제공하며, 애플리케이션 개발자가 트랜잭션 관리에 신경 쓰지 않고도 안전하고 일관된 데이터 처리를 할 수 있게 해준다.
위에서 이야기했듯이 이 글에선 오픈 소스로 제공하는 Atomikos를 이용할 에정이다.
XA는 분산 트랜잭션 처리를 위한 프로토콜이다. 2PC 형태, 즉 2단계 커밋 방식으로 분산 트랜잭션을 처리한다.
가장 중요한건 이와 같은 방법으로 DB 벤더(mysql,pgsql 등)와 상관 없이 하나 이상 사용할 때 트랜잭션을 보장해준다.
//JTA 멀티 트랜잭션
implementation 'com.atomikos:transactions-spring-boot3-starter:6.0.0'
implementation 'jakarta.transaction:jakarta.transaction-api:2.0.1'
implementation 'com.atomikos:transactions-jdbc:6.0.0'
implementation 'mysql:mysql-connector-java:8.0.33'
springboot 3.0 이상에서는 기존 지원하는 의존성이 바뀌었기 때문에 spring 버전에 맞춰 의존성을 추가해준다.
spring:
datasource:
one:
xa-properties:
url: jdbc:mysql://host1:3306/muliti_user?characterEncoding=UTF-8&serverTimezone=Asia/Seoul
user: ${user}
password: ${pwd}
xa-data-source-class-name: com.mysql.cj.jdbc.MysqlXADataSource
unique-resource-name: 'one'
test-query: SELECT 1
min-pool-size: 5
max-pool-size: 10
hibernate:
ddl-auto: update
naming:
implicit-strategy: org.hibernate.boot.model.naming.ImplicitNamingStrategyLegacyJpaImpl
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
two:
xa-properties:
url: jdbc:mysql://host2:3306/multi_transactional?characterEncoding=UTF-8&serverTimezone=Asia/Seoul
user: ${user}
password: ${pwd}
xa-data-source-class-name: com.mysql.cj.jdbc.MysqlXADataSource
unique-resource-name: 'two'
min-pool-size: 2
max-pool-size: 2
hibernate:
ddl-auto: update
YML 파일에서 중요한 부분은 기존 단일 DB를 사용했을 때와 다르게 MysqlXADataSource 를 사용하여 XA 프로토콜을 이용하여 분산 트랜잭션을 사용할 수 있도록 XA 전용 드라이버로 지정한 점이다.
@Data
@Configuration
@ConfigurationProperties(prefix = "spring.datasource.hikari")
public class DatabaseProperties {
private One one;
private Two two;
@Data
public static class One {
private XaProperties xaProperties;
private String xaDataSourceClassName;
private String uniqueResourceName;
private int maxPoolSize;
private Hibernate hibernate;
}
@Data
public static class Two {
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 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());
}
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;
}
xa-properties 설정 가능 리스트는 AtomikosDataSourceBean 을 보면 나와있다. 설정 파일을 객체에 매핑하기 위해 DatabaseProperties 를 작성해준다.
@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 {
var 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;
}
}
사용할 JTATRANSACTIONMANAGER 의 빈 이름을 지정하여 스프링 빈으로 등록해준다.
@Configuration
@EnableConfigurationProperties(DatabaseProperties.class)
@EnableJpaRepositories(
basePackages = {"dev.study.multitransaction.db1.user",
"dev.study.multitransaction.db1.Log"},
entityManagerFactoryRef = MainDbConfiguration.ENTITY_MANGER_BEAN_NAME,
transactionManagerRef = XaDataSourceConfig.TRANSACTION_MANAGER_BEAN_NAME
)
public class MainDbConfiguration {
public static final String ENTITY_MANGER_BEAN_NAME = "oneDBEntityManager";
private static final String DATASOURCE_BEAN_NAME = "oneDataSource";
private static final String DATASOURCE_PROPERTIES_PREFIX = "spring.datasource.one";
private static final String HIBERNATE_PROPERTIES = "oneHibernateProperties";
@Primary
@Bean(name = ENTITY_MANGER_BEAN_NAME)
public LocalContainerEntityManagerFactoryBean entityManager(EntityManagerFactoryBuilder builder, @Qualifier(DATASOURCE_BEAN_NAME) DataSource dataSource,
@Qualifier(HIBERNATE_PROPERTIES) DatabaseProperties.Hibernate hibernateProperties) {
return builder.dataSource(dataSource).packages("dev.study.multitransaction.db1.user", "dev.study.multitransaction.db1.Log")
.persistenceUnit(ENTITY_MANGER_BEAN_NAME)
.properties(DatabaseProperties.Hibernate.propertiesToMap(hibernateProperties)).build();
}
@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();
}
}
여기서는 Main DB라고 표현했다. user와 log 테이블이 있는 DB1을 설정해준다. ENTITY_MANGER_BEAN_NAME도 설정해주고,
@EnableJpaRepositories(
basePackages = {"dev.study.multitransaction.db1.user",
"dev.study.multitransaction.db1.Log"},
entityManagerFactoryRef = MainDbConfiguration.ENTITY_MANGER_BEAN_NAME,
transactionManagerRef = XaDataSourceConfig.TRANSACTION_MANAGER_BEAN_NAME
)
애논테이션으로 패키지도 매핑해준다.
@Configuration
@EnableConfigurationProperties(DatabaseProperties.class)
@EnableJpaRepositories(
basePackages = {"dev.study.multitransaction.db2.board"},
entityManagerFactoryRef = SecondDbConfiguration.ENTITY_MANAGER_BEAN_NAME,
transactionManagerRef = XaDataSourceConfig.TRANSACTION_MANAGER_BEAN_NAME
)
public class SecondDbConfiguration {
public static final String ENTITY_MANAGER_BEAN_NAME = "twoDBEntityManager";
private static final String DATASOURCE_BEAN_NAME = "twoDataSource";
private static final String DATASOURCE_PROPERTIES_PREFIX = "spring.datasource.two";
private static final String HIBERNATE_PROPERTIES = "twoHibernateProperties";
@Bean(name = ENTITY_MANAGER_BEAN_NAME)
public LocalContainerEntityManagerFactoryBean entityManager(EntityManagerFactoryBuilder builder, @Qualifier(DATASOURCE_BEAN_NAME) DataSource dataSource,
@Qualifier(HIBERNATE_PROPERTIES) DatabaseProperties.Hibernate hibernateProperties) {
return builder.dataSource(dataSource).packages("dev.study.multitransaction.db2.board").persistenceUnit(ENTITY_MANAGER_BEAN_NAME)
.properties(DatabaseProperties.Hibernate.propertiesToMap(hibernateProperties)).build();
}
@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();
}
}
Main DB 설정과 같이 Second DB도 설정해준다.
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "name", nullable = false)
private String name;
@Column(name = "email", nullable = false)
private String email;
@Column(name = "nickName")
private String nickName;
@Column(name = "password", nullable = false)
private String password;
@Column(name = "role")
@Enumerated(EnumType.STRING)
private ROLE role;
@Table(name = "log")
public class Log {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "log_id", nullable = false)
private Long logId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", referencedColumnName = "user_id",
foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) //같은 물리 DB에서는 먹힘
private User user;
@Column(name = "board_id", nullable = false)
private Long boardId;
@Column(name = "reg_date", nullable = false)
private LocalDateTime regDate;
}
@Table(name = "board")
public class Board {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "board_id", nullable = false)
private Long boardId;
@Column(name = "title", nullable = false)
private String title;
@Column(name = "content", nullable = false)
private String content;
@Column(name = "user_id", nullable = false)
private Long userId; //물리 주소가 다르면 CONSTRAINT가 먹히지 않는다,,
}
public interface BoardService {
void saveBoard(BoardCreateRequestVo requestVo);
}
필자는 JPA를 사용하고 있다 따라서 DB Insert는 Simple JPA Repo에 SAVE를 이용해서 Board DB에 데이터를 Insert 해보겠다.
@Override
@Transactional
public void saveBoard(BoardCreateRequestVo requestVo) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
User user = userRepository.findByEmail(authentication.getName())
.orElseThrow(() -> new UserNameNotFoundException(UserConstants.STATUS_404,UserConstants.MESSAGE_404));
Board board = Board.builder()
.title(requestVo.getTitle())
.content(requestVo.getContent())
.userId(user.getUserId())
.build();
//Db2
boardRepository.save(board);
Log log = Log.builder()
.boardId(board.getBoardId())
.user(user)
.regDate(LocalDateTime.now())
.build();
//Db1
logRepository.save(log);
}
DB2에 있는 게시물 작성 시 DB1 Log DB에도 함께 정보가 저장된다. 이는 글로벌 트랜잭션이 처리되는지 보기 위해 글 작성시 로그도 같이 작성되게 설계했다.

컨트롤러를 작성해서 Swagger로 테스트 해보자!

글 작성은 완료가 됐다 로그를 확인해 보면
2024-10-15T00:03:08.158+09:00 Creating new transaction with name [dev.study.multitransaction.db2.board.service.impl.BoardServiceImpl.saveBoard]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
2024-10-15T00:03:08.159+09:00 Getting transaction for [dev.study.multitransaction.db2.board.service.impl.BoardServiceImpl.saveBoard]
2024-10-15T00:03:08.159+09:00 No need to create transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findByEmail]: This method is not transactional.
2024-10-15T00:03:08.182+09:00 Participating in existing transaction
2024-10-15T00:03:08.182+09:00 Getting transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]
2024-10-15T00:03:08.212+09:00 Completing transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]
2024-10-15T00:03:08.213+09:00 Participating in existing transaction
2024-10-15T00:03:08.213+09:00 Getting transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]
2024-10-15T00:03:08.222+09:00 Completing transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]
2024-10-15T00:03:08.222+09:00 Completing transaction for [dev.study.multitransaction.db2.board.service.impl.BoardServiceImpl.saveBoard]
2024-10-15T00:03:08.222+09:00 Initiating transaction commit
2024-10-15T00:03:08.158+09:00 Creating new transaction with name [dev.study.multitransaction.db2.board.service.impl.BoardServiceImpl.saveBoard]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
saveBoard 메서드가 호출되면서 새로운 트랜잭션이 생성.
전파 방식은 PROPAGATION_REQUIRED로 설정되어 있으며, 이는 기존 트랜잭션이 없으면 새 트랜잭션을 생성하고, 이미 존재하는 경우 그 트랜잭션에 참여하는 방식이다.
격리 수준은 ISOLATION_DEFAULT로 설정되어 있습니다.
2024-10-15T00:03:08.159+09:00 No need to create transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findByEmail]: This method is not transactional.
2024-10-15T00:03:08.182+09:00 Participating in existing transaction
2024-10-15T00:03:08.182+09:00 Getting transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]
2024-10-15T00:03:08.212+09:00 Completing transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]
2024-10-15T00:03:08.213+09:00 DEBUG 5514 --- [nio-8080-exec-6] o.s.t.jta.JtaTransactionManager : Participating in existing transaction
2024-10-15T00:03:08.213+09:00 TRACE 5514 --- [nio-8080-exec-6] o.s.t.i.TransactionInterceptor : Getting transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]
2024-10-15T00:03:08.222+09:00 TRACE 5514 --- [nio-8080-exec-6] o.s.t.i.TransactionInterceptor : Completing transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]
2024-10-15T00:03:08.222+09:00 TRACE 5514 --- [nio-8080-exec-6] o.s.t.i.TransactionInterceptor : Completing transaction for [dev.study.multitransaction.db2.board.service.impl.BoardServiceImpl.saveBoard]
2024-10-15T00:03:08.222+09:00 DEBUG 5514 --- [nio-8080-exec-6] o.s.t.jta.JtaTransactionManager : Initiating transaction commit
Board 저장 후 LOG를 SAVE하기 위해 다시 기존 트랜잭션에 참가하며 Log Save 후 트랜잭션을 커밋한 것을 볼 수 있다.
public interface BoardRepository extends JpaRepository<Board, Integer>,BoardRepositoryCustom {
}
public interface BoardRepositoryCustom {
FetchDetailBoardDto getThisBoard(Long boardId);
FetchDetailBoardDto fetchDetailBoard(Long boardId, Long userId);
}
public class BoardRepositoryImpl implements BoardRepositoryCustom {
private final JPAQueryFactory queryFactoryDb1;
private final JPAQueryFactory queryFactoryDb2;
public BoardRepositoryImpl(@Qualifier("oneDBEntityManager") EntityManager em1,
@Qualifier("twoDBEntityManager") EntityManager em2) {
this.queryFactoryDb1 = new JPAQueryFactory(em1);
this.queryFactoryDb2 = new JPAQueryFactory(em2);
}
@Override
public FetchDetailBoardDto fetchDetailBoardTest(Long boardId, Long userId) {
return queryFactoryDb2.select(Projections.fields(FetchDetailBoardDto.class,
board.title,
board.content,
user.email,
log.regDate))
.from(board)
.leftJoin(user)
.on(board.userId.eq(userId))
.leftJoin(log)
.on(board.boardId.eq(log.boardId))
.where(user.userId.eq(userId).and(board.boardId.eq(boardId)))
.fetchOne();
}
@Override
public FetchDetailBoardDto fetchDetailBoard(Long boardId) {
FetchMyBoard boardInfo = queryFactoryDb2.select(Projections.fields(FetchMyBoard.class,
board.title,
board.content))
.from(board)
.where(board.boardId.eq(boardId))
.fetchOne();
UserBoardInfo userInfo = queryFactoryDb1.select(Projections.fields(UserBoardInfo.class,
user.email,
log.regDate))
.from(user)
.leftJoin(log)
.on(user.userId.eq(log.user.userId))
.where(log.boardId.eq(boardId))
.fetchOne();
return FetchDetailBoardDto.builder()
.email(Objects.requireNonNull(userInfo).getEmail())
.title(Objects.requireNonNull(boardInfo).getTitle())
.content(boardInfo.getContent())
.regDate(userInfo.getRegDate())
.build();
}
}
자 그럼 데이터 핸들링은 어떻게 해야할까? 여기 두개의 메서드가 있다. fetchDetailBoardTest는 queryFactoryDb2 만을 사용하여 게시판,유저,로그의 데이터를 가져오고
fetchDetailBoard 는 queryFactoryDb1, queryFactoryDb2 를 사용하여 게시판,유저,로그의 데이터를 가져온다
@GetMapping("/detail_board")
public ResponseEntity<FetchDetailBoardDto> fetchDetailBoard(@RequestParam("boardId") Long boardId){
return ResponseEntity
.status(HttpStatus.OK)
.body(boardService.getDetailBoard(boardId));
}
@GetMapping("/detail_board_test")
public ResponseEntity<FetchDetailBoardDto> fetchDetailBoardTest(@RequestParam("boardId") Long boardId){
return ResponseEntity
.status(HttpStatus.OK)
.body(boardService.getDetailBoardOnly2(boardId));
}
컨트롤러 코드도 작성해주고 스웨거로 가보자~
먼저 fetchDetailBoard 를 테스트하면

게시판 정보, 유저 정보, 로그 정보를 잘 가져오는 것을 볼 수 있다.
fetchDetailBoardTest 를 실행하면 다음과 같은 에러가 뜨는데

이를 자세히 보면
2024-10-15T00:30:34.585+09:00 TRACE 5514 --- [nio-8080-exec-3] o.s.t.i.TransactionInterceptor : Completing transaction for [dev.study.multitransaction.db2.board.service.impl.BoardServiceImpl.getDetailBoardOnly2] after exception: org.springframework.dao.InvalidDataAccessApiUsageException: org.hibernate.query.sqm.UnknownEntityException: Could not resolve entity name 'User'
2024-10-15T00:30:34.595+09:00 DEBUG 5514 --- [nio-8080-exec-3] o.s.t.jta.JtaTransactionManager : Initiating transaction rollback
2024-10-15T00:30:34.608+09:00 ERROR 5514 --- [nio-8080-exec-3] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.dao.InvalidDataAccessApiUsageException: org.hibernate.query.sqm.UnknownEntityException: Could not resolve entity name 'User'] with root cause
org.hibernate.query.sqm.EntityTypeException: Could not resolve entity name 'User
USER라는 엔티티를 찾을 수 없어 오류를 뿜는걸 볼 수 있다. 당연한 결과이다 queryFactoryDb2는 Board 테이블만 있는 DB에 매핑해주었으니 USER를 찾을 수 없던 것이라 오류를 뿜는 것이다.
이렇게 물리 주소가 다른 2개의 DB에서는 각각의 데이터를 가져온 뒤,, Builder 로 다시 만들어 return 해줘야하는 엄청난 불편함이 있었다,,(물론 내가 주니어라서 한방에 가져올 수 있는 방법이 있는데 못한걸 수도,,,)
이렇게 오늘은 서로 다른 IP를 가진 두개의 DB를 연결하여 트랜잭션 처리와 데이터 핸들링 하는 것을 알아봤다. JTA 가 분산트랜잭션을 처리해줘서 편했지만 단일 트랜잭션을 사용하는 것보다 당연히 성능의 이슈가 있을거라 생각한다.
DB를 마이그레이션 중의 개발을 진행한다든지, 기능 이전과 같은 작업을 할 때 유용하게 사용할 수 있겠지만,, 실제 프로덕션 환경에선,,, 잘 모르겠다,, MSA는 서로 다른 IP의 DB를 연결해서 데이터를 핸들링 할텐데,, 엔터프라이즈 기업들은 어떻게 잘 운영할 수 있을까?? 아직 갈 길이 멀다,,