본문 바로가기
Spring

트랜잭션 AOP 주의사항

by sangyunpark99 2025. 3. 1.
@Transactional을 사용했는데
왜 DB에 데이터가 저장이 안될까?

 

 

이번 글은 트랜잭션을 사용시 주의 사항에 대해 알아보겠습니다.

참고 강의 : 김영한의 스프링 DB 2편 - 데이터 접근 활용 기술

 

트랜잭션 AOP에 무슨 문제가 있는 걸까요? 왜 DB에 데이터가 저장되지 않을까요?

 

Transaction AOP

 

@Transactional 어노테이션을 사용하는 경우, Spring에서 프록시 방식의 Transaciton AOP를 사용합니다.

즉, 프록시 트랜잭션 객체가 트랜잭션을 처리하고, 실제 객체를 호출해주는 방식입니다.

따라서 트랜잭션을 적용하려면 항상 프록시를 통해서 대상 객체(Target) 호출해야 합니다.

 

혹시, 프록시 트랜잭션 객체를 거치지 않아서 DB에 데이터가 저장되지 않은걸까요?

 

네, 맞습니다. 프록시 객체를 거치지 않고 직접 대상 객체를 호출하는 경우 AOP가 적용되지 않아서, 트랜잭션이 적용되지 않습니다.

 

 

어떻게, 프록시 트랜잭션 객체를 거치지 않을 수 있나요?

 

대상 객체의 내부에서 메서드 호출이 발생하면 프록시를 거치지 않고 대상 객체를 직접 호출하는 문제가 발생합니다.

이 상황을 코드를 통해서 알아보겠습니다.

 

CallService

@Slf4j
static class CallService {

    public void external(){
        log.info("call external");
        internal();
        printTxInfo();
    }

    @Transactional
    public void internal() {
        log.info("call internal");
        printTxInfo();
    }

    private void printTxInfo() {
        boolean txActive =
            TransactionSynchronizationManager.isActualTransactionActive();
            log.info("tx active={}", txActive);
        }
}

 

CallService 클래서 내부에 @Transactional을 사용하는 메서드가 interanl()로 1개 존재하기 때문에, 트랜잭션 프록시 객체가 주입됩니다.

printTxInfo() 메서드를 통해 현재 메서드에서 트랜잭션이 사용중인지 확인할 수 있습니다.

 

정말, CallService를 주입받을때, 프록시 객체가 주입될까요?

 

코드로 확인해보겠습니다. 테스트 코드는 다음과 같습니다.

@Test
void printProxy() {
    log.info("callService class={}",callService.getClass());
}

 

출력 결과는 다음과 같습니다.

 

해당 클래스를 출력해보면 뒤에 CGLIB가붙은 것을 확인할 수 있습니다. 이는 원본 객체 대신에 트랜잭션을 처리하는 프록시 객체 주입 받았음을 의미합니다. 

 

프록시 객체를 주입받은 것을 확인했으니, 대상 객체의 내부에서 메서드 호출이 발생하면 프록시를 거치지 않고 대상 객체를 직접 호출하는 문제를 테스트 코드로 확인 해보겠습니다.

 

테스트 하는 상황은 2가지가 있습니다.

  • internal() 메서드 호출 - internal() 메서드 트랜잭션 적용
  • external() 메서드 내에서 internal() 메서드 호출 - internal() 메서드 트랜잭션 미적용

 

internal() 메서드 호출 Test

먼저, internal() 메서드만 호출해서 트랜잭션이 잘 적용되는지  확인해보겠습니다.

@Test
void internalCall() {
    callService.internal();
}

 

출력 결과는 다음과 같습니다.

 

internalCall() 메서드는 트랜잭션이 활성 상태입니다.

 

 

external() 메서드 내에서 internal() 메서드 호출 Test

test에 사용되는 external() 메서드는 다음과 같습니다.

public void external(){
    log.info("call external");
    internal();
    printTxInfo();
}

 

 

external()메서드를 호출하는 테스트 코드는 다음과 같습니다.

@Test
void externalCall() {
    callService.external();
}

 

 

출력 결과는 다음과 같습니다.

 

external() 메서드는 @Transactional이 선언되어 있지 않기 때문에 tx active=false임을 확인할 수 있습니다.

하지만, external() 메서드 내부에 호출된 internal() 메서드는 @Transactional이 선언되어 있음에도 tx active = false가 됩니다.

 

즉, 대상 객체의 내부에서 메서드 호출이 발생하면 프록시를 거치지 않고 대상 객체를 직접 호출하는 문제가 발생한 것입니다.

 

