본문 바로가기
Test

테스트코드에서의 @Transactional

by 아리❣️ 2024. 4. 7.

테스트코드에서 @Transactional을 쓰는 것이 마치 금기인 것 같은 분위기이다.

남들이 쓰지 말래서, 아무도 안 쓰니까, 코드 리뷰받을까 봐 등등의 이유 말고

제대로 된 이유를 알아보자!

 

[ 목차 ]

  1. 왜 테스트 코드에서 @Transactional을 사용하고 싶은 걸까?
  2. 언제, 어떤 문제가 발생할까?
  3. 그럼 어떻게 해야 할까?
  4. 그렇다면 테스트코드에서의 @Transactional은 무조건 나쁜 걸까?

 

1.  왜 테스트 코드에서 @Transactional을 사용하고 싶은 걸까?

 

대표적인 이유는 자동 롤백을 통한 손쉬운 테스트 격리이다.

 

 

JUnit의 @Test 내에서 @Transactional을 선언하면 예외가 발생하지 않아도 데이터가 롤백된다.

따라서 테스트 수행 이후 데이터가 모두 삭제되기 때문에 테스트 간 격리가 가능하다.

어노테이션 한 번에 손쉽게 격리되니 매력적일 수 밖에.

 

[ TMI ]

  • TranscationTestExecutionListener를 통해 해당 로직이 실행된다고 한다.
  • 자세한 내용이 궁금하다면 공식 문서 참고!

 

2.  언제, 어떤 문제가 발생할까?

 

[1] 메인 코드에서 @Transactional을 누락한 경우

메인 코드에서 @Transactional을 누락했지만 테스트 코드에서는 @Transactional이 존재한다면 메인 코드와 테스트 코드가 다르게 동작할 수 있다.

[ 예시 ]

특정 entity를 조회하는 로직을 테스트하는 경우를 가정해 보자.

테스트 코드에서는 @Transactional을 선언함으로써 lazy loading을 통해 정상 조회된다.

하지만 메인 코드에서 @Transactional 을 누락했다면 lazy loading 불가능함으로써 LazyInitializationException 발생할 수 있다.

 

[2] 비동기 메서드가 존재하는 경우

비동기 메서드가 존재하는 경우 동일한 트랜잭션이 아니므로 롤백되지 않는다.

 

[3] TransactionalEventListener를 이용하는 경우

메인 로직이 끝나더라도 테스트 로직의 트랜잭션이 끝나지 않았기 때문에 TransactionalEventListener가 정상적으로 수행되지 않는다.

 

[4] @BeforeTestClass, @AfterTestClass를 사용하는 경우

@BeforeTestClass, @AfterTestClass는 클래스 단위의 life cycle을 가진다.

따라서 @Test와 동일한 트랜잭션이 아니기 때문에 의도치 않은 동작이 발생할 수 있다.

[ FYI ]

BeforeEach, AfterEach는 메서드 단위의 life cycle을 가지기 때문에 @Test와 같은 트랜잭션을 공유한다.

 

[5] @SpringBootTest를 RANDOM_PORT / DEFINED_PORT로 이용하는 경우

RANDOM_PORT와 DEFINED_PORT는 별도의 스레드에서 스프링 컨테이너가 실행된다.

이는 클라이언트(테스트)와 서버가 별도의 스레드에 존재함을 의미한다.

따라서 테스트 코드에 @Transactional이 존재하더라도 동일한 트랜잭션이 아니므로 롤백되지 않는다.

 

[6] 트랜잭션 전파 속성을 조절한 경우

메인 코드의 트랜잭션 전파 속성이 REQUIRED_NEW 등인 경우 테스트 코드의 트랜잭션과 독립됨으로써 동일한 트랜잭션이 아니므로 롤백되지 않는다.

 

3.  그럼 어떻게 해야 할까?

 

[1] 해당 테스트에서 필요한 데이터는 테스트 안에서 만들고, 그 안에서만 사용하기

