일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | ||||
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |
- PR 오류
- JDK
- There isn't anything to compare.
- 인수테스트
- 자바의 종류
- 테스트 성능 개선
- 프로젝트 패키지 구조
- 상수와 Enum
- 상근날드
- 우테코4기
- JXM
- 블랙잭 회고
- 자판기미션
- 제임스고슬링
- throw 와 throws 차이
- 우테코
- 우아한테크코스
- 백준
- 객체지향적인 설계
- 윤년계산하기
- 방어적 복사
- 리스코프치환원칙
- Oracle JDK와 OpenJDK의 차이
- 자바 버전 다운 그레이드
- Getter Setter
- 자바로 만들수 있는 것
- ControllerTest
- ServiceTest
- 자바 4334
- java 1000번 A+B
- Today
- Total
개발새발
터놓고 테스트 성능 개선기 1 본문
안녕 안녕하세요. 터놓고 팀 수달입니다. ☺️
방학 맞이 뒷공부 중에 공유하고 싶은 내용이 생겨서 글을 작성하고 있습니다.
이번 레벨3 QA 를 할 때, 작은 변경 사항에도 기존 로직에 영향이 가지 않는지 확인하기 위해서
계속 테스트를 돌려보곤 했는데, 매번 1분 가까이 걸리는 것을 보면서 답답했어요.
그래서 테스트 코드 최적화를 해야겠다고 생각하고, 현재 저희 팀 코드의 문제점을 생각해보았습니다.
테스트 격리 방식의 문제점 첫번째
먼저 테스트 격리를 해야하는 이유를 간단하게 얘기할게요. 같은 데이터를 공유해서 사용하게 된다면 테스트는 실행 순서에 따라 성공과 실패 여부가 결정될 수 있어요. (운으로 테스트가 성공한다는 그런 뜻이죠☺️) 이렇게 된다면 실패 원인이 비지니스 로직에 있는지, 데이터에 있는지 알 수 없어요. 따라서 각 테스트는 실행 순서와 상관 없이 독립적으로 실행되어야한다고 생각했어요.
터놓고는 현재 @DirtiesContext 를 사용하여 매 테스트마다 별도의 컨텍스트를 로드하여 테스트를 수행하고 있어요. 컨텍스트를 로드할 때 빈 주입이 되기 때문에 이 작업은 생각보다 무게있는 작업이라는 걸 알고 있었어요. 그래서 개선하기 위해서는 @DirtiesContext 먼저 걷어 내야될 것 같아요.
또한 현재 조회, 수정 등등을 하기 위한 데이터 세팅을 Data.sql 을 통해서 하고 있어요. 이 말인즉, Data.sql 이 바뀌면 모든 테스트에 영향을 미친다는.. 그런 뜻이죠 .. !!! 하나의 파일의 의존적인 테스트라는 점을 발견했어요.
@DirtiesContext 동작원리
스프링은 의존성 주입을 위해서 Bean 으로 등록된 객체들을 관리하는
Context 라는 관리자가 있어요. context 는 Bean을 생성하고,제공하고, 관리하기도 하고,
스프링에서 제공하는 여러 기능들을 하는 친구예요. BeanFactory 확장판 느낌..
그 빈들을 사용해서 런타임 시점에 context 에 Bean 을 등록 시키고 난 뒤 빈이 주입됩니다.
그런데, 하나의 context 에서 관리되는 빈은 상태가 공유되기 때문에 프로그램 안에 여러 쓰레드 사이클이 돌게되면 안정성을 보장하기가 힘들어져요. (데이터도 공유되는 것 처럼요)
그래서 각 테스트 마다 객체의 상태를 격리하기 위해서 @DirtiesContext 어노테이션을 사용했어요. 이 어노테이션을 붙여주면 같은 컨테이너 내부에서 다른 Context 를 띄워줘서 각기 다른 빈을 사용할 수 있게 해줘요. 그래서 테스트 격리가 되긴 하지만,
컨텍스트를 띄우고 빈을 등록하고, 빈을 주입하는 행위 자체가 무거운 작업이기 때문에, 모든 테스트마다 다른 컨텍스트를 띄우게 되면, 설정 시간도 오래걸리고, 테스트가 끝나면 컨텍스트는 사라지니까 비효율적이라고 느껴졌어요.
위 두가지를 어떻게 개선할 수 있을까요?
가볍게 생각해보았을 때는 sql 문으로 데이터를 초기화 해주면 될 것 같았어요.
- data 를 delete 한다. 2. truncate 로 구조만 남기고 날린다. 정도의 방법이 생각났습니다.
여기서 delete 한다의 문제점은 해당 명령어는 조작어(Data Manipulation Language)에 속해서 데이터 rollback이 가능해요.
데이터를 지워도 auto increment 값은 초기화 되지 않고, 컬럼 틀이 남아있게 되는거죠.
그렇게 된다면 member 를 생성할 때 id 값이 1 이기를 기대했는데, 이미 앞전에 데이터가 누적되어 다른값이 초기화 되는 문제가 생길거예요.
Truncate 는 정의어(Data Definition Language에 속해서 데이터의 전체 골격에 영향을 미쳐요. 현재 저희팀 DB 구조상 테이블 마다 외래키로 연관관계를 맺고 있어서, 연관관계 잘 따져서 지우는 순서를 고려해야하는데. DDL 은 순서가 잘 보장되어 있어도 실행을 하지 못하게 막아두고 있어요. (항상 된다는 보장이 없기 때문에) 그래서 제약 조건을 무효화 하는 SET REFERENTIAL_INTEGRITY FALSE
작업이 필요해요)
아 참, 그리고 테이블이 추가되면 Truncate 를 위해 만들었던 SQL 파일을 수정해야하는 번거로움도 생기겠네요. 😭
머리가 아프던 찰나, 우아한 테크코스 팀 Prolog 코드를 통해 해결 방법을 찾았습니다.
테스트 격리 방식 변경 ✨
바로 현재 사용중인 entity 이름을 통해 table name 을 추출하여
trancate sql 문을 자동으로 만들어주는 DataCleaner 를 직접 구현하는 것입니다!
방법은 아래와 같습니다.
- 구아바 의존성 추가
구아바는 현재 등록된 Entity 의 문법을 -> mysql tables name 문법으로 바꿔주기 위해서 사용할 예정입니다.
의존성을 test 전용으로만 추가해서, 비지니스 로직에는 영향이 없도록 해줍니다!
// 공식 깃헙에 나와있는 의존성 주입 코드
implementation("com.google.guava:guava:31.1-jre")
// 터놓고에 적용한 의존성 주입 코드
testImplementation 'com.google.guava:guava:31.1-jre'
2. DataCleaner 구현
package com.woowacourse.ternoko.support.utils;
import com.google.common.base.CaseFormat;
import java.util.List;
import java.util.stream.Collectors;
import javax.persistence.Entity;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
public class DatabaseCleaner implements InitializingBean {
@PersistenceContext
private EntityManager entityManager;
private List<String> tableNames;
@Override
public void afterPropertiesSet() {
tableNames = entityManager.getMetamodel().getEntities().stream()
.map(entityType -> CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, entityType.getName()))
.collect(Collectors.toList());
}
@Transactional
public void tableClear() {
entityManager.flush();
entityManager.clear();
entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate();
for (String tableName : tableNames) {
entityManager.createNativeQuery("TRUNCATE TABLE " + tableName + " RESTART IDENTITY ").executeUpdate();
}
entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate();
}
}
Data Cleaner 에서 Truncate sql 생성 방법 로직
- afterPropertiesSet 메서드 실행 로직 설명
entityManager.getMetamodel() 영속성 컨텍스트에 등록되어 있는 클래스 가져오기
getMetamodel().getEntities() 클래스 중에 Entity 가져오기
CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, entityType.getName()
클래스이름은 카멜케이스인데 FormItem → mysql Table 문법은 LOWER_UNDERSCORE 라서 formatting
- tableClear
SET REFERENTIAL_INTEGRITY FALSE truncate 는 DDL(데이터 정의어) 라서 참조 무결성 제약조건을 위배할 가능성이 있으면 명령어를 실행 시키지 못하게 막아놔서 해당 옵션을 잠시 걷어내는 명령어 실행
entityManager.createNativeQuery("TRUNCATE TABLE " + tableName + " RESTART IDENTITY ").executeUpdate(); 쿼리 만든다음 실행 시켜서 테이블 데이터 날리기
SET REFERENTIAL_INTEGRITY TRUE 제약조건 다시 설정
2. h2 설정에 auto Increment 설정 초기화
h2 디비 설정상, truncate 되어도 auto Increment 된 Id 값이 자동으로 초기화 되지 않는 문제를 발견했습니다.
해당 문제 때문에 참조 무결성 제약조건을 위배했다는 에러를 만나서 2시간 넘게 고생했어요.
이 경험으로 디비 관련 지식을 조금더 쌓아야겠다고 생각했습니다.
아래와 같이 truncate 할 때 restart indntity 를 설정해주면 초기화 됩니다.
for (String tableName : tableNames) {
entityManager.createNativeQuery("TRUNCATE TABLE " + tableName + " RESTART IDENTITY ").executeUpdate();
}
@PersistenceContext 란?
영속성 Context 를 주입하는 Annotation 이다! 주입이라는 표현을 쓰니까 @Autowired 와 무슨 차이가 있을까 궁금해졌는데, EntityManager 는 Thread-safe 하지 않아서, 각자 다른 EntityManager 를 사용해야하는데, @Autowired 하게 되면 싱글톤이 보장되어 같은 빈이 주입되고, @PersistenceContext 를 사용하면 멀티스레드에서 각자 다른 EntityManager 를 사용하게 해준다고 해요
InitializingBean 을 상속받은 이유
빈이 초기화 된 후 최초 한번 afterPropertiesSet 을 사용해서 entity name 을 긁어와서 들고 있게 하기 위해서 사용했어요.
+여기서 더 개선한다면?
EntityManager 의 생명 주기에 대해서 생각해봅시다!
현재 저희가 필요한건 entity 들의 이름 이었어요. 그리고 다시 생각해보니까 EntityManager 의 역할이
엔티티 관리이기 때문에 빈이 등록되는 시점에 객체가 만들어지고, 엔티티 매니저 객체가 생성되는 시점에는
엔티티들이 모두 등록되어 있겠다는 생각이 떠올랐어요.
그렇게 된다면 InitializingBean 에 대한 상속을 제거해서 Spring 과 의존성을 제거할 수 있어요!
@Component
public class DatabaseCleaner { // InitializingBean 상속 제거
private EntityManager entityManager;
private List<String> tableNames;
// 생성 시점에 entityManager 에서 entity 값 가져와서 테이블 세팅
public DatabaseCleaner(final EntityManager entityManager) {
this.entityManager = entityManager;
tableNames = entityManager.getMetamodel().getEntities().stream()
.map(entityType -> CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, entityType.getName()))
.collect(Collectors.toList());
}
@Transactional
public void tableClear() {
entityManager.flush();
entityManager.clear();
entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate();
for (String tableName : tableNames) {
entityManager.createNativeQuery("TRUNCATE TABLE " + tableName + " RESTART IDENTITY ").executeUpdate();
}
entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate();
}
}
테스트 격리 방식의 문제점 두번째
Controller test 를 하기위해서 @SpringbootTest 을 사용하고 있어요. 그리고 Controller Test 를 통해서 RestDocs 를 만들고 있어요.
문제점
- 실제 구동되는 애플리케이션의 설정, 모든 Bean을 로드하기 때문에 시간이 오래걸리고 무겁다.
- 테스트 단위가 크기 때문에 디버깅이 어려운 편이다.
- 결과적으로 웹을 실행시키지 않고 테스트 코드를 통해 빠른 피드백을 받을 수 있다는 장점이 희석된다.
따라서 @SpringBootTest 어노테이션은 어플리케이션 컨텍스트 전체를 사용하는 통합 테스트에 사용돼야 한다고 생각해요.
그래서 @SpringBootTest 를 걷어내고, @WebMvcTest 로 변경하고자 해요.
@WebMvcTest
@WebMvcTest를 사용했을 때 등록되는 Bean들
@WebMvcTest 어노테이션을 사용하면 웹 레이어 테스트를 하는 데 필요한 빈들만 주입 돼요!한**@Controller, @ControllerAdvice, @JsonComponent, Converter, GenericConverter, Filter, WebMvcConfigurer, HandlerMethodArgumentResolver** 등만 Bean으로 등록한다.
이 밖에 테스트를 하는 데 필요하지 않은 컴포넌트들(ex. @Service, @Repository)은 Bean으로 등록하지 않는다.
그래서 @webMvcTest 를 쓸때, 해당 빈 외에 다른 빈이 필요할 때는 아래와 같이 추가로 Import 해주는 작업이 필요하게 되겠네요.
@Import({RestDocsConfiguration.class, MemberTypeCache.class, LoginMemberVerifier.class, WebConfig.class,
AuthenticationPrincipalConfig.class, JwtProvider.class})
Controller 의 책임은 어디까지일까?
현재 RestDocs 를 만들기 위해서 controllerTest 가 수행되고 있고, controller test 는 요청과 응답이 잘 내려지는지를 검증하기 위해서 있다고 생각해요. 그래서 service 나 repository 의 작업이 잘 수행되는지는 관심 밖의 영역인 듯 합니다.
그래서 비지니스로직을 수행하는 객체들은 Mocking 을 통해서 테스트에서 격리하고자 했어요.
주의 사항
인증 처리를 위해서 Interceptor 에서 JwtProvider 를 사용하고 있었는데, (j 해당 객체가 @MockBean 되고, 인증에 사용되는 메서드를
목킹해주지 않고 controller 가 호출될 때 사용되는 메서드만 목킹해주었더니, 에러가 나는데 원인을 찾을 수 없어서 답답했어요.
따라서 controller 요청이 들어온 이후 뿐만 아니라 이전 로직까지 고려해서 목킹을 해야한다는 사실을 유의해야할 것 같아요.
성능 개선 측정 결과
이전 작업에서 1분 걸렸던 내용을 23초까지 줄이게 되었어요. ☺️
하지만 슬라이스 테스트를 통해 MockBean 을 주입하면서 SpringBootTest 에서 캐싱된 빈을 재사용하지 못하는 문제가 있다는 것을 발견하고, 추가로 성능 개선을 해보겠습니다.
참고자료
'우아한테크코스' 카테고리의 다른 글
객체, 설계 (0) | 2022.08.24 |
---|---|
JDBC와 JDBC Template (0) | 2022.04.27 |
MVC 패턴 (0) | 2022.02.20 |
Enum을 사용하는 이유가 뭘까? (0) | 2022.02.20 |
오류와 예외는 같은걸까? (0) | 2022.02.20 |