Spring/Test-Driven Develop

[Spring] 테스트 환경 격리 시키기 - 각 테스트마다 DB 분리하기 (@Transactional 을 사용하면 안되는 이유)

민돌v 2023. 7. 19. 17:24
728x90

 

🔥현재 NEXT STEP 에서 진행하는 ATDD 강의를 수강하고 있습니다.
강의에서 예전부터 고민하던 통합테스트 환경에서 데이터베이스를 격리하는 방법에 대해 알게되어 기록을 남겨보고자 합니다.

 

[예전에 시도해봤던 방법]

통합테스트 환경에서 시도한건 아니지만 단위테스트에서 JPA 에 의존하는 Repository 를 온전히 격리해 어떤상황에서든 성공하는 단위테스 환경을 구축하고자 했던적이 있습니다. 👉  service 단위 테스트 하기 - DB 와 독립된 테스트 환경 구축 (service unit test)

 

하지만
1. 단위테스트 환경에서는 사실상 Mocking 을 하는것보다 큰 장점이 없었고
2. 필요로하는 Repsitory 기능을 매번 정의해주어야해서 많이 번거로웠습니다.

👏🏻 그치만 통합테스트 환경에서는 실제 DataBase에 의존하지 않을 수 있으니 어느정도 의미가 있는 행위가 되겠죠?
(그치만 여전히 복잡하고 번거로운 것은 사실입니다)


[그 외에 테스트 격리 방법]

이번글에서 정리해볼 내용이고 "테스트환경에서의 H2 메모리 데이터베이스를 사용" 한다는걸 가정으로 합니다.

  1. @SpringBootTest 시 각 테스트마다 컨텍스트 새로올리기
  2. 각 테스트가 시작할때마다 데이터 지우기

 

 


[목차]

  1. 테스트를 격리해야하는 이유
  2. @SpringBootTest 시 각 테스트마다 컨텍스트 새로올리기
  3. 테스트가 시작할 때 마다 데이터 지우기 (with. Truncate)

 

→ 요약 : EntityManager 를 사용한 DB Truncate 추천

 

레츠고 !


 

1.  🫡 테스트를 격리해야하는 이유

먼저 테스트환경에서 각각의 테스트를 왜 번거롭게 격리해야하는지 살펴봅니다

넥스트스텝의 ATDD 과정은 인수테스트(어떤 관점에서는 통합테스트라고 볼 수도 있음)를 E2E 테스트로 진행합니다.

그렇기때문에 @SpringBootTest를 사용하여 컨텍스트를 로드하고 1개의 메모리 디비 (H2)를 컨텍스트에서 공유하기 때문에 각 테스트가 서로 다른 테스트에 영향을 주게되어

 

아래처럼 통합테스트 (인수테스트)가 다른 테스트에 의해 실패하게됩니다! 

  • ex) 삭제 테스트 A 에서 생성 → 삭제 → 조회 시 0개의 데이터가 나와야하는데 
  • 다른 생성테스트 B에서 생성했던 데이터가 있다면 디비를 공유하기 때문에 1개의 데이터가 조회됩니다.

 


 

✔️ E2E 테스트에서 @Transactional 을 사용하면 안되는 이유

이게 쫌 야무진데, "간단하게 @Transactional을 사용해서 데이터를 롤백시키면 되는거 아닌가?" 라는 생각을 했었지만 결과적으로 아니였습니다.

 

@Transactional 이 안되는 이유를 찾아보니

  • @SpringBootTest 에서 실행되는 각 메소드의 스레드와 → RestAssured 로 호출하는 프로덕션 Controller 메소드의 스레드는 별개의 스레드를 사용하기 때문이었습니다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
@Transactional
class TransactionalTest {

    @DisplayName("지하철 노선을 생성한다.")
    @Test
    void createLine() {

        System.out.println(" !!!!!!! 테스트 스레드  : " + Thread.currentThread());

        //given
        Map<String, Object> params = 어떤어떤 파라미터;
       
        
        //when
        ExtractableResponse<Response> 노선등록응답값 = RestAssured
                .given().log().all()
                .body(params)
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .when().post("/lines")
                .then().log().all()
                .extract();
                
        //then
        검증코드
    }
}

//Controller
@PostMapping("/lines")
ResponseEntity<LineResponse> createStationLine(@RequestBody LineRequest request) {

    System.out.println(" !!!!!!! 프러덕션 스레드  : " + Thread.currentThread());

    LineResponse response = lineFacade.lineCreate(request);

    return ResponseEntity
        .status(HttpStatus.CREATED)
        .body(response);
}

 

위와같이 각각의 메소드가 실행될 때의 스레드를 확인해보니

  • 테스트 메소드는 Test worker, 5, main 이라는 스레드에서 동작
  • 프로덕션 Controller 메소드의 스레드는 http-nio-8080-exec-3, 5, main 이라는 별도의 스레드에서 동작하는걸 확인할 수 있었습니다.

 

👏🏻 따라서 @Transactinal 을 적용한 End-to-End 테스트 환경에서의 롤백과정은 다음과 같습니다,

  1. 테스트 메서드가 스레드 A 에서 실행된다.
  2. 테스트 메서드 내의 코드에서 컨트롤러의 Post 메서드를 호출한다.
  3. 호출을 받은 컨트롤러 메서드는 스레드 B에서 실행된다.
  4. 테스트 메서드가 완료되면 롤백을 수행한다.
  5. 하지만 트랜잭션의 범위는 스레드 A 내로 한정되므로 스레드 B에는 아무런 영향을 끼치지 못한다.

