- 테스트 커버리지 100% 달성기[1] - 레이어별 100% 달성 과정
- 테스트 커버리지 100% 달성기[2] - 테스트 환경 구축 및 시간 단축
- 테스트 커버리지 100% 달성기[3] - 테스트 코드 가독성 개선
테스트 커버리지 100%를 달성하니 650개 정도의 TC가 쌓였고 이에 따라 테스트 코드 실행 시간도 증가하게 되었다.
나의 로컬 컴퓨터 환경에서 평균 2분 50초 정도의 시간이 걸렸다.
테스트 코드를 작성하며 어느정도 시간 단축을 고려하였다고 하지만 2분 50초 가량 되는 시간은 너무 과도하다.
테스트 성능 개선을 위해 유명 기술블로그나 여러 포스팅에서 성능 개선 관련 내용들을 찾아보았고,
인텔리제이에 프로파일링 기능을 통해 테스트 코드 실행시간을 추적하며 작업 대상을 추려내며 작업을 진행하였다.
결과부터 말하면 650개의 TC 실행시간을 1분 30초 내외로, 40% 정도 단축하였다.
그 과정에 대해 설명하도록 하겠다.
작업 대상 선정
인텔리제이의 프로파일링을 통해 테스트 코드 실행시간을 추적할 수 있다고 하여 해당 기능을 사용하여 나의 테스트 코드 실행시간을 분석하였다.
허나, 분석결과를 보더라도 정확하게 어디에서 쓰이는지에 대해서 명확하게 파악하기 어렸웠다.
gradle, junit, spring에서 사용되는 메서드들이 많이 존재하였는데 내부 동작을 모두 파악하고 있지 않는 이상 파악하기 쉽지 않다.
또한 리플렉션과 같이 많은 라이브러리나 프레임워크에서 사용되는 메서드가 정확히 어떤 라이브러리에서 부하가 발생하는지 특정하기 쉽지 않다.
따라서 프로파일링을 할 때에는 전체 테스트 코드를 한번에 실행하는 것보다 실행 범위를 좁혀 분석하는 것이 성능 파악을 하는데 더욱 수월하게 만든다.
나 또한 모듈 단위로 프로파일링을 하며 작업 대상을 선정하였다.
느려지는 테스트 원인
1. 스프링 컨텍스트 로딩
2. 모키토
3. 직렬 테스트
4. 테스트 컨테이너
5. IO 테스트
1. 스프링 컨텍스트 로딩
컨텍스트 로딩 시간에 많은 시간이 필요로한다는 것은 누구나 다 알고 있을 것이다.
실제로 어느 정도 시간이 걸리는 프로파일링을 통해 알아보자.

프로파일링의 결과를 보니 대다수가 스프링 관련 함수이다.
압도적으로 비중이 높아 다른 부분에서 어떤 문제가 생기는지 파악하기 힘들 정도이다.
컨텍스트 로딩은 상당히 많은 부하를 주는 작업이다.
때문에 불필요한 컨텍스트 로딩은 막아야한다.
나의 경우 5번의 컨텍스트 로딩이 이루어졌다.
- WebMvcTest (컨트롤러 슬라이트 테스트)
- DataJpaTest(리포지토리 슬라이스 테스트)
- Security 모듈의 Config 설정 테스트
- Aws-S3 Config 설정 테스트
- 부트스트랩 클래스의 run 함수 테스트
다행이 컨텍스트 캐싱을 통해 테스트 간에 컨텍스트를 공유할 수 있도록 하여 한 모듈에서 2번 이상 로드되게 설정하지는 않았었다.
WebMvcTest나 DataJpaTest는 필수적이었기에 제거 대상에서 제외하였다.
허나, config 설정이나 부트스트랩 클래스를 테스트하기 위해 컨텍스트를 로딩하는 것은 불필요해 보이기에 제거 대상으로 결정하였다.
2. 모키토
스프링 테스트를 진행하는 경우 스프링 내부 동작이 프로파일링 결과의 압도적인 비율을 차지해 다른 곳에서 원인 파악을 하기 힘들다.
따라서 스프링 테스트가 없는 서비스 모듈에서 프로파일링을 진행하니 아래와 같았다.

