이전에 Volunteer 프로젝트의 동시성 테스트 코드를 작성하면서 예상과 다른 결과가 발생한 상황이 있었다.
그땐 문제를 단순히 해결하는 데만 급급했고, 정확한 원인을 정리하는데 집중하지 않았다.
@Transactional 롤백 테스트에 대해서 느낀 점과 함께 기록해보고자 한다.
1. 동시성 테스트 시 문제 상황
먼저 문제의 테스트 코드를 살펴보면, 아래와 같이 테스트에 @Transactional 이 사용되는 것을 볼 수 있다.
상황: 여러 회원이 동시에 일정 참여를 시도할 때, 참여 가능 인원 임계값을 넘어가는 상황
해결: 추가 데드락 이슈로 인해 비관적 락을 사용한 상태
일반적으로 테스트 코드에서 데이터 롤백을 위해서 @Transactional을 습관적으로 사용한다.
그럼 현재 상황에서 테스트가 성공할까?
결과를 보게 되면 5개 thread 모두 Not Found 예외가 발생한다.
테스트 전에 데이터(일정 등)를 저장했는데, 왜 찾지 못하는 것일까?
디버그를 위해 로깅레벨을 debug로 설정한 후, 로그를 보게 되면 그 이유를 알 수 있다.
Opened new EntityManager [SessionImpl(56185058<open>)] for JPA transaction
Opened new EntityManager [SessionImpl(1374431359<open>)] for JPA transaction
Opened new EntityManager [SessionImpl(141387669<open>)] for JPA transaction
Opened new EntityManager [SessionImpl(1393092708<open>)] for JPA transaction
Opened new EntityManager [SessionImpl(1485952222<open>)] for JPA transaction
Opened new EntityManager [SessionImpl(794261233<open>)] for JPA transaction
Closing JPA EntityManager [SessionImpl(1485952222<open>)] after transaction
Closing JPA EntityManager [SessionImpl(794261233<open>)] after transaction
Closing JPA EntityManager [SessionImpl(1374431359<open>)] after transaction
Closing JPA EntityManager [SessionImpl(1393092708<open>)] after transaction
Closing JPA EntityManager [SessionImpl(141387669<open>)] after transaction
Closing JPA EntityManager [SessionImpl(56185058<open>)] after transaction
- EntityManager는 트랜잭션마다 생긴다.
- EntityManager 마다 영속성 컨텍스트가 생긴다.
위 로그를 보게 되면 @Transactional을 명시한 테스트 코드 EntityManager가 가장 처음 생기고, 가장 마지막에 닫힌 것을 볼 수 있다.
그 말은 테스트를 위해 저장한 데이터가 아직 DB에 반영되지 않은 것이다.
이로 인해 다른 thread는 이 데이터를 찾지 못해 예외가 발생한다.
2. 테스트에 @Transactional 제거하기
이를 해결하는 방법으로는 아래와 같은 방법들이 있다.
- @Transactional 제거하기
- 테스트 데이터를 TransactionTempate + 트랜잭션 전파 레벨 수정을 통해 트랜잭션 격리
사실 두 번째 방식은 첫 번째 방식과 비슷하다.
@Transactional 제거하게 되면 테스트의 트랜잭션이 없어지기 때문에 save()를 호출할 때마다 DB에 즉시 반영된다.
save() 내부에서도 @Transactional(readOnly=true)를 기본적으로 사용하고 있어 트랜잭션 문제도 발생하지 않는다.
실제로, @Transactional 제거하고 테스트 다시 진행해 보면 정상적인 결과를 볼 수 있다.
또한, 로그를 보게 되면 save()를 호출하기 전에 EntityManager가 생기고 커밋 후 사라지는 것을 볼 수 있다.
Opened new EntityManager [SessionImpl(486750945<open>)] for JPA transaction
Hibernate:
insert
into
vlt_user
(userno, created_date, modified_date, beforealarm_yn, birth_day, email, gender, id, joinalarm_yn, nick_name, noticealaram_yn, password, picture, provider, provider_id, refresh_token, role)
values
(default, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
Committing JPA transaction on EntityManager [SessionImpl(486750945<open>)]
Closing JPA EntityManager [SessionImpl(486750945<open>)] after transaction
이처럼 @Transactional을 잘못 사용하면 비동기 테스트 시 의도치 않은 결과를 얻을 수 도 있다.
이 밖에도 여러 문제점들이 있다. 이 부분은 밑에서 조금 더 살펴보자.
그럼 테스트에 @Transactional을 사용하면 안 될까? 안티 패턴일까?
@Transactional 롤백 테스트에 대해서 많은 사람들의 의견이 있었다.
이 부분에 대해서 느낀 점을 추가로 정리해보려고 한다.
3. @Transactional 롤백 테스트
나도 그렇고 대부분의 사람들이 테스트 코드에 @Transactional을 사용하는 것을 많이 보았다.
그럼 @Transactional을 사용했을 때 이점이 무엇일까?
- 테스트 데이터 클린업에 신경 쓸 필요 없다.
- 테스트 간의 데이터 충돌을 막을 수 있다.
반대로 문제점은 무엇일까?
- 의도하지 않은 트랜잭션 적용(실제 프로덕션 코드에는 @Transactional 누락)
- 비동기 테스트 데이터 롤백 실패
- 트랜잭션 전파 속성을 변경했을 때 테스트 데이터 롤백 실패
- 테스트 데이터 초기화 문제
이를 봤을 때는 트랜잭션과 관련된 테스트에는 @Transactional을 사용하지 않는 것이 더 좋아 보인다.
@Transactional을 사용하지 않는 것이 실제 서버 환경과 가장 유사하기 때문에 발생할 수 있는 문제점을 사전에 발견할 수 있을 것이다.
하지만, @Transactional을 사용하지 않는 것이 가장 좋을까?
@Transactional을 사용하지 않는다면 아래와 같은 고민이 있을 것이다.
- 매번 테스트 데이터를 수동으로 롤백해야 할까?
- 누락하면 어쩌지?
- FK 제약 조건이 있는 테이블들은 초기화 순서를 항상 고려해야 한다.
테스트용 DB를 사용하거나 테스트 데이터 간 충동을 막을 수 있다면 굳이 롤백할 필요도 없을 것이다.
(누락 걱정도 사라질 것이고)
또, 아래와 같이 데이터 초기화 환경을 구축하고 @BeforEach or @AfterEach에서 사용한다면 초기화 순서도 해결할 수 있을 것이다.
(아래 코드는 h2 DB 기준)
@Component
public class DatabaseCleaner {
private static final String FOREIGN_KEY_CHECK_FORMAT = "SET REFERENTIAL_INTEGRITY %s";
private static final String TRUNCATE_TABLE_FORMAT = "TRUNCATE TABLE %s";
private List<String> tableNames;
@PersistenceContext
private EntityManager em;
@PostConstruct
public void findAllTable() {
tableNames = em.getMetamodel()
.getEntities()
.stream()
.map(Type::getJavaType)
.map(javaType -> javaType.getAnnotation(Table.class))
.map(Table::name)
.collect(Collectors.toList());
}
@Transactional
public void execute() {
em.flush();
em.createNativeQuery(String.format(FOREIGN_KEY_CHECK_FORMAT, "FALSE")).executeUpdate();
for (String tableName : tableNames) {
em.createNativeQuery(String.format(TRUNCATE_TABLE_FORMAT, tableName)).executeUpdate();
}
em.createNativeQuery(String.format(FOREIGN_KEY_CHECK_FORMAT, "TRUE")).executeUpdate();
}
}
@Transactional 사용에 대해서 고민하다 보니 결론은 정답이 없는 거 같다.(정답이 없는 게 가장 어렵다...)
가장 중요한 건 테스트 간에 영향을 주지 않는다면, 어떤 방식으로도 사용해도 될 거 같다.
어느 방식을 사용하든 각 방식의 문제점을 인식하고, 팀 내 규칙을 따르면 될 거 같다.