Today I Learned

[스프링5를 활용한 리액티브 프로그래밍] 01 왜 리액티브 스프링인가? 본문

JAVA & Spring/리액티브 프로그래밍

[스프링5를 활용한 리액티브 프로그래밍] 01 왜 리액티브 스프링인가?

하이라이터 2022. 8. 30. 17:53
728x90

이 장에서 다루는 내용

  • 왜 반응성이 필요한가?
  • 리액티브 시스템의 기본 원리
  • 리액티브 시스템 설계에 완벽하게 일치하는 비즈니스 사례
  • 리액티브 시스템에 좀 더 적합한 프로그래밍 기술
  • 스프링 프레임워크가 리액티브로 전환하는 이유

왜 리액티브인가?

온라인 쇼핑몰을 만든다고 가정해보자.

웹 서버는 500개의 스레드로 톰캣 스레드 풀을 구성하였고, 사용자 요청에 대한 평균 응답시간은 250ms이다.

구성된 시스템은 초당 2,000명의 사용자 요청을 처리할 수 있고, 평균 사용자 요청건수는 초당 1,000건 정도였다.

이 웹사이트는 안정적으로 운영되어 왔으나, 블랙 프라이데이에 폭발적인 고객 증가가 발생하며 부하가 예상을 초과하기 시작한다.

결국 증가된 부하에 대한 응답 능력을 유지하지 못하면서 서비스가 중단되기에 이른다.

 

앞선 예제에서 알 수 있듯이 애플리케이션은 사용자 요청에 대한 응답 능력에 영향을 미칠 수 있는 모든 변화에 대응해야 한다.

일차적인 목표를 달성하는 첫 번째 방법은 탄력성(elasticity)을 통한 것이다.

이는 다양한 작업 부하에서 응답성을 유지하는 능력을 의미한다.

사용량에 따라 시스템 처리량이 자동으로 증감하면, 평균 지연 시간에 영향을 미치지 않고 시스템을 확장할 수 있기 때문에 시스템 응답성을 유지할 수 있다.

 

그러나 장애 발생과 무관하게 응답성을 유지하는 능력을 갖추지 않고 확장 가능한 분산 시스템을 구축하는 것은 어렵다.

시스템 실패에도 반응성을 유지할 수 있는 능력, 즉 시스템 복원력이 유지되어야한다.

이는 시스템의 기능 요소를 격리해 모든 내부 장애를 격리하고 독립성을 확보함으로써 달성할 수 있다.

탄력성과 복원력은 밀접하게 결합돼 있으며, 이 두가지를 모두 사용할 때만 시스템의 진정한 응답성을 달성할 수 있다.


메시지 기반 통신

분산 시스템에서 컴포넌트를 연결하는 방법과 낮은 결합도, 시스템 격리 및 확장성 유지를 어떻게 동시에 달성할 수 있을지는 여전히 불분명하다.

스프링 프레임워크 4에서 HTTP 통신을 수행하는 예제를 살펴보자.

@RequestMapping("/resource") // (1)
public Object processRequest() {
  RestTemplate template = new RestTemplate(); // (2)
  
  ExamplesCollection result = template.getForObject( // (3)
    "http://example.com/api/resource2",
    ExamplesCollection.class
  );
  ... // (4)
  processResultFurther(result); // (5)
}
  • (1) @RequestMapping 애노테이션을 이용한 리퀘스트 핸들러(request handler) 매핑을 선언
  • (2) RestTemplate 인스턴스를 생성
  • (3) 요청을 생성하고 실행
  • (4) 생략
  • (5) 결과를 이용해 다른 처리를 실행

이 코드는 친숙하고 논리적으로 투명해 보이지만, 몇 가지 문제가 있다.

코드의 실제 동작을 살펴보면, 처리 시간의 일부만 효과적인 CPU 사용을 위해 할당되고 나머지 시간 동안 스레드 A는 I/O에 의해 차단되며 다른 요청을 처리할 수 없다.

자바에는 병렬 처리를 위해 추가 스레드를 할당할 수 있는 스레드 풀이 있지만, 부하가 높은 상태에서는 I/O 작업을 동시에 처리하기에 매우 비효율적이다.

 

I/O 측면에서 리소스 활용도를 높이려면 비동기 논블로킹(asynchronous and non-bloking) 모델을 사용해야 한다.

현실에서 이러한 종류의 커뮤니케이션은 문자메시지이다.

일반적으로 분산 시스템에서 자원을 효율적으로 사용하기 위해서는 메시지 기반(message-driven) 통신 원칙을 따라야 한다.

구성 요소들은 메시지 도착을 기다리고 이에 반응하며, 나머지 시간은 휴면 상태에 있지만, 동시에 논블로킹 방식으로 메시지를 보낼 수 있어야 한다.

 

