시냅스

코드로 살펴보는 Spring Thread Model 과 blocking/non-blocking I/O 본문

Java, Spring

코드로 살펴보는 Spring Thread Model 과 blocking/non-blocking I/O

ted k 2023. 4. 2. 20:56

https://liltdevs.tistory.com/128

 

Blocking vs Non-blocking , Synchronous vs Asynchronous

Blocking 요청한 작업을 마칠 때까지 계속 대기한다. return 값을 받아야 끝난다. 호출된 함수가 자신의 작업을 모두 마칠 때까지 호출한 함수에게 제어권을 넘겨주지 않고 대기하게 만드는 것 e.g.

liltdevs.tistory.com

 

https://liltdevs.tistory.com/130

 

옵저버 패턴 Java로 구현

옵저버 패턴 Observer Pattern 객체 사이에 일대다의 의존 관계가 있고, 어떤 객체의 상태변하게 되면 그 객체에 의존성을 가진 다른 객체들이 변화의 통지(notify or update)를 받고 자동으로 갱신될 수

liltdevs.tistory.com

 

https://liltdevs.tistory.com/187

 

Java NIO 의 작동 원리

https://liltdevs.tistory.com/100 입출력 시스템, I/O System 입출력 시스템, I/O System Web, File 수정, Youtube 시청, game 등 컴퓨터는 입출력 작업을 주로 한다. PCI 버스로 모든 Device 와 연결한다. 메모리 맵드 입

liltdevs.tistory.com

 

Blocking/Non-Blocking I/O 는 애플리케이션에서 중요한 문제입니다.

성능과 자원 관리에 직결되는 문제이기 때문인데요.

WAS 로 사용하는 tomcat 은 스레드 풀을 갖고 요청이 올 때마다 스레드를 선택하여 요청을 처리하고

요청을 처리하면서 스레드가 I/O 작업을 하게된다면

Blocking/Sync 인 Spring MVC 에서는 해당 작업이 완료될 때까지 대기해야 합니다.

 

이러한 I/O 작업은 꽤 빈번하며 가장 많은 시간을 차지합니다.

파일을 처리하거나 sysout 을 찍거나 socket과 관련된, 네트워크 I/O와 데이터베이스 I/O 등등이 있겠습니다.

 

물론, 스레드의 개수를 예상되는 트래픽만큼 늘려두면 좋겠지만

메모리는 무한하지 않고 스레드 컨텍스트 스위칭 비용을 무시할 수 없습니다.

 

이러한 단점들을 타파하기 위해 non-blocking 을 사용하는 WebFlux가 나왔습니다.

non-blocking i/o 로 적은 수의 스레드를 가지고 급증하는 요청을 다룰 수 있게 합니다.

아래에서는 Spring MVC와 WebFlux 를 실제로 구현해보며 살펴볼 예정입니다만,

그 이전에 Thread Model 에 대해서 살펴보겠습니다.

 

Thread-Per-Request Model

Spring MVC 에서 사용하고 있는 전통적인 모델입니다.

요청이 올 때마다 스레드 하나를 할당하여 작업을 처리하게 됩니다.

다만, 위에서 살펴봤듯이 Blocking I/O 에 대한 문제가 있습니다.

Blocking I/O 를 진행될 때면 Thread 는 아무것도 하지 않고 대기합니다.

 

예를 들면, 서버 A 에서 B, C, D 로 어떠한 요청을 보내 데이터를 취합하고

B, C, D가 응답까지 각각 1초씩 걸린다는 상황을 가정하면

모든 요청이 마무리 되어 데이터를 취합하기까지 3초가 걸리게됩니다.

 

 

Event Loop Model

Event Loop Model 은 Reactive Programming을 구현하는 방법 중 하나입니다.

Reactive Programming 은 데이터 스트림과 Subscriber/Publisher 를 통한 데이터 흐름에 초점을 둡니다.

 

Event Loop 는 단일(혹은 코어의 수만큼) 스레드에서 지속적으로 실행됩니다.

이벤트 큐에 들어온 이벤트들을 순차적으로 처리하고 publisher를 바로 반환합니다.

