본문 바로가기
dev/spring

[Spring Boot] Virtual Threads vs Reactive vs Kotlin Coroutines 성능 비교

by igooo 2024. 9. 10.
728x90

개요

Spring Boot의 Java 21의 Virtual Thread, Spring Reactive, Kotlin Coroutiones를 사용한 API 서버를 구현하고 각각의 프로젝트의 성능 테스트를 진행하여 성능을 비교한다.

 

테스트 방법

Spring Reactive로 임의의 지연을(30ms) 추가한 API를 3번씩 호출하고, 3번의 호출결과를 모두 합하여 응답하는 API 서버를 Virtual Thread, Spring Reactive, Kotlin Coroutiones, Blocking 3개의 프로젝트로 만들어 성능 테스트한다.

 

/delay/v1.0/api        <->          Virtual Thread

(30ms 지연)                           Spring Reactive

                                               Kotlin Coroutiones

                                               Blocking

방식 내용 비고
Blocking 순차적으로 API를 호출한다. 지연 시간 * 호출 수(3번)
Virtual Threads ExecutorService로 Virtural Thread를 사용하여 API를 호출한다.  
Reactive zip operator를 사용하여 API를 호출한다.  
Kotlin Coroutines Coroutines을 사용하여 API를 호출한다.  

 

프로젝트 구조

Delay API 서버

@RestController
@RequestMapping("/delay/v1.0.")
public class DelayApiController {
	private Map<String, Object> data = Map.of("index", 1, "data", "test data");

	@GetMapping("/api")
	Mono<Map<String, Object>> api() {
		return Mono.just(this.data).delayElement(Duration.ofMillis(30L));
	}
}

Blocking 

@RestController
@RequestMapping("/delay/v1.0/")
public class ApiController {
	private final RestClient restClient;

	public ApiController(RestClient restClient) {
		this.restClient = restClient;
	}

	@GetMapping("delay")
	Map<String, Object> delay() {
		var result1 = this.restClient.get().uri("/delay/v1.0/api").retrieve().body(Map.class);
		var result2 = this.restClient.get().uri("/delay/v1.0/api").retrieve().body(Map.class);
		var result3 = this.restClient.get().uri("/delay/v1.0/api").retrieve().body(Map.class);

		return Map.of("data", (result1.get("data") + " " + result2.get("data") + " " + result3.get("data")), "index",
				((int) result1.get("index") + " " + (int) result2.get("index") + " " + (int) result3.get("index")));
	}
}

 

Virtual Thread

@RestController
@RequestMapping("/delay/v1.0/")
public class VTApiController {
	private final RestClient restClient;
    private final ExecutorService executorService;

	public VTApiController(RestClient restClient, ExecutorService executorService) {
		this.restClient = restClient;
        this.executorService = executorService;
	}

	@GetMapping("delay")
	Map<String, Object> delay() {
		var request1 = executorService.submit(() -> this.restClient.get().uri("/delay/v1.0/api").retrieve().body(Map.class));
		var request2 = executorService.submit(() -> this.restClient.get().uri("/delay/v1.0/api").retrieve().body(Map.class));
		var request3 = executorService.submit(() -> this.restClient.get().uri("/delay/v1.0/api").retrieve().body(Map.class));

		var result1 = request1.get();
		var result2 = request2.get();
		var result3 = request3.get();
	
		return Map.of("data", (result1.get("data") + " " + result2.get("data") + " " + result3.get("data")), "index",
				((int) result1.get("index") + " " + (int) result2.get("index") + " " + (int) result3.get("index")));
	}
}

@Bean
ExecutorService executorService() {
	var factory = Thread.ofVirtual().name("v-thread", 1).factory();
    return Executors.newThreadPerTaskExecutor(factory);
}

 

Reactive

@RestController
@RequestMapping("/delay/v1.0/")
public class ReactiveApiController {
	private final WebClient webClient;

	public ReactiveApiController(WebClient webClient) {
		this.webClient = webClient;
	}

	@GetMapping("delay")
	Mono<Map<String, Object>> delay() {
		var request1 = this.webClient.get().uri("/delay/v1.0/api").retrieve().toEntity(Map.class);
		var request2 = this.webClient.get().uri("/delay/v1.0/api").retrieve().toEntity(Map.class);
		var request3 = this.webClient.get().uri("/delay/v1.0/api").retrieve().toEntity(Map.class);

		return Mono.zip(request1, request2, request3).flatMap(data -> {
			var result1 = data.getT1().getBody();
			var result2 = data.getT2().getBody();
			var result3 = data.getT3().getBody();

			return Mono.just(
			    Map.of("data", (result1.get("data") + " " + result2.get("data") + " " + result3.get("data")),
			           "index", ((int) result1.get("index") + " " + (int) result2.get("index") + " "
									+ (int) result3.get("index"))));
		});

	}
}

 

