[Test] 테스트에서 @Transactional을 써도 될까
0. 서론
최근 우리 Moment의 기능을 추가하던중 기묘한 현상을 마주했다.
임시 질문을 사용하면 상태가 true로 업데이트되어야 하는데 프로덕션 환경에서는 이 처리가 되지 않았다. 애플리케이션 로그에서는 함수가 정상적으로 실행됐지만, DB로 쿼리가 날아가지 않은 것이다.
더 황당한 점은, 작성해둔 테스트 코드에서는 이 변경 사항을 정상적으로 감지하고 통과했다는 사실이다.
원인은 테스트 클래스에 달린 @Transactional어노테이션에 있었다.
1. 상황
문제의 1차적 원인은 내 코드의 실수였다. 상태 변수를 업데이트하는 메서드에 @Transactional 을 달아주지 않았다. 또한 이 메서드를 호출한 상위 메서드는 외부 API를 불러오는 역할을 하는 Facade 메서드라 @Transactional 을 달지 않았다.
결과적으로 상태를 변경하는 객체는 영속성 컨텍스트의 관리를 받는 ‘영속 상태’가 아니었고, 더티 체킹이 발생하지 않았다.
근데 왜? 테스트는 통과한걸까?
2. 테스트에서 @Transactional 이 하는 일
테스트에서 @Transactional 은 테스트 간의 데이터 격리를 위한 롤백의 용도로 흔히 사용된다. 테스트 메서드에 @Transactional 이 달리면 테스트가 실행되는 스레드는 하나의 트랜잭션 스코프로 묶이게 된다.
1
2
3
4
5
6
7
8
9
10
11
12
@Test
@Transactional
void A가_일어나면_B를_반환한다(){
//given
xxRepository.save(something);
//when
Object ob = xxService.doSomething(); // 이 메서드에 @Transactional 누락
//then
assertThat(ob.isXX()).isTrue();
}
이 시나리오에서 doSomething()에 @Transactional 이 없더라도, 이미 부모(테스트 메서드)가 트랜잭션을 시작했기 때문에 자연스럽게 그 트랜잭션에 참여하게 된다.
즉, 테스트 환경에서는 영속성 컨텍스트가 살아서 유지된다. 그 결과, 실제 DB에 update 쿼리가 커밋되지 않고 롤백되더라도, 메모리 상의 1차 캐시에서는 더티 체킹이 동작해 객체의 상태가 변경된다. assertThat은 이 메모리 상의 변경된 객체를 검증하기 때문에 테스트가 교묘하게 통과하게 됐다.
3. 해결방법
이러한 문제를 방지하려면 “테스트 환경은 최대한 프로덕션 환경과 동일해야 한다”는 원칙을 지켜야 한다. 우리 팀은 테스트 클래스에서 @Transactional을 제거했다. 대신, 테스트 간 데이터 전파를 막기 위해 DB 테이블을 초기화(Truncate)하는 유틸 클래스를 만들어 테스트 전후에 실행하도록 변경했다.
1
2
3
4
5
6
7
8
9
10
11
@BeforeEach
void setUp(){
db.clean();
}
//... 테스트 코드 ...
@AfterEach
void tearDown(){
db.clean();
}
4. 그럼 테스트에서 @Transactional 은 언제 사용할까?
테스트에서 @Transactional 은 절대악일까? 위 상황에서 얻은 교훈, “테스트 시나리오는 최대한 현실과 가까워야 한다.” 를 생각하면 비지니스 로직엔 가급적 사용하지 않는 것이 좋아보인다.
한 가지 예외, Repository 테스트에선 사용해도 좋을 것 같다. 스프링 팀에서도 테스트에 @Transactional을 사용하고 있다.
먼저, 비지니스 로직 테스트와 레포지토리 테스트는 “트랜잭션 경계 유무”라는 큰 차이점이 존재한다. 비지니스 로직에선 도메인 로직에 따라 트랜잭션 경계가 중요하지만, 레포지토레 레벨에선 중요하지 않다. 그렇기에 “빠른 데이터 롤백”의 이유로 적극적으로 사용해도 좋을 듯 싶다. 심지어 @DataJpaTest 어노테이션에 @Transactional 이 달려있다.
결론적으로, 테스트의 목적과 계층에 따라 트랜잭션 제어 방식을 명확하게 분리하는 것이 안전하고 신뢰성 있는 테스트 코드를 작성하는 길이다.
