안녕하세요, Junit-team/junit5, spring/spring-boot, apache/seata, naver/fixture-monkey 등 여러 오픈소스 프로젝트에 기여한 YongGoose입니다.
처음에는 오픈소스 기여에 대한 관심을 높이려는 의도로 글을 쓰기 시작했지만, 점점 나의 소중한 자식들(?)을 소개하는 재미가 생기네요.
이번 글은 아래의 글의 후속 편입니다. (미리 읽고 오시면 좋습니다)
JUnit5에 병렬화를 도입한 이야기 - 클래스 단위
devocean.sk.com
소개
저번 글에서는 JUnit의 Vintage 엔진에 클래스 단위의 병렬화를 도입한 것을 설명했습니다.
여러 개의 테스트 클래스를 실행할 때는 해당 기능으로 인해 성능 향상을 기대할 수 있지만, 만약 하나의 테스트 클래스에 있는 여러 개의 메서드를 실행할 때는 성능 향상을 기대하기 어렵습니다.
그로 인해 메서드 단위의 병렬화도 메인테이너님께 제안을 드렸고, 긍정적인 답변을 받아 작업을 하게 되었습니다.
메인테이너님의 코멘트에서 볼 수 있다시피 클래스 단위 병렬화에서 사용하던 스레드 풀을 활용하면 쉽게 해결이 되는 듯했습니다.
그래서 위 다이어그램과 같이 Runner가 ParentRunner인 경우, 모든 childStatement(메서드)를 스레드 풀을 활용해 비동기로 실행하도록 구현했습니다.
교착상태
하지만, 제 기대와 달리 교착 상태가 발생하였습니다.
위 이미지는 Github Actions의 워크플로우 결과 이미지인데 6시간 동안 실행을 한 뒤, 시간이 초과되어 자동으로 실패한 것을 볼 수 있습니다.
이때 교착 상태는 클래스와 메서드에서 모두 병렬화를 활성화시키고, 테스트 클래스의 수보다 스레드 풀의 크기가 작을 때 발생하였습니다.
클래스와 메서드 단위에서 병렬화를 모두 활성화시키면 다음과 같은 순서로 실행이 됩니다.
- 상위 작업(클래스) 제출
- 각 상위 작업에 대해 실행 시작
- 상위 작업에 대한 하위작업(메서드) 제출
- 각 하위 작업에 대해 실행 시작
- 상위 작업(클래스)은 하위 작업(메서드) 완료 대기
- 상위 작업 완료 대기 후, 결과 반환
이때 교착 상태는 테스트 클래스의 개수보다 스레드 풀의 크기가 작을 때 발생했습니다. (동일할 때도 발생했습니다)
자세히 디버깅한 결과, 현재 사용 중인 스레드 풀의 특성과 연관하여 교착 상태가 발생한 원인을 확인할 수 있었습니다.
이전 글에서 작성했듯이, 현재 스레드 풀은 고정된 크기를 가지는 newFixedThreadPool을 사용 중입니다.
해당 스레드 풀의 대표적인 특징으로는 작업이 추가되더라도 지정된 스레드 수를 초과하지 않으며, 초과된 작업은 대기열에 저장되는 특성을 가지고 있습니다.
실행 순서 중, 2번(각 상위 작업에 대해 실행 시작)을 할 때 하나의 스레드가 할당이 됩니다.
그리고 해당 스레드는 모든 하위 작업이 완료되어 상위 작업이 완료가 되면 스레드가 반환이 됩니다.
만약 스레드 풀의 크기가 3이고, 테스트 클래스의 개수가 3, 각 하위 메서드의 개수도 3이라면 모든 스레드는 상위 클래스가 점유하게 되어 하위 메서드는 작업 큐에만 저장이 될 뿐 실행이 되지 않아 교착상태가 발생하는 것입니다.
교착상태가 일어나는 4가지 조건과 함께 더 자세히 설명드리겠습니다.
우선, 교착 상태가 일어나는 4가지 조건은 아래와 같습니다.
교착상태가 일어나는 4가지 조건
1. 상호 배제 : 자원을 한 번에 하나의 프로세스(스레드)만 사용할 수 있음
2. 점유 대기 : 이미 자원을 가진 상태에서 다른 자원을 기다리는 상황
3. 비선점 : 다른 프로세스가 점유한 자원을 강제로 빼앗을 수 없음
4. 순환 대기 : 프로세스들이 순환적으로 서로의 자원을 기다리는 상황
그러면 현재의 상태에 대입해 설명하겠습니다.
- 상호 배제 : 스레드는 한 번에 하나의 작업만 사용할 수 있습니다.
- 점유 대기 : 상위 작업(클래스)은 각각 스레드를 점유한 상태에서 하위 작업(메서드)을 제출하고 완료를 기다립니다.
- 비선점 : 현재 사용 중인 스레드는 점유된 스레드를 강제로 해제하거나 다른 작업에 재할당 하지 않습니다.
- 순환 대기 : 상위 작업(클래스)은 각각 하위 작업(메서드)의 완료를 기다리고, 하위 작업(메서드)은 상위 작업(클래스)이 완료되어 스레드가 반환되기를 기다리고 있습니다.
아래의 그림은 다이어그램으로 설명한 그림입니다. (조금 난잡해 보이더라도 순서대로 읽으시면 잘 이해가 될 거라고 생각합니다...)
해결
그래서 메인테이너님과 현재 상황에 대해 토론을 한 결과 ForkJoinPool을 사용하기로 결정이 되었습니다.
결국 돌고 돌아 ForkJoinPool...
ForkJoinPool은 작업 큐에 작업이 남아있는 경우 스레드가 블로킹되지 않고 다른 작업을 처리하는 특성을 가지고 있습니다.
그래서 교착상태가 발생하는 4가지 조건 중, 순환 대기를 해결할 수 있다는 장점이 있습니다.
각각의 작업이 서로의 작업의 완료를 기다리고 있는 상태가 순환 대기인데 ForkJoinPool을 활용하여 서로의 작업의 완료를 기다리지 않고 다른 작업을 처리하도록 구현하였습니다.
Jupiter 엔진의 경우 이미 병렬화에서 ForkJoinPool을 사용하고 있었습니다.
Vintage 엔진의 경우 단순히 ForkJoinPool을 활용했는데 Jupiter 엔진의 경우 자원 잠금, 작업 연기 등 흥미로운 로직이 많았습니다.
관심이 있으신 분이 계시다면 해당 내용도 한 번 분석해 글을 작성해 보겠습니다.
junit5/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorSe
✅ The 5th major version of the programmer-friendly testing framework for Java and the JVM - junit-team/junit5
github.com
결과
아래의 코드는 ForkJoinPool을 활용해 교착상태를 해결하고 메서드 단위의 병렬화를 구현한 코드입니다.
work stealing을 허용하기 위해 Future.get()을 각각 호출한 것을 볼 수 있습니다.
public void setExecutorService(ExecutorService executorService) {
Runner runner = getRunnerToReport();
if (runner instanceof ParentRunner) {
((ParentRunner<?>) runner).setScheduler(new RunnerScheduler() {
private final List<Future<?>> futures = new CopyOnWriteArrayList<>();
@Override
public void schedule(Runnable childStatement) {
futures.add(executorService.submit(childStatement));
}
@Override
public void finished() {
ThrowableCollector collector = new OpenTest4JAwareThrowableCollector();
AtomicBoolean wasInterrupted = new AtomicBoolean(false);
for (Future<?> future : futures) {
collector.execute(() -> {
// We're calling `Future.get()` individually to allow for work stealing
// in case `ExecutorService` is a `ForkJoinPool`
try {
future.get();
}
catch (ExecutionException e) {
throw e.getCause();
}
catch (InterruptedException e) {
wasInterrupted.set(true);
}
});
}
collector.assertEmpty();
if (wasInterrupted.get()) {
logger.warn(() -> "Interrupted while waiting for runner to finish");
Thread.currentThread().interrupt();
}
}
});
}
}
아래의 이미지는 교착상태를 해결한 흐름을 나타내는 다이어그램입니다.
결과적으로는 6시간 동안 실행을 했던 Github Actions도 정상적으로 완료가 되었고, 스레드 풀의 크기에 상관없이 교착상태가 더 이상 발생하지 않고 정상적으로 실행이 되는 것을 확인했습니다.
아래의 공식 문서에서 클래스, 메서드 수준의 병렬화를 스레드 사용 예시와 함께 볼 수 있습니다.
JUnit 5 User Guide
Although the JUnit Jupiter programming model and extension model do not support JUnit 4 features such as Rules and Runners natively, it is not expected that source code maintainers will need to update all of their existing tests, test extensions, and custo
junit.org
다음 글 예고
이번 글이 올라왔을 때는 아래의 PR이 병합되었을 것이라고 생각합니다. (현재 문서화 작업 마무리 중)
대부분의 티스토리, 벨로그 글을 보면 ExtensionContext를 활용해 테스트 간 자원을 공유하는 것을 확인할 수 있습니다.
하지만, 이러한 자원 공유는 범위가 병확하지 않아 특정 시점이나 테스트 세션 간 자원 관리가 복잡해질 수 있습니다.
아래의 PR은 JUnit 플랫폼에서 자원 공유를 request / session 이라는 두 가지 범위로 나누어 관리할 수 있도록 하는 기능을 추가하고 있습니다. 이를 통해 자원을 더 세밀하게 제어하고, 테스트 실행 간의 자원의 재사용성을 높이는 것이 목표입니다.
해당 기능은 잘 모르고 사용한다면 기존의 ExtensionContext와 별 다르게 사용할 수 없다고 생각합니다.
하지만 잘 알고 사용한다면 자원 공유를 매우 효율적으로 사용할 수 있습니다.
각종 tech blog들에서도 분명 해당 기능이 merge가 된 후 배포가 되면 자세히 다룰 것으로 예상합니다.
하지만, 문서와 코드를 보고 글을 작성하는 분들보다는 직접 만든 사람이 더 잘 알 것이라고 생각합니다. ㅎㅎ
해당 글도 devocean와 같이 접근성이 좋은 곳에 외부 기고를 하겠습니다.
감사합니다.