테스트의 격리성이 보장되지 않아요

SeokHwan An·2024년 12월 13일
0

축제 사이트

목록 보기
3/3

문제사항

단일 테스트를 진행할 때 성공했던 테스트가 전체 동작 테스트에서 실패를 하는 상황이 발생했습니다.

위는 부스에 댓글을 추가하는 기능을 테스트를 하는 것이 단일로 실행을 할 경우에는 성공을 하지만 테스트 전체 코드를 실행 했을 때에는 실패하는 것을 확인할 수 있습니다.

오류 내용을 보면 result의 id가 1L인 것을 예상했지만 실제 결과는 3L이 나왔다는 것을 나타내고 있습니다. 이 에러 뿐만 아니라 데이터를 조회하는 테스트에서도 단일 테스트로 실행했을 때에는 통과했지만 전체 테스트를 실행한 경우 테스트의 순서에 따라 의도한 데이터 개수보다 더 많은 개수의 데이터가 나타나는 것을 확인할 수 있었습니다.

이와 같은 문제가 발생하는 이유는 무엇일까요?

이 문제는 여러 테스트가 데이터베이스와 같은 공통 자원을 이용했을 때 발생하는 문제였는데요. 즉, 이전에 테스트에서 발생한 데이터들이 롤백이 되는 것이 아니라 그대로 유지가 되어 이후에 테스트에 영향을 미치지는 상황이었습니다. (물론 @DataJpaTest의 경우에는 내부에 @Transactional이 있어 롤백이 되지만 id가 초기화되지 않는 문제가 발생했고 @SpringBootTest의 경우 @Transactional 적용되지 않아 테스트 후 데이터의 롤백이 자동화 되어 있지 않아 다른 테스트에 영향을 미치는 상황이었습니다.)

해결방안

문제 발생 원인이 database가 테스트마다 동일한 환경을 가지지 못해서 발생한 문제였기에 database를 초기화하는 방법에 대해서 찾아보았습니다.

  • @Transactional 이용하기
  • @DirtyContext 를 이용하기
  • entityManager를 이용해서 테이블 초기화 하기

각각의 방식에 대해서 알아보겠습니다.

Transcational 어노테이션 이용하기

Annotating a test method with @Transactional causes the test to be run within a transaction that is, by default, automatically rolled back after completion of the test.

spring framework의 공식문서를 보면 테스트 코드에서 @Transactional 을 이용하면 테스트가 완료된 이후에 자동으로 rollback이 수행된다고 합니다.

해당 내용을 보면 간단하게 테스트 데이터를 rollback할 수 있어 @Transactional 을 테스트 코드에 이용하는 것이 현재 직면한 테스트간의 db 격리성을 보장하는데 용이할 것 입니다. 하지만 @Transaction을 테스트 코드에 이용하면 유의해야할 점이 발생합니다.

“실제 로직 환경과 테스트의 환경이 동일하지 않게 됩니다.”

테스트코드에 @Transaction을 이용하게 되면 비즈니스 로직에만 적용되던 트랜잭션이 테스트 메소드 전체로 확장이 됩니다. 확장된 트랜잭션 범위로 인해서 의도하지 않은 결과가 발생할 수 있습니다.

그 예시로 비즈니스 로직에 @Transaction 을 빼먹은 경우에 대해서도 테스트는 성공하는 상황이 발생할 수 있습니다. 간단한 예시를 살펴보겠습니다. 현재 Booth와 Menu는 1대다의 연관관계를 맺고 있습니다.

위의 메소드는 booth의 menu를 조회하는 기능으로 현재 @Transactional을 적용되지 않은 상태입니다. 해당 기능은 테스트에서 성공을 하지만 실제 운영상황에서는 menu를 불러오는 과정에서 LazyInitializationException이 발생하게 됩니다. 이렇듯 개발자의 실수가 있었지만 실제환경과 테스트 환경이 달라지면서 해당 문제를 파악하지 못할 수 있습니다.

