[스프링/테스트] 스프링의 테스트 컨텍스트 프레임워크와 트랜잭션

테스트 컨텍스트 프레임워크에서 트랜잭션은 테스트 클래스에 @TestExecutionListeners를 명시적으로 선언하지 않더라도 기본적으로 구성된 TransactionalTestExecutionListener에 의해 관리된다. 하지만 트랜잭션 지원을 활성화하려면 @ContextConfiguration에 의해 로드된 ApplicationContextPlatformTransactionManager 빈을 구성해야 하며, 테스트 클래스나 메서드 수준에서 @Transactional을 선언해야 한다. @Transactional가 적용된 테스트 클래스 내에서 수행되는 트랜잭션은 TransactionalTestExecutionListener에 의해 자동으로 롤백되므로 트랜잭션 내에서 테스트 메서드를 실행한 후 직접 해당 트랜잭션을 롤백하는 코드는 필요하지 않다. 테스트 컨텍스트 프레임워크에 의해 기본적으로 수행되는 이러한 트랜잭션 관리 동작을 테스트 컨텍스트 프레임워크가 제공하는 @Commit 또는 @Rollback을 사용하여 변경하거나 TestTransaction을 사용하여 보다 세밀하게 사용자화할 수도 있다.

TransactionalTestExecutionListener에 의해 자동으로 관리되거나 TestTransaction을 통해 프로그래밍 방식으로 관리되는 트랜잭션을 테스트 관리(test-managed) 트랜잭션이라고 한다. 테스트 관리 트랜잭션은 테스트를 위해 로드된 ApplicationContext 내에서 스프링에 의해 직접 관리되는 트랜잭션인 스프링 관리(spring-managed)(또는 컨테이너 관리(container-managed)) 트랜잭션과, 테스트 시 호출되는 애플리케이션 코드 내에서 프로그래밍 방식으로 관리되는 트랜잭션인 애플리케이션 관리(application-managed) 트랜잭션과 다르다. 스프링 관리 및 애플리케이션 관리 트랜잭션은 일반적으로 테스트 관리 트랜잭션에 참여하지만 스프링 관리 또는 애플리케이션 관리 트랜잭션이 REQUIRED 또는 SUPPORTS 이외의 트랜잭션 전파 속성으로 구성된 경우 주의해야 한다.

TestTransaction의 정적 메서드를 사용하면 프로그래밍 방식으로 테스트 관리 트랜잭션을 관리할 수 있다. 예를 들어, 테스트 메서드 실행 전, 실행 중, 실행 후에 TestTransaction을 사용하여 현재 테스트 관리 트랜잭션을 수동으로 시작 또는 종료하거나 롤백 또는 커밋할 수 있다. TransactionalTestExecutionListener가 활성화되어 있다면 TestTransaction의 기능은 별다른 설정 없이 자동으로 사용 가능하다.

테스트 메서드(예: JUnit Jupiter의 @Test를 설정한 메서드)에 @Transactional을 설정하면 기본적으로 테스트가 완료된 후 자동으로 롤백되는 트랜잭션 내에서 테스트 메서드가 실행된다. 테스트 클래스에 @Transactional을 설정하는 경우, 해당 클래스 계층 구조 내의 모든 테스트 메서드는 하나의 트랜잭션 내에서 실행된다. 클래스 또는 메서드 수준에서 @Transactional을 설정하지 않은 테스트 메서드는 트랜잭션 내에서 실행되지 않으므로 해당 메서드에서 이루어지는 데이터베이스 변경 작업은 반영 또는 취소되지 않는다.