테스트를 위해 필요한 데이터는 여러 테스트에서 공유하지 않고 새롭게 생성한다. ( 개인적으로는 가장 정석의 방법이라고 생각한다. )

@Test
@DisplayName("주문 로직 테스트")
void test(){
	// given
	상품, 회원 등의 객체를 새롭게 만든다.

	// when
	주문 로직을 실행한다.

	// then
	정상 실행되었는지 확인한다.
}
  • 장점
    • 테스트코드 내에서 필요한 데이터들을 given절에서 모두 확인할 수 있다.
  •  단점
    • 매번 데이터를 생성하는 로직을 작성해야 한다.

 

[2] 테스트 실행 전/후에 데이터를 초기화하기

테스트 실행 전/후에 데이터를 수동으로 초기화할 수 있는 로직을 작성한다.

 

[ 방법 1 ] @AfterEach & EntityManager 조합

@AfterEach를 통해 모든 테이블의 데이터를 Truncate 하는 로직을 작성함으로써 매 테스트 이후 데이터가 삭제될 수 있도록 한다.

private EntityManage em;

@AfterEach
void databaseClean(){
	// 전체 테이블명 취득
	var tableNames = em.getMetamodel()
                .getEntities()
                .stream()
                .map(Type::getJavaType)
                .map(javaType -> javaType.getAnnotation(Table.class))
                .map(Table::name)
                .collect(Collectors.toList());

	entityManager.flush();
        
	// FK 체크 임시 비활성화
	entityManager.createNativeQuery("SET foreign_key_checks = 0").executeUpdate();

	// 전체 테이블 삭제
	for (String tableName : tableNames) {
		entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate();
	}

	// FK 체크 활성화
	entityManager.createNativeQuery("SET foreign_key_checks = 1").executeUpdate();
}

 

[ 방법 2 ] @Sql 활용

특정 sql 파일에 모든 테이블을 초기화하는 쿼리를 작성하고, @Sql을 통해 클래스 실행 이전 데이터가 삭제될 수 있도록 한다.

 

  • 장점
    • 한번 만들어두면 각 테스트클래스에서 주입받아 사용할 수 있다.
  • 단점
    • JPA 이외의 기술을 사용하면 EntityManager를 사용할 수 없거나, DB 기술이 바뀌어 쿼리문 문법이 바뀌는 등 기술의 변동이 일어나면 함께 수정해야 할 수 있다.
    • JUnit의 기본적으로 순차 실행 되지만, 병렬 실행되도록 설정을 변경하는 경우 각 테스트 간에 영향을 미칠 수 있다.

 

[3] @DirtiesContext(classMode = ClassMode.BEFORE_EACH_TEST_METHOD)를 통해 매번 새로운 컨텍스트를 로딩하기 (비추천)

@DirtiesContext 어노테이션에 BEFORE_EACH_TEST_METHOD 파라미터를 추가하면 테스트 메서드마다 새로운 컨텍스트를 로딩한다. 이는 테이블까지 새로 만들어지면서 자연스레 격리가 이루어지는 방식이다.

  • 장점
    • 어노테이션 하나만으로 격리가 가능하니 매우 간단하다.
  • 단점
    • 매번 컨텍스트를 새로 로드해야 하니 그만큼 실행시간이 오래 걸린다.

 

4.  그렇다면 테스트코드에서의 @Transactional은 무조건 나쁜 걸까?

프로그래밍 관련 질문에 무조건이라는 단어가 붙는다면 답은 No일 가능성이 크다.

실제로 @DataJpaTest에는 @Transactional이 포함되어 있다.

@DataJpaTest를 사용하는 목적은 단순한 데이터 조작만 테스트이므로 롤백되는 것이 보다 효율적인 방안이기 때문이리라 짐작한다.

 

 

 

그러니 이제 테스트코드에서 @Transactional을 쓰겠다고 주장하든, 쓰지 않겠다고 주장하든 제대로 된 이유를 근거로 내밀자.

남들이 쓰지 말래서, 아무도 안 쓰니까, 코드리뷰 받을까봐 등등의 이유 말고!