Kotlin Coroutines

@RestController
@RequestMapping("/delay/v1.0/")
class CoroutinesApiController(private val webClient: WebClient) {

	@GetMapping("delay")
	suspend fun delay(): Map<String, Any?> = coroutineScope {
		var request1 = async {
			webClient.get().uri("/delay/v1.0/api").retrieve().awaitBody<Map<String, Any>>()
		}
		var request2 = async {
			webClient.get().uri("/delay/v1.0/api").retrieve().awaitBody<Map<String, Any>>()
		}
		var request3 = async {
			webClient.get().uri("/delay/v1.0/api").retrieve().awaitBody<Map<String, Any>>()
		}
		
		var result1 = request1.await()
		var result2 = request2.await()
		var result3 = request3.await()
		
		mapOf("data" to (result1.get("data").toString() + " " + result2.get("data").toString() + " " + result3.get("data").toString())
			  "index" to (result1.get("index").toString().toInt() + " " + result2.get("index").toString().toInt() + " " + result3.get("index").toString().toInt())
	}
}

 

 

Performance test

성능 테스트 전 10회 정도 호출 했을 때 각각의 API는 아래와 같은 응답 속도를 보여준다.

Blocking Virtual Thread Reactive Kotlin Coroutines
97~105ms 50~65ms 47~55ms 47~53ms

 

  • Delay API : 30ms의 지연이 있기 때문에 대략 적으로 40ms 이상의 응답 시간을 보여준다.
  • Blocking  : Delay API는 30ms의 지연이 있고 순차적으로 3번을 호출하기 때문에 대략 100ms 정도의 응답 시간을 보여준다.
  • Virtual Thread , Reactive, Kotlin Coroutines : 3가지 방법은 모두 3번에 API 호출에 대하여 비동기로 호출하고 3대의 응답이 모두 도착하는 대기 시간만 필요하기 때문에 대략적으로 비슷한 응답 시간을 보인다.

성능 테스트 방법

nGrinder를(https://naver.github.io/ngrinder/) 사용하여 Vuser 수를 30부터 180까지 늘려가면서 테스트를 진행했다.

각 시스템은 K8S Pod로 CPU :2, Memory 1G로 설정된 Pod로 테스트를 진행했고, Java 21 이미지에 Heap Size는 512M로 통일하여 테스트를 진행했다.

 

성능 비교

Vuser 30

  TPS MTT
Blocking 54.1 552.5
Virtual Thread 874.8 34
Reactive 901.1 33
Kotlin Coroutines 896 33.2

 

Vuser 60

Blocking은 이미 Vuser 30에서 부하가 Max상태였고 Vuser를 60으로 늘려도 TPS가 늘어나지 않는다.

  TPS MTT
Blocking 54.3 1105.3
Virtual Thread 1499.4 39.6
Reactive 1757.6 33.9
Kotlin Coroutines 1745.6 34.1

 

Vuser 90

  TPS MTT
Virtual Thread 1909.7 46.8
Reactive 2463.7 36.3
Kotlin Coroutines 2399.5 37.3

 


Vuser 150

  TPS MTT
Virtual Thread 2489.3 59.9
Reactive 3225.3 46.2
Kotlin Coroutines 2499.3 50.6

 

Vuser 180

  TPS MTT
Virtual Thread 2629.1 68.1
Reactive 3385.9 52.8
Kotlin Coroutines 3018.8 59.3

 

성능 테스트 결과

  • 성능 테스트 결과 Vuser가 낮은 상황에서는 Blocking 코드를 제외한 3가지 방법 모두 비슷한 TPS 수치를 보여준다.
  • Vuser가 많아 질수록 Reactive로 작성한 코드의 성능이 좋은 모습을 보여준다.
  • Virtural Thread, Kotlin Coroutines 모두 경량의 스레드로 비동기 코드를 작성할 수 있게 해 주지만 많은 스레드가 생성되는 만큼 Reactive 코드보다는 부하가 발생하는 것으로 보인다.
  • Virtural Thread, Kotlin Coroutines는 순차적 코드에서 비동기 처리가 필요한 부분에만 별도의 처리를 하여 가독성 및 코드개발, 유지보수에 장점이 있는 만큼 성능이 최우선하지 않는 프로젝라면 얼마든지 적용할 만하다.
  • Blocking 코드에 대서는 생각보다 TPS가 너무 낮게 나와서 나중에 개선 포인트에 대하여 좀 찾아봐야겠다.