Dev/Reactive Programming

[Reactive Programming] Blocking I/O와 Non-Blocking I/O

김세진 2024. 7. 1. 19:46
반응형

 

 

 

 

Blocking I/O

 

 

 

Blocking I/O의 설명을 위한 예시 그림이다. 본사 서버의 스레드가 지점 서버에 데이터를 요청하면 해당 데이터를 반환받을 때까지 스레드는 대기 상태가 되며 다른 작업을 처리하지 못한다. 이렇게 하나의 스레드가 I/O에 의해 차단되어 대기하는 것을 Blocking I/O라 한다.

 

이같은 Blocking I/O의 단점을 극복하기 위해 멀티스레딩 기법을 이용하여 추가 스레드들을 할당해  동시 작업이 가능하게끔 한다.

 

멀티스레딩 기법의 단점

1. 컨텍스트 스위칭

멀티스레딩의 작동 방식은 사실 하나의 프로세서가 여러 개의 스레드를 번갈아서 실행하는 것이다. 이때, 스레드를 전환하는 과정에서 오버헤드가 발생하게 되어 스레드가 많아질수록 CPU의 성능 저하가 일어날 수 있다.

 

2. 메모리 오버헤드

스레드가 많아질수록 총 스레드가 차지하는 메모리가 증가하게 된다.

 

JVM은 각 스레드를 위한 스택 영역의 일부를 1024KB만큼 디폴트로 할당하는데, 64,000명이 동시 접속할 경우 64GB의 메모리가 필요한 셈이다.

 

3. 스레드 풀(Thread Pool) 응답 지연

대량의 요청이 발생할 경우 스레드 풀에 유휴 스레드가 존재하지 않아 응답 지연이 발생할 수 있다.

 


Non-Blocking I/O

 

 

Blocking I/O와 달리 Non-Blocking I/O에서는 I/O 작업에 의해 스레드가 차단되지 않는다. 

 

위 그림에서 본사 서버에서 지점 서버로 데이터를 요청할 때, 다시 응답을 받을 때까지 대기하는 것이 아니기 때문에 다른 지점에도 동시에 요청을 보내는 것을 볼 수 있다. 그리고 다시 응답을 받았을 때 해당 부분에 대한 처리를 해 줄 뿐이다.

 

만약 Blocking I/O 를 사용했을 경우 순차적으로 A 지점에 대한 요청의 응답을 받을 때까지 대기했다가 다시 B 지점에 대해 요청을 보내 작업을 처리했을 것이다.

 

장점

  1. Blocking I/O 보다 적은 수의 스레드를 사용하여 컨텍스트 스위칭 오버헤드나 메모리 오버헤드 등 멀티스레딩 기법의 문제점들이 생기지 않음
  2. 하나의 스레드로 많은 수의 요청을 처리할 수 있음

 

단점(주의할 점)

  1. CPU를 많이 사용하는 작업이 포함된 경우에는 성능에 악영향 (스레드의 I/O 작업 처리는 용이하나 CPU 집약적인 작업이 생길 경우 I/O 작업을 처리할 여유가 없게 됨)
  2. 사용자의 요청에서 응답까지의 전체 과정에 Blocking I/O 요소가 포함될 경우 병목 현상이 발생하여 Non-Blocking의 이점을 누릴 수 없음 (Fully Non-Blocking I/O 필요)

 


Spring Framework에서의 Blocking I/O와 Non-Blocking I/O

 

 

Spring Framework의 Spring MVC 기반 웹프레임워크는 기본적으로 Blocking I/O 방식을 사용한다. 따라서 Non-Blocking I/O를 사용하기 위해 그 대안으로 Spring WebFlux가 등장했다.

 

  • Spring MVC: Apache Tomcat 같은 동기 Blocking I/O 기반
  • Spring WebFlux: Netty와 같은 비동기 Non-Blocking I/O 기반

 

이제 각 Framework에 해당하는 예제 코드를 작성하여 Blocking I/O와 Non-Blocking I/O를 직접 테스트해보도록 하겠다.

 

Spring MVC

1. 프로젝트 구성도

 

2. Model

@Getter
@Setter
public class Book {
	private String name;

	public Book() {
	}
}

controller에서 restTemplate.getForEntity 을 사용하여 객체를 받을 때 setter 메서드를 사용하기 때문에 불변인 객체를 사용하지 않고 setter를 넣었다.

 

3. Controller

@Slf4j
@RestController
@RequestMapping("/v1/books")
public class SpringMvcHeadOfficeController {
	private final RestTemplate restTemplate;
	URI baseUri = UriComponentsBuilder.newInstance().scheme("http")
		.host("localhost")
		.port(7070)
		.path("/v1/books")
		.build()
		.encode()
		.toUri();

	public SpringMvcHeadOfficeController(RestTemplateBuilder restTemplateBuilder) {
		this.restTemplate = restTemplateBuilder.build();
	}

