Spring

[Spring] 트랜잭션 내부 호출 문제

코딩로봇 2025. 5. 1. 11:18

https://computerreport.tistory.com/manage/newpost#

 

티스토리

좀 아는 블로거들의 유용한 이야기, 티스토리. 블로그, 포트폴리오, 웹사이트까지 티스토리에서 나를 표현해 보세요.

www.tistory.com

 

해당 글에서 트랜잭션 내부 호출 문제에 대해 직접 코드를 작성하여 이해도를 높이고자 실습을 해보았다.

 

트랜잭션은 데이터베이스의 상태를 변화시키기 위해서 수행하는 작업의 단위이고 만약 그 안에서 오류가 발생하면 원자성에 의해 Rollback 이 된다.

 

 

코드를 실행시키면 어떤 Data 가 저장될까 (내부 메서드만 @Transactional)

public class MyService {

    private final OrderRepository orderRepository;

    public void save() {
        orderRepository.save(new Order("내부 아이템", 10));
        innerSave();

    }

    @Transactional
    public void innerSave() {
        orderRepository.save(new Order("외부 아이템", 10));
        throw new RuntimeException("내부 예외 발생");
    }
}

 

innerSave 를 Transactional 어노테이션으로 등록하고 만약 save() 메소드를 실행시키면 어떻게 될까?

 

  • 트랜젝션을 처음 배우면 대부분 innerSave 에서는 RuntimeException 이 발생했기 때문에  orderRepository.save(new Order("내부 아이템", 10)); 이 코드만 실행되어 하나만 저장된다고 생각할 것이다. 

 

과아아아ㅏㅏㅏㅏㅏㅏㅏㅏㅏㅏㅏㅏ연?

 

 

 

예상과 다르게 두개의 코드가 모두 실행되었다.

이유가 뭘까?

 

 

Transactional 은 Proxy 패턴으로 구성되어있다.

  • 위의 코드를 실행하면 내부적으로 MyService 라는 클래스에서 Proxy 객체가 만들어지고 그 안에 save() 메소드를 요구한다. 

대략 형태를 보여주면 아래의 코드와 같다.

// Spring이 생성하는 프록시 클래스
public class MyServiceProxy extends MyService {

    private MyService target; // 실제 서비스 인스턴스

    @Override
    public void save() {
        try {
            target.save();
        } catch (Exception e) {
            throw e;
        }
    }
    
    @Override
    public void innersave() {
        transcation.start();
        try {
            target.innersave();
            transaction.commit();
        } catch (Exception e) {
            transcation.rollback();
            throw e;
        }
    }   
}

 

 

 

 

Spring 의 트랜잭션 프록시 동작 방식을 보면 이해할 수 있다.

 public void save() {
        orderRepository.save(new Order("내부 아이템", 10));
        innerSave();
        }

 

위의 코드 일부분인 save() 메소드이다.

이 메소드를 실행하면 아래의 코드가 실행되고 innerSave()는 프록시를 거치지 않고 바로 호출된다

 @Override
    public void save() {
        try {
            target.save();
        } catch (Exception e) {
            throw e;
        }
    }

 

그래서 아래의 코드의 @Transactional은 실행이 되지 않는 것이다.

@Transactional
    public void innerSave() {
        orderRepository.save(new Order("외부 아이템", 10));
        throw new RuntimeException("내부 예외 발생");
    }

 

 

 

 

  코드를 실행시키면 어떤 Data 가 저장될까(둘다 @Transactional 작성)

public class MyService {

    private final OrderRepository orderRepository;

    @Transactional
    public void save() {
        orderRepository.save(new Order("내부 아이템", 10));
        innerSave();

    }

    @Transactional
    public void innerSave() {
        orderRepository.save(new Order("외부 아이템", 10));
        throw new RuntimeException("내부 예외 발생");
    }
}

 

둘다 있으면 save() 메소드가 프록시 객체에 참조되어 데이터가 나오지 않는다

@Override
    public void save() {
        transcation.start();
        try {
            target.save();
            transaction.commit();
        } catch (Exception e) {
            transcation.rollback();
            throw e;
        }
    }

 

 

 

  코드를 실행시키면 어떤 Data 가 저장될까(외부 메서드 @Transactional 작성)

public class MyService {

    private final OrderRepository orderRepository;

    @Transactional
    public void save() {
        orderRepository.save(new Order("내부 아이템", 10));
        innerSave();

    }

    public void innerSave() {
        orderRepository.save(new Order("외부 아이템", 10));
        throw new RuntimeException("내부 예외 발생");
    }
}

 

이부분은 테스트를 하고 신기했다. 왜냐하면 JPA SAVE()메서드 안에는 기본적으로 Transcational 어노테이션이 존재한다.

 

그래서  orderRepository.save(new Order("외부 아이템", 10)); 가 실행되는순간 저장되기 때문에 DataBase 에 저장될 줄 알았지만 둘 다 저장되지 않았다.

 

이유는 하위메소드는 Transactional 이 없더라도 상위메소드로 묶이게 된다.

 

 

