BOOK 4 - 스프링 5를 활용한 리액티브 프로그래밍(1)

왜 리액티브 스프링인가?

왜 리액티브인가?

  • 일반적으로 초당 약 1000명의 사용자가 방문하는 웹 서비스를 가정해보자.
    • 500개의 스레드로 톰캣 thread pool을 구성하고, 사용자 요청에 대한 평균 응답 시간이 약 250msec 일 때
    • 단순하게 계산하면 초당 약 2000명의 사용자 요청을 처리할 수 있다.
    • 즉, 현재 시스템 용량은 평균 부하를 처리하기에 충분하다.
  • 그러나 예상치 못하게 사용자 접속 수가 많아지고, 부하가 많아지는 경우에는 응답 시간이 증가하고 서비스가 중단되는 상황이 발생하게 된다.

이런 문제에 어떻게 대응할 것인가?

  • 어플리케이션은 변화에 대응해야 한다.
    • 즉, 부하의 변화 및 외부 서비스의 가용성 변화같은 사용자 요청에 대한 응답 능력에 영향을 미칠 수 있는 모든 변화에 대응해야 한다.
    • 탄력성을 높여 변화에 대응할 수 있다.

탄력성(elasticity)

  • 다양한 작업 부하에서 응답성을 유지하는 능력
    • 즉, 수요가 증가할 때 시스템 처리량이 자동으로 증가해야 하고, 수요가 감소하면 자동으로 감소해야 한다.
    • 어플리케이션 관점에서 볼 때 이 기능을 사용하면 평균 지연 시간에 영향을 미치지 않고 시스템을 확장할 수 있기 때문에 시스템 응답성을 유지할 수 있다.
  • 추가 연산 자원 또는 추가 인스턴스를 제공해 시스템의 처리량을 증가시킬 수 있다.
  • 그러나 장애가 발생해도 응답성을 유지하는 능력을 갖추지 않고 확장 가능한 분산 시스템을 구축하는 것은 어렵다.

복원력

  • 시스템 실패에도 반응성을 유지할 수 있는 능력
  • 시스템의 기능 요소를 격리해 모든 내부 장애를 격리하고 독립성을 확보함으로써 달성할 수 있다.
  • 예를 들어, 온라인 쇼핑 서비스에서 결제 서비스가 중단된 경우에 일단 사용자 주문을 접수하고 이후에 자동으로 재시도함으로써 원치 않는 장애로부터 사용자를 보호할 수 있다.

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

확장성을 통해 다수의 복제본을 가지고, 하나의 노드에 장애가 발생한 경우에 이를 탐지하고 나머지 부분에 미치는 영향을 최소화하며 다른 복제본으로 전환할 수 있다.

메시지 기반 통신

  • 그렇다면, 분산 시스템에서 어떻게 하면 낮은 결합도, 시스템 격리, 확장성을 유지하면서 각 컴포넌트들을 연결할 수 있을까?
  • 먼저 스프링 프레임워크4에서 HTTP를 통해 컴포넌트 간 통신을 수행하는 경우를 보자.
@RequestMapping("/resource")
public Object processRequest() {
  RestTemplate template = new RestTemplate();
  
  ExampleCollection result = template.getForObject("http://example.com/api/resource2",
                                                  ExampleCollection.class);
  ...
  processResultFurther(result);
}
  1. @RequestMapping 을 통해 request handler 매핑을 선언한다.
  2. 서비스 간 요청 - 응답을 처리하는 웹 클라이언트인 RestTemplate 인스턴스를 생성한다.
  3. RestTemplate API를 이용해 HTTP 요청을 생성하고 실행한다.
  • 이러한 요청 방식은 다음과 같은 문제가 있다.

image

  • 스레드 A는 다른 컴포넌트에 HTTP 요청을 보내면서 I/O에 의해 차단되며, 이 시간 동안 다른 요청을 처리할 수 없다.
  • 병렬 처리를 위해 스레드 풀을 만들어 추가 스레드를 할당할 수 있지만, 부하가 높은 상태에서는 새로운 I/O 작업을 동시에 처리하는데 매우 비효율적일 수 있다.
  • I/O 측면에서 리소스(스레드) 활용도를 높이려면 비동기 논블로킹(Asynchronous and Non-blocking) 모델을 사용해야 한다.

  • 분산 시스템에서 서비스 간에 통신할 때 자원을 효율적으로 사용하기 위해 메시지 기반(message-driven) 통신 원칙을 따른다.
    • 컴포넌트들은 메시지가 도착하면 이에 반응하며, 나머지 시간에는 휴면 상태에 있지만 동시에 논블로킹 방식으로 메시지를 보낼 수 있어야 한다.
  • 메시지 기반 통신을 수행하는 방법 중 하나는 message broker를 사용하는 것이다.
    • 메시지 대기열을 모니터링해 시스템이 부하 관리 및 탄력성을 제어할 수 있다.

