스프링 JPA 테스트 코드에 무심코 적용한 @Transactional
스프링 프로젝트에서 많은 사람들이 JUnit으로 테스트 코드를 작성할 때, @Transactional 어노테이션을 사용한다.
하지만 Spring Data JPA와 함께 Service 테스트 클래스에 @Transactional을 무심코 사용한다면 예상하지 못한 테스트 실패나 누락이 발생할 수 있다.
문제 상황
1:N 연관 관계에 있는 엔티티들을 fetch join으로 잘 조회되는지 테스트하고자 했다.
@Entity
public class Board extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "board_id")
private Long boardId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@Column(nullable = false)
private String title;
@Column(columnDefinition = "TEXT")
private String content;
@OneToMany(mappedBy = "board", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Image> images = new ArrayList<>();
}
@Entity
public class Image {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "image_id")
private Long imageId;
@JoinColumn(name = "board_id")
@ManyToOne(fetch = FetchType.LAZY)
private Board board;
@Column(name = "image_address")
private String imageAddress;
}
※ 설명에 필요한 부분만 남겼다.
- Board 엔티티와 Image 엔티티는 1:N 관계이고 LAZY 전략으로 조회한다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class BoardServiceSupport {
private final BoardRepository boardRepository;
public Board getBoardWithImages(Long boardId) {
return boardRepository.getBoardWithImages(boardId).orElseThrow(()-> new BoardNotFoundException());
}
}
- 1:N 관계인 게시글과 이미지를 fetch join으로 함께 가져온다.
테스트 코드
@SpringBootTest
@Transactional
public class TransactionTest {
@Autowired
private UserRepository userRepository;
@Autowired
private ImageRepository imageRepository;
@Autowired
private BoardRepository boardRepository;
@Autowired
private BoardServiceSupport boardServiceSupport;
private User user;
@BeforeEach
void setUp() {
user = User.create("testEmail", "testName", "testPicture", "testValue", List.of("USER"));
userRepository.save(user);
}
@Test
void getBoardWithImages() {
/** given */
Board board = Board.create(user, "testTitle", "testContent");
Image image1 = Image.create(board, "testAddress1");
Image image2 = Image.create(board, "testAddress2");
Image image3 = Image.create(board, "testAddress3");
boardRepository.save(board);
imageRepository.saveAll(List.of(image1, image2, image3));
/** when */
Board boardWithImages = boardServiceSupport.getBoardWithImages(board.getBoardId());
/** then */
assertTrue(Hibernate.isInitialized(boardServiceSupport.getImages()));
assertThat(boardServiceSupport.getImages().size()).isEqualTo(3);
}
실행 결과


분명히 Board와 Image 3개를 DB에 저장했다.
그리고 board과 image의 연관 관계를 설정해주진 않았지만, boardServiceSupport.getBoardWithImages() 함수로 Board와 Image를 fetch join으로 가져왔기 때문에 assertThat(boardWithImages.getImages().size()).isEqualTo(3) 은 통과되어야 한다.
하지만 결과는 Board와 연관 관계에 있는 Image 엔티티들이 조회되지 않았다.
또한 Hibernate.isInitialized(boardWithImages.getImages())로 Image 엔티티가 프록시 객체인지 확인해보니, 모두 초기화도 된 상태였다.
WHY ?
결론부터 말하자면,
Test 클래스와 Service 클래스의 중첩된 트랜잭션과 JPA의 1차 캐시에 의한 것이다.
스프링에서 트랜잭션의 전파 설정은 기본적으로 REQUIRED로 설정되어 있다.
REQUIRED는 부모 트랜잭션이 있는 경우 새로운 트랜잭션을 생성하지 않고, 부모 트랜잭션에 합쳐지는 것이다.
그래서 Test 클래스에 있는 @Transactional에 의해 테스트가 시작되면 트랜잭션이 열리고,
Service 클래스에 있는 @Transactional에 의해 시작되는 트랜잭션은 Test 클래스에 있는 트랜잭션과 합쳐지는 것이다.
둘의 트랜잭션이 합쳐지는게 무슨 상관이 있을까 ?
JPA에서는 트랜잭션이 시작되고 엔티티를 조회하거나 저장하게 되면, 그 엔티티는 1차 캐시에 저장된다.
후에 그 엔티티를 다시 조회하게 된다면 데이터베이스에서 다시 가져오는 것이 아니라, 1차 캐시에 저장된 엔티티를 가져오게 된다.
즉, 위 테스트 코드에서 boardServiceSupport.getBoardWithImages()로 조회한 boardWithImages는
boardRepository.save(board)로 저장한 1차 캐시에 있는 board이다.
그래서 board에는 image1, image2, image3이 연관 관계로 설정되지 않고 저장되었기 때문에,
board의 images에 포함되어 있지 않고 초기화된 상태인 것이다.
board와 boardWithImages가 같은 객체인지 테스트 코드로 확인해보자.
@Test
void board와_getBoard가_같은_객체이다() {
/** given */
Board board = Board.create(user, "testTitle", "testContent");
Image image1 = Image.create(board, "testAddress1");
Image image2 = Image.create(board, "testAddress2");
Image image3 = Image.create(board, "testAddress3");
boardRepository.save(board);
imageRepository.saveAll(List.of(image1, image2, image3));
/** when */
Board boardWithImages = boardServiceSupport.getBoardWithImages(board.getBoardId());
/** then */
assertThat(boardWithImages).isEqualTo(board);
System.out.println("board: " + board);
System.out.println("boardWithImages: " + getBoard);
}


boardWithImages는 1차 캐시에 있는 board가 반환되었기 때문에 같은 객체가 된다.
Test 클래스에 @Transactional을 붙이지 않는다면 ?


Test 클래스에 @Transactional이 없기 때문에 테스트를 시작할 때 트랜잭션이 열리지 않는다.
그렇기 때문에 board는 boardServiceSupport.getBoardWithImages()가 시작될 때 1차 캐시에 존재하지 않고,
boardWithImages는 image를 fetch join하여 데이터베이스에서 조회한 엔티티가 된다.
Test 클래스에 @Transactional을 붙이지 않고, 처음 테스트 코드를 실행해보자.

처음 테스트 코드를 다시 실행하면 테스트를 통과한다.
위에서 말한대로 Test 클래스에는 @Transactional이 없기 때문에 boardServiceSupport.getBoardWithImages()를 호출할 시점의 1차 캐시에 board가 저장되어있지 않고,
그래서 Board 엔티티를 데이터베이스에서 조회하여 가져왔기 때문이다.
원인 정리
스프링의 트랜잭션 전파는 REQUIRED로 기본 설정되어 있고, 이 설정은 부모 트랜잭션이 있는 경우 자식 트랜잭션이 새로 생성되지 않고 트랜잭션이 합쳐진다.
JPA의 1차 캐시는 트랜잭션이 열려있는 동안 유지 되고,
그래서 getBoardWithImages(board.getId())를 호출하면 1차 캐시에 저장된 연관 관계를 설정하지 않은 board를 바로 반환하게 된다.
그렇기 때문에
assertThat(Hibernate.isInitialized(boardWithImages.getImages())는 통과하지만
assertThat(boardWithImages.getImages()).hasSize(3)은 통과하지 못한다.
해결 방법
나는 테스트 클래스에 @Transactional로 데이터베이스를 롤백시키는 것 대신 @AfterEach로 데이터베이스를 모두 지워주는 방법을 택했다.
@AfterEach
void tearDown() {
imageRepository.deleteAllInBatch();
boardRepository.deleteAllInBatch();
userRepository.deleteAllInBatch();
}
엔티티의 연관 관계에서 cascade 옵션으로 Board만 지워도 Image가 지워지도록 할 수 있지만, 이렇게 한다면 성능상 차이가 생길 수 있다.
위 방법으로 데이터베이스를 지운다면

2번의 쿼리에 모두 지워진다.
cascade 옵션으로 image가 지워지도록 한다면
@AfterEach
void tearDown() {
boardRepository.deleteAll();
userRepository.deleteAll();
}

총 4번의 쿼리가 생기고 Image를 지우는데 쿼리가 3번 생긴다.
간단한 테스트에서는 성능상 크게 차이가 없겠지만,
테스트의 규모가 커져서 given절이 많아지면 이런 쿼리가 쌓여 성능 저하가 발생할 수도 있다.
테스트 또한 유지 보수의 대상이고 비용이기 때문에 성능을 고려해야 한다.
결론
테스트 코드는 하나의 테스트 내에서 독립성을 보장해야 하며, 두 개 이상의 테스트 간에도 독립성을 보장해야 한다.
그래서 각 테스트가 끝난 후 데이터베이스를 롤백시키기 위해 테스트 클래스에 @Transactional을 많이 사용하지만,
테스트 클래스에 트랜잭션이 생기면 내 예상과 다른 결과가 나올 수도 있다는 것을 주의하고 사용해야 한다.