노란색으로 표시된 항목이 개발자가 직접 작성한 테스트 코드 메서드이다.
스택에 쌓인 내역을 하나하나 확인을 해보니 대다수가 mock() 함수 였고 any() 함수도 더러 존재하였다.
테스트 더블을 만들고 메서드 인자값을 만드는 로직이 부하가 많이 걸리는 것 같다.
Method List에서 실행시간을 확인해보니 gradle과 junit이 상단에 위치하고 그 아래 항목을 보니 Mockito가 가장 많은 비율을 차지했다.

위 이미지에는 나오지 않았지만 스크롤을 내려보면 ByteBuddy 항목도 많이 존재하였다.
ByteBuddy가 런타임에서 ByteCode를 생성하여 테스트 더블을 만드는 클래스이다.
런타임에서 ByteCode를 생성하여 테스트 더블을 만드는 것이 많은 부하가 걸리는 것을 알 수 있다.
아무래도 컴파일 타임에 만들어지는 것보다 부하가 클 것이다.
3. 직렬 테스트
직렬 테스트는 프로파일링을 통해 확인할 수 있는 내용은 아니지만
많은 사람들이 테스트 코드 성능 개선을 위해 직렬 테스트가 아닌 병렬 테스트를 시도한다.
하지만, 병렬 테스트는 동시성 문제로 인해 도입을 어렵게 만든다.
나 또한 회사에서 실무를 하며 빌드 시간을 개선하기 위해 한번 시도해보았지만
동시성을 고려하지 않은 테스트 코드들로 인해 실패했던 경험이 있다.
다행스럽게 이부분은 실무에서 실패한 경험이 있어 미리 고려하고 테스트를 작성해왔다.
의존객체는 항상 테스트 더블로 대체하고 클래스 단위로 테스트 케이스를 작성하였고,
DB 테스트시 자동 롤백을 보장하여 병렬로 수행되었을 때, 각각의 커넥션에서 이뤄지는 작업이 영향을 주지 않도록 하였다.
허나, 전혀 생각하지 못했던 부분에서 문제가 되었다.
[모키토 프레임워크의 동시성 문제]
여러 스레드에서 동시에 목 객체를 생성하는 경우,
객체를 생성하는 모키토 프레임워크의 메서드에서 반환객체 타입이 서로 뒤엉켜 정상적으로 타입변환이 되지 않는 문제가 발생하였다.
즉, 동시에 여러 목 객체 생성을 요청하면 다른 스레드에서 요청한 목객체가 반환될 수 있어 예외가 발생한다.
문제의 소스는 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을 db 인스턴스화시 실행하는 것은 실행시간의 부하를 더욱 일으킨다.
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을 사용하였다.
앞서 설명했듯 모키토는 바이트코드 생성은 thread-safe 하지 않기에 사용하지 않으려 했지만 이부분은 예외적으로 허용하였다.
2, 3. 모키토 제거
모키토 제거는 바이트 코드 생성으로 인한 테스트 코드 실행시간 부하와 thread-unsafe하여 병렬 테스트를 실행하지 못하는 원인이라 반드시 제거해야했다.
모든 테스트 더블을 직접 구현하여 모키토를 제거하였다.
테스트 코드 작성시에 모키토를 통해 테스트 더블을 사용할 수 있는 점이 굉장히 장점이라고 생각했는데,
직접 테스트 더블을 구현해보니 오히려 훨씬 빠르고 편리한 장점이 많다.
모키토를 제거하면 아래와 같은 장점을 얻는다.
- 성능 향상
모키토는 런타임에 바이트 코드를 생성하지만 테스트 더블을 직접 설계하면 컴파일 타임에 생성되기에 성능이 더욱 빠르다. - 테스트 더블 재사용성 향상
모키토는 테스트마다 생성하고 동작을 제어해야하므로 재사용성이 떨어진다.
재사용성이 높은 prod 코드일수록 모키토 사용 테스트 코드는 재사용성이 떨어진다.
테스트 더블을 직접 작성하게 되면 테스트 케이스에서 재사용성이 굉장히 높아진다. - 가독성 향상
when과 같은 테스트 더블을 제어하는 모키토 문법이 테스트 코드에서 제거된다.
테스트 더블 작성시 본인이 규칙을 정할 수 있는데, 나는 인자값에 따라 성공, 실패, 예외를 발생시키도록 하였다.
또한 인자값 상수에 SUCCESS_ID, FAIL_ID, EXCEPTION_ID와 같은 이름을 주어 테스트 코드에서 어떤 케이스를 테스트하는지 파악하기 쉽게 구현을 하였다. - 테스트 코드 작성 시간 단축
SUCCESS_ID, FAIL_ID, EXCEPTION_ID와 같은 인자값에 따라 성공/실패/예외 동작을 하는 테스트 더블을 구현하게 되면 사용할 때에도 빠르게 작성하는데 도움이 된다.
자세한 내용은 아래 글에 정리되어 있다. 아래 글을 참조하기 바란다.
4. 테스트 컨테이너 제거
테스트 컨테이너를 제거하긴 했지만, 실행시간이 얼마나 줄어드는지 확인 하기 위한 목적이었을 뿐 현재는 다시 원복하여 테스트 컨테이너를 사용하고 있다.
로컬 환경에 구애 받지 않고 도커만 있다면 어디서든 실행할 수 있다는 점이 나에게는 매우 편리했기에 포기하지 않았다.
로컬에 mysql이 설치되어 있어 로컬 mysql에 새로운 db를 생성하여 해당 경로로 접근하도록 하였다.
테이블 및 데이터 초기화는 flyway로 그대로 진행하였고, 테스트 실행과 별개로 마이그레이션이 있을 때마다 초기화하여 반영하면 된다.
5. IO 테스트 개선
IO 테스트는 사실 방법이 없다. 최대한 테스트 범위를 줄이는 것이 좋다.
가급적이면 util화하여 하나의 메서드에서 io 처리가 이루어지게 하여 다른 테스트에서는 IO 처리가 이루어지지 않도록 하는 것이 좋다.
그렇다면 이제 IO 테스트를 진행 할 때, '디스크에 파일을 만들어 놓고 접근할지, 아니면 프로그래밍 코드를 통해 메모리에서 파일을 직접 만들어 접근할지' 결정해야한다.
case1. text 파일
- 메모리 접근 : 10ms
- 디스크 접근 : 10ms
case2. ppt 파일 - 슬라이드 생성 필요
- 메모리 접근 : 3700ms
- 디스크 접근 : 2100ms
case3. zip 파일 - 여러 파일 생성 필요
- 메모리 파일 : 244ms
- 디스크 파일 : 35ms
파일이 단순한 경우 메모리에서 만들어 접근하는게 빠르지만, 복잡한 파일의 경우 디스크에 미리 만들어 놓는게 더욱 빠를 수가 있다.
즉, 파일 생성 비용이 크면 디스크에 미리 만들어 놓고 테스트하고, 생성 비용이 작으면 테스트 케이스마다 메모리에 파일을 생성해서 사용하는 것이 유리하다.
위 테스트에서 ppt파일이나 zip파일은 단순히 확장자만 갖춘 파일이 아니다.
ppt 파일은 내부 슬라이드를 갖추고 있어야하며, zip 파일은 이미지 파일과 html 파일 그리고 저장된 폴더 구조도 맞춰야한다.
생성 비용이 꽤 크기에 테스트 시 파일을 직접 생성하면 시간이 오래 걸린다.
이런 경우 디스크 접근 보다 메모리에 파일을 생성하는 비용이 더 크므로 차라리 디스크에 파일을 만들어놓고 접근하는게 유리할 수 있다.
이러한 부분은 직접 실행시간을 측정하며 비교해보지 않는 이상 뭐가 더 빠를지 판단하는 것은 쉽지 않다.
테스트용 파일을 만드는 것에 집중하는 것보다는 prod 코드 검증에 초점을 맞춰 테스트 코드를 작성하고 추후 리팩토링을 거치며 실행시간에 부하를 찾아내 수정하는 것도 좋다고 생각한다.
결과 공유
8core 환경
직렬 | 병렬 | TC 제거 (도입 안함) |
||||||||
최초 | 모키토 제거 | 컨텍스트 로딩 3회 제거 | ||||||||
1회 | 2m 55s | 2m 21s | 2m 18s | 1m 28s | 1m 15s | |||||
2회 | 2m 47s | 2m 34s | 2m 5s | 1m 29s | 1m 18s | |||||
3회 | 2m 59s | 2m 39s | 2m 10s | 1m 32s | 1m 14s | |||||
4회 | 2m 46s | 2m 38s | 2m 6s | 1m 27s | 1m 20s | |||||
5회 | 2m 48s | 2m 36s | 2m 5s | 1m 35s | 1m 15s | |||||
평균 | 2m 51s |
2m 33s |
2m 8s |
1m 30s | 1m 16s |
|||||
시간 단축(%) | -10% | -16% | -30%(29.6%) | 최종 40% |
모키토 제거, 컨텍스트 로딩 제거, 병렬 테스트까지 진행하여 전체의 40% 가량 시간을 단축하였다.
(IO 테스트의 경우 테스트 케이스가 많지 않고 성능 저하가 심각한 테스트가 몇개 없어 성능 개선이 눈에 띌 정도로 드러나지 않았다.)
테스트 컨테이너를 제거하고 로컬 환경에 DB를 구축한것 까지 포함하면 55% 가량의 시간을 단축하긴 하였지만,
테스트 컨테이너는 최종적으로 제거하지 않았다.
가장 의아스러웠던 부분은 모키토 제거가 10% 단축 밖에 되지 않았다는 것이다.
굉장히 많이 단축 될 것이라 생각했는데 10% 정도의 예상 보다 적은 수치였다.
내가 모키토를 생각보다 적게 사용하고 있었다거나, 런타임에 테스트 더블을 생성하는 모키토 성능이 생각 보다 좋다는 것 둘 중 하나인 것 같다.
허나, 모키토를 제거하는 과정이 나에게는 가장 큰 도움이 되었다.
테스트 대상과 테스트 더블을 명확하게 분리한 것,
테스트 더블을 일관되게 동작 시킴으로써 테스트 더블 사용을 일관되게 하여 가독성 향상,
일관된 테스트 더블 사용으로 테스트 코드 작성 시간 단축,
테스트 더블 재사용성 향상 등 너무나 장점이 많다.
모키토를 제거하여 병렬 테스트까지 진행할 수 있었으니 성능 향상에도 꽤나 도움이 됬다고 할 수도 있겠다.
'테스트' 카테고리의 다른 글
Flyway, TestContainer를 통한 독립된 테스트 DB 환경 구성 (0) | 2025.02.22 |
---|---|
테스트 더블을 직접 구현하여 테스트 환경 구축하기(feat. 동시성 제어) (0) | 2025.02.21 |
스프링 컨텍스트 캐싱을 위한 테스트 환경 구축 (0) | 2025.02.20 |
테스트 커버리지 100% 달성기[3] - 테스트 코드 가독성 개선 (0) | 2025.02.19 |
테스트 커버리지 100% 달성기[1] - 레이어별 100% 달성 과정 (2) | 2025.01.04 |