본문 바로가기
개발/Spring

[Spring] 동일한 클래스 내에서 내부 메서드 호출시 @Transactional 적용 안되는 이슈

by baau 2023. 4. 2.

프로젝트를 개발하던 도중 다음과 같은 이슈가 발생했다.

이슈

아래 코드는 낙관적 락을 이용하여, 메뉴 재고에 대한 동시성 문제를 해결하기 위한 코드이다.

낙관적 락에서 락 획득이 실패한 경우에는 개발자가 실패에 대한 처리를 직접 구현해주어야 하기 때문에, 다음과 같이 무한 루프를 통해서 일정시간 대기하고 다시 락 획득을 할 수 있도록 구현하였다.

 

* 실제 매번 무한 루프를 돌며 락을 재획득하는 방법은 성능적인 측면에서 비효율적일 수 있다. 하지만 예시를 쉽게 작성하기 위해서 다음과 같이 작성하였습니다.

 

✍🏻 중요한 것은 낙관적 락(@Lock(value = LockModeType.OPTIMISTIC))을 사용하는 decrease 메서드는 반드시 트랜잭션 내에서 실행해야 한다는 점이다.

@Service
public class MenuStockService {

	...
    
    public void decreaseQuantity(Menu menu, Long count) {
        while (true) {
                decrease(menu, count);
                break;
            } catch (Exception e) {
                sleep();
            }
        }
    }

    @Transactional
    public void decrease(Menu menu, Long count) {
        MenuStock menuStock = menuStockRepository.findByMenuWithOptimisticLock(menu)
        		.orElseThrow(MenuStockNotFoundException::new);
        menuStock.decrease(count);
    }
}

겉보기에는 decrease 메서드에 @Transactinal이 붙어 있어 decreaseQuantity 메서드가 decrease 메서드를 호출하더라도 오류가 나지 않을 것으로 보인다. 하지만 실제 테스트를 돌려보면, 아래와 같은 오류가 등장한다.

org.springframework.dao.InvalidDataAccessApiUsageException: no transaction is in progress; nested exception is javax.persistence.TransactionRequiredException: no transaction is in progress

즉, 선언적 트랜잭션(@Transactional)이 잘 적용되지 않았다는 뜻이다.

 

원인

Spring의 @Transactional은 Spring AOP 기반으로 동작한다.

@Transactional을 메소드 또는 클래스에 명시하면, AOP를 통해 타깃이 상속하고 있는 인터페이스 또는 타깃을 상속한 Proxy 객체가 생성된다. 이때 프록시 객체의 메서드를 호출하면 타깃 메서드 전 후로 트랜잭션 처리를 수행한다.

 

즉, Proxy 객체가 만들어지고, Transactional Interceptor를 통해 트랜잭션을 관리하게 된다.

하지만, 동일한 클래스 내에서 @Transactional이 적용된  내부 메서드를 호출하는 경우, 호출되는 메서드는 Proxy 객체를 거치지 않고 직접 호출되기 때문에, Transactional Interceptor가 적용되지 않는다. 

 

* Transactional Interceptor : Spring Framework에서 @Transactional이 붙은 메서드를 실행할 때, AOP 기술을 이용하여 트랜잭션 처리를 담당하는 인터셉터, PlatformTransactionManager를 이용하여 트랜잭션의 시작, 커밋, 롤백 등의 처리를 수행한다.

 

따라서 위의 예시에서, @Transactional이 적용된 decrease 메서드를 동일한 클래스 내에서 호출하더라도, Transactional Interceptor가 적용되지 않아 문제가 발생한 것이다.

 

해결

다음과 같이 동일 클래스 내에서 @Transactional이 적용한 메서드를 정상적으로 호출하기 위해서 두 가지 방법이 존재한다.

 

[1] self injection

Spring에서 지원하는 의존성 주입 방법 중 하나로, Bean을 자기 자신으로 주입받는 방법이다. 따라서 같은 클래스 내에서도 AOP가 적용된다.

@Service
@Transactional
public class MenuStockService {

	...
    @Autowired
    private MenuStockService self;
    
    public void decreaseQuantity(Menu menu, Long count) {
        while (true) {
                self.decrease(menu, count);
                break;
            } catch (Exception e) {
                sleep();
            }
        }
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void decrease(Menu menu, Long count) {
        MenuStock menuStock = menuStockRepository.findByMenuWithOptimisticLock(menu)
        		.orElseThrow(MenuStockNotFoundException::new);
        menuStock.decrease(count);
    }
}

 

[2] TransacitonTemplate 사용

TransacitonTemplate은 Spring에서 제공하는 프로그래밍 방식의 트랜잭션 처리를 위한 클래스이다. 따라서 코드 내에서 트랜잭션의 시작과 커밋, 롤백을 직접 제어할 수 있다.

@Service
@Transactional
public class MenuStockService {

	...
    @Autowired
    private PlatformTransactionManager transactionManager;
    
    public void decreaseQuantity(Menu menu, Long count) {

        TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
        transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);

        while (true) {
            try {
                transactionTemplate.execute(new TransactionCallbackWithoutResult() {
                    @Override
                    protected void doInTransactionWithoutResult(TransactionStatus status) {
                        decrease(menu, count);
                    }
                });
                break;
            } catch (Exception e) {
                sleep();
            }
        }
    }

    public void decrease(Menu menu, Long count) {
        MenuStock menuStock = menuStockRepository.findByMenuWithOptimisticLock(menu)
                .orElseThrow(MenuStockNotFoundException::new);
        menuStock.decrease(count);
    }
}

위의 방법을 이용하면 클래스 내에서 내부 메서드를 호출할 때 @Transactional이 적용된다.

 

첫번째(self injection) 같은 경우는, 서로 다른 객체가 참조하면서 생길 수 있는 순환 참조(circular reference)의 위험이 있다.

이러한 경우 메모리 누수와 같은 문제가 발생할 수 있고, 프로젝트의 크기가 커질수록 참조 관리가 복잡해진다.

따라서 두번째(TransacitonTemplate)를 추천한다.

 

만약 동일한 클래스 내에서 호출하지 않고, 외부 클래스에서 호출한다면 당연히 문제는 발생하지 않는다.

따라서 decreaseQuantity 메서드를 별도의 클래스로 분리시킨 이후 decrease 메서드를 호출한다면, no transaction is in progress 오류는 발생하지 않는다.

@Service
@Transactional
public class A {

    ...
    
    @Autowired
    private B b;
    
    public void decreaseQuantity(Menu menu, Long count) {
        while (true) {
                b.decrease(menu, count);
                break;
            } catch (Exception e) {
                sleep();
            }
        }
    }
}


@Service
@Transactional
public class B {

    ...
    
    public void decrease(Menu menu, Long count) {
        MenuStock menuStock = menuStockRepository.findByMenuWithOptimisticLock(menu)
        		.orElseThrow(MenuStockNotFoundException::new);
        menuStock.decrease(count);
    }
}