왜, 이런 문제가 발생할까요?

 

테스트 코드 흐름을 그림으로 나타내면 아래와 같습니다.

이미지 출처 : 김영한의 "스프링 DB 2편 - 데이터 접근 활용 기술"

 

  1. 테스트 코드에서 callService.external() 메서드를 호출합니다. callService()는 트랜잭션 프록시 객체입니다.
  2. external() 메서드에는 @Transactional이 없기 때문에, 트랜잭션 프록시 객체는 트랜잭션을 적용하지 않습니다.
  3. 트랜잭션을 적용하지 않고, 실제 callService 객체 인스턴스의 external()을 호출합니다.
  4. external() 내부에서 internal() 메서드를 호출합니다. 

자바에서 메서드 안에서 별다른 참조 없이 다른 메서드를 부르면, 자동으로 this를 통해 자기 자신을 호출하게 됩니다.
예를 들어, internal()을 부르는 경우 사실은 this.internal()을 부르는 것입니다. 그런데 이 this는 프록시 객체가 아니라, 원래 객체(target)를 직접 가리키게 됩니다. 그럼 결국 진짜 객체의 메서드를 바로 실행하게 됩니다.

 

즉, 자기 자신의 메서드를 호출하면 프록시를 건너뛰고 바로 실제 객체를 부르기 때문에, 스프링이 제공하는 @Transactional 같은 기능이 빠지는 문제가 발생합니다. 즉, 프록시라는 '중간 관리자'를 건너뛰면서 트랜잭션이 걸리지 않게 됩니다.

 

이 문제를 어떻게 해결할 수 있을까요?

 

internal()메서드를 별도의 클래스로 분리하면 됩니다.

 

아래 코드 처럼 external()과 internal() 메서드를 별도의 클래스로 분리해줍니다.

@Slf4j
@RequiredArgsConstructor
static class CallService {
    private final InternalService internalService;
    public void external() {
        log.info("call external");
        printTxInfo();
        internalService.internal();
    }
    private void printTxInfo() {
        boolean txActive =
                TransactionSynchronizationManager.isActualTransactionActive();
        log.info("tx active={}", txActive);
    }
}

@Slf4j
static class InternalService {
    @Transactional
    public void internal() {
        log.info("call internal");
        printTxInfo();
    }
    private void printTxInfo() {
        boolean txActive =
                TransactionSynchronizationManager.isActualTransactionActive();
        log.info("tx active={}", txActive);
    }
}

 

테스트 코드는 다음과 같습니다.

@Test
public void external() {
    callService.external();
}

 

출력 결과는 다음과 같습니다.

 

internal() 메서드에 트랜잭션이 적용된 것을 확인할 수 있습니다.

 

왜 적용이 됬을까요?

 

테스트 코드 흐름을 그림으로 나타내면 아래와 같습니다.

이미지 출처 : 김영한의 "스프링 DB 2편 - 데이터 접근 활용 기술"

 

  1. callService.external()을 호출합니다. 이때 callService는 프록시가 아닌 실제 CallService 객체입니다.
  2. external() 메서드 내에서 주입받은 internalService.internal()을 호출합니다.
  3. 이때 internalService는 internal()에 @Transactional이 있기 때문에, 트랜잭션 프록시 객체입니다. 
  4. 트랜잭션 적용 후, internalService 객체 인스턴스의 internal()메서드를 호출합니다.

 

external() 메서드 내부에서 internal()메서드를 호출해도, internal()메서드는 트랜잭션 프록시 객체로 주입 받은 internalService 프록시 객체의 내부 메서드이기 때문에, 트랜잭션이 잘 적용됩니다.

 

정리

  • 프록시 객체를 거치지 않고 직접 대상 객체를 호출하는 경우 AOP가 적용되지 않아서, 트랜잭션이 적용되지 않습니다.
  • 외부에서 주입받은 다른 서비스(InternalService)를 호출하는 경우, 그 서비스가 트랜잭션 프록시로 감싸져 있다면 트랜잭션이 정상적으로 적용됩니다.
  • 트랜잭션 프록시 객체에서 this.메서드명을 호출할 경우에 this는 트랜잭션 프록시 객체가 아니라 실제 객체를 가리키기 때문에 트랜잭션이 적용되지 않습니다.

'Spring' 카테고리의 다른 글

Thread Local  (0) 2025.02.28
@Transactional 원리  (0) 2025.02.25
트랜잭션 전파 다양한 옵션  (0) 2025.02.17
싱글톤 컨테이너  (0) 2025.02.17
트랜잭션 전파 REQUIRES_NEW 활용  (0) 2025.02.15