지금까지의 내용으로 리액티브 시스템의 기본 원리를 알 수 있다.

분산 시스템으로 구현되는 모든 비즈니스의 핵심 가치는 응답성이다.

시스템이 높은 응답성을 확보한다는 것은 탄력성 및 복원력 같은 기본 기법을 따른다는 의미이다.

또한 응답성과 탄력성, 복원력을 모두 확보하는 기본적인 방법 중 하나가 메시지 기반 통신을 사용하는 것이다.

이러한 원칙에 따라 구축된 시스템은 모든 구성 요소가 독립적이고 적절하게 격리돼 있기 때문에 유지 보수 및 확장이 용이하다.

 

리액티브 시스템의 개념을 설명하는 용어들은 리액티브 선언문에 정리되어 있다.

https://www.reactivemanifesto.org/ko/glossary

 

용어집 - 리액티브 선언문

© 2014, the above authors, this declaration may be freely copied in any form, but only in its entirety through this notice.

www.reactivemanifesto.org


반응성에 대한 유스케이스

다음 예제는 리액티브 시스템을 활용할 수 있는 유용한 사례 중 하나를 보여준다.

모던 마이크로서비스 패턴을 적용한 웹스토어 개선안으로, 위치 투명성을 달성하기 위해 API 게이트웨이 패턴을 사용한다.

각 서비스 가용성 정보를 최신으로 유지하는 책임은 서비스 레지스트리 패턴으로 구현하고, 클라이언트 측 디스커버리 패턴을 통해 달성한다.

또한 서비스 요소 일부에 복제본을 구성해 높은 시스템 응답성을 얻을 수 있다.

장애 복원력은 아파치 카프카(Apache Kafka)를 이용해 적절하게 구성한 메시지 기반 통신과 독립적인 결제 시스템에 의해 이루어진다.

데이터베이스에는 복제 서비스를 활성화해 복제본 중 하나가 중단된 경우에도 복원력을 유지한다.

응답성을 유지하기 위해 주문 요청을 받자마자 우선 응답을 보낸 후, 이를 비동기적으로 처리해 사용자 결제 요청을 결제 서비스로 보낸다.

 

리액티 시스템 접근법의 또다른 예로 애널리틱스(analytics) 분야가 있다.

애널리틱스는 엄청난 양의 데이터를 다루면서 런타임에 처리하고 사용자에게 실시간으로 통계를 제공함으로써 항상 최신의 정보를 유지하는 등의 기능을 제공한다.

이 시스템을 설계하기 위해서는 스트리밍(streaming)이라는 효율적인 아키텍처를 사용할 수 있다.

스트리밍 아키텍처는 데이터 처리 및 변환 흐름을 만드는 것이며, 짧은 지연 시간과 높은 처리량이 특징이다.

이러한 가용성이 높은 시스템을 구축하려면 리액티브 선언문에서 언급한 기본 원칙에 의존해야 한다.

복원성 확보를 위해서는 배압 지원을 활성화해서 처리 단계 사이의 작업 부하를 관리해야 하며, 메시지 브로커를 통한 메시지 기반 통신으로 작업 부하를 효율적으로 관리할 수 있다.

또한 시스템의 각 구성 요소를 적절하게 변경해 시스템 처리량을 탄력적으로 확장하거나 줄일 수 있다.


왜 리액티브 스프링인가?

JVM 세계에서 리액티브 시스템을 구축하는 데 쓰이는 가장 널리 알려진 프레임워크는 Akka와 Vert.x이다.

Akka는 다양한 기능과 커다란 커뮤니티의 지원을 받는 인기 프레임워크이지만, Scala 생태계를 기반으로 두고 있고 자바 커뮤니티에서는 큰 인기를 누리지 못하고 있다.

Vert.x는 Node.js를 대체하기 위해 논블로킹 및 이벤트 기반으로 설계되었다. 하지만 불과 몇년 전부터 경쟁력을 갖추기 시작했다.

스프링 프레임워크는 오랫동안 시장을 점령하고 있고 개발자에게 친숙한 프로그래밍 모델을 사용해 웹 애플리케이션을 구축할 수 있는 다양한 방법을 제공한다. 하지만 견고한 리액티브 시스템을 구축하는 데는 몇 가지 제약사항이 있다.

 

서비스 레벨에서의 반응성

하지만 최근 리액티브 시스템을 위한 프로젝트인 스프링 클라우드(Spring Cloud)가 시작됐다.

스프링 클라우드 프레임워크는 몇 가지 문제점을 해결하고 분산 시스템을 구축을 단순화하는 기반 프로젝트이다.

 

큰 시스템은 더 작은 규모의 시스템으로 구성되기 때문에 구성 요소의 리액티브 특성에 의존한다.
즉, 리액티브 시스템은 설계 원칙을 적용하고, 이 특성을 모든 규모에 적용해 그 구성 요소를 합성할 수 있게
하는 것을 의미한다.

 

