[Spring] 왜 @Transactional 내부에서 호출한 @Transactional은 안 먹힐까?
안녕하세요. 트랜잭션이 흥미로운 개발자 stark입니다!
최근 트랜잭션에 대해서 공부하다 보니 좀 더 궁금한 것들이 있어서 포스팅을 작성하게 되었습니다. 이번 포스팅에서는 같은 클래스 내에서 메서드를 호출할 때 프록시 기반 트랜잭션이 동작하지 않는 문제를 다뤄봤습니다.
Spring에서 트랜잭션 관리는 데이터의 무결성을 보장하는 중요한 기법입니다. 잘못된 트랜잭션 관리로 인해 발생할 수 있는 데이터 손실이나 오류를 방지하기 위해서는 트랜잭션의 동작 방식을 제대로 이해해야 합니다. 특히 같은 클래스 내에서의 메서드 호출 시 트랜잭션이 의도한 대로 동작하지 않는 경우가 있어 이를 이해하고 적절히 대응하는 것이 중요합니다.
프록시 기반 트랜잭션이란?
Spring에서 트랜잭션 관리를 위해 @Transactional 어노테이션을 사용할 때, Spring은 기본적으로 프록시(proxy) 패턴을 사용합니다. 이 프록시는 해당 메서드가 호출될 때 트랜잭션을 시작하고, 완료하거나 롤백하는 역할을 합니다.
프록시 방식은 클래스 외부에서 트랜잭션이 적용된 메서드를 호출할 때 잘 동작합니다. 그러나 같은 클래스 내의 다른 메서드를 호출할 때는 프록시가 개입하지 않기 때문에 트랜잭션이 올바르게 적용되지 않을 수 있습니다. 이는 Java의 내부 메서드 호출이 단순히 객체의 메모리 내 메서드 참조로 처리되기 때문입니다. 트랜잭션 프록시가 관여하지 못해 @Transactional이 적용된 부분이 무시되는 것입니다.
문제 상황을 예시 코드로 이해하기
- 아래 예시는 같은 클래스 내 메서드 호출로 인해 트랜잭션이 제대로 동작하지 않는 상황을 보여줍니다.
@RequiredArgsConstructor
@Service
public class OrderService {
private final OrderRepository orderRepository;
@Transactional
public void placeOrder() {
// 주문을 처리하는 로직
System.out.println("주문이 시작되었습니다.");
saveOrder(); // 같은 클래스 내의 다른 메소드 호출
}
@Transactional
public void saveOrder() {
// 주문을 저장하는 로직
orderRepository.save(new Order());
System.out.println("주문이 저장되었습니다.");
}
}
위 코드에서 placeOrder() 메서드는 saveOrder() 메서드를 호출하고 있습니다. 두 메서드 모두 @Transactional이 적용되어 있지만, 실제로 saveOrder() 메서드의 트랜잭션은 적용되지 않습니다. 이유는 placeOrder() 메서드가 같은 클래스 내에서 직접 saveOrder()를 호출하고 있기 때문입니다. 이 경우 Spring의 프록시는 개입할 수 없으며, 트랜잭션 경계가 생성되지 않습니다.
사실, 이와 같은 코드는 잘못된 설계로 인해 발생한 문제입니다. 클래스 내에서 직접 트랜잭션 메서드를 호출하는 경우, 프록시가 개입하지 못하기 때문에 트랜잭션이 제대로 설정되지 않습니다. 올바른 설계 방법을 통해 이 문제를 해결할 수 있습니다.
프록시의 동작 방식을 이해해 봅시다.
스프링은 기본적으로 클래스 기반의 AOP를 적용할 때 CGLIB을 사용하고, 인터페이스가 있는 경우 JDK 동적 프록시를 사용합니다. 개발자는 proxyTargetClass 속성을 통해 이를 강제할 수도 있습니다. 지금부터 Spring이 프록시 객체를 생성하고 트랜잭션을 관리하는 방식을 설명해 드리겠습니다.
스프링은 @Transactional이 적용된 메서드가 포함된 클래스를 사용할 때, 트랜잭션 경계를 제어하기 위해 프록시를 생성합니다. 아래 코드는 OrderService 클래스의 프록시가 생성될 때 어떻게 동작하는지 보여주는 예시입니다.
// Spring이 생성하는 프록시 클래스 (의사 코드)
public class OrderServiceProxy extends OrderService {
private OrderService target; // 실제 서비스 인스턴스
@Override
public void placeOrder() {
// 트랜잭션 시작
try {
target.placeOrder(); // 실제 서비스 메소드 호출
// 트랜잭션 커밋
} catch (Exception e) {
// 트랜잭션 롤백
throw e;
}
}
@Override
public void saveOrder() {
// 트랜잭션 시작
try {
target.saveOrder();
// 트랜잭션 커밋
} catch (Exception e) {
// 트랜잭션 롤백
throw e;
}
}
}
위 코드에서 프록시 객체의 placeOrder() 메서드가 호출될 때, 프록시는 먼저 트랜잭션을 시작하고, 그 이후에 실제 target 객체의 placeOrder() 메서드를 호출합니다. 문제는 placeOrder() 내부에서 saveOrder()를 호출할 때 발생합니다. 이 내부 호출은 this.saveOrder() 형태로 이루어지기 때문에 프록시 객체를 거치지 않고 직접 실제 객체의 saveOrder() 메서드를 호출하게 됩니다. 결과적으로 saveOrder() 호출 시 프록시의 트랜잭션 부가 기능(시작 및 종료)을 거치지 않게 되어 트랜잭션이 적용되지 않는 것입니다.
즉, this.saveOrder()는 실제 객체의 메서드를 직접 호출하게 되므로 프록시의 부가 기능(트랜잭션 시작 및 종료)을 거치지 않게 되는 것입니다. 이는 스프링 AOP가 프록시 기반으로 동작하기 때문에 발생하는 제약사항입니다.
트랜잭션 문제 해결을 위한 접근법
1. 메서드 분리하기
- 트랜잭션이 적용된 메서드를 다른 클래스로 분리하면, 프록시가 올바르게 동작할 수 있습니다.
@RequiredArgsConstructor
@Service
public class OrderService {
private final OrderRepository orderRepository;
@Transactional
public void placeOrder() {
// 주문을 처리하는 로직
System.out.println("주문이 시작되었습니다.");
orderRepository.save(new Order());
System.out.println("주문이 저장되었습니다.");
}
}
public interface OrderRepository extends JpaRepository<Order, Long> {
// JpaRepository를 사용하면 기본적인 CRUD 메소드가 자동으로 제공됩니다.
}
위 코드에서 OrderRepository 인터페이스는 JPA의 JpaRepository를 상속하여 기본적인 CRUD 기능을 제공합니다. 이제 OrderService는 OrderRepository를 호출하게 되고, 프록시가 개입하여 save() 메서드에 트랜잭션을 적용할 수 있게 됩니다. 이를 통해 트랜잭션 경계가 올바르게 설정됩니다.
2. 자기 자신 호출을 프록시를 통해 호출하도록 변경하기
- Spring의 AopContext를 사용하여 자신에 대한 프록시 참조를 가져올 수 있습니다. 이 방법은 빠르게 문제를 해결할 수 있지만, 가독성과 유지보수성에 있어서 주의가 필요합니다.
@RequiredArgsConstructor
@Service
public class OrderService {
private final OrderRepository orderRepository;
@Transactional
public void placeOrder() {
// 주문을 처리하는 로직
System.out.println("주문이 시작되었습니다.");
((OrderService) AopContext.currentProxy()).saveOrder(); // 프록시를 통한 호출
}
@Transactional
public void saveOrder() {
// 주문을 저장하는 로직
orderRepository.save(new Order());
System.out.println("주문이 저장되었습니다.");
}
}
위 코드에서 AopContext.currentProxy()를 사용해 현재 프록시를 가져와 호출함으로써 트랜잭션이 적용되도록 할 수 있습니다. 하지만 이 방법은 코드의 복잡도를 증가시키고, 향후 유지보수가 어려워질 수 있기 때문에 신중하게 사용해야 합니다.
참고로 AopContext.currentProxy()를 사용할 때는 다음 설정이 반드시 필요합니다.
@Configuration
@EnableAspectJAutoProxy(exposeProxy = true) // 프록시 객체를 AopContext에 노출
public class AppConfig {
// ...
}
이 설정이 없다면 "IllegalStateException: Cannot find current proxy" 예외가 발생하게 됩니다.
3. 빈 메서드를 사용하여 트랜잭션 메서드 호출 (이 방법은 같은 서비스 간의 순환참조를 주의해야 합니다.)
- 또 다른 해결 방법으로는 Spring에서 트랜잭션이 적용된 메서드를 별도의 빈(bean)으로 만들고 이를 주입받아 사용하는 것입니다. 이로 인해 프록시 객체가 제대로 동작하게 되어 트랜잭션이 정상적으로 적용됩니다.
@RequiredArgsConstructor
@Service
public class OrderService {
private final OrderHelperService orderHelperService;
@Transactional
public void placeOrder() {
// 주문을 처리하는 로직
System.out.println("주문이 시작되었습니다.");
orderHelperService.saveOrder();
}
}
@Service
public class OrderHelperService {
private final OrderRepository orderRepository;
@Transactional
public void saveOrder() {
// 주문을 저장하는 로직
orderRepository.save(new Order());
System.out.println("주문이 저장되었습니다.");
}
}
이렇게 함으로써 OrderHelperService의 saveOrder() 메서드 호출 시 프록시 객체가 개입하여 트랜잭션이 정상적으로 동작하게 됩니다. 이 방법은 코드의 역할 분리가 명확해지고 유지보수성 또한 높아집니다.
마무리하며
프록시 기반 트랜잭션의 동작 방식을 이해하는 것은 Spring 기반 애플리케이션 개발에서 매우 중요합니다. 같은 클래스 내의 메서드 호출은 프록시가 개입하지 않아 트랜잭션이 적용되지 않음을 명심해야 합니다. 이러한 문제를 피하기 위해 메서드를 다른 클래스에 분리하거나, 프록시를 명시적으로 사용하여 트랜잭션을 적용할 수 있습니다.
이제 여러분은 같은 클래스 내 메서드 호출 시 트랜잭션이 적용되지 않는 이유를 이해하고, 이를 어떻게 해결할 수 있는지 알게 되었습니다. 이를 통해 Spring 기반의 트랜잭션 관리에 대해 깊이 있는 이해를 갖추고, 실무에서 더욱 안전하고 일관된 트랜잭션 관리를 할 수 있을 것입니다.
이번 포스팅은 여기서 마치도록 하겠습니다. 읽어주셔서 감사합니다 :)