@Transacnal 의 공식문서에서는 트랜잭션이 내부에서 시작된 스레드로 전파되지 않는다고 나와있습니다.
그렇기때문에 @Transacnal을 사용해서는 통합테스트환경을 격리시킬 수 없습니다.

 


 

2. @SpringBootTest 시 각 테스트마다 컨텍스트 새로올리기

어차피 테스트 환경을 격리시켜야한다면, 매번 테스트가 실행될 때 컨텍스트를 새롭게 올려 온전히 새로운 환경에서 돌리는 것도 하나의 방법입니다.

 

✔️ @DirtiesContext

  • @DirtiesContext 어노테이션은 SpringBootTest 로 올라가는 컨텍스트 다시 로드시켜 캐시정보를 아예 삭제시킵니다.

 

@DirtesContext 원리

  • @SpringBootTest 를 돌릴 때 처음에만 빈을 띄우고 캐싱해서 재사용하여 다음 테스트 케이스는 빠르게 실행이 가능하도록 합니다
  • 이러한 빈을 재사용하는 조건 중 하나가 컨텍스트가 오염되지 않았을 때 (상태가 변경되지 않았을 떄) 인데
  • @DirtiesContext 는 빈을 오염시켜 캐시기능을 사용하지 않도록하는 설정하는 역할을 수행합니다.

 

@DirtiesContext 단점

  • @DirtiesContext 를 사용하면 편리하지만 매번 새로운 Context를 구성하다보니 시간이 많이 걸리게 됩니다.

@DirtiesContext 실행시간

 

테스트 9개에 7초정도 걸리는데, 기존의 진행하던 사이드 프로젝트는 단위테스트만 작성하여 104개에 3초가 걸리지 않으니 굉장히 느린 테스트 시간임을 확인할 수 있습니다.

단위테스트 실행시간

 


 

✨ 3. 테스트가 시작할 때 마다 데이터 지우기 (with. Truncate)

어차피 계속 문제가되는건 DB! DB를 격리시키자 
@DirtiesContext는 편리하지만 굳이 매번 테스트마다 컨텍스트를 리로드할 필요가 없습니다. 중요한건 데이터의 격리이니까요

 

👏🏻 자 데이터의 격리 방법은 3가지가 있습니다.

  1. @Sql 혹은 쿼리 수행 (Truncate)
  2. EntityManager 를 사용 (Truncate)
  3. 테스트 컨테이너의 사용 (이건 안다룰거임 다음 블로그 참고 → [사내 TestContainer 적용] Spring boot 통합테스트 도입기 )

 

 

1. @Sql 혹은 쿼리 수행 (Truncate)

  • @Sql 어노테이션을 사용해 미리정의해 놓은 쿼리문을 테스트 실행전에 날리는 방법입니다.
  • @쿼리를 날려 디비를 클린한 상태로 만들어주기 때문에 각 데이터베이스는 서로 데이터를 침범하지 못합니다.
  • @DirtiesContext보다 낮은 비용을 가지지만, 테이블이 늘어날 때 마다 관리해주어야하는 귀찮음이 존재합니다.

 

2. EntityManager 를 사용 (Truncate)

  • 이게 야무집니다.
  • 1번 방법의 관리포인트를 EntityManager 를 사용하여 알아서 찾도록 바꿨습니다.

 

AcceptanceTestConfig.class

  • 전체 E2E 테스트에서 공통적인 부분을 추출했습니다.
  • DataBaseCleanUp이라는 클래스의 excute 메소드를 매번 테스트 실행전에 수행합니다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
public abstract class AcceptanceTestConfig {
    @Autowired
    private DatabaseCleanup databaseCleanup;

    @BeforeEach
    public void setUp() {
        databaseCleanup.execute();
    }
}

 

DatabaseCleanUp.class

  • EntityManager 를 사용해 테이블 이름을 추출한 후 trucate 시킵니다.
  • truncate 쿼리 실행 후 auto-increament 되었던 id 값도 다시 시작점을 초기화 시킵니다.
@TestComponent
public class DatabaseCleanup implements InitializingBean {
    @PersistenceContext
    private EntityManager entityManager;

    private List<String> tableNames;

    @Override
    public void afterPropertiesSet() {
        tableNames = entityManager.getMetamodel().getEntities().stream()
                .filter(entity -> entity.getJavaType().getAnnotation(Entity.class) != null)
                .map(entity -> entity.getName())
                .collect(Collectors.toList());
    }

    @Transactional
    public void execute() {
        entityManager.flush();
        entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate();
        for (String tableName : tableNames) {
            entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate();
            entityManager.createNativeQuery("ALTER TABLE " + tableName + " ALTER COLUMN ID RESTART WITH 1").executeUpdate();
        }
        entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate();
    }
}

 

 

이렇게 수행했을 때 16개의 인수테스트가 3초대로 끊어졌습니다.

  1. 테스트 환경을 분리하였고
  2. 테이블이 늘어나도 관리포인트가 생기지 않고
  3. @DirtiesContext 보다 빠르다

 

 

 

 

 

통합테스트 데이터베이스 분리 환경 구성 끝!

 

 

 

 

 

반응형