테스트

테스트 커버리지 100% 달성기[2] - 테스트 시간 단축

코딩공장공장장 2025. 1. 27. 11:51

 

테스트 커버리지 100%를 달성하니 650개 정도의 TC가 쌓였고 이에 따라 테스트 코드 실행 시간도 증가하게 되었다.

나의 로컬 컴퓨터 환경에서 평균 2분 50초 정도의 시간이 걸렸다.

 

테스트 코드를 작성하며 어느정도 시간 단축을 고려하였다고 하지만 2분 50초 가량 되는 시간은 너무 과도하다.

테스트 성능 개선을 위해 유명 기술블로그나 여러 포스팅에서 성능 개선 관련 내용들을 찾아보았고,

인텔리제이에 프로파일링 기능을 통해 테스트 코드 실행시간을 추적하며 작업 대상을 추려내며 작업을 진행하였다.

 

결과적으로 650개의 TC 실행시간을 1분 30초 내외로, 40% 정도 단축하였다.

그 과정에 대해 설명하도록 하겠다.

 

작업 계획


인텔리제이의 프로파일링을 통해 테스트 코드 실행시간을 추적할 수 있다고 하여 해당 기능을 사용하여 나의 테스트 코드 실행시간을 분석하였다.

허나, 분석결과를 보더라도 정확하게 어디에서 쓰이는지에 대해서 명확하게 파악하기 어렸웠다.

처음 보는 메서드가 많은데 이는 프레임워크 내부 동작에서 사용되는 메서드들이다.

프로파일링을 통해 정확한 분석을 진행하려면 프레임워크 내부에서 사용되는 메서드를 정확하게 파악하고 있어야한다.

이는 사실 쉬운일은 아니라고 생각한다.

실행시간이 오래 걸리는 메서드가 어느 라이브러리나 프레임워크에서 사용되는지 찾아보았다고 하더라도

리플렉션과 같이 많은 라이브러리나 프레임워크에서 사용되는 경우 정확히 어떤 라이브러리가 부하를 주는지 특정하는 것은 쉽지 않다.

따라서 프로파일링을 전체 프로젝트에 대해 수행하는 것보다 모듈 단위로 수행하여 의존성 범위를 줄여 분석하는 것이 보다 수월할 것이다. 

 

느려지는 테스트 원인


1. 스프링 컨텍스트 로딩
2. 모키토
3. 직렬 테스트
4. 테스트 컨테이너
5. IO 테스트

 

1. 스프링 컨텍스트 로딩

컨텍스트 로딩 시간에 많은 시간이 필요로한다는 것은 누구나 다 알고 있을 것이다.

실제로 어느 정도 시간이 걸리는 프로파일링을 통해 알아보자.

 

프로파일링의 결과를 보니 대다수가 스프링 관련 함수이다.

압도적으로 비중이 높아 다른 부분에서 어떤 문제가 생기는지 파악하기 힘들 정도이다.

 

컨텍스트 로딩은 상당히 많은 부하를 주는 작업이다. 

때문에 불필요한 컨텍스트 로딩은 막아야한다.

나의 경우 5번의 컨텍스트 로딩이 이루어졌다.

  • WebMvcTest (컨트롤러 슬라이트 테스트) 
  • DataJpaTest(리포지토리 슬라이스 테스트)
  • Security 모듈의 Config 설정 테스트
  • Aws-S3 Config 설정 테스트
  • 부트스트랩 클래스의 run 함수 테스트

다행이 컨텍스트를 캐싱하여 테스트 간에 공유할 수 있도록 테스트 환경을 설정하여 같은 목적의 테스트에서는 컨텍스트가 2번 이상 실행되게 하지는 않았다.

WebMvcTest는 컨트롤러 모듈에서 api 요청 테스트를 위해 사용되야하고,

DataJpaTest는 리포지토리 모듈에서 쿼리 수행 결과를 테스트 해야하니 이마저도 사용되야한다.

 

config 설정 테스트나 부트스트랩 클래스의 run 함수는 불필요해 보이기에 제거 대상으로 결정하였다.

 

2. 모키토

스프링 테스트를 진행하는 경우 스프링 내부 동작이 프로파일링 결과의 압도적인 비율을 차지해 다른 곳에서 원인 파악을 하기 힘들다. 

따라서 스프링 테스트가 없는 서비스 모듈에서 프로파일링을 진행하니 아래와 같았다.

 

노란색으로 표시된 항목이 테스트 코드의 메서드이다.