	@ResponseStatus(HttpStatus.OK)
	@GetMapping("/{book-id}")
	public ResponseEntity<Book> getBook(@PathVariable("book-id") long bookId) {
		URI getBookUri = UriComponentsBuilder.fromUri(baseUri)
			.path("/{book-id}")
			.build()
			.expand(bookId)
			.encode()
			.toUri(); // http://localhost:7070/v1/books/{book-id}

		ResponseEntity<Book> response = restTemplate.getForEntity(getBookUri, Book.class);
		Book book = response.getBody();

		return ResponseEntity.ok(book);
	}
}


@RestController
@RequestMapping("/v1/books")
public class SpringMvcBranchOfficeController {

	@ResponseStatus(HttpStatus.OK)
	@GetMapping("/{book-id}")
	public ResponseEntity<Book> getBook(@PathVariable("book-id") long bookId) throws InterruptedException {
		Thread.sleep(3000); // 3초 지연

		Book book = new Book();
		book.setName("IT Book " + bookId);

		return ResponseEntity.ok(book);
	}
}

HeadOffice는 BranchOffice에 book-id 를 포함한 request를 보내 Book 객체를 반환받고자 한다.

BranchOffice는 HeadOffice로부터 요청을 받으면 해당 book-id 를 이용하여 Book 객체를 생성한 뒤 전달한다. 이 때, 연산이 많이 소요되는 것처럼 가정하기 위해 Thread.sleep(3000)으로 3초간 작업을 지연시킨다.

 

원래 DB를 이용하여 실제 book-id에 해당하는 Book 데이터를 불러오면 더 좋겠으나, 테스트를 편하게 진행하기 위해 그냥 id를 name에 포함한 Book을 반환하도록 코드를 작성하였다.

 

4. Application

@Slf4j
@SpringBootApplication
public class SpringMvcHeadOfficeApplication {
	@Bean
	public WebServerFactoryCustomizer<ConfigurableWebServerFactory> webServerFactoryCustomizer1() {
		return factory -> factory.setPort(8080); // port 세팅
	}

	URI baseUri = UriComponentsBuilder.newInstance().scheme("http")
		.host("localhost")
		.port(8080)
		.path("/v1/books")
		.build()
		.encode()
		.toUri();

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

	@Bean
	public RestTemplateBuilder restTemplateBuilder() {
		return new RestTemplateBuilder();
	}

	@Bean
	public CommandLineRunner run() {
		return (String... args) -> {
			log.info("# 요청 시작 시간: {}", LocalTime.now());

			for (int i = 1; i <= 5; i++) {
				Book book = this.getBook(i);
				log.info("{}: book name: {}", LocalTime.now(), book.getName());
			}
		};
	}

	private Book getBook(long bookId) {
		RestTemplate restTemplate = new RestTemplate();

		URI getBooksUri = UriComponentsBuilder.fromUri(baseUri)
			.path("/{book-id}")
			.build()
			.expand(bookId)
			.encode()
			.toUri(); // http://localhost:8080/v1/books/{book-id}

		ResponseEntity<Book> response = restTemplate.getForEntity(getBooksUri, Book.class);

		return response.getBody();
	}
}


@Slf4j
@SpringBootApplication
public class SpringMvcBranchOfficeApplication {
	@Bean
	public WebServerFactoryCustomizer<ConfigurableWebServerFactory> webServerFactoryCustomizer1() {
		return factory -> factory.setPort(7070); // port 세팅
	}

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

}

HeadOfficeApplication은 서비스가 실행될 경우 1부터 5까지 순환하며 해당 숫자에 해당하는 id로 자신의 Controller에 Book 정보를 요청한다. HeadOffice의 Controller에선 전달받은 데이터로 7070 포트에 데이터를 요청한다.

BranchOfficeApplication은 단순히 7070 포트로 요청을 받을 경우 값을 반환하기 위한 어플리케이션이다.

 

Head에서 Branch에 값을 요청하기 위해서는 Branch가 먼저 기동되고 있어야 하므로 Branch -> Head 순으로 서비스를 기동해보자. 그러면 아래와 같은 결과를 얻을 수 있다.

 

 

Book을 얻을 때마다 약 3초의 시간이 흘러 총 15초의 시간이 소요되었음을 확인할 수 있다. 결과적으로, Spring MVC 기반의 애플리케이션은 Blocking I/O 방식이기 때문에 스레드가 차단된다는 사실을 알 수 있다.

 


Spring WebFlux

1. 프로젝트 구성도

 

2. Model

@Getter
@Setter
public class Book {
	private String name;

	public Book() {
	}
}

 

3. Controller

@Slf4j
@RestController
@RequestMapping("/v1/books")
public class SpringReactiveHeadOfficeController {
	URI baseUri = UriComponentsBuilder.newInstance().scheme("http")
		.host("localhost")
		.port(5050)
		.path("/v1/books")
		.build()
		.encode()
		.toUri();

	@Autowired
	public SpringReactiveHeadOfficeController() {
	}

	@ResponseStatus(HttpStatus.OK)
	@GetMapping("/{book-id}")
	public Mono<Book> getBook(@PathVariable("book-id") long bookId) {
		URI getBookUri = UriComponentsBuilder.fromUri(baseUri)
			.path("/{book-id}")
			.build()
			.expand(bookId)
			.encode()
			.toUri(); // http://localhost:7070/v1/books/{book-id}

		return WebClient.create()
			.get()
			.uri(getBookUri)
			.retrieve()
			.bodyToMono(Book.class);
	}
}