두번째 상황은 RestAssured를 통한 인수테스트에서 데이터를 불러오지 못하는 문제가 발생할 수 있습니다.

위의 테스트는 RestAssured를 통한 인수 테스트로 사용자가 booth에 속한 menu 정보를 잘 불러오는지 테스트 하는 것입니다. 해당 테스트 메소드에 @Transactional 을 이용하면 데이터를 불러오지 못하는 문제가 발생합니다.

분명 테스트를 위한 API 요청 전에 테스트 데이터를 저장하는 과정이 있지만 해당 정보를 불러오지 못하는 이유는 다음과 같습니다.

  1. 현재 메소드에 @Transactional 으로 인해서 transaction의 범위가 테스트 테스트 데이터는 db에 flush가 되지 않은 상태입니다.
  2. RestAssured의 경우에는 별도의 thread를 활용해서 동작합니다.
  3. 트랜잭선은 thread 별로 생성되고 그로인해 jpa의 1차캐시 역시 thread 별로 관리됩니다. (여러 thread에서 트랜잭션이나 영속성 컨텍스트를 공유하지 않습니다.)

위의 3가지 내용을 잘 조합해 테스트 코드를 보겠습니다.

  1. 현재 테스트 데이터는 영속성 컨택스트에만 있고 db에는 저장되지 않은 상태입니다.
  2. RestAssured를 통해 api가 실행될 때 새로운 transaction과 영속성 컨택스트가 활성화 됩니다.
  3. 데이터를 불러오는 과정에서 db에 요청을 보냈지만 해당 테스트 데이터는 db에 존재하지 않아 에러를 발생합니다.

이렇듯 @Transactional 을 테스트 코드에 이용하는 것은 데이터 롤백 측면에서는 간단하지만 예상치 못한 상황에서 문제가 발생할 수 있습니다.

DirtiesContext 어노테이션 이용하기

먼저 @DirtiesContext 를 적용하기에 앞서서 @DirtiesContext에 대한 정보를 찾아보면 다음과 같이 나타나 있습니다.

DirtiesContext
Test annotation which indicates that the ApplicationContext associated with a test is dirty and should therefore be closed and removed from the context cache.

Use this annotation if a test has modified the context — for example, by modifying the state of a singleton bean, modifying the state of an embedded database, etc. Subsequent tests that request the same context will be supplied a new context.

@DirtiesContext may be used as a class-level and method-level annotation within the same class or class hierarchy. In such scenarios, the ApplicationContext will be marked as dirty before or after any such annotated method as well as before or after the current test class, depending on the configured methodMode and classMode.

This annotation may be used as a meta-annotation to create custom composed annotations.
As of Spring Framework 5.3, this annotation will be inherited from an enclosing test class by default. See @NestedTestConfiguration for details.

두번째 문단을 보면 DirtiesContext를 이용하는 상황을 알 수 있습니다. DirtiesContext는 이미 올라온 context의 bean이 오염이 되거나 혹은 database가 변경되었을 때 이용하는 것으로 새롭게 ApplicationContext구성하여 테스트를 하는 것입니다. 즉, 각 테스트마다 초기의 context를 생성해 테스트를 진행하는 것입니다.

이와 같이 테스트 메소드마다 새롭게 ApplicationContext를 만들어서 진행하는 것은 느낀점은 테스트 동작 시간이 길어진다는 것입니다.

위와 같이 테스트 메소드가 실행되기 전에 새롭운 ApplicationContext를 구성하도록 하여 테스트를 진행해보았습니다.

위에서 말했던 것과 같이 모든 테스트가 같은 환경에서 동작을 하다보니 모든 테스트가 의도했던 대로 동작하는 것을 확인할 수 있습니다. 하지만 5개 테스트가 동작하는데 1.6초가 발생한 것을 볼 수 있습니다.