스택에 쌓인 내역을 하나하나 확인을 해보니 대다수가 mock() 함수 였고 any() 함수도 더러 존재하였다.

테스트 더블을 만들고 메서드 인자값을 만드는 로직이 부하가 많이 걸리는 것 같다.

 

Method List에서 실행시간을 확인해보니 gradle과 junit이 상단에 위치하고 그 아래 항목을 보니 Mockito가 가장 많은 비율을 차지했다.

 

위 이미지에는 나오지 않았지만 스크롤을 내려보면 ByteBuddy 항목도 많이 존재하였다.

ByteBuddy가 ByteCode를 직접 생성하여 가짜 객체를 만드는 클래스이다.

테스트 더블과 데이터를 mockito를 통해 만드는 경우  ByteBuddy로 인해 많은 부하가 걸리는 것이다.

(뒤에서 설명하겠지만 모키토를 제거해야할 치명적인 이유가 하나 더 있다.)

 

3. 직렬 테스트

직렬 테스트는 프로파일링을 통해 확인할 수 있는 내용은 아니지만

테스트 코드 성능 개선에서 가장 많이 시도하는 방법이다.

 

동시성에 문제 되는 경우가 많아 도입이 어렵기도 한다.

회사에서 실무를 하며 빌드 시간을 개선하기 위해 한번 시도해보았지만

동시성을 고려하지 않은 테스트 코드들이 많이 작성되어있어 실패했던 경험이 있다.

다행스럽게 이부분에 대해서는 한번의 실패 경험이 있어 미리 고려하여 테스트를 작성하였다.

의존객체는 항상 테스트 더블로 대체하고 클래스 단위로 테스트 케이스를 작성하였고,

DB 테스트시 update 쿼리로 인해 값이 바뀔수 있어 select 쿼리에서는 값 자체에 대한 테스트 보다

DB 제약조건과 프로그래밍 속성이 알맞게 변환되는지, null일 때 값이 존재할 때 정상적으로 수행이 되는지에 집중하여 테스트를 하였다. create나 delete 쿼리는 select 쿼리에서 수행 결과 갯수를 테스트 하는 코드를 작성하지 않는다면 문제될 일은 없을 것이다.


동시성을 고려하고 테스트 코드를 작성했다고 생각했지만, 전혀 생각하지 못했던 부분에서 문제가 되었다.

 

[모키토 프레임워크의 동시성 문제 발생] 

여러 스레드에서 동시에 목 객체를 생성하는 경우,

객체를 생성하는 모키토 프레임워크의 메서드에서 반환객체가 서로 뒤엉켜 정상적으로 타입변환이 되지 않는 문제가 발생한다.

즉, 한번에 여러 목 객체 생성을 요청하면 다른 스레드에서 요청한 목객체가 반환될 수 있어 예외가 발생한다.

 

문제의 소스는 InlineBytecodeGenerator 클래스에 존재한다.

lastException 속성이 존재하는 경우 해당 예외가 발생된다.

Throwable throwable = this.lastException;
if (throwable != null) {
     throw new IllegalStateException(StringUtil.join(new Object[]{"Byte Buddy could not instrument all classes within the mock's type hierarchy", "", "This problem should never occur for javac-compiled classes. This problem has been observed for classes that are:", " - Compiled by older versions of scalac", " - Classes that are part of the Android distribution"}), throwable);
}

 

transfrom 메서드의 byteBuddy.redefine 메서드를 실행하며 예외가 발생 하는 경우 해당 예외를 캐치하여 lastException 속성에 저장한다.

byteBuddy의 redefine 메서드는 인자값으로 전달된 클래스 타입 구현체를 생성해주는데 이 과정에서 동시성 문제가 발생한다.

public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {
    if (classBeingRedefined != null && (this.mocked.contains(classBeingRedefined) || this.flatMocked.contains(classBeingRedefined)) && !EXCLUDES.contains(classBeingRedefined)) {
        try {
            return this.byteBuddy.redefine(classBeingRedefined, Simple.of(classBeingRedefined.getName(), classfileBuffer)).visit(new ParameterWritingVisitorWrapper(classBeingRedefined)).visit(this.mockTransformer).make().getBytes();
        } catch (Throwable var7) {
            this.lastException = var7;
            return null;
        }
    } else {
        return null;
    }
}

 

모키토 프레임워크를 통해 목 객체를 생성하는 것부터 병렬 처리가 되지 않으니 병렬 테스트를 위해서는 모키토 프레임워크를 제거해야한다.