이후 이벤트가 완료되어 notify 를 받게 되면 event loop는 subscribe로 등록한 콜백을 실행합니다.

이를 통해 이벤트들은 비동기적으로 수행될 수 있게 됩니다.

 

다시 정리하자면, WebFlux Application 에서 GET 요청을 보낼 때

GET 요청을 보내는 메소드 호출 자체는 Event Loop가, 실제로 네트워크 I/O 는 Netty 가 하게 됩니다.

또한 Netty 가 I/O 작업을 완료하게 된다면 (당연히 Netty 는 non-blocking 으로 작동합니다!)

Event Loop 에게 notify 하여 Event Loop 가 콜백을 실행하거나 다른 작업을 할 수 있게 합니다.

 

Thread-Per-Request Model 에서 들었던 예시를 동일하게 적용하면,

서버 A 에서 B, C, D 로 요청을 보내 데이터 취합할 때

B, C, D 가 각각 비동기로 처리하게 되어 데이터 취합까지 1초가 걸리게 됩니다.

 

아래에서 실제로 구현해보며 확인해보도록 하겠습니다.

 

테스트 환경

A 에서 B 로 요청을 보내는 상황을 가정합니다.

이를 위해 애플리케이션은 다음과 같이 구성하겠습니다.

  • A1
    • Spring MVC + RestTemplate
  • A2
    • Spring WebFlux + Webclient
  • B
    • Spring MVC

 

실제로 부하를 일으키거나 Monitoring 이 필요합니다.

이를 위해선 다음과 같이 구성합니다.

  • JMeter
    • Load Tester
  • Yourkit
    • Monitoring Tool

 

구현1 - Thread 개수

구현1 에서는 요청에 의한 Thread 사용을 중점적으로 알아보겠습니다.

 

  • Application B
@RestController
class DefaultController {

	@GetMapping("/")
	public String defaultResponse() throws InterruptedException {
		Thread.sleep(10000);
		return "ok";
	}
}

@SpringBootApplication
public class ServerApplication {

	public static void main(String[] args) {
		System.setProperty("server.port", "8081");
		SpringApplication.run(ServerApplication.class, args);
	}
}

B는 10초 이후에 단순한 response 를 보내도록 합니다.

이는 차후 보게될 스레드의 활동을 자세히 보기 위함입니다.

 

  • Application A1
@RestController
class DefaultController {

	@GetMapping("/")
	public String defaultRequest() {
		RestTemplate restTemplate = new RestTemplate();
		return restTemplate.getForObject("<http://localhost:8081>", String.class);
	}
}

@SpringBootApplication
public class Client1Application {

	public static void main(String[] args) {
		SpringApplication.run(Client1Application.class, args);
	}

}

단순히 8081 로 get 요청을 보내기만 하는 controller 를 하나 만들었습니다.

이후 JMeter 로 100개의 요청을 보내볼 예정입니다.

Spring MVC 를 사용하고 있고, Request per model 이라는 것을 앞에서 보았으니

tomcat 의 thread 수가 100개가 될 것을 예상할 수 있습니다.

 

실행 전 Yourkit 으로 thread 의 활동을 확인합니다.

Tomcat 의 minSpareThreads인 10개로 확인할 수 있습니다.

(tomcat 은 필요에 의해 thread 를 늘리거나 줄입니다.)

 

JMeter 를 통해 방금 만들었던 A1에 100개의 요청을 보내보겠습니다.

 

실제로 thread 가 100개 이상으로 치솟았고

 

 

10초 동안 대기한 것을 볼 수 있습니다.

 

  • Application A2
@RestController
class DefaultController {
	private final WebClient webClient;

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

	@GetMapping("/")
	public Mono<String> defaultRequest() {
		return webClient.get()
				.uri("<http://localhost:8081>")
				.retrieve()
				.bodyToMono(String.class);
	}
}

@SpringBootApplication
public class Client2Application {
	@Bean
	public WebClient webClient() {
		return WebClient.create();
	}

	public static void main(String[] args) {
		System.setProperty("reactor.netty.ioWorkerCount", "1");
		SpringApplication.run(Client2Application.class, args);
	}
}