리액티브 선언문에서 주지하다시피, 전체 시스템 설계는 리액티브 시스템을 구축하는 데 있어 하나의 요소일 뿐이다.

따라서 구성 요소 수준에서도 리액티브 설계 및 구현을 제공하는 것이 중요하다.

이러한 맥락에서 설계 원칙이란 컴포넌트 사이의 관계, 예를 들면 각 기본 요소를 조합하는 데 사용되는 프로그래밍 기법을 말한다. 

 

자바에서 코드를 작성하는 가장 보편적인 기법인 명령형 프로그래밍이 리액티브 시스템 설계 원칙을 따르는지 이해하기 위해 다음 그림을 살펴보자.

OrderService는 사용자 요청을 처리하기 위해 ShoppingCardService를 호출하고, ShoppingCardService는 HTTP 요청이나 데이터베이스 쿼리와 같은 시간이 걸리는 I/O 작업을 실행한다고 가정하자.

interface ShoppingCardService { // (1)
  Output calculate(Input value);
}

class OrdersService { // (2)
  private final ShoppingCardService scService;
 
   void process() {
     Input input = ...;
     Output output = scService.calculate(input); // (2.1)
     ... // (2.2)
   }
}
  • (1) ShoppingCardService 인터페이스 선언
  • (2) OrderService 인터페이스 선언. ShoppingCardService를 동기적으로 호출(2.1)하고 실행 직후 결과(2.2)를 받는다.

scService.calculate(input) 메서드를 실행하면 로직이 처리되는 동안 스레드가 차단되며, OrderService에서 별도의 독립적인 처리를 실행하려면 추가 스레드를 할당해야 한다.

자바에서는 이 문제를 컴포넌트 사이의 통신을 위한 콜백 기법을 적용해 해결할 수 있다.

interface ShoppingCardService { // (1)
  void calculate(Input value, Consumer<Output> c);
}
class OrdersService { // (2)
  private final ShoppingCardService scService;
 
   void process() {
     Input input = ...;
     scService.calculate(input, output -> { // (2.1)
       ... // (2.2)
     });
  }
}
  • (1) calculate 메서드는 두 개의 인자를 받고 void를 반환한다. 호출하는 인스턴스가 즉시 대기 상태에서 해제될 수 있으며, 그 결과는 나중에 지정된 Consumer<> 콜백으로 전달된다.
  • (2) 비동기식으로 ShoppingService를 호출하고, 이후 작업을 진행한다. 콜백 함수가 실행되면 실제 결과에 대한 처리를 계속할 수 있다(2.2).

이제 OrderService는 작업 완료 후에 반응할 콜백 함수를 전달한다. 이는 OrderService가 ShoppingCardService로부터 분리됐음을 의미한다.

또한 OrderService로 결과를 전달하는 함수형 콜백 호출을 위해 비동기적인 방식으로 ShoppingCardService#calculate 메서드를 구현할 수 있다.

class SyncShoppingCardService implements ShoppingCardService { // (1)
  public void calculate(Input value, Consumer<Output> c) {
    Output result = new Output();
    c.accept(result); // (1.1)
  }
}

class AsyncShoppingCardService implements ShoppingCardService { // (2)
  public void calculate(Input value, Consumer<Output> c) {
    new Thread(() -> { // (2.1)
      Output result = template.getForObject(...); // (2.2)
      ...
      c.accept(result); // (2.3)
    }).start(); // (2.4)
  }
}
  • (1) SyncShoppingCardService 클래스 선언. 여기서는 I/O 실행을 하지 않기 때문에 결과를 콜백 함수(1.1)에 전달해 즉시 반환할 수 있다.
  • (2) AsyncShoppingCardService 클래스 선언. (2.2)에서 표현한 것처럼 I/O를 차단할 때 별도의 Thread(2.1)(2.4)로 래핑할 수 있다. 결과를 받으면 (2.3)에서 콜백 함수를 호출해 결과를 전달한다.

위 예제에서 ShoppingCardService를 동기식 구현과 비동기식 구현으로 나눴다. OrderService는 실행 프로세스와 분리돼 콜백으로 결과를 받을 수 있다.

이러한 방식의 장점은 컴포넌트가 콜백 함수에 의해 분리된다는 것이다.

단점은 공유 데이터 변경 및 콜백 지옥을 피하기 위해 개발자가 멀티 스레딩을 잘 이해하고 있어야 한다는 것이다.

 

콜백 대신 java.util.concurrent.Future를 사용할 수도 있다.

interface ShoppingCardService { // (1)
  Future<Output> calculate(Input value);
}
class OrdersService { // (2)
  private final ShoppingCardService scService;
  