중요한 점은 @Transactional은 모든 테스트 클래스 내 모든 메서드에 대해 지원되지 않는다는 점이다. 테스트 프레임워크가 제공하는 테스트 수명 주기(test lifecycle) 메서드에 대해서는 @Transactional을 설정하더라도 트랜잭션(테스트 관리 트랜잭션)이 적용되지 않는다. 예를 들어, JUnit Jupiter의 @BeforeAll, @BeforeEach @AfterAll 또는 @AfterEach 등의 어노테이션이 설정된 테스트 메서드가 이에 해당한다. 또한 @Transactional이 설정되었지만 트랜잭션 전파 속성이 NOT_SUPPORTED 또는 NEVER로 설정된 테스트 메서드는 트랜잭션 내에서 실행되지 않는다. 따라서 위의 경우 TransactionalTestExecutionListener에 의해 자동으로 롤백되지 않는다.

메서드 수준의 수명 주기 메서드(예: JUnit Jupiter의 @BeforeEach 또는 @AfterEach가 설정된 메서드)는 테스트 관리 트랜잭션 내에서 실행되는 반면, 클래스 수준의 수명 주기 메서드(예: JUnit Jupiter의 @BeforeAll 또는 @AfterAll이 설정된 메서드와 TestNG의 @BeforeSuite, @AfterSuite, @BeforeClass 또는 @AfterClass가 설정된 메서드)는 테스트 관리 트랜잭션 내에서 실행되지 않는다. 따라서 메서드 수준의 수명 주기 메서드에서 TestTransaction을 사용하여 프로그래밍 방식으로 테스트 관리 트랜잭션을 관리할 수 있지만 클래스 수준 수명 주기 메서드에서는 불가능하다.

트랜잭션 내에서 클래스 레벨 수명 주기 메서드를 실행해야 하는 경우, PlatformTransactionManager를 테스트 클래스에 주입한 후 TransactionTemplate을 사용하여 프로그래밍 방식의 트랜잭션(애플리케이션 관리 트랜잭션) 관리를 수행할 수 있다.

특정 테스트 메서드는 트랜잭션 내에서 실행되지 않는 것이 의도될 수도 있다. 예를 들어, 트랜잭션이 적용된 테스트 코드를 실행하기 전에 초기 데이터베이스 상태를 확인하거나, 트랜잭션을 커밋하는 테스트 코드 실행 후 예상된 트랜잭션 커밋 동작을 확인하는 경우 이를 위해 트랜잭션이 적용된 테스트 메서드 실행 전후에 트랜잭션 외부에서 특정 코드를 실행할 필요성이 있다. 이러한 상황에서는 TransactionalTestExecutionListener가 제공하는 @BeforeTransaction@AfterTransaction을 사용하면 된다. 테스트 클래스의 void 메서드 또는 테스트 인터페이스의 void 기본 메서드에 두 어노테이션 중 하나를 사용할 수 있으며, 이 경우 TransactionalTestExecutionListener는 트랜잭션 전 메서드 또는 트랜잭션 후 메서드가 적절한 시점에 실행되도록 보장한다.

모든 테스트 메서드 실행 전 메서드(예: JUnit Jupiter의 @BeforeEach 메서드)와 모든 실행 후 메서드(예: JUnit Jupiter의 @AfterEach 메서드)는 트랜잭션 내에서 실행된다. 모든 실행 전 메서드와 모든 실행 후 메서드에 대해 @BeforeTransaction 또는 @AfterTransaction을 적용할 수 있다. 트랜잭션 내에서 실행되도록 구성되지 않은 테스트 메서드에 대해서는 @BeforeTransaction 또는 @AfterTransaction가 적용되지 않는다.

@BeforeEach 메서드에서 데이터베이스의 데이터 초기화 작업(테스트에 필요한 데이터를 삽입 또는 갱신) 및 커밋 후 테스트 트랜잭션을 종료하지 않으면 이후 실행되는 테스트 메서드에서 커밋된 초기화 데이터를 확인할 수 없다(트랜잭션 격리 수준이 커밋되지 않은 읽기라면 가능하다). @BeforeEach 메서드는 메서드 수준의 수명 주기 메서드이므로 테스트 관리 트랜잭션 내에서 실행되지만 @Transactional이 적용되지 않으므로 메서드 종료 시 테스트 트랜잭션이 롤백되지 않는다. 따라서 데이터베이스의 데이터를 초기화하는 테스트 메서드에서는 테스트 트랜잭션을 직접 커밋 후 종료하고, 이후 데이터를 확인하는 테스트 메서드에서는 새로운 테스트 트랜잭션을 시작하면 된다. 초기 데이터를 저장하는 작업은 @BeforeTransaction을 사용하여 테스트 트랜잭션 외부에서 실행할 수 없다.

