본문 바로가기

스프링 부트

@DirtiesContext로 무거워진 인수 테스트 시간을 줄이는 실험을 해봅시다

인수 테스트 - @DirtiesContext 를 제거하자!

각각의 인수 테스트는 테스트 간 격리성을 가져야 하는데, RestAssured 테스트는 테스트 전용 @Transactional을 사용하지 못한다. 
즉 데이터 관련 컨텍스트가 공유되고, 이 데이터 베이스를 각각의 테스트마다 초기화해주기 위해 지금껏 당연히 @DirtiesContext를 사용해왔다.

하지만 @DirtiesContext를 사용할 경우 매번 컨텍스트를 새로 로드하기 때문에 인수테스트만 어마무시한 시간이 들게 된다. 
실제로 놀토 프로젝트도 인수테스트를 수행하는데만 (사양마다 다르겠지만) 내 맥북 기준 2분정도가 소요되었다.

69개 인수테스트만 수행

결국 인수테스트에서 우리가 현재 원하는 것은 데이터의 격리성이고, 이를 위해 매번 빈들을 초기화 해주는 것이었다. 
즉, 우리는 데이터베이스의 초기화만을 위해 모든 빈들을 초기화해주고 있다. 

쿼리를 이용한 데이터 베이스 초기화

그렇다면 빈들을 모두 초기화 하지 않고 우리가 원하는 데이터 베이스만 초기화 할 수는 없을까?
놀랍게도 방법은 존재한다.

(사실 테스트 최적화를 위해 관련 글들을 찾아보다가 우테캠 프로 글을 우연찮게 발견했는데, 이에 대한 방법론이 잘 나와있어서 인용해보려한다.)

DatabaseCleanUp

@Service
class DatabaseCleanup implements InitializingBean {

    @PersistenceContext
    private EntityManager entityManager;

    private List<String> tableNames;

    @Override
    public void afterPropertiesSet() {
        tableNames = entityManager.getMetamodel().getEntities().stream()
                .filter(e -> e.getJavaType().getAnnotation(Entity.class) != null)
                .filter(e -> e.getJavaType().getAnnotation(Table.class) == null)
                .map(e -> CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, e.getName()))
                .collect(Collectors.toList());
        List<String> tableNamesWithAnnotation = entityManager.getMetamodel().getEntities().stream()
                .filter(e -> e.getJavaType().getAnnotation(Table.class) != null)
                .map(e -> CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, e.getJavaType().getAnnotation(Table.class).name()))
                .collect(Collectors.toList());
        tableNames.addAll(tableNamesWithAnnotation);
    }

    @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();
    }
}

해당 클래스는 Database를 초기화하는 서비스이다. 
이 클래스가 어떤 역할을 하는지 InitializingBean에 대해 알아보면서 하나씩 살펴본다. 

InitializingBean#afterPropertiesSet()

해당 인터페이스를 구현하면 afterPropertiesSet 메서드를 사용할 수 있다. 
해당 메서드는 BeanFactory에 의해 모든 property 가 설정되고 난 뒤 실행되는 메소드다.

엔티티를 포함한 모든 빈들이 등록되고 나면 해당 메서드가 실행되는데, 이 때 Entity 어노테이션을 가진 모든 빈들을 찾고 엔티티의 이름을 모은 tableNames를 만든다. 
여기서 주의할 점은 우리 같은 경우 Like라는 엔티티가 있지만, 그 실제 테이블 이름은 @Table("Likes")로 재정의해주었기 때문에 필터링이 필요했다. 
때문에 엔티티 어노테이션을 가진 클래스 이름, 테이블 어노테이션을 가진 엔티티는 그 테이블 name을 tableNames에 할당해주었다.
이렇게 하지 않으면 Like 테이블을 찾을 수 없다는 오류가 뜬다. 

 

execute()

@Transactional
public void execute() {
    entityManager.flush();
    // 제약 조건 무효화 - 데이터를 지우는데 외래키, 유일키 등의 제약조건에 영향을 받지 않기 위해
    entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate();
	
    // 테이블을 돌면서 데이터 TRUNCATE, 컬럼 ID 시작 값을 1로 초기화
    for (String tableName : tableNames) {
        entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate();
        entityManager.createNativeQuery("ALTER TABLE " + tableName + " ALTER COLUMN ID RESTART WITH 1").executeUpdate();
    }
	
    // 무효화한 제약 조건 다시 TRUE로
    entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate();
}

 

Native SQL?

  • 보통 JPQL로 작성하기 어려운 복잡한 SQL 쿼리를 작성하거나 SQL을 최적화해서 데이터베이스 성능을 향상할 때 사용
  • JPA는 SQL이 지원하는 대부분의 문법과 SQL 함수들을 지원하지만 특정 데이터베이스에 종속적인 기능은 잘 지원하지 않음
  • 특정 데이터베이스에 종속적인 기능이 필요할 경우 SQL을 직접 사용할 수 있는 기능
  • 즉, 사용자가 직접 데이터베이스에 날리는 쿼리를 작성
  • Native SQL는 엔티티를 조회할 수 있고 JPA가 지원하는 영속성 컨텍스트의 기능을 그대로 사용할 수 있음(JDBC API와 차이점)
  • 참고자료

AcceptanceTest의 setUp 메서드

@BeforeEach
public void setUp() {
    RestAssured.port = port;
    databaseCleanup.execute();
	
    //... 
}

각각의 인수테스트가 상속하고 있는 AcceptanceTest의 setUp 메서드에서 데이터베이스 초기화 메서드를 실행시킨다.

결과

놀랍게도 결과가 어마무시하게 줄어듦을 볼 수 있다.

 

참고 자료

[h2] h2 db에서 제약조건 제거하고 테이블 초기화 & 삭제하기

[Spring Boot] JUnit을 활용한 테스트 코드 작성(1)

[ATDD] ATDD와 함께 클린 API로 가는 길 3기 - 오리엔테이션

[Spring Boot] JUnit을 활용한 테스트 코드 작성(1)