개발/Test

테스트 성능 개선

Debin 2023. 11. 3.
반응형

오늘은 프로젝트 테스트 성능을 개선한 이야기를 써보려고 합니다.

 

로컬에서 진행했으며, 컴퓨터 스펙은 M1 AIR(8코어) RAM 16GB SSD 512 입니다.

 

프로젝트 설명

간단하게 프로젝트 구조에 대해 설명하겠다. 

 

멀티 모듈

  • 프로젝트는 스프링 부트를 사용하고 있으며, 멀티 모듈로 구성돼 있다.
  • api 모듈은 프레젠테이션 영역으로 인수 테스트와 컨트롤러 단위 테스트(Spring Rest Docs 생성)를 진행하고 있다.
  • core 모듈은 비즈니스 로직을 수행하며 핵심 도메인 모델 클래스로 구성되어 있다.  통합 테스트와 단위 테스트를 진행하고 있다.

이 두 모듈의 테스트 성능을 개선해보려고 한다.

 

통합 테스트

  • 운영 코드와 동일한 테스트 코드를 가지기 위해 명시적 롤백 전략을 사용하고 있다.
  • 쉽게 말해 테스트 코드에서, @Transactional 애노테이션을 사용하고 있지 않다.
  • 테스트 픽스쳐를 재사용하기 위해 BeforeEach문에서 직접 데이터베이스에 저장된 픽스쳐를 삭제하고 있다.
  • 데이터베이스 외부 의존성과는 직접 통신을 진행하고, 메시지 큐는 모킹을 했다.

 

인수 테스트

  • 메시지 큐는 직접 큐에서 꺼내와서 값을 검증하고 데이터베이스의 값 검증은 API를 활용하고 있다.
  • 테스트 픽스쳐를 재사용하는 코드가 많으므로 하나의 테스트를 마치면 메시지 큐, 데이터베이스 모두 데이터 롤백을 진행한다.

 

현재까지 테스트 성능을 위해 시도한 것들

  • ContextCaching을 사용해 ContextLoad를 1회로 줄이기 위한 추상 클래스(테스트 설정 클래스) 작성
  • test 코드 로깅 모드 끄기

 

현재 통합 테스트, 단위 테스트 성능

 

현재 통합 테스트와 단위 테스트를 수행하는 빌드는 약 33초가 걸린다.

 

 

통합 테스트 , 단위 테스트 성능 개선하기

 

통합 테스트와 단위 테스트는 병렬 수행을 통해 성능을 개선할 것이다.

 

junit에서 병렬 테스트를 진행하려면 junit-platform.properties를 사용하면 된다.

 

junit-platform.properties

#병렬 테스트 실행을 활성화한다.
junit.jupiter.execution.parallel.enabled = true

#병렬 처리를 결정하기 위해 사용 가능한 프로세서/코어 수에 곱할 값
junit.jupiter.execution.parallel.config.dynamic.factor = 2

#최상위 클래스는 병렬로 실행한다.
junit.jupiter.execution.parallel.mode.classes.default = concurrent

# 메서드는 동일한 스레드에서 실행한다
junit.jupiter.execution.parallel.mode.default = same_thread

 

함수도 병렬로 실행시켜봤는데 static mocking 부분에서 스레드가 이미 닫혔다는 에러가 나오면서 동시성 문제가 발생했다

따라서 클래스만 병렬로 수행했다.

 

core 모듈은 통합 테스트, 단위 테스트를 합쳐서 총 138개의 테스트가 존재한다.

 

테스트 실행 시간으로 비교가 애매하므로 빌드 시간으로 비교해보았다.

 

리팩토링

 

병렬 실행을 위한 코드로 리팩토링을 진행하면서 통합 테스트에서 픽스쳐 재사용이 큰 문제였다.

 

이는 AtomicInteger, AtomicLong 클래스를 사용해 픽스쳐 내부 필드 값을 테스트 마다 고유하게 만들면서 멀티 스레드에 안전한 테스트 코드를 작성했다.

 

픽스쳐를 재사용하지 않으면서 명시적 롤백 코드 또한 모두 삭제했다.

 

아래는 이전에 사용한 명시적 롤백 예시 코드다. 

//JpaRepository를 상속한 인터페이스 구현체들

@BeforeEach
void beforeEach() {
    memberRepository.deleteAll();
    issueRepository.deleteAll();
    commentRepository.deleteAll();
    projectRepository.deleteAll();
    projectMemberRepository.deleteAll();
}

 

최종 결과물 빌드  결과

 

이제 빌드가 약 20초가 걸리도록 수정됐다. 약 65% 정도 성능이 향상 됐다.

정말 마지막 최종 결과물 빌드  결과  (11.29)