정리하면,

  • 프록시 우회 문제: 동일 클래스 내 메서드 호출은 프록시를 우회하여 @Transactional이 적용되지 않을 수 있다.
  • 트랜잭션 전파: 상위 메서드의 트랜잭션은 하위 메서드 작업을 포함한다.
  • JPA save() 동작: save()는 트랜잭션 내에서 관리되며, 상위 트랜잭션 롤백 시 저장되지 않는다

이러한 문제가 있고 해결방법도 정리해보았다.

 

🛠️ 해결 방안 🛠️

  1. 클래스 분리: innerSave()를 별도 서비스 클래스로 이동.
  2. 셀프 인젝션: MyService를 자기 자신에 주입하여 프록시 호출.
  3. 전파 설정: @Transactional(propagation = Propagation.REQUIRES_NEW)로 새 트랜잭션 시작(내부 호출 문제는 해결 안 됨).

 

1️⃣ 클래스 분리

  • innerSave()를 별도의 서비스 클래스로 분리하여 프록시가 정상적으로 동작하도록 한다.
@Service
public class MyService {
    private final OrderRepository orderRepository;
    private final InnerService innerService;

    public MyService(OrderRepository orderRepository, InnerService innerService) {
        this.orderRepository = orderRepository;
        this.innerService = innerService;
    }

    public void save() {
        orderRepository.save(new Order("내부 아이템", 10));
        innerService.innerSave();
    }
}

@Service
public class InnerService {
    private final OrderRepository orderRepository;

    public InnerService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional
    public void innerSave() {
        orderRepository.save(new Order("외부 아이템", 10));
        throw new RuntimeException("내부 예외 발생");
    }
}

 

innerSave()@Transactional이 적용되어 예외 발생 시 "외부 아이템"만 롤백되고, "내부 아이템"은 저장된다.

장점:

  • 프록시가 정상적으로 동작하여 트랜잭션 동작이 명확하다.
  • 코드의 책임 분리가 명확해진다.
  • 가장 권장되는 표준 접근법이다.

단점:

  • 클래스 구조가 복잡해질 수 있다.
  • 기존 코드를 리팩토링해야 한다.

 

 

 2️⃣ 셀프 인젝션

  • MyService를 자기 자신에 주입하여 프록시를 통해 innerSave()를 호출한다.
@Service
public class MyService {
    private final OrderRepository orderRepository;
    @Autowired
    private MyService self; // 자기 자신 주입

    public MyService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    public void save() {
        orderRepository.save(new Order("내부 아이템", 10));
        self.innerSave(); // 프록시를 통해 호출
    }

    @Transactional
    public void innerSave() {
        orderRepository.save(new Order("외부 아이템", 10));
        throw new RuntimeException("내부 예외 발생");
    }
}

 

innerSave()가 프록시를 통해 호출되어 @Transactional이 적용되며, "외부 아이템"만 롤백된다.

장점:

  • 기존 클래스 구조를 크게 변경하지 않아도 된다.
  • 간단한 수정으로 문제를 해결할 수 있다.

단점:

  • 셀프 인젝션은 Spring의 순환 의존성을 유발할 가능성이 있다.
  • 코드가 다소 직관적이지 않을 수 있다.'

 

💡 Spring 4.3 이상에서는 @Autowired로 셀프 인젝션을 지원하지만, 순환 의존성 문제를 피하기 위해 주의해야 한다.

 

 

 

3️⃣ 전파 설정

@Transactional(propagation = Propagation.REQUIRES_NEW) 를 사용하여 innerSave()가 독립적인 트랜잭션을 시작하도록 설정한다.

@Service
public class MyService {
    private final OrderRepository orderRepository;
    @Autowired
    private MyService self

    public MyService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional
    public void save() {
        orderRepository.save(new Order("내부 아이템", 10));
        self.innerSave();
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void innerSave() {
        orderRepository.save(new Order("외부 아이템", 10));
        throw new RuntimeException("내부 예외 발생");
    }
}

 

innerSave()는 새로운 트랜잭션에서 실행되므로 예외 발생 시 "외부 아이템"만 롤백되고, "내부 아이템"은 저장된다.

장점:

  • 트랜잭션 경계를 명확히 분리할 수 있다.
  • 특정 작업을 독립적으로 처리할 때 유용하다.

단점:

  • 내부 호출 문제 자체는 해결되지 않으므로 셀프 인젝션 또는 클래스 분리가 필요하다.
  • 트랜잭션 오버헤드가 증가할 수 있다.
  • 복잡한 트랜잭션 전파 설정은 디버깅을 어렵게 할 수 있다.

 

 

종합 정리

해결 방안 적용 난이도 보수성 성능  권장 상황
별도 클래스 분리 중간 높음 없음 표준적이고 명확한 트랜잭션 분리가 필요할 때
셀프 인젝션 낮음 중간 없음 빠른 수정이 필요하고 구조 변경을 최소화할 때
AspectJ 사용 높음 낮음 미세함 프록시 제약을 완전히 제거하고 싶을 때
트랜잭션 전파 설정 중간 중간 중간 독립적인 트랜잭션 경계가 필요할 때

 

실무에서나 개발을 할땐 별도 클래스 분리가 대표적으로 Spring의 철학에 부합하며, 코드의 책임 분리와 유지보수성을 높인다.

대부분의 프로젝트에서 표준 접근법으로 사용된다.