본문 바로가기
테스트코드

동시성 테스트와 테스트 초기화를 위한 @Transactional 사용의 생각

by 구본식 2024. 2. 21.

이전에 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 사용에 대해서 고민하다 보니 결론은 정답이 없는 거 같다.(정답이 없는 게 가장 어렵다...)

가장 중요한 건 테스트 간에 영향을 주지 않는다면, 어떤 방식으로도 사용해도 될 거 같다.

어느 방식을 사용하든 각 방식의 문제점을 인식하고, 팀 내 규칙을 따르면 될 거 같다.

 

'테스트코드' 카테고리의 다른 글

시간여행 테스트  (1) 2024.02.05