반응성에 대한 usecase

  • 온라인 쇼핑을 지원하는 웹 서비스 예제를 살펴보자.

image

  • 장애 복원력을 위해 Apache Kafka를 이용해 메시지 기반 통신과 독립적인 결제 서비스를 구성하였다.
    • 외부 결제 시스템의 장애 발생시 결제 요청을 재시도할 수 있다.
  • 데이터베이스에서도 복제 서비스를 활성화해서 복제본 중 하나가 장애로 중단된 경우에도 복원력을 유지하도록 한다.
  • 응답성을 유지하기 위해 주문 요청을 받자마자 우선 요청에 대한 응답을 보낸 후, 비동기적으로 사용자 결제 요청을 결제 서비스에 전달한다.
    • 최종 결제 결과는 지원하는 채널 중 하나(여기서는 이메일)를 통해 나중에 전달된다.

스트리밍 아키텍쳐

  • 예를 들어, 애널리틱스 분야 서비스는 엄청난 양의 데이터를 런타임에 처리하여 실시간으로 통계를 제공함으로써 항상 최신의 정보를 유지해야 한다.
    • 이 시스템을 설계하기 위해 스트리밍 아키텍쳐를 사용할 수 있다.

image

  • 스트리밍 아키텍쳐는 데이터 처리 및 변환 흐름을 만드는 것이다.
  • 복원성을 위해 Back Pressure 지원을 활성화해야 한다.
    • Back Pressure는 데이터 처리 단계 사이의 작업 부하를 관리하는 메커니즘으로, 현재 단계의 부하가 다른 프로세스로 파급되는 것을 방지한다.
      • 과부하 상태의 컴포넌트가 상류 컴포넌트들에 자신이 과부하 상태라는 것을 알려 부하를 줄이도록 한다.
    • 메시지 브로커를 통한 메시지 기반 통신을 사용해 작업 부하 관리를 효율적으로 수행할 수 있다.

리액티브 시스템의 원리는 interactive한 피드백을 제공하는 거의 모든 종류의 분산 시스템 구축에 적용할 수 있기 때문에 여러가지 영역에 적용되어 있다.

서비스 레벨에서의 반응성

  • 리액티브 시스템 구축은 전체 시스템 설계에 있어 하나의 요소일 뿐이다.

큰 시스템은 더 작은 규모의 시스템으로 구성되기 때문에 구성 요소의 리액티브 특성에 의존한다.

즉, 리액티브 시스템은 설계 원칙을 적용하고, 이 원칙을 모든 규모에 적용해 그 구성 요소를 합성할 수 있게 하는 것을 의미한다.

  • 따라서, 구성 요소 수준에서 리액티브 설계 및 구현을 제공하는 것이 중요하다.
  • 먼저 자바의 가장 보편적인 기법인 명령형 프로그래밍 예제를 살펴보자.
interface ShoppingCardService {
  Output calculate(Input value);
}

class OrderService {
  private final ShoppingCardService scService;
  
  void process() {
    Input input = ...;
    Output output = scService.calculate(input);
    ...
  }
}
  • OrderServiceShoppingCardService.calculate() 를 동기적으로 호출하고, 실행 직후에 결과를 전달 받는다.
    • 즉, ShoppingCardService.calculate() 를 실행하는 동안 OrderServiceprocess() 를 수행하는 스레드는 차단된다.
    • 이 경우 OrderService 는 시간과 강결합되거나 ShoppingCardService 의 실행 결과와 강결합된다.

콜백 함수

  • 이 문제를 컴포넌트 사이의 통신을 위한 콜백 기법을 적용하여 해결할 수 있다.
interface ShoppingCardService {
  void calculate(Input value, Consumer<Output> c);
}

class OrderService {
  private final ShoppingCardService scService;
  
  void process() {
    Input input = ...;
    scService.calculate(input, output -> {
      ...
    });
  }
}
  • calculate() 메소드는 두 개의 파라미터를 전달받고 void 를 반환한다.
    • 이 경우 호출하는 인스턴스가 즉시 대기 상태에서 해제될 수 있으며, 메소드 실행 결과는 파라미터로 전달받은 Consumer<> 콜백으로 전달된다.
  • OrderService 는 비동기식으로 콜백 함수와 함께 ShoppingCardService.calculate() 를 호출하고, 이후 로직을 수행한다.
    • ShoppingCardService 가 콜백 함수를 실행하면 실제 결과에 대한 처리를 계속 할 수 있다.

