테스트 데이터 초기화에 @Transactional 사용하는 것에 대한 생각
얼마 전에 2개의 핫한 컨텐츠가 공유되었다. 존경하는 재민님의 유튜브 - 테스트에서 @Transactional 을 사용해야 할까? 존경하는 토비님의 페이스북 2개의 컨텐츠에서 테스트 데이터 초기화에 @Transa
jojoldu.tistory.com
테스트에서의 @Transactional 사용에 대해 질문이 있습니다. - 인프런
안녕하세요 토비 선생님!강의 너무 재밌게 잘 듣고 있습니다. 이제 몇개 남지 않아서 많이 아쉽네요.다름이 아니라 테스트 코드 작성시 `@Transactional` 어노테이션의 사용에 대해 질문이 있습니다.
www.inflearn.com
최근에 향로님 블로그를 보면서 테스트 초기화에 @Transactional 을 사용하는 것에 대한 글을 읽게 됐다.
이 주제에 대해서 향로님, 토비님, 재민님, 따로 의견을 남기시진 않은 영한님까지 다양한 의견들이 있었다.
- 향로님, 재민님 : @Transactional을 사용하지 말자.
- 토비님, 영한님 : @Transactional을 사용하자.
각기 다른 의견들이 나왔다.
어떤 의견들이 있었는지 한번 보고, 현재 진행중인 프로젝트에 어떻게 적용할지 고민해본 내용은 적어보겠다.
향로님 재민님 토비님 영한님 의견은 밑에서 정리만 할 것이다. 혹시 자세한 내용이 궁금하다면 위의 3개 링크를 참고 바란다.
테스트 데이터 초기화를 하는데 @Transactional을 사용해서 초기화 하자
토비님 의견
@Transactional을 썼을때의 장점
- @Transactional 롤백 테스트는 db가 사용되는 테스트를 편리하게 작성할 수 있고, 각 테스트가 고립되어 수행되는 것을 보장해준다.
- 테스트용 DB까지 동작하는 단위 테스트(보기에 따라서 통합 테스트)를 작성할 수 있다.
- 병렬 테스트 수행도 가능하다.
- 테스트 코드 작성 속도가 빠르기 때문에 테스트를 더 적극적으로 활용한 가능성이 높아진다.
@Transactional을 썼을때의 단점
- @Transactional 테스트는 테스트 수행 중에 단 한 개의 트랜잭션 경계만 사용이 되고, 그 경계를 테스트 메서드로 확장을 해도 문제가 없는 상황에서만 유효하다. 트랜잭션 설정을 제대로 하지 않는 코드도 테스트에서는 문제가 없는 것 처럼 보인다.
- JPA의 detached 상태 오브젝트의 변경이 자동감지 되지 않는 코드가 @Transactional 테스트에서는 정상 동작하게 보이는 현상
- @Transactional이 동일 클래스의 메서드 사이의 호출에서 적용되지 않는 문제 (Spring AOP)
- JPA에서는 save한 오브젝트가 영속 컨텍스트에만 존재하고 db로 flush되지 않은 상태로 rollback되기 떄문에 명시적으로 flush하지 않으면 실제 db 매핑에 문제가 있어서 검증하지 못한다는 문제
- @Transactional 테스트에서 제대로 검증이 되지 않는 문제를 잘 인식하고 작성을 해야한다.
@Transactional을 쓰지 않았을 때의 단점
- 테스트 경계가 바르게 설정되어 있는지의 문제, 수행하는 작업 전체가 하나의 트랜잭션으로 잘 묶여있는지에 대한 검증은, 내부에서 여러번 db 작업을 수행하는 중간에 에러가 났을 경우 전체 작업이 다 롤백 되는지 확인해야 한다.
- @Transactional 대신 tearDown 등에서 db를 클리어 하는 작업은 불가능한 건 아니지만, 테스트 이전 상태가 모든 데이터가 다 비어있는 것으로 하기도 하지만 어느 정도 초기 데이터 상태를 db에 넣고 하는 경우도 많은데, 데이터 클리어하는 작업에서 이를 정확하게 원복한다는게 롤백 방식을 쓰지 않으면 매우 귀찮고 실수하기 쉽다.
영한님 의견
- 그러면 실용성이 너무 떨어지잖아요. 몇가지 조심하면 되는데 그것 떄문에 오만가지 불편함을 감수하면서 초가삼간 다 태울 수 없으니...
테스트 데이터 초기화를 하는데 @Transactional을 사용하지 말자.
향로님 의견
@Transactional을 썼을때의 단점
- 의도치 않은 트랜잭션 적용. 실제 코드에는 @Transactional이 누락되어 있으나, 테스트 코드에서는 @Transactional이 포함되어 있다. 실제 실행시 오류가 발생하지만, 테스트에서는 정상 동작한다.
- 트랜잭션 전파 레벨을 REQUIRED_NEW로 했을 경우 롤백이 되지 않는 현상. 초기화가 되지 않은 데이터로 인해 다른 테스트가 영향을 받을 수 있다.
- 비동기 메서드 테스트 롤백 실패
- TransactionalEventListener 동작 실패
@Transactional을 쓰지 않았을 때 장점
- @Transactional을 썼을때의 단점들은 상황에 따라서 다르기 때문에, 그 케이스를 인식하고 있어야한다.
- 팀의 구성원 모두가 실수할 수 없을만큼 쉬운 방법들을 팀의 Ground Rule 기준으로 둔다.
- AA 상황에서는 XX로 해야하고, BB 상황에서는 YY로 해야한다 등의 규칙을 만들면 팀의 Ground Rule 이 복잡해지고, 제대로 이해하지 못할 수도 있다. 그래서 최대한 간단하게, 모두가 이해할 수 있는 수준으로 설정해왔다.
재민님 의견
@Transactional을 썼을때의 단점
- 실제 서버를 실행했을때와 동작이 다르다.
@Transactional을 쓰지 않았을 때 단점
- 손으로 명시적 롤백하는 것은 너무 낭비다.
재민님은 테스트에 @Transactional을 쓰지 않고, 명시적 롤백도 하지 않는다.
데이터 충돌이 나지 않게 테스트를 구성한다. 데이터 구성을 할때 타켓키 라는 것을 만들어서 데이터가 충돌나지 않게 만든다.
지금까지 네분의 의견을 알아봤다.
프로젝트에서는?
현재 진행하고 있는 프로젝트의 테스트 클래스에는 @Transactional이 클래스 레벨에 존재한다.
테스트 데이터 초기화가 쉽다는 이유 하나만으로 습관적으로 붙였다.
네분의 의견을 보고 테스트 케이스가 1개만 존재하는 테스트에서 @Transactional 을 제거해보았다.
테스트 케이스가 1개이기 때문에 데이터 충돌이 나지 않을 것을 예상하고 정상적으로 돌아갈 것이라고 생각했다.
하지만 예상치 못한 문제가 발생했다.
@Test
@DisplayName("댓글과 대댓글을 조회한다.")
void search_question_and_reply() {
//given
ThumbnailImage thumbnailImage = thumbnailImageSaveHelper.saveThumbnailImage();
Member leader = memberSaveHelper.saveMember(thumbnailImage);
...
}
@RequiredArgsConstructor
public class ThumbnailImageSaveHelper {
// ThumbnailImageRepository는 Spring Data Jpa
private final ThumbnailImageRepository thumbnailImageRepository;
public ThumbnailImage saveThumbnailImage() {
ThumbnailImage thumbnailImage = TestThumbnailImageFactory.createThumbnailImage();
return thumbnailImageRepository.save(thumbnailImage);
}
}
@RequiredArgsConstructor
public class MemberSaveHelper {
// MemberRepository는 Spring Data Jpa
private final MemberRepository memberRepository;
public Member saveMember(ThumbnailImage thumbnailImage) {
Member member = TestMemberFactory.createMemberWithThumbnailImage(thumbnailImage);
return memberRepository.save(member);
}
위와 같은 테스트를 실행했는데
아래와 같은 Spring Data Jpa의 thumbnailImageRepository.save의 반환값인 thumbnailImage 객체가 준영속 상태가 된것이다.
A detached entity is a Java object that’s no longer tracked by the persistence context.
Entities can reach this state if we close or clear the session. Similarly, we can detach an entity by manually removing it from the persistence context.
준영속 엔티티는 영속성 컨텍스트에 의해 더 이상 추척되지 않는 자바 객체이다. 세션을 닫거나 세션을 클리어하는 경우 엔티티가 이 상태에 도달할 수 있다. 마찬가지로, 수동으로 엔티티를 영속성 컨텍스트에서 제거함으로써 엔티티를 준영속 상태로 만들 수도 있다.
준영속 상태의 thumbnailImage를 member 객체를 만들기 위해 사용하고 있다.
ThumbnailImage thumbnailImage = thumbnailImageSaveHelper.saveThumbnailImage();
Member leader = memberSaveHelper.saveMember(thumbnailImage);
참고: Spring Data Jpa의 구현체는 SimpleJpaRepository이다. SimpleJpaRepository의 클래스 레벨에 @Transactional(readOnly=true)가 붙어있다. readOnly=false 인 경우 메서드 레벨에 @Transactional이 붙어있다.
그러므로, 따로 @Transactional 을 붙이지 않아도 트랜잭션이 적용된다.
member 객체를 Spring Data Jpa인 memberRepository.save(member) 를 호출하여 저장을 시도하니 다음과 같은 오류가 발생했다.
nested exception is org.hibernate.PersistentObjectException:
detached entity passed to persist: com.example.demo.domain.member.ThumbnailImage
PersistentObjectException 은 준영속 상태의 엔티티를 persist 하려고 할때 발생하는 예외이다.
오류를 보면 준영속 상태인 ThumbnailImage 에 대한 persist를 시도하려고 하고있다.
memberRepository.save(member) 메서드를 사용해서 member를 persist하려고 하고 있는데 왜 thumbnailImage에 대한 persist 오류가 발생했을까?
public class Member {
@Id
@GeneratedValue
@Column(name = "member_id")
private Long id;
...
@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.PERSIST)
@JoinColumn(name = "thumbnail_image_id")
private ThumbnailImage thumbnailImage;
}
Member 엔티티는 ThumbnailImage를 일대일 관계이면서 cascade가 persist 상태이다.
즉, Member 엔티티가 persist를 하려고 하면 ThumbnailImage 또한 persist를 시도하고 있다.
하지만, ThumbnailImage가 영속 상태의 엔티티라면 cascade에 의한 persist를 시도하더라도 예외가 발생하지 않는다.
if an instance is already persistent, then this call has no effect for this particular instance (but it still cascades to its relations with cascade=PERSIST or cascade=ALL).
if an instance is detached, we’ll get an exception, either upon calling this method, or upon committing or flushing the session.
인스턴스가 이미 영속 상태인 경우, persist 호출은 해당 인스턴스에 대해 아무런 영향을 미치지 않습니다.(그러나 'cascade=PERSIST' 또는 'cascade=ALL'이 설정된 관계에서는 여전히 전파됩니다).
인스턴스가 준영속 상태인 경우, persist을 호출하거나 세션을 커밋 또는 플러시할 때 예외가 발생합니다.
해결 방법
1. 테스트가 시작할 때 트랜잭션을 시작한다.
위에서 다음과 같이 말했다.
하지만, ThumbnailImage가 영속 상태의 엔티티라면 cascade에 의한 persist를 시도하더라도 예외가 발생하지 않는다.
스프링 트랜잭션은 전파를 한다.
영속성 컨텍스트는 트랜잭션의 생명주기와 동일하다.
테스트 클래스 레벨 또는 메서드 레벨에 @Transactional 을 붙이거나, 트랜잭션 매니저를 통해 트랜잭션을 시작한다면,
@Test
@DisplayName("댓글과 대댓글을 조회한다.")
@Transactional
void search_question_and_reply() {
//given
ThumbnailImage thumbnailImage = thumbnailImageSaveHelper.saveThumbnailImage();
Member leader = memberSaveHelper.saveMember(thumbnailImage);
...
}
위 코드에서 테스트 메서드가 끝날떄까지 트랜잭션은 살아있다.
트랜잭션은 테스트가 끝날때까지 전파가 되고 같은 영속성 컨텍스트를 사용하게 된다.
이는 ThumbnailImage가 영속성 컨텍스트내에서 관리가 되는 엔티티이므로 saveMember 가 실행하고 CascadeType.PERSIST 여도 엔티티 매니저의 persist 메서드가 호출 되더라도 아무런 영향을 미치지 않는다.
스프링 트랜잭션 전파 : 스프링의 트랜잭션 기본 옵션은 REQUIRED 이다.
REQUIRED에서 상위 메서드(외부 메서드)에서 트랜잭션이 실행되면 상위 메서드 내의 하위 메서드(내부 메서드) 까지 트랜잭션은 전파된다.
자세한 내용은 스프링 트랜잭션 전파로 구글링해보길 바란다.
2. 엔티티를 Save 시켜주는 메서드에서 트랜잭션을 시작하고 준영속 엔티티를 영속상태로 만들어준다.
ThumbnailImage thumbnailImage = thumbnailImageSaveHelper.saveThumbnailImage();
Member leader = memberSaveHelper.saveMember(thumbnailImage);
트랜잭션을 시작하지 않는다면, 위 코드에서 PersistentObjectException 예외가 발생한다.
아래 코드에서 @Transactional을 통해 트랜잭션을 시작한다.
ThumbnailImage를 save(merge) 하고 영속상태로 만든 뒤, Member 객체를 만든다.
ThumbnailImageRepository는 spring data jpa 로 save 메서드를 호출할 때, @Id 값이 존재하면 entityManage.save 하지 않고 entityManager.merge 하기 때문에 PersistentObjectException 예외가 발생하지 않는다.
@RequiredArgsConstructor
@Transactional
public class MemberSaveHelper {
private final MemberRepository memberRepository;
//spring data jpa
private final ThumbnailImageRepository thumbnailImageRepository;
public Member saveMember(ThumbnailImage thumbnailImage) {
ThumbnailImage mergedThumb = thumbnailImageRepository.save(thumbnailImage);
Member member = TestMemberFactory.createMemberWithThumbnailImage(mergedThumb);
return memberRepository.save(member);
}
}
이 과정에서 트랜잭션을 시작하고 Member 엔티티를 save 하는 과정이 중요한 이유는 Member 엔티티를 생성하는 과정에서 ThumbnailImage 엔티티의 지연로딩이 실행될 수 있기 떄문이다.
트랜잭션이 시작하지 않은 상태에서 지연로딩이 발생할 경우 LazyInitializationException 예외가 발생한다.
3.. CascadeType.PERSIST를 제거한다.
CascadeType.PERSIST를 제거한다면
public class Member {
@Id
@GeneratedValue
@Column(name = "member_id")
private Long id;
...
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "thumbnail_image_id")
private ThumbnailImage thumbnailImage;
}
@Test
@DisplayName("댓글과 대댓글을 조회한다.")
@Transactional
void search_question_and_reply() {
//given
ThumbnailImage thumbnailImage = thumbnailImageSaveHelper.saveThumbnailImage();
Member leader = memberSaveHelper.saveMember(thumbnailImage);
...
}
위 코드에서 thumbnailImage는 준영속 상태의 엔티티이지만 saveMember할때 thumbnailImage는 persist 를 호출하지 않으므로, 예외가 발생하지 않는다.
그러면 필자는 어떤 방식을 선택했을까?
@Transactional 을 제거하고 두번째 방법인 Save를 도와주는 메서드에서 트랜잭션을 시작한다.
Save를 도와주는 SaveHelper 클래스로 따로 분리하고 엔티티를 생성하는 과정에서 cascade, 지연로딩 문제 등 영속성 컨텍스트와 관련된 다양한 문제를 만나게 된다.
영속성 컨텍스트는 트랜잭션 내에서 실행되어야 하기 때문에 두번째 방법을 선택했다.
테스트에서 given 절에 해당하는 테스트 데이터 준비 과정에서만 트랜잭션이 실행되는 것이 아니라,
테스트가 실행될 떄 트랜잭션이 실행되고 트랜잭션이 전파가 되면 아래와 같은 문제가 발생한다.
- JPA에서는 save한 오브젝트가 영속 컨텍스트에만 존재하고 db로 flush되지 않은 상태로 rollback되기 떄문에 명시적으로 flush하지 않으면 실제 db 매핑에 문제가 있어서 검증하지 못한다는 문제
필자는 이미 테스트 코드를 리팩토링 하는 과정에서 @Transactional을 제거하게 되었고, 이미 위의 문제를 만나게 되었다.
서비스 코드에는 문제가 존재하지 않지만, 테스트가 실패하고 있다.
또한 @Transactional을 제거한 이유는 다음과 같다.
- @Transactional을 썼을때의 단점들은 상황에 따라서 다르기 때문에, 그 케이스를 인식하고 있어야한다.
- 팀의 구성원 모두가 실수할 수 없을만큼 쉬운 방법들을 팀의 Ground Rule 기준으로 둔다.
위 두가지 이유를 뽑았다.
테스트 코드는 결국엔 팀원들과 같이 작성해야하는 것이다.
작성하는 방법, @Transactional을 사용했을 때 만나는 케이스들을 의식해야 한다는 점이다.
테스트는 하나의 문서로써 사용될 수 있다. 테스트는 누군가 읽을 수 있으므로 작성하기 쉬워야 한다.
향로님 말대로 Ground Rule을 생각해서 이러한 방식을 선택했다.
참고
https://jojoldu.tistory.com/761
https://www.inflearn.com/questions/792383/%ED%85%8C%EC%8A%A4%ED%8A%B8%EC%97%90%EC%84%9C%EC%9D%98-transactional-%EC%82%AC%EC%9A%A9%EC%97%90-%EB%8C%80%ED%95%B4-%EC%A7%88%EB%AC%B8%EC%9D%B4-%EC%9E%88%EC%8A%B5%EB%8B%88%EB%8B%A4
https://www.youtube.com/watch?v=mB3g3l-EQp0
김영한님의 스프링 DB 1편 트랜잭션 내용
김영한님의 스프링 DB 2편 트랜잭션 내용
https://bldev2473.github.io/spring/test/transaction
https://www.baeldung.com/hibernate-save-persist-update-merge-saveorupdate
https://www.baeldung.com/hibernate-detached-entity-passed-to-persist#detached-entities
'테스트' 카테고리의 다른 글
| 테스트 코드 개선기 - given 절을 쉽게 구성해보기 (0) | 2024.12.07 |
|---|---|
| 자바의 LocalTime와 MariaDB(MySQL)의 TIME 의 관계 (0) | 2024.07.06 |
| [단위 테스트] 단위 테스트의 세 가지 스타일 (0) | 2024.06.18 |
| [단위 테스트] 리팩토링 내성 (좋은 단위 테스트의 요소) (0) | 2024.06.17 |
| [단위 테스트] 회귀 방지 (좋은 단위 테스트의 요소) (0) | 2024.06.17 |