하나의 서비스에서 여러 데이터베이스를 동시에 처리해야 하는 경우가 있다.

이때 단일 DataSource 기반의 @Transactional만으로는 여러 데이터베이스 간의 트랜잭션 일관성을 보장하기 어렵다.

예를 들어, 하나의 함수에서 A 데이터베이스 수정 → B 데이터베이스 수정 중 오류 발생

이런 상황에서 A와 B 데이터베이스를 모두 함께 롤백하려면 글로벌 트랜잭션(JTA) 이 필요하다.

JTA가 필요없는 예시:
•회원 정보 수정 → A DB만 사용
•인증 정보 수정 → B DB만 사용

이처럼 상황에 따라 특정 데이터베이스만 접근하는 구조라면, 트랜잭션 매니저를 동적으로 선택하는 방식을 참고하는 것이 더 적절할 수 있다.
(관련 내용은 아래 링크 참고)
https://thornapp.tistory.com/10

 

하나의 DB에서 트랜잭션 매니저를 동적으로 선택하는 방법 (Spring AOP)

배경 및 문제점운영 중인 시스템에서 다음과 같은 상황이 있었다.• 하나의 물리 DB• 특정 역할에 따른 DB 계정 분리• MyBatis + Spring Transaction 사용하지만 Spring의 기본 @Transactional 은→ 트랜잭션

thornapp.tistory.com

 

아키텍처 구성

• Spring Boot
• Atomikos (JTA Transaction Manager)
• Oracle XA DataSource
• MyBatis
• 다중 DB (Primary / Secondary / Third)

java 코드 예제
@Configuration
public class DataSourceConfig {

    /* =====================================================
     * XA DataSource 설정
     *
     * - AtomikosDataSourceBean은 XA 트랜잭션을 지원하는 DataSource
     * - 각 DataSource는 서로 다른 DB를 바라보지만
     * - JTA TransactionManager에 의해 하나의 글로벌 트랜잭션으로 묶인다
     * ===================================================== */

    /**
     * Primary XA DataSource
     * - 기본으로 사용될 DataSource
     * - @Primary로 지정하여 주입 시 우선 선택
     */
    @Primary
    @Bean(name = "primaryDataSource")
    @ConfigurationProperties("spring.datasource.primary")
    public DataSource primaryDataSource() {
        return new AtomikosDataSourceBean();
    }

    /**
     * Secondary XA DataSource
     */
    @Bean(name = "secondaryDataSource")
    @ConfigurationProperties("spring.datasource.secondary")
    public DataSource secondaryDataSource() {
        return new AtomikosDataSourceBean();
    }

    /**
     * Third XA DataSource
     */
    @Bean(name = "thirdDataSource")
    @ConfigurationProperties("spring.datasource.third")
    public DataSource thirdDataSource() {
        return new AtomikosDataSourceBean();
    }

    /* =====================================================
     * JTA Transaction Manager 설정
     *
     * - 여러 XA DataSource를 하나의 글로벌 트랜잭션으로 관리
     * - @Transactional 하나로 다중 DB 트랜잭션을 제어 가능
     * - 내부적으로 2-Phase Commit(2PC) 수행
     * ===================================================== */

    @Bean
    public PlatformTransactionManager transactionManager() throws SystemException {

        // UserTransaction: 트랜잭션의 시작 / 커밋 / 롤백을 제어
        UserTransactionImp userTx = new UserTransactionImp();
        userTx.setTransactionTimeout(30);

        // Atomikos의 핵심 트랜잭션 매니저
        // 여러 XA Resource(DataSource)를 관리
        UserTransactionManager atomikosTxManager = new UserTransactionManager();
        atomikosTxManager.init();

        // Spring에서 사용하는 JTA 트랜잭션 매니저
        // @Transactional 이 이 매니저를 통해 글로벌 트랜잭션을 제어
        return new JtaTransactionManager(userTx, atomikosTxManager);
    }

    /* =====================================================
     * SqlSessionFactory 설정
     *
     * - 각 DataSource마다 별도의 SqlSessionFactory 생성
     * - 하지만 트랜잭션은 모두 JTA(TransactionManager) 하나로 관리됨
     * ===================================================== */

