과정을 즐기자

테스트 코드에서 @Transactional 사용에 대한 끊임 없는 논쟁 본문

테스트 코드

테스트 코드에서 @Transactional 사용에 대한 끊임 없는 논쟁

320Hwany 2024. 3. 1. 18:43

각종 블로그 글, 유튜브, 페이스북, 오픈 카톡방, 인프런 글 등을 보다 보면 테스트 코드 작성시 @Transactional의 사용 여부

끊임없이 논쟁되는 주제 같습니다.

 

여러 글들을 많이 보았는데 향로님의 블로그에서 이러한 의견들을 잘 정리해놓은 글을 보았습니다.

 

테스트 데이터 초기화에 @Transactional 사용하는 것에 대한 생각

얼마 전에 2개의 핫한 컨텐츠가 공유되었다. 존경하는 재민님의 유튜브 - 테스트에서 @Transactional 을 사용해야 할까? 존경하는 토비님의 페이스북 2개의 컨텐츠에서 테스트 데이터 초기화에 @Transa

jojoldu.tistory.com

정말 책, 강의, 유튜브 등에서만 보았던 존경하는 분들의 의견이 정리되어 있었습니다.

테스트 코드에서 @Transactional 사용을 찬성하는 의견으로는 토비님, 김영한님이 있었고

반대하는 의견으로는 김재민님, 향로님이 있었습니다.

 

우선 결론부터 이야기하면 저의 의견은 "테스트 DB를 초기화 하기 위해서 @Transactional을 사용하지 말자"입니다.

찬성 의견

먼저 테스트 DB 롤백을 위한 @Transactional 사용을 찬성하는 의견을 정리해보겠습니다.

 

"장점이 압도적으로 많기 때문에 저는 @Transactional 테스트를 적극적으로 권장합니다. 테스트용 DB까지 동작하는

단위 테스트(보기에 따라선 통합 테스트)를 작성할 수 있고, 심지어 병렬 테스트 수행도 가능해집니다.

테스트 코드 작성 속도가 빠르기 때문에 테스트를 더 적극적으로 활용할 가능성도 높아집니다."

 

"@Transactional 대신 tearDown 등에서 db를 클리어 하는 작업은 불가능한 건 아니지만 별로 추천하고 싶지 않습니다.

초기 데이터가 달라지기라도 하면 모든 db 정리하는 코드를 또 다 고쳐야 하는데, 거기에 오류가 있으면 테스트가 다 깨지거나,

실패해야 할 다음 테스트가 성공하게 만들 수도 있겠죠. 그래서 아주 간단한 경우가 아니면 권장하지 않습니다.

아니면 테스트 하나 수행할 때마다 db 전체를 다 날리고 초기화 하는 작업을 하는 방법도 있긴한데,

애플리케이션이 커지면서 테스트가 매우 느려질테니 결국 테스트를 덜 만들거나 잘 하지 않게 될 겁니다. 단점이 더 많은 거죠."

 

"그러면 실용성이 너무 떨어지잖아요. 몇가지 조심하면 되는데 그것 때문에 오만가지 불편함을 감수하면서 초가삼간 다 태울 수 없으니..."

 

위 의견들의 공통점은 실용성에 초점을 두었다는 것입니다. @Transactional을 사용함으로써 발생하는 단점(뒤에서 정리)이 있지만

그것보다는 사용함으로써 얻는 장점이 더 크다고 생각하시는 것 같습니다.

반대 의견

먼저 테스트 DB 롤백을 위한 @Transactional 사용을 반대하는 의견을 정리해보겠습니다.

 

"너무 유명한 사례인, 의도치 않은 트랜잭션 적용 이 있다.

  • 실제 코드에서는 @Transacational 이 누락되어있으며
  • 테스트 코드에서는 데이터 초기화를 위해 @Transacational 이 포함되어있다.

물론 요즘의 대부분의 스프링에서의 개발은 Service 클래스에 @Transacational(readOnly=true) 를 기본적으로 선언해서

이렇게 ORM 에서 발생할만한 여지를 최소화하고 있다. 그래서 팀의 규칙만 잘 정한다면 해당 이슈가 발생할만한 여지가 거의 없다.
그렇지만, 실제 환경과 테스트 환경의 불일치로 정확한 테스트가 되지 않고 놓치는 부분이 발생할 수 있다는 것 역시 사실이다."

 

"또 다른 경우로는 트랜잭션 전파 레벨을 수정해서 새로운 트랜잭션이 필요한 경우 롤백이 되지 않는 것이 있다."

이외에도 비동기 메소드, TransactionalEventListener 에서도 정상적으로 작동하지 않습니다.

 

반대하는 의견은 주로 실용성보다는 @Transactional 사용시 발생할 수 있는 문제점에 대해 이야기합니다.

그럼 어떻게 테스트 데이터를 초기화 할 것인가

저는 반대 의견에 더 가깝습니다. 물론 실제 서버와 100% 같은 테스트 코드는 작성할 수 없다고 생각합니다.

하지만 할 수 있는 부분은 최대한 가깝게 작성해야 한다고 생각합니다.

다른 모든 문제점을 제쳐두어도 실제 서버에서는 동작하지 않는 트랜잭션이 테스트 코드에서는 동작하는 문제

즉, 의도치 않은 트랜잭션 적용만큼은 꼭 피해야 하는 문제라고 생각합니다.

 

