-
Junit 테스트 병렬 수행으로 빌드 시간 단축하기Spring Framework 2022. 8. 16. 01:14반응형
1) 서론
혹시 무언가를 기다리는 시간이 지루하게 느껴진 경험이 있으신가요?
저는 놀이기구를 타기 위해 기다리는 시간이 참 지루하다고 느껴집니다. 대기열에서 약 1시간씩 기다리다 보면, 자유이용권을 구매했지만 몇 개 못 타는 경험을 하기도 하는데요. 가끔 대기열이 전혀 없고, 실컷 탈 수 있었으면 하는 상상도 해봅니다.
최근 문득 애플리케이션의 build 시간이 지루하고, 아깝다는 생각이 들었는데요.
특히 테스트 코드가 증가하게 되면, 빌드 시간도 계속해서 지연됩니다. Githbub actions를 사용해서 PR 생성 혹은 commit 시 자동으로 build가 동작하거나, 배포를 위해 build 할 때도 마찬가지입니다.
만약- 총 10명의 개발자가
- 하루에 5번 build 하고
- 1분씩 아낄 수 있다면
총 50분이라는 소중한 시간을 아낄 수 있습니다.
Build 시간을 단축하기 위해 사용해본 JUnit 병렬 수행을 공유합니다.
2) 상황
- Spring boot 2.7.2
- Gradle 7.5
- Junit 5
class FirstTestClass { // 각 테스트 클래스 시작 전 출력 companion object { @BeforeAll @JvmStatic fun print_start() { println("=========================== First class start ===========================") } } @Test fun first() { Thread.sleep(5000) println("1-1 test function start ===> ${Thread.currentThread().name}") println("Hello World 1") } @Test fun second() { Thread.sleep(5000) println("1-2 test function start ===> ${Thread.currentThread().name}") println("Hello World 2") } @Test fun third() { Thread.sleep(5000) println("1-3 test function start ===> ${Thread.currentThread().name}") println("Hello World 3") } @Test fun forth() { Thread.sleep(5000) println("1-4 test function start ===> ${Thread.currentThread().name}") println("Hello World 4") } @Test fun fifth() { Thread.sleep(20000) println("1-5 test function start ===> ${Thread.currentThread().name}") println("Hello World 5") }
- 단순히 "Hello World"를 출력하는 테스트 클래스가 있습니다.
- 테스트 클래스 시작 전 '시작한다는 내용' 출력합니다.
- 각 함수마다 5초의 sleep 합니다.
- 마지막 함수에서 20초 sleep 합니다.
- 테스트 클래스 총 5개, 20개의 함수가 있습니다.
3) 순차적 vs 병렬 실행
3. 1) 순차적 실행
우선 아무런 설정을 하지 않고, 순차적으로 테스트 수행합니다.
- 각 테스트 클래스 / 함수들이 순차적으로 동작합니다.
- 총 3분 20초 소요됐습니다.
3. 2) 병렬 실행
(참고) JUnit은 오직 .properties 파일의 설정만 읽습니다.
// path: test/resourcese/junit-platform.properties junit.jupiter.execution.parallel.enabled=true junit.jupiter.execution.parallel.mode.classes.default=concurrent junit.jupiter.execution.parallel.mode.default=same_thread junit.jupiter.execution.parallel.config.strategy=dynamic junit.jupiter.execution.parallel.config.dynamic.factor=1
- enabled: 병렬 실행
- mode.classes.default: 클래스 단위 병렬로 동시에 수행
- mode.default: 함수 단위 동일 스레드
- config.strategy: 병렬 수행 및 스레드 풀 전략
- dynamic: 수행 환경의 프로세서/코어 수에 따라 병렬 실행 수 자동 계산
- fixed: 병렬 실행 수 임의의 값 지정
- config.dynamic.factor: dynamic 전략 시 곱하기될 인자 수
- default factor 1, 코어 수만큼만 수행 (로컬 환경 8코어)
- 각 테스트 클래스가 병렬적으로 수행됩니다.
- 총 3분 21초 소요됐습니다.
병렬로 수행된다면, 분명히 더 빨라야 합니다. 하지만 수행 시간에 차이가 없습니다.
왜 그럴까요?
JUnit 테스트에서 표시하는 테스트 수행 시간은 각 함수의 수행 시간을 더하기 하여 보여줍니다.
즉 병렬로 실행되어 각각 1, 1, 1 수행 시간을 가지더라도 결국 3(1+1+1) 초로 표기합니다.3. 3) 실제 Build 시간 측정
JUnit 테스트 수행으로 수행 시간 차이를 파악하기 어려운데요. 실제 빌드 소요 시간을 측정하겠습니다.
- 병렬 수행 시: 44초
- 순차적 수행 시: 3분 29초
실제 빌드 시간은 약 2분 45초 병렬 수행이 빠른 것을 확인할 수 있습니다.
4) 함수 단위의 병렬 수행은 어떨까요?
이전의 설정을 참고했을 때 클래스, 함수 단위 모두 병렬 수행이 가능합니다.
아래는 함수의 병렬 실행을 설정합니다.
기존 same_thread(동일 스레드)에서 수행되던 것을 concurrent(병렬)로 변경합니다.// same_thread --> concurrent junit.jupiter.execution.parallel.mode.default=concurrent
- 각 클래스 / 함수 별로 병렬로 수행되는 것을 볼 수 있습니다.
- 총 build 시간은 38초 걸렸습니다.
각각의 클래스 / 함수 모두를 병렬적으로 수행시켰을 때 아주 큰 성능상 이득은 보이지 않는 듯합니다.
개인적으로 유닛 테스트는 하나의 기능을 가진 함수들을 테스트하는 것이라고 생각하는데요. 그렇기 때문에 유닛 테스트의 수행은 짧은 것이 당연하고, 함수 단위 병렬 수행은 큰 이득이 없을 수밖에 없다고 생각합니다.
그리고 함수 단위 병렬 테스트는 동시성 문제가 있을 수 있는데요. 비슷한 로직들이 모여있는 테스트 클래스 특성상 스레드를 공유하는 객체를 사용할 수 있습니다. 이때는 하나의 객체를 여러 스레드가 바라보게 되면서, 동시성 문제가 있을 수 있다고 생각합니다.
그래서 이 글에서는 클래스 단위의 병렬 수행만 사용했습니다.
5) 어떻게 병렬적으로 수행되나요?
어떻게 병렬적으로 수행되는지 궁금합니다. 흔히 알고 있는 thread pool을 이용해서, 각각의 스레드들이 멀티스레드로 동작하게 되는 걸까요?
JUnit의 parallel.enabled 옵션을 사용하게 되면 ForkJoinPool을 사용하게 됩니다.5. 1) ForkJoinPool이란?
위는 일반적인 Thread pool의 구조입니다. 각각의 Task들이 있고, 이를 각각의 스레드들에게 할당되는 방식입니다.
하나의 태스크를 세 개의 스레드가 나눠서 수행합니다.
만약 한 개의 스레드는 오랫동안 일하고, 두 개의 스레드는 일찍 끝나면 어떻게 될까요?
두 개의 스레드는 일이 없지만 하나의 태스크가 끝나지 않았기 때문에 놀아야 합니다. 즉 낭비되는 스레드가 두 개인데요.
하지만 ForkJoinPool은 아래와 같습니다.하나의 태스크를 Fork(분기)하여 나눕니다. Github repository를 fork 하는 것과 비슷한 개념으로 이해하면 좋을 것 같습니다.
그리고 마지막에 Join(합치기)을 통해서 전체 계산을 합치는 과정을 거칩니다.
이때 중요한 것은 각각의 스레드는 queue를 가집니다.
그리고 각 스레드는 queue를 확인하고 비었다면, 다른 스레드의 job을 가지고 오는데요. B 스레드는 작업이 없기 때문에 A 스레드의 job을 훔쳐(steal) 옵니다.
이를 work-stealing 알고리즘이라고 합니다. 유휴 스레드가 없도록 최대한 활용하는 알고리즘입니다.
work-stealing의 장점은 유휴 스레드가 있음에도 불구하고, 추가적인 작업이 필요할 때 스레드를 생성하는 것을 방지할 수 있습니다. 즉 스레드를 추가적으로 생성하는 것이 아닌, 유휴 스레드를 최대한 활용하여 작업을 수행할 수 있습니다.5. 2) ForkJoinPool은 무조건 좋을까요?
유휴 스레드가 없이 동작하는 것은 무조건 좋아 보입니다. 왠지 자원을 낭비하지 않고, 최대한으로 사용할 수 있을 것 같은데요.
하지만 모든 스레드가 동일한 양의 일을 한다면, 불리할 수 있습니다.
이와 같은 상황에서는 유휴 스레드가 존재하지 않을 것입니다. 동시에 시작되고, 끝날 것입니다.
이때는 불필요하게 fork, join 객체를 생성하는 과정이 필요합니다. 그리고 각 스레드마다 queue를 만들고, 공간을 할당해야 합니다. 오히려 불필요한 작업이 발생합니다.
하지만 위의 ForkJoinPool 예시처럼 각각의 테스트 클래스의 수행이 다를 때는 효과적일 수 있을 것 같습니다.
6) 결론
동일한 테스트 코드지만, 수행 방법의 변경으로 build 시간 단축을 할 수 있습니다.
Build 시간을 단축한다는 것은 단순히 기다리는 데에 지겨움의 문제는 아니라고 생각합니다.
불필요하게 소모되는 시간을 단축시켜, 더 많은 생산성을 낼 수 있다고 생각하는데요.
흔히 요즘에 표현되는 '미친 생산성'을 달성하는데 필요하다고 생각합니다.
(혹은 집에 더 빨리 갈 수도 있고요)
꼭 테스트 코드뿐만 아니라, 실질적인 애플리케이션의 build 시간 단축의 방법도 적극적으로 적용해보면 좋을 것 같습니다.
7) 참고 문헌
https://junit.org/junit5/docs/snapshot/user-guide/
https://ryukato.github.io/parallel-computing/2017/10/24/work-stealing.html반응형'Spring Framework' 카테고리의 다른 글
Spring Cloud Sleuth + logback 적용기 (0) 2023.02.01 스프링에서 재처리를 위한 @Retryable 사용하기 (2) 2022.08.22 Querydsl cross join 개선 하기 (0) 2022.06.18 FK 없는 OneToOne 즉시 로딩 개선하기 (1) 2022.06.07 처음으로 해본 리팩토링 후기 (0) 2022.03.24