DirtiesContext를 이용하지 않는 방식으로 테스트를 진행했을 때(약 0.3초)보다 약 5배정도 차이가 나는 것을 확인할 수 있습니다.

지금은 테스트의 개수가 적지만 테스트의 개수가 많아지게 되면 이는 빠르게 테스트를 진행하는데 어려움을 줄 수 있습니다. 물론 DirtiesContext를 class 단위가 아닌 필요한 메소드에만 붙여서 진행하여 ApplicationContext의 생성횟수를 줄일 수 있을 것이라 생각합니다.

EntityManager활용하기

EntitiyManager를 통해 동적으로 전체 테이블을 불러와서 테이블을 초기화해주는 컴포넌트를 만들어 테스트 간의 격리성을 보장해주는 방법입니다.

동작 방식은 크게 테이블 정보를 로드하고 이를 통해 테이블을 truncate하는 방식입니다.

  1. 먼저 @Entity 인 클래스를 모두 불러오고 이를 테이블 이름에 맞게 Carmel 케이스에서 Snake 케이스로 변경을 합니다.
  2. 영속성 컨텍스트를 비운 후 테이블 이름 통해 truncate table SQL을 처리합니다.

이를 테스트 @AfterEach 에 적용한 후 테스트를 실행하면 테스트 종료 후 다음과 같은 쿼리문을 볼 수 있습니다.

이를 통해서 테스트 간에 database가 격리되어 여러 테스트를 동작시켜도 서로 영향을 주지 않아 테스트 메서드가 독립적으로 동작합니다.

하지만 이 방식에도 문제점이 존재합니다.

  1. 동적으로 테이블을 불러와서 테이블을 초기화하가 보니 불필요한 테이블까지도 truncate를 한다는 것입니다. 즉 테이블의 개수가 많아지게 되면 이 부분도 리소스가 많이 발생할 수 있습니다.
  2. 테스트에 사용하는 db SQL 문에 종속적입니다. mongoDB에서는 truncate 문을 허용하지 않듯 데이터베이스가 변경될 때 문제가 발생할 수 있습니다.

3가지 방법에 대해서 알아보고 비교해보면서 저는 EntityManager를 통해서 테스트 격리성을 보장하는 방안을 택했습니다.

Transactional 어노테이션을 이용하는 것이 가장 간단했지만 생각보다 많은 부분에서 문제가 될 수 있다는 것을 테스트 데이터 초기화에 @Transactional 사용하는 것에 대한 생각 글을 통해서 더 볼 수 있었습니다. 그리고 테스트 목적은 해당 코드가 실제 환경에서도 잘 동작하는지를 검증하는 것이기에 테스트의 환경이 실제 환경과 달라지는 것은 좋지 않다고 판단했습니다.

DirtiesContext 역시 사용법은 간단했지만 테스트의 성능이 저하되는 것은 서비스를 운영하는 점에서 치명적으로 다가올 수 있다고 생각했습니다. 예를 들면 CI/CD 과정은 보편적으로 build 후 진행되는데 이때 테스트가 동작합니다. 즉 테스트 성능이 낮아질 수록 CI/CD의 속도도 느려진다는 것입니다. 이는 신속하게 에러를 해결해야하는 상황에서 병목지점이 될 수 있다고 판단했습니다.

느낀점

이번에 테스트가 실패하면서 해당 문제의 원인 및 해결방법을 찾아보고 적용해보면서 각 방식의 장단점을 몸소 배울 수 있었고 테스트 코드 역시 비즈니스 코드 처럼 신경써야 한다는 것을 느꼈습니다.

테스트 간 격리성을 보장하는 다른 방법으로 테스트에서 @Transactional 을 사용해야 할까? 을 접했는데 이 방식도 테스트 코드가 많아서 성능을 높이기 위해 테스트 코드를 병렬적으로 수행하는데 적용해보면 좋을 것 같다고 느꼈습니다.

0개의 댓글