병렬 테스트를 위해 모키토를 사용할지 말자는 것이 가혹해 보일 수 있겠지만,

테스트 더블을 일관된 규칙을 적용하여 구현하면 모키토를 쓰는 것보다 훨씬 쉽고 단순하다.(개인적 느낌)

 

4. 테스트 컨테이너 로딩

 

리포지토리 모듈에서 프로파일링을 진행해보니 위와 같이 테스트 컨테이너 실행시간이 압도적이었다.

프로그래밍 코드를 통해 DB 테스트 환경을 바로 구축할 수 있고 로컬환경이 바뀌어도 테스트용 DB를 별도로 구축하지 않아도 된다는 엄청난 장점에 테스트 컨테이너를 도입했지만, 실행시간을 굉장히 많이 잡아먹는다.

또한 flyway를 통해 모든 ddl과 dml을 초기에 실행하니 이로인한 실행시간이 더욱 걸린다.

 

5. IO 테스트

 

나의 프로젝트에서 파일 관련된 테스트를 진행할 때, 프로젝트 내에 파일을 만들어놓고 실제 IO 작업을 수행하는 경우도 있고

위와 같이 메모리에 파일을 만들어서 수행하는 경우도 있다.

 

디스크의 파일에 접근하는게 더 오래 걸릴것 같아 라이브러리를 통해 메모리에서 파일을 만들었는데 이 방법이 더 오랜 시간이 걸렸다.

제거해야할 대상이다.

 

 

테스트 성능 개선


1. 스프링 컨텍스트 로딩 제거

모듈 테스트 종류 해결
컨트롤러 WebMvcTest 컨텍스트 캐싱
리포지토리 DataJpaTest 컨텍스트 캐싱
Security SpringBootTest (Config 설정 테스트) Config 메서드 직접 실행
Aws-S3 SpringBootTest Config 설정 테스트 Config 메서드 직접 실행
부트스트랩 SprringApplication.run mockStatic으로 대체

 

1.1 컨텍스트 캐싱

컨텍스트 캐싱이란 테스트시 컨텍스트 환경이 같은 경우 컨텍스트를 리로드하지 않고 기존에 로드된 컨텍스트를 공유하여 사용하는 방법이다. 별도의 옵션으로 설정하는 것은 아니고 스프링에서 컨텍스트 환경이 다르지 않은 경우 자동으로 적용해준다.

자세한 내용은 아래 글에 정리해 두었으니 참고 바란다.

[참고] 컨텍스트 캐싱을 위한 테스트 환경 구축

 

1.2 Config 메서드 직접 실행

커버리지 100%를 위해서는 Config 파일에 설정된 메서드들도 실행해야한다.

이전에는 SpringBootTest를 통해 Config 설정이 정상적으로 등록됬는지 확인했지만 이부분도 제거하고 직접 메서드를 실행시켰다.

AwsS3 Config의 경우 S3 관련 객체를 생성하는 것인데, 키값이나 경로만 전달해주면 되었기에 단순하게 해결되었다.

Security Config는 꽤나 까다로웠다.

SecurityFilterChain에서 사용되는 HttpSecurity 객체 생성이 굉장히 까다롭다.

HttpSecurity를 생성하기 위해 필요한 의존객체를 모두 주입시켜줘야하는데 시큐리티 프레임워크에서 알아서 생성해주는 의존성을

직접 파악하여 넣어줘야한다.

디버깅을 통해 에러가 나는 부분들을 파악하여 필요한 의존성을 넣어주었다.

 

1.3 mockStatic으로 대체

@SpringBootApplication
class BootstrapApplication

fun main(args: Array<String>) {
    runApplication<BootstrapApplication>(*args)
}

 

스프링 애플리케이션 실행은 위와 같이 부트스트랩 클래스에서 SpringApplication의 run 함수를 호출함으로써 실행된다.

단순히 함수 하나를 위해 컨테스트를 로딩하는 것은 비효율적이고, 커버리지 100%는 도달해야하니 

이부분은 어쩔수 없이 mockStatic을 사용하였다.

앞서 설명했듯 모키토는 바이트코드 생성과 스레드-세이프 하지 않기에 사용하지 않으려 했는데 이부분은 예외적으로 허용하였다.

 

2, 3. 모키토 제거

모키토 제거는 바이트 코드 생성으로 인한 테스트 코드 실행시간 부하와 스레드-세이프 하지 못하는 문제로