    @Primary
    @Bean(name = "primarySqlSessionFactory")
    public SqlSessionFactory primarySqlSessionFactory(
            @Qualifier("primaryDataSource") DataSource dataSource,
            ApplicationContext context) throws Exception {

        return createSqlSessionFactory(dataSource, context);
    }

    @Bean(name = "secondarySqlSessionFactory")
    public SqlSessionFactory secondarySqlSessionFactory(
            @Qualifier("secondaryDataSource") DataSource dataSource,
            ApplicationContext context) throws Exception {

        return createSqlSessionFactory(dataSource, context);
    }

    @Bean(name = "thirdSqlSessionFactory")
    public SqlSessionFactory thirdSqlSessionFactory(
            @Qualifier("thirdDataSource") DataSource dataSource,
            ApplicationContext context) throws Exception {

        return createSqlSessionFactory(dataSource, context);
    }

    /**
     * SqlSessionFactory 공통 생성 메서드
     *
     * - DataSource만 다르고 MyBatis 설정은 동일
     * - 유지보수를 위해 공통 메서드로 분리
     */
    private SqlSessionFactory createSqlSessionFactory(
            DataSource dataSource,
            ApplicationContext context) throws Exception {

        SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
        factoryBean.setDataSource(dataSource);
        factoryBean.setConfigLocation(
                context.getResource("classpath:mybatis/mybatis-config.xml"));
        factoryBean.setMapperLocations(
                context.getResources("classpath:mapper/**/*.xml"));
        return factoryBean.getObject();
    }

    /* =====================================================
     * SqlSessionTemplate 설정
     *
     * - MyBatis에서 실제 SQL 실행에 사용되는 객체
     * - 각각 다른 DB를 바라보지만
     * - 트랜잭션은 JTA에 의해 하나로 묶인다
     * ===================================================== */

    @Primary
    @Bean(name = "primarySqlSessionTemplate")
    public SqlSessionTemplate primarySqlSessionTemplate(
            @Qualifier("primarySqlSessionFactory") SqlSessionFactory factory) {
        return new SqlSessionTemplate(factory);
    }

    @Bean(name = "secondarySqlSessionTemplate")
    public SqlSessionTemplate secondarySqlSessionTemplate(
            @Qualifier("secondarySqlSessionFactory") SqlSessionFactory factory) {
        return new SqlSessionTemplate(factory);
    }

    @Bean(name = "thirdSqlSessionTemplate")
    public SqlSessionTemplate thirdSqlSessionTemplate(
            @Qualifier("thirdSqlSessionFactory") SqlSessionFactory factory) {
        return new SqlSessionTemplate(factory);
    }
}
DB 설정 yml 예제
spring:
  datasource:
    primary:
      unique-resource-name: primary-db
      xa-datasource-class-name: oracle.jdbc.xa.client.OracleXADataSource
      xa-properties:
        URL: jdbc:oracle:thin:@...
        user: primary_user
        password: primary_password
      min-pool-size: 1
      max-pool-size: 50
      borrow-connection-timeout: 30
      maintenance-interval: 60
      max-lifetime: 3600
      max-idleTime: 60

    secondary:
      unique-resource-name: secondary-db
      xa-datasource-class-name: oracle.jdbc.xa.client.OracleXADataSource
      xa-properties:
        URL: jdbc:oracle:thin:@...
        user: secondary_user
        password: secondary_password
      min-pool-size: 1
      max-pool-size: 50
      borrow-connection-timeout: 30
      maintenance-interval: 60
      max-lifetime: 3600
      max-idleTime: 60
      
    third:
      unique-resource-name: third-db
      xa-datasource-class-name: oracle.jdbc.xa.client.OracleXADataSource
      xa-properties:
        URL: jdbc:oracle:thin:@...
        user: third_user
        password: third_password
      min-pool-size: 1
      max-pool-size: 50
      borrow-connection-timeout: 30
      maintenance-interval: 60
      max-lifetime: 3600
      max-idleTime: 60

lifetime 등 설정값은 찾아보시고 본인 프로젝트에 맞게 변경하시기 바랍니다.