이번에는 WebFlux 로 구성해보겠습니다.

크게 다르지 않지만 워커 스레드만 1개로 셋팅하였습니다.

Netty 가 실제로 100개의 요청을 처리하면서 non-blocking 으로,

스레드를 1개만 가지고 작동하는지에 대한 여부를 확인하기 위함입니다.

(Spring MVC 는 Blocking I/O 로 수행되며 100개의 스레드를 생성하는 것을 위에서 확인하였습니다.)

 

스레드는 reactor-http-nio 로 1개가 있는 것을 확인할 수 있습니다.

위와 마찬가지로 100개의 요청을 보내보겠습니다.

 

스레드에 전혀 변화가 없습니다.

nio 하나만을 사용하여 네트워크 관련 작업을 했다는 것으로 유추할 수 있습니다.

 

JMeter 로 확인한 결과 모두 제대로 된 응답을 받았다는 것을 확인할 수 있었습니다.

또한 비동기 작업이므로 작업순서를 보장하지 않은 결과를 확인할 수 있습니다.

(첨부한 이미지로 100번의 request 이지만, 맨 마지막에 response 를 받지 않았음을 확인할 수 있습니다.)

 

 

구현2 - 응답 속도

구현2에서는 Spring MVC Blocking I/O Synchronous 와

WebFlux Non-Blocking ASynchronous 의 응답 속도의 차이에 대해 알아보겠습니다.

 

  • Application B
  • 위와 동일한 코드로 진행합니다.

 

  • Application A1
@RestController
class DefaultController {

	@GetMapping("/")
	public String defaultRequest() {
		RestTemplate restTemplate = new RestTemplate();
		restTemplate.getForObject("http://localhost:8081", String.class);
		return restTemplate.getForObject("http://localhost:8081", String.class);
	}
}

@SpringBootApplication
public class Client1Application {

	public static void main(String[] args) {
		SpringApplication.run(Client1Application.class, args);
	}

}

restTemplate 으로 한 번에 2번의 요청을 보내게 됩니다.

Blocking / Snchronous 를 사용하므로 20초가 걸릴 것을 예상할 수 있습니다.

이번에는 단순히 브라우저로 요청을 보내보겠습니다.

 

예상대로 20초가 걸린 것을 확인할 수 있습니다.

첫번째 요청에서 Thread 가 Block 되어 대기하고 있다가, 다시 한 번 요청을 보내 총 20초가 걸린 것으로 유추할 수 있습니다.

 

 

  • Application A2
@RestController
class DefaultController {
	private final WebClient webClient;

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

	@GetMapping("/")
	public Mono<String> defaultRequest() {
		webClient.get()
				.uri("http://localhost:8081")
				.retrieve()
				.bodyToMono(String.class);
		return webClient.get()
				.uri("http://localhost:8081")
				.retrieve()
				.bodyToMono(String.class);
	}
}

@SpringBootApplication
public class Client2Application {
	@Bean
	public WebClient webClient() {
		return WebClient.create();
	}

	public static void main(String[] args) {
		System.setProperty("reactor.netty.ioWorkerCount", "1");
		SpringApplication.run(Client2Application.class, args);
	}

}

마찬가지로 동일한 코드에 요청만 2번 보내는 것으로 바꾸었습니다.

Non-Blocking 에 Asynchronous 를 사용하므로 총 10초가 걸릴 것을 예상할 수 있습니다.

 

총 10초가 걸렸습니다.

restTemplate 과 비교하면 절반의 시간으로 효과적인 성능 향상이라고 말할 수 있겠습니다.

 

다만 그렇다고 해서 WebFlux 가 무조건 좋다고 할 수는 없습니다.

WebFlux 내부에서 Blocking I/O 관련 작업을 하거나,

실제로 무거운 연산을 수행하는 경우에는 오히려 성능을 저하시키는 결과를 얻을 수 있습니다.

 

그러므로 WebFlux의 사용은 게이트웨이와 같이 무거운 연산이 적고, I/O 작업 위주인 경우

혹은 애플리케이션의 스케일을 손쉽게 업 다운할 경우 적절합니다.

 

끝!

Comments