배경 및 문제점

운영 중인 시스템에서 다음과 같은 상황이 있었다.
• 하나의 물리 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