적용예시
@Transactional
public void process() {
    primaryMapper.insertA();
    secondaryMapper.insertB();
    thirdMapper.insertC();
    // 하나라도 실패하면 전체 롤백
}

 

주의사항

• unique-resource-name 중복 금지
• 커스텀한 로컬 트랜잭션과 혼용해서 사용 X
• 성능 오버헤드 고려
• XA 지원 DB만 가능

JTA방식 VS AOP기반 트랜잭션 동적 선택방식
구분 Atomikos + JTA AOP 기반 트랜잭션 선택
트랜잭션 범위 여러 DB를 하나의 글로벌 트랜잭션으로 묶음 DB별 로컬 트랜잭션
정합성 보장 ✅ 완전 보장 (2PC) ❌ 직접 처리 필요
실패 시 처리 자동 전체 롤백 보상 트랜잭션 필요
구현 난이도 높음 중~낮음
성능 상대적으로 느림 빠름
DB 제약 XA 지원 DB 필요 제약 거의 없음
추천 사용처 금융, 정산, 주문, 결제 조회 위주, 비핵심 데이터
배경 및 문제점

운영 중인 시스템에서 다음과 같은 상황이 있었다.
• 하나의 물리 DB
• 특정 역할에 따른 DB 계정 분리
• MyBatis + Spring Transaction 사용

하지만 Spring의 기본 @Transactional 은
트랜잭션 매니저를 컴파일 타임에 고정해야 한다는 한계가 있다.

따라서 @Transactional 어노테이션 사용 시 롤백이 안되는 상황이 발생함.

해결전략

1. 커스텀 어노테이션 정의
2. AOP에서 요청 정보로 국가 식별
3. 테넌트 코드 기반으로 트랜잭션 매니저 Bean 동적 조회
4. Spring PlatformTransactionManager 직접 제어

전체 구조

@TenantTransactional
        ↓
Aspect (Around)
        ↓
RequestVO 에서 tenantId 추출
        ↓
tenantId → TransactionManager 매핑
        ↓
commit / rollback 직접 제어

예제 코드
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TenantTransactional {

    Class<? extends Throwable>[] rollbackFor() default {};
}

 

예제 코드2
@Slf4j
@Aspect
@Component
public class TenantTransactionAspect implements ApplicationContextAware {

    private static final String TX_MANAGER_PREFIX = "mybatisTransactionManager_";

    // 테넌트별 트랜잭션 매니저 캐시
    private final ConcurrentMap<String, PlatformTransactionManager> txManagerCache =
            new ConcurrentHashMap<>();