현재 통합 테스트와 인수테스트는 클래스 단위로 병렬 프로그래밍을 수행하고 있다.

단위 테스트는 병렬 프로그래밍을 하지 않아도 수행이 굉장히 빠르다.

그러므로 단위 테스트는 병렬 테스트를 진행하면 컨텍스트 스위칭으로 인해 더 느리다고 생각해 단일 스레드로 수행하도록 변경했다.

 

병렬 프로그래밍은 실제 컴퓨터의 코어를 사용한다.

현재 병렬 수행에서 컴퓨터의 코어 * 2 만큼의 스레드를 테스트에서 사용하고 있었다.

이 설정도 컨텍스트 스위칭으로 인한 손해가 많이 발생하는 것 같아 컴퓨터의 코어만큼의 스레드를 테스트에서 사용하도록 변경했다.

 

최종 빌드가 약 15초가 걸리도록 수정됐다. 약 120% 정도 성능이 향상 됐다.

 

현재 인수테스트 성능

 

현재 인수 테스트를 수행하는 빌드는 약 1분 27초가 걸린다.

 

인수 테스트 성능 개선하기

 

앞에서 언급했지만 인수 테스트는 모든 테스트를 실행하기 전에 BeforeEach구문을 통해 데이터베이스를 초기화하고, RabbitMQ를 초기화한다. 

 

그리고 가능하면 데이터베이스에 저장된 값의 검증은 API를 통해 진행하고 있다.

 

메시지 큐 같은 경우는 사용하는 클라이언트가 다양하므로 메시지 큐에서 직접 꺼내와서 값을 검증하고 있다.

 

이제 개선을 진행해보자. 

 

병렬 수행 가능한 코드로 리팩토링

먼저 이전과 똑같이 병렬 수행 가능한 코드로 리팩토링을 진행했다.

 

픽스쳐 코드로 생성된 인스턴스의 중복을 막기 위해 UUID를 사용하면서 유니크한 값을 부여했다.

public class MemberFactory {

    private MemberFactory() {}

    private static AtomicInteger aInteger = new AtomicInteger(1);

	//테스트마다 고립된 픽스쳐 생성
    public static Member createAdminWithTempEmail() {
        String random = getRandomString();
        return Member.builder()
                .auth(new Auth(random + "@gmail.com", "1q2w3e4r!"))
                .authProvider(AuthProvider.local)
                .role(Role.ROLE_USER)
                .name(random + "name")
                .build();
    }

    private static String getRandomString() {
        int i = aInteger.getAndIncrement();
        String uuid = UUID.randomUUID().toString();
        String random = uuid.substring(uuid.length() - 12) + i;
        return random;
    }
 }

 

픽스쳐를 코드를 최대한 재사용하고 쓰레드 세이프하게 사용하기 위해, ThreadLocal을 활용하기로 했다.

 

//쓰레드 로컬 
public static ThreadLocal<MemberContext> memberContext = new ThreadLocal<>();

//픽스쳐 클래스
@Getter
public static class MemberContext {

    private final Member admin;
    private final Member member;

    public MemberContext(Member admin, Member member) {
        this.admin = admin;
        this.member = member;
    }
}

 

@BeforeEach문에서 ThreadLocal에 픽스쳐로 생성한 인스턴스를 주입하고,

@AfterEach문에서 ThreadLocal에 존재하는 인스턴스를 삭제했다.

 

@BeforeEach
void beforeEach(){
    RestAssured.port = port; //RestAssured를 인수테스트에서 사용 중

    Member admin = createMember(MemberFactory.createAdminWithTempEmail());
    Member member = createMember(MemberFactory.createMemberWithTempEmail());
    setMemberContext(new MemberContext(admin, member));
}
    
@AfterEach
void afterEach() {
    memberContext.remove();
}

 

이렇게 픽스쳐를 가지고 테스트 사이에서 격리된 인스턴스를 활용할 수 있었다.

 

단순히 병렬 수행을 진행하지 않고 리팩토링한 코드로 빌드를 돌려보았다.

 

 

1분 1초가 나오면서 약 25초 정도 시간이 줄어든 것을 확인할 수 있다.

 

명시적인 롤백 트러블 슈팅과 남은 과제

앞으로 말하는 명시적인 롤백은 데이터베이스와 메시징 큐 모두 포함되는 것이다.

 

본인은 병렬 수행을 진행하는 인수테스트에서 명시적인 롤백을 1번 수행하려고 @BeforeAll을 선택했다.

@BeforeAll을 사용해 @PostContruct 시점에 값을 주입해 명시적인 롤백을 진행하려 했으나 이는 불가능했다.

 

다행히 @AfterAll을 사용해 명시적인 롤백이 가능한 것 같았다.

 

그러나 테스트를 진행하면서 오류가 너무 많이 발생했다.