  void process() {
    Input input = ...;
    Future<Output> future = scService.calculate(input); // (2.1)
    ...
    Output output = future.get(); // (2.2)
    ...
  }
}
  • (1) calculate 메서드는 하나의 인자를 받고 Future를 반환한다. Future는 클래스 래퍼(wrapper)를 사용해 사용 가능한 결과가 있는지 확인한다.
  • (2) 비동기적으로 ShoppingCardService를 호출하고 Future 인스턴스를 반환받는다(2.1). 결과가 비동기적으로 처리되는 동안 다른 처리를 계속할 수 있다. calculate와는 독립적으로 수행될 수 있는 몇번의 실행 후에 결과를 얻게되며, 이 결과는 블로킹 방식으로 기다리거나 즉시 결과를 반환할 수 있다(2.2).

Future 클래스 사용으로 결과값 반환을 지연시킬 수 있다. Future 클래스를 통해 콜백 지옥을 피할 수 있고, Future 구현 뒤에 멀티 스레드의 복잡성을 숨길 수 있다.

어쨋든 필요한 결과를 얻으려면 현재 스레드를 차단하고 확장성을 현저히 저하시키는 외부 실행과 동기화해야 한다.

 

자바 8에서는 이를 개선해 CompletionStage 및 CompletionStage를 직접 구현한 CompletableFuture를 제공한다.

interface ShoppingCardService { // (1)
  CompletionStage<Output> calculate(Input value);
}
class OrdersService { // (2)
  private final ShoppingCardService scService;
  
  void process() {
    Input input = ...;
    scService.calculate(input) // (2.1)
      .thenApply(out1 -> { ... }) // (2.2)
      .thenCombine(out2 -> { ... })
      .thenAccept(out3 -> { ... })
   }
}
  • (1) 이제 calculate 메서드는 하나의 인자를 받고 CompletionStage를 반환한다. CompletionStage는 Future와 비슷한 클래스 래퍼지만, 반환된 결과를 기능적 선언 방식으로 처리할 수 있다.
  • (2)ShoppingCardService를 비동기적으로 호출하고 실행 결과로 CompletionStage를 즉시 반환받는다(2.1). CompletionStage는 thenAccept 및 thenCombine 같은 메서드를 작성할 수 있는 다양한 API를 제공한다. API를 이용해 결과에 대한 변형 연산을 정의하거나 결과를 처리하는 최종 컨슈머를 정의할 수 있는 thenAccept 메서드를 정의할 수 있다.

CompletionStage의 지원으로 함수형 스타일 또는 선언형 스타일로 코드를 작성할 수 있다. 코드는 깔끔해지고 비동기적으로 결과를 처리한다.

하지만 스프링4는 구형 자바 버전과의 호환성을 목표로 했기 때문에 스프링 MVC는 CompletionStage를 오랫동안 지원하지 않고, 그 역할을 하는 ListenableFuture를 자체적으로 제공했다.

다음은 ListenableFuture와 AsyncRestTemplate 사용법에 대한 간단한 예제이다.

AsyncRestTemplate template = new AsyncRestTemplate();
SuccessCallback onSuccess = r -> { ... };
FailureCallback onFailure = e -> { ... };
ListenableFuture<?> response = template.getForEntity(
  "http://example.com/api/examples",
  ExamplesCollection.class
);
response.addCallback(onSuccess, onFailure);

위 코드는 비동기 호출을 처리하기 위한 콜백 스타일을 보여준다. 스프링 프레임워크는 블로킹 네트워크 호출을 별도의 스레드로 래핑하고 스프링 MVC는 모든 구현체가 각각의 요청에 별도의 스레드를 할당하는 서블릿(Servlet) API를 사용한다.

이 모델은 상당히 비효율적이다. 멀티 스레딩에는 공유 메모리 사용과 컨텍스트 스위칭로 인한 오버헤드가 발생한다.

커넥션마다 별도의 스레드를 할당하는 모델에서 요청을 동시에 처리하기 위해 스레드 수를 늘리면 비용이 많이 들고, 스레드 풀을 제한하면 클라이언트 응답 시간이 길어진다.

이를 위해 리액티브 선언문은 논블로킹 작업을 사용하도록 권장했으나, 스프링 생태계에서는 누락돼있었다.

또한 컨텍스트 스위칭 문제를 해결한 네티와 같은 리액티브 서버와는 제대로 된 통합을 지원하지 않았다.

 

리액티브 프로그래밍은 스프링 프레임워크 안에서 잘 통합되어있지 않았고 이는 프레임워크 경쟁력의 약화로 이어졌다.

그래서 핵심 개발자들은 리액티브 시스템의 기반으로서 스프링 프레임워크의 역량을 보여주는 새로운 모듈을 구현하기로 했다.

728x90
Comments