지금부터 테스트간의 독립성을 유지하기 위해서 @Transactional을 사용하지 않고 테스트 데이터를 초기화 하는 방법에

대해 알아보겠습니다.

 

우선 저는 명시적 롤백을 사용합니다. 명시적 롤백은 말 그대로 각 테스트를 실행하기 전에 @BeforeEach와 같은 어노테이션을

이용해서 데이터를 초기화하는 방법을말합니다.

@BeforeEach
public void setUp() {
    orderRepository.deleteAll();  
    memberRepository.deleteAll();
}

위와 같은 방식으로 테스트 데이터를 초기화 할 수 있습니다. 하지만 지금은 필요한 각각의 Repository를 모두 작성해야 하며 

delete 명령어를 사용하여 truncate 명령어보다 성능이 떨어질 수 있습니다. 여기서 조금 더 개선해보겠습니다.

 

우선 테스트 환경에서만 동작하는 DatabaseCleaner 스프링 빈을 생성해줍니다.

@Component
public class DatabaseCleaner {

    private final JdbcTemplate jdbcTemplate;
    private final EntityManager entityManager;
    private final List<String> tableNames = new ArrayList<>();

    public DatabaseCleaner(final JdbcTemplate jdbcTemplate, final EntityManager entityManager) {
        this.jdbcTemplate = jdbcTemplate;
        this.entityManager = entityManager;
    }

    public void cleanUp() {
        for (String tableName : tableNames) {
            jdbcTemplate.execute("TRUNCATE table " + tableName);
        }
    }

    @PostConstruct
    public void findAllTables() {
        Set<EntityType<?>> entities = entityManager.getMetamodel().getEntities();
        for (EntityType<?> entity : entities) {
            Class<?> javaType = entity.getJavaType();
            Table table = javaType.getAnnotation(Table.class);

            if (table != null) {
                String tableName = table.name();
                tableNames.add(tableName);
            }
        }
    }
}

 

여기서 주목해볼 점은 자바 Reflection 사용Truncate 명령어 사용입니다.

 

Reflection을 이용하여 @Table 어노테이션을 사용한 DB 엔티티를 통해서 테이블의 이름을 가져온 후 tableNames 리스트에

넣어줍니다. tableNames를 사용하는 이유는 자바 Reflection은 컴파일 시점이 아닌 런타임 시점에 동작하여 성능상 문제가 

발생할 수 있기 때문에 테스트를 시작하기 전에 한번만 수행되도록 해주었습니다.

 

다음으로 truncate를 사용한 이유를 알아보기 위해 delete, truncate, drop을 비교해보겠습니다.

테스트 데이터 롤백 용도로는 truncate 명령어가 가장 적합하다는 것을 알 수 있습니다.

 

이렇게 만든 DatabaseCleaner를 이용해서 다음과 같은 방식을 사용할 수 있습니다.

@SpringBootTest
public abstract class ServiceTest {

    @Autowired
    protected DatabaseCleaner databaseCleaner;

	...

    @BeforeEach
    void setUpDatabase() {
        databaseCleaner.cleanUp();
    }
}

 

Service Layer 에 대한 테스트 코드를 작성하고 싶으면 ServiceTest를 상속 받아 사용하면 됩니다.

하지만 이 방식은 외래키를 사용하는 경우에는 문제가 발생할 수 있습니다. 하지만 최근에는 외래키 사용시 성능, 운영상의 문제로

아예 사용하지 않는 경우가 많아지는 것 같습니다. 저 역시 외래키를 사용하지 않았기 때문에 위와 같은 방식을 사용하였습니다.

 

다른 방법으로 김재민님은 명시적 롤백을 사용하지 않고 아예 데이터 충돌이 발생하지 않도록 테스트를 구성하도록 한다고 합니다.

핵심은 테스트가 독립적으로 작동한다는 것이므로 데이터가 저장되는 DB가 같아도 특정 key 값으로 분리하여 충돌이 발생하지 

않도록 할 수도 있습니다.

정리

테스트 데이터 초기화를 위한 @Transactional의 사용 여부는 정답이 없는 문제인 것 같습니다.

실용성에 초점을 두느냐 아니면 실제 서버와 최대한 유사한 테스트 코드 작성에 초점을 두느냐에 따라 달라지는 것 같습니다.

 

저는 명시적 롤백 사용을 조금 더 선호하여 자바 리플렉션과 Truncate 명령어를 사용하여 테스트용 스프링 빈을 만들어서 

사용하였습니다. 이외에도 명시적 롤백을 사용하지 않더라도 데이터 충돌이 발생하지 않도록 테스트를 구성할 수도 있을 것 

같습니다.

 

참고한 자료

 

테스트에서의 @Transactional 사용에 대해 질문이 있습니다. - 인프런

안녕하세요 토비 선생님!강의 너무 재밌게 잘 듣고 있습니다. 이제 몇개 남지 않아서 많이 아쉽네요.다름이 아니라 테스트 코드 작성시 `@Transactional` 어노테이션의 사용에 대해 질문이 있습니다.

www.inflearn.com

 

[SQL] DELETE / TRUNCATE / DROP 차이점

1) DELETE - WHERE절을 사용하여 테이블에 있는 데이터를 하나하나 선택하여 제거하는 방식 - WHERE절을 사용하지않고 테이블의 모든 데이터를 삭제하더라도, 내부적으로는 한줄 한줄 일일히 제거하

prinha.tistory.com