본문 바로가기
BackEnd/Project

[PharmNav] Ch05. Spring Transactional 사용시 주의사항

by 개발 Blog 2024. 9. 3.

공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.

 

Spring Transactional 이란?

Spring 프레임워크에서는 @Transactional 어노테이션을 이용해 트랜잭션 처리를 간편하게 할 수 있다. 이 어노테이션은 Spring AOP 기반으로 동작하며, 프로그래머가 트랜잭션의 시작, 커밋, 롤백을 수동으로 처리할 필요 없이 자동으로 관리해 준다.

  • Spring AOP 기반: @Transactional은 AOP(Aspect-Oriented Programming)를 기반으로 동작하며, 내부적으로 Proxy 패턴을 활용한다.
  • Proxy 객체의 역할: @Transactional이 포함된 메서드가 호출되면, Proxy 객체가 생성되어 트랜잭션을 시작하고, 메소드 실행이 완료되면 트랜잭션을 커밋 또는 롤백한 후 트랜잭션을 종료한다.
  • 부가 기능 위임: Proxy 객체는 메소드 호출을 가로채어 트랜잭션 처리와 같은 부가 기능을 수행한다. 이를 통해 개발자는 비즈니스 로직에만 집중할 수 있다.

Spring Transactional 주의사항

  • Self Invocation 문제: @Transactional, @Cacheable, @Async와 같은 AOP 기반 어노테이션을 사용할 때, 클래스 내부에서 자기 자신을 호출하는 경우(Self Invocation) 문제가 발생할 수 있다. 이는 Proxy 객체가 메서드를 가로채지 못해 트랜잭션이 적용되지 않는 상황을 초래한다.
  • Proxy 객체와 외부 호출: 외부에서 bar() 메소드를 호출할 때는 Proxy가 정상적으로 동작하지만, bar()가 내부에서 foo()를 호출하고, foo()에 @Transactional이 선언된 경우에는 Self Invocation으로 인해 트랜잭션이 적용되지 않는다.

Self Invocation 해결방법

  • (좌) 트랜잭션 위치 이동: 트랜잭션을 적용할 메서드를 외부에서 호출하는 메서드로 옮긴다.
  • (우) 객체의 책임 분리: 객체의 책임을 최대한 분리하여 외부에서 호출하도록 리팩토링 한다.

Self Invocation 주의사항 (읽기 전용)

  • @Transactional(readOnly = true): 스프링에서는 트랜잭션을 읽기 전용으로 설정할 수 있다. 이 경우, JPA에서는 스냅샷 저장 및 Dirty Checking 작업을 수행하지 않아 성능적인 이점이 있다.
  • Dirty Checking 불가: 읽기 전용 트랜잭션에서는 Dirty Checking이 발생하지 않는다.

Self Invocation 주의사항 (우선순위)

  • 우선순위: @Transactional은 적용 우선순위를 가지고 있다. 클래스에 적용된 @Transactional(readOnly = true)보다, 메서드에 적용된 @Transactional이 우선적으로 적용된다.
  • Selective 적용: 클래스에 @Transactional(readOnly=true)을 적용하고, 업데이트가 발생하는 메서드에만 readOnly=false로 적용할 수 있다. 이는 SimpleJpaRepository에서 자주 사용하는 패턴이다.

실습 : Spring Transactional과 Self Invocation 테스트

PharmacyRepositoryService 클래스에서 bar() 메서드가 내부적으로 foo() 메서드를 호출하는 상황을 테스트한다. foo() 메서드에 @Transactional이 선언되어 있으나, Self Invocation으로 인해 트랜잭션이 적용되지 않는 것을 확인할 수 있다.

@Slf4j
@Service
@RequiredArgsConstructor
public class PharmacyRepositoryService {
    private final PharmacyRepository pharmacyRepository;

    // self invocation test
    public void bar(List<Pharmacy> pharmacyList) {
        log.info("CurrentTransactionName: "+ TransactionSynchronizationManager.getCurrentTransactionName());
        foo(pharmacyList);
    }

    // self invocation test
    @Transactional
    public void foo(List<Pharmacy> pharmacyList) {
        pharmacyList.forEach(pharmacy -> {
            pharmacyRepository.save(pharmacy);
            throw new RuntimeException("error");
        });
    }

    // read only test
    @Transactional(readOnly = true)
    public void startReadOnlyMethod(Long id) {
        pharmacyRepository.findById(id).ifPresent(pharmacy ->
                pharmacy.changePharmacyAddress("서울 특별시 광진구"));
    }

    @Transactional
    public List<Pharmacy> saveAll(List<Pharmacy> pharmacyList) {
        if(CollectionUtils.isEmpty(pharmacyList)) return Collections.emptyList();
        return pharmacyRepository.saveAll(pharmacyList);
    }