병렬 테스트를 실행하지 못하게 하는 원인이라 반드시 제거해야했다.

 

모든 테스트 더블을 직접 구현하여 모키토를 제거하였다.

모키토 제거에 걱정하는 분이 있다면 아래글을 참고하기 바란다.

테스트 더블을 작성하는 규칙을 정한다면 모키토를 사용하는 것보다 훨씬 생산성있게 작업을 진행할 수 있을 것이다.

 

테스트 더블 직접 구현하여 테스트 환경 구축하기

 

4. 테스트 컨테이너 제거

테스트 컨테이너를 제거하긴 했지만, 실행시간이 얼마나 줄어들을 수 있는지 확인 하기 위한 목적이었을 뿐 다시 원복하여 현재는 테스트 컨테이너를 사용하고 있다. (성능 개선 결과만 확인하고 다시 원복을 하였다.)

로컬 환경에 구애 받지 않고 도커만 있다면 어디에서든 실행할 수 있다는 장점이 나에게는 매우 편리했기에 포기하지 않았다.

 

로컬에 mysql이 설치되어 있어 로컬 mysql에 새로운 db를 생성하여 해당 경로로 접근하도록 하였다.

테이블 및 데이터 초기화는 flyway로 그대로 진행하였고, 테스트 실행과 별개로 마이그레이션이 있을 때마다 초기화하여 반영하면 된다.

 

5. IO 테스트 개선

IO 테스트는 사실 방법이 없다. 최대한 테스트 범위를 줄이는 것이 좋다.

가급적이면 util화하여 하나의 메서드에서 테스트를 집중하는게 좋지 않나 생각한다.

 

문제는 디스크에 파일을 만들어 놓고 접근하느냐, 아니면 프로그래밍 코드를 통해 메모리에서 파일을 직접 만들어 접근하느냐이다.

 

case1. text 파일

  • 메모리 접근 : 10ms
  • 디스크 접근 : 10ms

case2. ppt 파일 - 슬라이드 생성 필요

  • 메모리 접근 : 3700ms
  • 디스크 접근 : 2100ms

case3. zip 파일 - 여러 파일 생성 필요

  • 메모리 파일 : 244ms
  • 디스크 파일 : 35ms

파일이 단순한 경우 메모리에서 만들어 접근하는게 빠르지만 복잡한 파일의 경우 디스크에 미리 만들어 놓는게 더욱 빠를 수가 있다.

위 테스트에서 ppt파일이나 zip파일은 단순히 확장자만 저렇게 만들어서는 안된다.

prod 로직이 파일 내부에 접근하기에 형식을 맞춰 생성하는 로직이 필요해 이 과정이 오래걸리면 디스크에 미리 만들어 놓은 파일로 테스트하는게 더욱 빠를 수 있다.

ppt파일의 경우 슬라이드 또한 만들어야하고, zip파일도 저장한 파일 구조를 갖춰 생성해야한다.

 

단순한 파일이나 inputStream으로 접근하는 로직은 프로그래밍단에서 생성하여 바로 넘겨주고,

특정 확장자 형식이 정해져있고 내부 구성요소들의 형식을 맞춰줘야하는 경우는 파일 생성 비용이 비싸기에 디스크에 미리 만들어놓고 테스트를 진행하였다.

 

결과 공유


8core 환경

  직렬 병렬
최초 모키토 제거 모키토 제거,
컨텍스트 로딩 3회 제거
1회 2m 55s 2m 21s 2m 18s 1m 28s
2회 2m 47s 2m 34s 2m 5s 1m 29s
3회 2m 59s 2m 39s 2m 10s 1m 32s
4회 2m 46s 2m 38s 2m 6s 1m 27s
5회 2m 48s 2m 36s 2m 5s 1m 35s
평균 2m 51s
2m 33s
2m 8s
1m 30s
시간 단축(%)    10% 16% 30%(29.6%) 최종 : 40%

 

IO 테스트의 경우 테스트 케이스가 많지 않고 1초가 넘는 심각한 테스트는 하나 밖에 존재하지 않아 성능 개선이 눈에 띌 정도로 드러나지는 않앗다.

 

 

테스트 컨테이너 10초 가량 줄임

 

 

jacoco 최신버전 사용 0.8.11 -> 0.8.12

 

lateinit 브랜치 커버리지 못 잡아내는 버그 해결

https://www.jacoco.org/jacoco/trunk/doc/changes.html

 

반응형