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);
}
@RequestMapping을 통해 request handler 매핑을 선언한다.- 서비스 간 요청 - 응답을 처리하는 웹 클라이언트인
RestTemplate인스턴스를 생성한다. RestTemplateAPI를 이용해 HTTP 요청을 생성하고 실행한다.
- 이러한 요청 방식은 다음과 같은 문제가 있다.

- 스레드 A는 다른 컴포넌트에 HTTP 요청을 보내면서 I/O에 의해 차단되며, 이 시간 동안 다른 요청을 처리할 수 없다.
- 병렬 처리를 위해 스레드 풀을 만들어 추가 스레드를 할당할 수 있지만, 부하가 높은 상태에서는 새로운 I/O 작업을 동시에 처리하는데 매우 비효율적일 수 있다.
-
I/O 측면에서 리소스(스레드) 활용도를 높이려면 비동기 논블로킹(Asynchronous and Non-blocking) 모델을 사용해야 한다.
- 분산 시스템에서 서비스 간에 통신할 때 자원을 효율적으로 사용하기 위해 메시지 기반(message-driven) 통신 원칙을 따른다.
- 컴포넌트들은 메시지가 도착하면 이에 반응하며, 나머지 시간에는 휴면 상태에 있지만 동시에 논블로킹 방식으로 메시지를 보낼 수 있어야 한다.
- 메시지 기반 통신을 수행하는 방법 중 하나는 message broker를 사용하는 것이다.
- 메시지 대기열을 모니터링해 시스템이 부하 관리 및 탄력성을 제어할 수 있다.
반응성에 대한 usecase
- 온라인 쇼핑을 지원하는 웹 서비스 예제를 살펴보자.

- 장애 복원력을 위해 Apache Kafka를 이용해 메시지 기반 통신과 독립적인 결제 서비스를 구성하였다.
- 외부 결제 시스템의 장애 발생시 결제 요청을 재시도할 수 있다.
- 데이터베이스에서도 복제 서비스를 활성화해서 복제본 중 하나가 장애로 중단된 경우에도 복원력을 유지하도록 한다.
- 응답성을 유지하기 위해 주문 요청을 받자마자 우선 요청에 대한 응답을 보낸 후, 비동기적으로 사용자 결제 요청을 결제 서비스에 전달한다.
- 최종 결제 결과는 지원하는 채널 중 하나(여기서는 이메일)를 통해 나중에 전달된다.
스트리밍 아키텍쳐
- 예를 들어, 애널리틱스 분야 서비스는 엄청난 양의 데이터를 런타임에 처리하여 실시간으로 통계를 제공함으로써 항상 최신의 정보를 유지해야 한다.
- 이 시스템을 설계하기 위해 스트리밍 아키텍쳐를 사용할 수 있다.

- 스트리밍 아키텍쳐는 데이터 처리 및 변환 흐름을 만드는 것이다.
- 복원성을 위해 Back Pressure 지원을 활성화해야 한다.
- 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);
...
}
}
OrderService는ShoppingCardService.calculate()를 동기적으로 호출하고, 실행 직후에 결과를 전달 받는다.- 즉,
ShoppingCardService.calculate()를 실행하는 동안OrderService의process()를 수행하는 스레드는 차단된다. - 이 경우
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구현 뒤에 멀티 스레드의 복잡성을 숨길 수 있다.
- 또한, Callback hell을 피할 수 있고,
CompletionStage
- Java8에서는
CompletionStage와CompletableFuture를 지원하여 함수형 스타일 또는 선언형 스타일로 비동기 호출 코드를 작성할 수 있다.CompletionStage는Future와 비슷한 클래스 래퍼지만, 반환된 결과를 기능적 선언 방식으로 처리할 수 있다.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 -> {...});
}
}
OrderService는calculate()를 비동기적으로 호출하고 실행 결과로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의 해당 기능들이 모두 무효화되었다.
- 스프링 프레임워크 5에서 reactive
-
자바의 멀티스레딩 디자인은 몇몇 스레드가 작업을 동시에 실행하기 위해 하나의 CPU를 공유할 수도 있다고 가정한다.
- CPU가 여러 스레드 간에 공유된다는 것은 Context Switching 이 일어난다는 의미이다.
- 즉, 나중에 해당 스레드를 다시 시작하려면 레지스터, 메모리 맵 등 기타 관련 요소들을 저장하고 다시 불러와야 한다.
- 따라서 적은 수의 CPU에 동시에 많은 수의 스레드를 활성화시키는 어플리케이션은 비효율적이다.
-
일반적인 자바 스레드는 메모리 소비에 오버헤드가 있다.
- 64비트 JVM에서 스레드의 스택 크기는 대략 1024KB 이다.
- 커넥션마다 별도의 스레드를 할당하는 모델에서 64000개의 동시 요청을 처리하려고 할 때, 약 64GB의 메모리를 필요로 한다.
- 이는 비용이 많이 들 뿐만 아니라 어플리케이션 관점에서 위험하다.