    @Transactional(readOnly = true)
    public List<Pharmacy> findAll() {
        return pharmacyRepository.findAll();
    }
}
  • bar(List<Pharmacy> pharmacyList):
    • 이 메서드는 내부적으로 foo() 메소드를 호출한다. foo() 메소드에 @Transactional 어노테이션이 적용되어 있지만, bar() 메소드가 foo()를 호출하는 구조에서는 Self Invocation 문제가 발생한다. 이로 인해 foo()의 트랜잭션이 제대로 적용되지 않는다.
    • 메소드 호출 시 현재 트랜잭션 이름을 로그로 출력하여 트랜잭션이 제대로 적용되고 있는지 확인한다.
  • foo(List<Pharmacy> pharmacyList):
    • @Transactional이 적용된 메서드로, 전달받은 약국 리스트의 각 약국 정보를 저장한 후, 예외를 발생시켜 트랜잭션이 롤백되는지 테스트한다.
    • 그러나, bar() 메서드에서 호출될 때는 Self Invocation 문제로 인해 트랜잭션이 적용되지 않아 데이터가 롤백되지 않는다.
  • startReadOnlyMethod(Long id):
    • @Transactional(readOnly = true) 어노테이션을 사용해 읽기 전용 트랜잭션을 설정한다. 이 메서드는 주어진 ID의 약국 정보를 조회한 후, 주소를 변경하려 하지만, 읽기 전용 트랜잭션이기 때문에 변경 사항이 데이터베이스에 반영되지 않는다.
    • 읽기 전용 트랜잭션은 JPA에서 성능을 최적화하기 위해 스냅샷 저장 및 Dirty Checking을 생략하는데, 이로 인해 데이터 변경이 불가능하다.
  • saveAll(List<Pharmacy> pharmacyList):
    • @Transactional을 적용해, 주어진 약국 리스트를 데이터베이스에 저장한다. 리스트가 비어 있을 경우 빈 리스트를 반환한다.
    • 이 메서드는 일반적인 저장 작업을 수행하며, 트랜잭션에 의해 데이터가 안전하게 저장되도록 보장한다.
  • findAll():
    • 모든 약국 데이터를 조회하는 메서드로, @Transactional(readOnly = true)가 적용되어 있다. 이 메서드에서는 조회만 수행하며, 성능 최적화를 위해 읽기 전용 트랜잭션으로 설정되었다.

PharmacyRepositoryServiceTest 클래스는 PharmacyRepositoryService 클래스의 메서드들이 제대로 동작하는지 테스트하기 위한 통합 테스트 클래스다. 이 테스트는 Spring의 트랜잭션 관리와 관련된 주요 개념들을 검증한다.

class PharmacyRepositoryServiceTest extends AbstractIntegrationContainerBaseTest {

    @Autowired
    private PharmacyRepositoryService pharmacyRepositoryService;

    @Autowired
    private PharmacyRepository pharmacyRepository;

    void setup() {
        pharmacyRepository.deleteAll();
    }

    def "self invocation"() {

        given:
        String address = "서울 특별시 성북구 종암동";
        String name = "은혜 약국";
        double latitude = 36.11;
        double longitude = 128.11;

        def pharmacy = Pharmacy.builder()
                .pharmacyAddress(address)
                .pharmacyName(name)
                .latitude(latitude)
                .longitude(longitude)
                .build();

        when:
        pharmacyRepositoryService.bar(Arrays.asList(pharmacy));

        then:
        def e = thrown(RuntimeException.class);
        def result = pharmacyRepositoryService.findAll();
        result.size() == 1; // 트랜잭션이 적용되지 않음(롤백 적용 X)
    }

    def "transactional readOnly test"() {

        given:
        String inputAddress = "서울 특별시 성북구";
        String modifiedAddress = "서울 특별시 광진구";
        String name = "은혜 약국";
        double latitude = 36.11;
        double longitude = 128.11;

        def input = Pharmacy.builder()
                .pharmacyAddress(inputAddress)
                .pharmacyName(name)
                .latitude(latitude)
                .longitude(longitude)
                .build();

        when:
        def pharmacy = pharmacyRepository.save(input);
        pharmacyRepositoryService.startReadOnlyMethod(pharmacy.id);

        then:
        def result = pharmacyRepositoryService.findAll();
        result.get(0).getPharmacyAddress() == inputAddress;
    }
}
  • self invocation 테스트
    • 이 테스트는 Self Invocation 문제를 검증한다. PharmacyRepositoryService의 bar() 메서드는 내부적으로 foo() 메서드를 호출한다. foo() 메소드에는 @Transactional 어노테이션이 적용되어 있지만, Self Invocation으로 인해 트랜잭션이 제대로 적용되지 않는다.
    • 테스트 시나리오는 약국 데이터를 하나 생성한 후, bar() 메소드를 호출해 예외가 발생하도록 유도한다. 트랜잭션이 정상적으로 적용되었다면 예외가 발생할 때 데이터가 롤백되어야 하지만, Self Invocation으로 인해 롤백되지 않고 데이터가 그대로 남아 있는 것을 확인할 수 있다.
  • transactional readOnly test 테스트
    • 이 테스트는 @Transactional(readOnly = true)가 적용된 메서드의 동작을 검증한다. PharmacyRepositoryService의 startReadOnlyMethod() 메서드는 읽기 전용 트랜잭션으로 설정되어 있다.
    • 테스트 시나리오는 약국 데이터를 생성한 후, startReadOnlyMethod() 메서드를 호출하여 약국의 주소를 변경하려 한다. 하지만 읽기 전용 트랜잭션이기 때문에 데이터베이스에 변경 사항이 반영되지 않으며, 테스트 결과는 원래의 주소 값이 그대로 유지됨을 확인한다.

이번 글에서는 Spring의 @Transactional 어노테이션을 사용하면서 주의해야 할 Self Invocation 문제와 읽기 전용 트랜잭션의 동작 방식을 다루었다. 이를 통해 트랜잭션 관리의 올바른 사용법과 발생할 수 있는 잠재적 문제를 이해하고 예방할 수 있었다. 이러한 개념들은 안정적인 애플리케이션 개발에 중요한 기반이 된다.