콜백 함수 호출 (동기 / 비동기)

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

    class SyncShoppingCardService implements ShoppingCardService {
      public void calculate(Input value, Consumer<Output> c) {
        Output result = new Output();
        c.accept(result);
      }
    }
      
    class AsyncShoppingCardService implements ShoppingCardService {
      public void calculate(Input value, Consumer<Output> c) {
        new Thread(() -> {
          Output result = template.getForObject(...);
          ...
          c.accept(result);
        }).start();
      }
    }
    
    • SyncShoppingCardService 는 I/O 실행을 하지 않고 결과를 바로 콜백 함수에 전달해 반환한다.
    • AsyncShoppingCardService 는 I/O blocking할 때 별도의 스레드로 래핑한다.
      • 결과를 전달받으면 콜백 함수를 호출해 결과를 전달한다.

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

즉, OrderService는 calculate() 메서드를 호출한 후 응답을 기다리지 않고 즉시 다른 작업을 진행할 수 있다.

Future

interface ShoppingCardService {
  Future<Output> calculate(Input value);
}

class OrderService {
  private final ShoppingCardService scService;
  
  void process() {
    Input input = ...;
    Future<Output> future = scService.calculate(input);
    ...
    Output output = future.get();
  }
}
  • OrderService 는 비동기적으로 ShoppingCardService.calculate() 를 호출하고 Future 인스턴스를 반환받는다.
    • 메서드가 비동기적으로 처리되는 동안 다른 작업을 계속할 수 있다.
    • 블로킹 방식으로 결과를 기다리거나 즉시 결과를 반환할 수 있다.
  • Future 클래스 사용으로 결과값 반환을 지연시킬 수 있다.
    • 또한, Callback hell을 피할 수 있고, Future 구현 뒤에 멀티 스레드의 복잡성을 숨길 수 있다.

CompletionStage

  • Java8에서는 CompletionStageCompletableFuture 를 지원하여 함수형 스타일 또는 선언형 스타일로 비동기 호출 코드를 작성할 수 있다.
    • CompletionStageFuture 와 비슷한 클래스 래퍼지만, 반환된 결과를 기능적 선언 방식으로 처리할 수 있다.
    • thenAccept , thenCombine 과 같은 API를 제공한다.
interface ShoppingCardService {
  CompletionStage<Output> calculate(Input value);
}

class OrderService {
  private final ShoppingCardService scService;
  
  void process() {
    Input input = ...;
    scService.calculate(input)
      .thenApply(out1 -> {...})
      .thenCombine(out2 -> {...})
      .thenAccept(out3 -> {...});
  }
}
  • OrderServicecalculate() 를 비동기적으로 호출하고 실행 결과로 CompletionStage 를 즉시 반환받는다.
    • CompletionStage 가 제공하는 API를 이용해 결과에 대한 변형 연산을 정의하거나 결과를 처리하는 최종 consumer를 정의(thenAccept)할 수 있다.

ListenableFuture

  • 스프링 4 MVC는 구형 자바 버전과의 호환성을 위해 CompletionStage 대신 ListenableFuture 를 자체적으로 제공했다.
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);
  • 위 코드는 비동기 호출을 처리하기 위한 콜백 스타일을 보여준다.

  • 스프링 프레임워크는 blocking 네트워크 호출을 별도의 스레드로 매핑한다.

    • 스프링 프레임워크 5에서 reactive WebClient 가 도입되면서 모든 서비스 간 통신에 non-blocking 통신을 지원한다.
    • 서블릿 3.0은 비동기 client-server 통신을 지원하고, 서블릿 3.1은 non-blocking I/O를 허용한다.
      • 이에 따라 서블릿 3 API에 포함된 대부분 비동기 논블로킹 기능은 스프링 MVC에 잘 통합되어 있지만, 스프링 MVC가 비동기 논블로킹 클라이언트를 제공하지 않음으로써 서블릿 API의 해당 기능들이 모두 무효화되었다.
  • 자바의 멀티스레딩 디자인은 몇몇 스레드가 작업을 동시에 실행하기 위해 하나의 CPU를 공유할 수도 있다고 가정한다.

    • CPU가 여러 스레드 간에 공유된다는 것은 Context Switching 이 일어난다는 의미이다.
    • 즉, 나중에 해당 스레드를 다시 시작하려면 레지스터, 메모리 맵 등 기타 관련 요소들을 저장하고 다시 불러와야 한다.
    • 따라서 적은 수의 CPU에 동시에 많은 수의 스레드를 활성화시키는 어플리케이션은 비효율적이다.
  • 일반적인 자바 스레드는 메모리 소비에 오버헤드가 있다.

    • 64비트 JVM에서 스레드의 스택 크기는 대략 1024KB 이다.
    • 커넥션마다 별도의 스레드를 할당하는 모델에서 64000개의 동시 요청을 처리하려고 할 때, 약 64GB의 메모리를 필요로 한다.
    • 이는 비용이 많이 들 뿐만 아니라 어플리케이션 관점에서 위험하다.