@RestController
@RequestMapping("/v1/books")
public class SpringReactiveBranchOfficeController {

	@ResponseStatus(HttpStatus.OK)
	@GetMapping("/{book-id}")
	public Mono<Book> getBook(@PathVariable("book-id") long bookId) throws InterruptedException {
		Thread.sleep(3000); // 3초 지연

		Book book = new Book();
		book.setName("IT Book " + bookId);

		return Mono.just(book);
	}
}

Spring MVC와 달리, restTemplate을 사용하지 않고 대신 WebClient를 사용한다. WebClient는 Non-Blocking 방식으로 리액티브 타입을 송수신하는 역할을 한다. Mono는 Reactor에서 지원하는 Publisher 타입 중 하나로, 단 하나의 데이터만 emit하는 Publisher 타입이다.

 

위 부분 외의 작동 로직은 Blocking 코드에서와 동일하다.

 

4. Application

@Slf4j
@SpringBootApplication
public class SpringReactiveHeadOfficeApplication {
	@Bean
	public WebServerFactoryCustomizer<ConfigurableWebServerFactory> webServerFactoryCustomizer1() {
		return factory -> factory.setPort(6060); // port 세팅
	}

	URI baseUri = UriComponentsBuilder.newInstance().scheme("http")
		.host("localhost")
		.port(6060)
		.path("/v1/books")
		.build()
		.encode()
		.toUri();

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

	@Bean
	public CommandLineRunner run() {
		return (String... args) -> {
			log.info("# 요청 시작 시간: {}", LocalTime.now());

			for (int i = 1; i <= 5; i++) {
				this.getBook(i)
					.subscribe(
						book -> {
							log.info("{}: book name: {}", LocalTime.now(), book.getName());
						}
					);
			}
		};
	}

	private Mono<Book> getBook(long bookId) {
		URI getBooksUri = UriComponentsBuilder.fromUri(baseUri)
			.path("/{book-id}")
			.build()
			.expand(bookId)
			.encode()
			.toUri(); // http://localhost:6060/v1/books/{book-id}

		return WebClient.create()
			.get()
			.uri(getBooksUri)
			.retrieve()
			.bodyToMono(Book.class);
	}
}


@Slf4j
@SpringBootApplication
public class SpringReactiveBranchOfficeApplication {
	@Bean
	public WebServerFactoryCustomizer<ConfigurableWebServerFactory> webServerFactoryCustomizer1() {
		return factory -> factory.setPort(5050); // port 세팅
	}

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

}

Blocking 방식과 다른 점

  • Blocking에서는 this.getBook(i)를 호출하여 응답 데이터를 바로 처리
  • Non-Blocking 에서는 .subscribe() 메서드를 호출하여 전달 받은 데이터를 처리. Publisher 인터페이스는 subscribe()를 호출해서 전달받은 데이터를 처리하도록 정의되어 있기 때문

 

실행 결과

 

Blocking과 달리 모든 요청을 처리하는 데 3초가 걸린 것을 확인할 수 있다. 또한 출력되는 번호가 무작위로 되어 있는데, 이는 단일 스레드가 요청을 순차적으로 처리하지 않고, 비동기적으로 처리되었음을 의미한다.

 


Non-Blocking I/O 방식의 통신이 적합한 시스템

 

위에서 확인한 결과 단일 스레드일 때 Non-Blocking I/O 가 Blocking I/O에 비해 월등한 성능을 보임을 확인할 수 있었다. 하지만 그렇다고 해서 모든 애플리케이션에 Spring MVC 대신 Spring WebFlux가 적합하다고는 할 수 없다.

 

WebFlux 도입 시 고려 사항

1. Learning Curve가 높다.

Spring WebFlux를 도입하기 위해 리액티브 스트림즈라는 표준 사양을 구현한 구현체를 능숙하게 사용하기까지 학습에 대한 노력과 시간이 상당히 소요된다.

 

2. 숙련된 개발 인력 확보가 어렵다.

Spring MVC과 비교하여 Spring WebFlux와 같은 선언현 프로그래밍과 Non-Blocking I/O 방식에 숙련된 개발 인력이 상대적으로 적다.

 

 

WebFlux를 도입하면 좋은 프로젝트

1. 대량의 요청 트래픽이 발생하는 시스템

상대적으로 적은 컴퓨팅 파워로 대량의 요청 트래픽을 처리할 수 있으므로 Spring MVC 기반보다 유리할 수 있다.

 

2. 마이크로 서비스 기반 시스템

마이크로 서비스 또한 서비스들 간에 수많은 I/O가 지속적으로 발생하게 되므로 Non-Blocking 방식이 더 유리하다.

 

3. 스트리밍 또는 실시간 시스템

계속해서 들어오는 데이터 스트림을 전달받아서 효율적으로 처리할 수 있다.

 

 

 

 

 

 

반응형