@BeforeEach
void init() {
  데이터베이스 데이터 초기화  커밋 작업;
  TestTransaction.flagForCommit();
  TestTransaction.end();
}

@Test
@Transactional
void test() {
  TestTransaction.start();
  데이터베이스 데이터 확인 작업;
}


테스트 메서드에 @Transactional 적용하는 것의 문제점

데이터베이스 변경 작업을 단위 및 통합 테스트하기 위해 테스트 클래스 및 메서드에서 해당 작업을 수행할 수 있다. 이때, 실제 운영 데이터베이스에 대한 테스트를 수행하는 경우 운영 데이터 변경을 막기 위해 스프링의 테스트 컨텍스트 프레임워크가 지원하는 트랜잭션 자동 롤백 기능을 사용하면 된다. 테스트 메서드에 @Transactional을 적용하는 경우 테스트 메서드 실행 후 해당 트랜잭션(테스트 관리 트랜잭션) 내 모든 데이터베이스 변경 작업은 롤백된다.

JPA를 사용하는 경우 영속성 컨텍스트가 닫힌 상태가 되면 이후 연관 객체에 대한 지연 로딩(lazy loading)이 수행될 때 LazyInitializationException 예외가 발생하게 된다. 해당 예외 발생을 막기 위해 연속성 컨텍스트가 닫힌 상태이더라도 연관 객체 조회 시 지연 로딩 대신 즉시 로딩을 사용하거나, 엔티티 그래프 기능을 사용할 수 있다. 주의할 점은 즉시 로딩 사용 시 JPA는 내부적으로 최적화를 위해 조인을 사용하지만 JPQL 사용 시 N+1 문제가 발생할 수 있다는 것이다. 성능 문제를 보완하기 위해 JPQL의 페치(fetch) 조인을 사용하거나 하이버네이트의 기능 지원을 통해 IN 절 또는 서브쿼리를 사용할 수도 있다.

LazyInitializationException 예외 발생을 막는 또다른 방법으로는 영속성 컨텍스트가 열린 상태에서만 연관 객체의 지연 로딩이 수행되도록 하는 것이다. 즉, 트랜잭션이 종료될 때까지 영속성 컨텍스트가 열린 상태를 유지하도록 한다. 트랜잭션에 결합되는 영속성 컨텍스트를 트랜잭션 범위 영속성 컨텍스트(transaction-scoped persistence context)라고 한다. 트랜잭션 범위 영속성 컨텍스트에서는 트랜잭션이 종료되는 즉시 영속성 컨텍스트에 있는 엔티티가 데이터베이스로 플러시(flush)된다. 테스트 메서드가 완료될 때까지 영속성 컨텍스트를 열린 상태로 만들고 테스트 메서드가 완료되어 트랜잭션이 종료되면 영속성 컨텍스트도 닫히도록 하면 되며, 이를 위해 테스트 메서드 레벨에 @Transactional을 적용하면 된다.

@Transactional을 적용한 테스트 메서드가 테스트 통과되어 데이터베이스 변경 작업이 성공적으로 완료됨을 성공적으로 테스트하였더라도 실제 서비스 동작 시에는 LazyInitializationException 예외가 발생할 수 있다. 따라서 영속성 컨텍스트가 열린 상태에서 연관 객체에 대한 지연 로딩이 이루어지도록 서비스 코드 레벨에도 @Transactional을 적용하거나 메서드를 호출하는 상위 레벨에서 영속성 컨텍스트가 열린 상태로 적절하게 관리되도록 해야 한다.


참고

Comments