    private ApplicationContext applicationContext;

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) {
        this.applicationContext = applicationContext;
    }

    /**
     * 테넌트(Tenant) 기준으로 트랜잭션 매니저를 동적으로 선택하여
     * 트랜잭션을 제어하는 Around Aspect
     */
    @Around("@annotation(tenantTx)")
    public Object applyTenantTransaction(
            ProceedingJoinPoint pjp,
            TenantTransactional tenantTx
    ) throws Throwable {

        /*
         * [1] ProceedingJoinPoint(pjp)
         *
         * - AOP가 가로챈 실제 비즈니스 메서드에 대한 실행 정보
         * - 주요 기능
         *   - pjp.getArgs()  : 메서드 파라미터 조회
         *   - pjp.proceed() : 실제 메서드 실행
         */

        // [2] 메서드 인자 중 RequestVO 추출
        //     → 테넌트 식별자(tenantId)를 얻기 위함
        RequestVO requestVO = extractRequestVO(pjp.getArgs());

        // RequestVO가 없으면 테넌트 판단 불가
        // → 트랜잭션 개입 없이 원본 메서드 그대로 실행
        if (requestVO == null) {
            return pjp.proceed();
        }

        // [3] RequestVO에서 테넌트 ID 추출
        String tenantId = requestVO.getTenantId();

        // [4] 테넌트 ID 기반으로 트랜잭션 매니저 동적 조회
        // ex) mybatisTransactionManager_kr
        PlatformTransactionManager txManager =
                resolveTransactionManager(tenantId);

        // [5] 트랜잭션 정의 생성
        // - PROPAGATION_REQUIRED:
        //   기존 트랜잭션이 있으면 참여, 없으면 신규 생성
        DefaultTransactionDefinition definition =
                new DefaultTransactionDefinition();
        definition.setPropagationBehavior(
                TransactionDefinition.PROPAGATION_REQUIRED
        );

        // [6] 트랜잭션 시작
        TransactionStatus status =
                txManager.getTransaction(definition);

        try {
            // [7] 실제 비즈니스 메서드 실행
            Object result = pjp.proceed();

            // [8] 정상 종료 시 트랜잭션 커밋
            commit(txManager, status);

            return result;

        } catch (Throwable ex) {

            /*
             * [9] 예외 발생 시 롤백 여부 판단
             *
             * - @TenantTransactional.rollbackFor 에 명시된 예외면 롤백
             * - 지정되지 않은 경우 기본 정책 적용
             */
            if (shouldRollback(ex, tenantTx.rollbackFor())) {

                // [10] 롤백 수행
                rollback(txManager, status, ex);

            } else {

                // [11] 롤백 대상이 아닌 예외는 커밋 처리
                commit(txManager, status);
            }

            // [12] 예외는 반드시 다시 던져
            //      상위 레이어에서 처리하도록 함
            throw ex;
        }
    }

    private PlatformTransactionManager resolveTransactionManager(String tenantId) {

        String beanName = TX_MANAGER_PREFIX + tenantId.toLowerCase();

        return txManagerCache.computeIfAbsent(beanName, name -> {
            try {
                return applicationContext.getBean(
                        name,
                        PlatformTransactionManager.class
                );
            } catch (Exception e) {
                log.warn(
                        "Failed to resolve transaction manager. tenantId={}, beanName={}",
                        tenantId,
                        name,
                        e
                );
                throw new IllegalStateException(
                        JavaMessage.getMessage("util_define_server_error"),
                        e
                );
            }
        });
    }

    private void commit(
            PlatformTransactionManager txManager,
            TransactionStatus status
    ) {
        if (!status.isCompleted()) {
            try {
                txManager.commit(status);
            } catch (Exception e) {
                log.warn(
                        "Transaction commit failed. txManager={}",
                        txManager,
                        e
                );
                throw new IllegalStateException(
                        JavaMessage.getMessage("util_define_server_error"),
                        e
                );
            }
        }
    }

    private void rollback(
            PlatformTransactionManager txManager,
            TransactionStatus status,
            Throwable original
    ) {
        if (!status.isCompleted()) {
            try {
                txManager.rollback(status);
            } catch (Exception e) {
                e.addSuppressed(original);
                log.warn(
                        "Transaction rollback failed. txManager={}",
                        txManager,
                        e
                );
                throw new IllegalStateException(
                        JavaMessage.getMessage("util_define_server_error"),
                        e
                );
            }
        }
    }

    private boolean shouldRollback(
            Throwable ex,
            Class<? extends Throwable>[] rollbackFor
    ) {
        if (rollbackFor == null || rollbackFor.length == 0) {
            return ex instanceof Exception;
        }
        for (Class<? extends Throwable> type : rollbackFor) {
            if (type.isAssignableFrom(ex.getClass())) {
                return true;
            }
        }
        return false;
    }

    private RequestVO extractRequestVO(Object[] args) {
        if (args == null) {
            return null;
        }
        for (Object arg : args) {
            if (arg instanceof RequestVO) {
                return (RequestVO) arg;
            }
        }
        return null;
    }
}

 

사용예시
@TenantTransactional(rollbackFor = { BusinessException.class })
public void processOrder(RequestVO requestVO) {
    // 비즈니스 로직
}

 

장점과 주의사항

장점
• 테넌트 추가 시 설정만 추가하면 됨
• 서비스 코드 오염 없음
• 하나의 DB에서 유저 분리 구조에 적합

주의사항
• 중첩 트랜잭션 / 다른 @Transactional 과 혼용 금지
• 반드시 Aspect 우선순위 관리
• RequestVO 누락 시 동작 정의 필요

+ Recent posts