로그를 확인해보니@AfterAll이 무조건 1번 실행되는 줄 알았는데 10번이나 실행되었다.

테스트를 수행하는 인수 클래스가 총 10개인데 이에 맞게 10번이나 실행된 것 같다.

이게 테스트간에 영향을 줘서 꽤 고생을 했다.

 

결국 본인은 우선 명시적인 롤백을 아예 빼버리고 몇 번을 반복해도 격리되는 테스트 픽스쳐 클래스(UUID 활용)를 만들어 이를 활용했다.

 

단점은 언젠가 나중에 본인이 직접 지워야한다는 것이다. 이를 위한 수행 클래스도 만들어뒀지만... 이는 추후에 개선을 해야겠다.

 

아마 build.gradle에서 task로 정의를 해야할 것 같다.

 

값 비교 트러블 슈팅

멀티 스레드로 테스트 진행을 하면서 많은 버그 코드를 발견하고 이를 수정했다.

 

그중에서도 제일 기억 남는 것은 엔티티의 id인 Long 인스턴스를 비교하는 과정이었다.

 

일정 수준에서는 단순히 == 연산자로 비교가 가능하지만 128 정도를 넘어가면 equals()메서드를 활용해야된다고 한다.

 

public void validateAccess(Comment comment, Long memberId) {
    if(memberId != comment.getWriterId()){
       throw new CommentAccessException();
    }
}

//아래 처럼 수정

public void validateAccess(Comment comment, Long memberId) {
    if(!Objects.equals(memberId, comment.getWriterId())){
       throw new CommentAccessException();
    }
}

 

병렬 수행 빌드 결과

 

병렬 수행이 가능하도록 테스트 코드를 변경했고 30초가 나왔다.

 

현재까지 1분 27초에서 30초로 줄었으니 57초가 줄어들었다. 

 

토큰 캐싱하기

 

현재 제일 많이 호출되는 API는 로그인 API다.

확인을 해보니 184번이나 호출되고 있었다.

 

모든 API가 호출되기 이전에 로그인 API를 호출하기 때문이다.

하나의 테스트 메서드에서 회원가입을 하고 프로젝트 만들기 전에 로그인 API 호출, 바로 프로젝트 멤버 초대하면서도 API를 호출한다.

 

이 같은 과다한 로그인 API는 불필요하다고 느꼈고 토큰을 캐싱하기로 결정했다.

 

테스트 픽스쳐의 문제를 해결했을 때와 동일하게 쓰레드 로컬을 사용해 문제를 해결했다.

 

쓰레드 로컬에 토큰이 없으면 API로 요청을 하고 존재한다면 쓰레드 로컬에 존재하는 토큰을 사용했다.

 

public static String getMemberAuthorizationHeaderToken(){

    //토큰이 쓰레드 로컬에 존재한다. 따라서 API 요청을 보내지 않는다.
    if(AcceptanceTest.memberTokenContext.get() != null){
        String token = AcceptanceTest.memberTokenContext.get();
        return "Bearer " + token;
    }

    //토큰이 쓰레드 로컬에 없다. 미리 데이터베이스에 저장된 픽스쳐 활용해서 API에 요청을 보낸다.
    Member member = AcceptanceTest.memberContext.get().getMember();
    
    //API 요청
    ExtractableResponse response = requestLogin(new LocalLoginRequest(member.getAuth().getEmail(), member.getAuth().getPassword()));
    
    //토큰 파싱
    String accessToken = response.as(LocalLoginResponse.class).getAccessToken();
    AcceptanceTest.memberTokenContext.set(accessToken);
    return "Bearer " + accessToken;
}

 

그리고 테스트가 마무리되면 @AfterEach 구문에서 쓰레드 로컬에 존재하는 토큰을 삭제했다.

 

코드를 수정하고 로그인 API 호출 수를 확인해봤는데 57회로 줄어있었다.

 

최종 빌드 결과물

 

이제 최종 결과물을 빌드해보았다. 걸린 시간이 약 24초다.

1분 27초에서 24초까지 시간을 단축시켰다. 성능을 약 262% 향상시켰다.

 

 

 

결과물이 생각보다 잘 나와서 뿌듯하다.

명시적 롤백과 같은 고민은 추후에 진행해보자. 아예 테스트 데이터가 중복되지 않으면 롤백을 굳이 하지 않아도 될 거 같기도 하고...

 

이상으로 포스팅을 마치겠습니다. 감사합니다.

 

참고자료

https://www.baeldung.com/junit-5-parallel-tests

https://junit.org/junit5/docs/snapshot/user-guide/#writing-tests-parallel-execution

https://www.youtube.com/watch?v=PDhN6aiF7QQ

https://www.infitry.com/49

반응형

댓글