기대하던 항해 실전프로젝트(6주)가 시작되었고, 항해99 17기 최고 아웃풋으로 남겠다는 다짐과 함께 Challenging한 주제를 잡고 꾸역꾸역 해내자 라는 마음가짐으로 팀원들과 어려운 주제를 잡고 가기로 했다.
주제는 트위치와 같은 라이브 스트리밍 방송 송출/시청 플랫폼 개발 이였는데, 운명적이게도 주제를 선정하고 4일 뒤 쯤에 트위치가 한국에서 망 사용료를 감당하지 못해 사업을 철수하겠다는 기사가 났다.
먼저 벌써부터 많은 트래픽이 발생될 예정이고, "실시간" 스트리밍이 되어야하기때문에 대용량 트래픽에 대해서 빠르게 대응할 수 있도록 아키텍쳐를 설계해야했다.
MSA를 경험해보고싶은 마음과 현실적인 솔루션인 SOA 아키텍쳐가 혼용된 애매한 아키텍쳐 구조를 시작으로,
필요없는 부분은 버리고, 나눌 필요 없는 부분은 합치고, 뒤늦게라도 autoscailing이 적용되어야 할 서비스의 한 부분을 식별하거나 서비스의 책임이 분리되어야하는 상황이 발견된다면 서비스를 분리하기로 하고 아래와 같이 아키텍쳐를 고안했다.
항해99 17기 실전프로젝트 주제를 실시간 라이브스트리밍 서버 구축으로 잡으니까 배워야 할 것들이 정말 많았다. RTMP 프로토콜, 동영상 트랜스코딩을 위한 외부 라이브러리(FFMPEG), SOA, 모니터링 및 로그 등등 어려운 개념들이 난무했지만, 정말정말 어려웠던 건 단연코 비동기 프로그래밍을 위한 Reactor, SpringWebflux와 Spring mvc의 차이를 이해하는 것이였다.
사실 차이가 있다기 보다는 그냥 여태까지 배운것과 전혀 다른 수준의 차이를 보이고있어서 막막하기만 한데,,
프로젝트 1주차가 마무리될때까지 학습, 예제코드 레퍼런스, 깃헙 리포지터리 탐방하고 코드 참고하기, 왜 되는지, 왜 안되는지 확인하기… 의 반복이였는데, 자꾸만 이 Spring Webflux가 발목을 잡았다.
초원을 달리는 야생마처럼 마구잡이로 배워도 해결할 수 있다고 생각하고 있었는데, Reactive Programming은 그 격이 달랐다. Spring에서 Reactive Programming을 하려면 Spring WebFlux를 사용해야하고 WebFlux는 Reactor 라이브러리를 사용한다.
Spring MVC와 Spring WebFlux의 차이
먼저 Spring mvc에 비한 spring webflux의 강점을 설명하려면 쓰레드 풀에 대해서 얘기해야한다.
병렬 처리를 통해 성능을 향상시키기 위해 사용되는 쓰레드 풀은 아래와 같은 특징이 있다.
- 재사용성: 쓰레드 풀은 일정한 수의 쓰레드를 생성하고 관리한다. 이 쓰레드들은 필요할 때마다 작업을 처리하고, 작업이 완료되면 다른 작업을 받을 수 있도록 재사용 된다. 쓰레드를 생성하고 제거하는 등의 작업이 아닌, 쓰레드는 계속해서 갖고있고, 쉬고 잇는 쓰레드에게 task를 전달한다고 생각하면 편하다, 따라서 쓰레드 생성 종료에 따른 오버헤드를 없앨 수 있다.
- 성능 향상: 여러 작업을 병렬로 처리함으로써 시스템의 전반적인 성능을 향상시킬 수 있습니다. 특히 I/O 바운드 작업이나 네트워크 작업과 같이 대기 시간이 긴 작업에서 쓰레드 풀은 특히 유용합니다.
- 스케줄링 및 작업 관리: 쓰레드 풀은 쓰레드를 효율적으로 스케줄링하기위해서 들어온 작업을 큐에 저장하며 관리하다가 큐에 있는 작업을 현재 유휴상태에 있는 쓰레드에 할당하여 실행시킨다.
- 자원 제어: 쓰레드 풀은 동시에 실행되는 스레드의 수를 제한하여 시스템 자원을 효율적으로 관리합니다. 쓰레드가 과도하게 많으면 context switching에 드는 비용이 오히려 동시에 작업할 수 있는 쓰레드의 수가 많아서 생긴 장점을 상쇄시키며 cpu, 리소스 부하가 더 많이 일어난다.
쓰레드풀이 하는 일을 한눈에 보자면 아래와 같다.
쓰레드풀의 딜레마는, CPU 와 메모리가 충분하지만 쓰레드가 모자라서 throughput이 저하되는데,
그렇지만 쓰레드를 늘리 면 컨텍스트 스위칭에 드는 부하가 생거나서 오히려 메모리와 cpu에 부하가 걸리게 되고, 전체 성능이 저하된다.
따라서 내 웹앱에서 요청이 많아진다고 해서 쓰레드를 무조건적으로 늘리면 오히려 컨텍스트 스위칭에 드는 부하가 한번에 처리할수 있는 요청의 수를 늘려서 얻는 성능 개선보다 커서 문제가 발생한다.
비동기 프로그래밍이 중요한 이유
인터넷을 사용하는 유저가 늘어나 전체 트래픽이 매년 걷잡을 수 없이 상승하고 있는 요즘, 약 2년마다 반도체 집적회로에 집적할수 있는 트랜지스터 숫자가 두 배씩 증가한다는 무어의 법칙이 한계에 다다르게 될 정도로 네트워크 요청의 수가 늘어났다.
이에 더해 심지어 사용자들은 더 화려한 UI 상호작용 경험과 더 빠른 반응속도를 요구한다. 더 많은 트래픽에 더 빠른 반응속도로 응답해야하는 세상이 온것이다.
이러한 환경에서 기존 SpringMVC 모델 보다 Spring WebFlux를 선택하게 되는 것은 자연스럽다(내생각)
넷플릭스가 만든 RxJava 라이브러리 탄생 배경
넷플릭스는 한명의 유저가 사이트에 접속했을 때, 화면을 렌더링하기 위해서(인증 등의 다른 부가적인 요소 포함) 여러 api요청을 보내고 netflix 서버에서 각 api 요청에 대한 대응을 하여 원하는 정보를 리턴하는 구조였었는데, 이는 2012년 기준 북미 인터넷 트래픽의 33%를 차지할정도로 많은 트래픽을 발생시켰고, 이를 개선하기 위해서 user가 사이트에 접속했을 때 단 하나의 api만 보내도록 단일화했다.
사용자가 netflix 서버에는 하나의 api요청만 보내지만, 서버내에서 처리해야하는 일들의 순서가 보장되어야했다, 예를 들면, auth 인증을 하지 않고서는 다른 a,b 작업으로 얻을 수 있는 정보를 클라이언트에게 줄 수 없었고, c라는 작업은 a 작업이 끝난 후에 실행되어야 했다.
이때, jvm환경에서 ReactiveExtensionbs 모델이 동작되는 java 버전을 개발하게 되었으며 rxJava를 만들게 되었다.
MVC 패턴에서조차도 Non-Blocking I/O 방식이 필요했던 이유
spring mvc는 각 요청마다 다른 쓰레드를 부여받아서 서버내에서 필요한 작업들을 수행한 뒤 client에게 응답하는 ResponseEntity를 완성시켜서 Controller 계층에서 반환하고 그것이 Servlet, Filter를 거쳐서 client에게 응답이 되고 나면 다시 해당 쓰레드가 새로운 요청을 받을 준비가 된 것이다.
조금 자세히 설명하면,
클라이언트의 HTTP요청은 서블릿 컨테이너(Tomcat) 에 의해 수신되며, 서블릿 컨테이너가 요청을 처리하기 전에 Filter를 거쳐 보안, 로깅, 캐싱 등의 작업을 마친 후 서블릿 컨테이너가 요청을 처리할 쓰레드를 할당하여 요청을 처리하게 된다. 이후 응답할 때 까지 이 쓰레드가 작업하게된다.
이때 서블릿 컨테이너는 웹앱의 배포 서술자를 기반으로 요청을 처리할 서블렛을 결정한다.
이때 디스패쳐서블렛은 클라이언트의 요청을 캐치하여 핸들러 매핑에게 요청을 어떤 컨트롤러에게 매핑할지 물어보고, 핸들러매핑은 요청받은 URL을 이해하여 컨트롤러에 매핑해주는 역할을 한다.
이후 Controller가 호출되어 서버내에서 사람이 작성한 비즈니스 로직을 수행하고 모델을 반환한다.
이후 모델은 ViewResolver에게 전달되어 View의 논리적인 이름을 실제 View 객체로 변환하고, 클라이언트에게 보낼 응답을 생성한다.
이후 응답을 전송하기 전에 Filter를 거쳐 적절한 로직이 수행된 이후 클라이언트가 View를 화면에 볼 수 있게 된다.
위의 과정에서 비즈니스 로직이 수행되는 동안(db read 등) 쓰레드가 blocking된다.
만약 이 시간동안 쓰레드가 다른 작업을 할 수 있다면 어떨까? 라는 생각이 절로 든다.
위의 쓰레드 blocking에 대하여 Webflux는 Non Blocking I/O를 지원하기 때문에 아래와 같은 방식이 가능해진다.
그리고 서버의의 쓰레드 풀 내의 모든 쓰레드가 작업중이라면 서블렛 컨테이너는 새로운 요청을 처리할 쓰레드가 없어 대기 큐에 새로운 요청을 대기시키게 되고, 유휴상태의 쓰레드가 생길 때 까지 기다릴 수밖에 없다.
만약에 대기 큐가 꽉차게 되면 서버는 더이상의 요청을 받아서 저장할 수가 없어서 리소스 부족으로 인해 클라이언트의 요청이 거부될 수 있다. 이러한 상황을 방지하기 위한 추가적인 대기 큐를 사용할 수 있다.
애초에 spring mvc 패턴에서는 한번에 해결할 수 있는 요청의 수가 쓰레드의 수로 제한되어있다.(정확하게 파고들자면 틀린 말이긴 하다. @Async 어노테이션이나 CompletableFuture 등을 활용하지않고, Servlet 3.0 이전의 상황으로 생각했습니다. 또 반대로 reactive programming에 대해 비관적으로 얘기하면 어차피 한번에 해결할 수 잇는 요청의 수는 쓰레드의 수로 제한되어있다는 것은 동일하긴 합니다. 당연히 서블릿 컨테이너의 쓰레드풀 모델과 비교하면 쓰레드가 block되어있는 시간이 짧겠죠?)
위의 내용을 종합해서 SpringMVC 패턴에 대해서 나쁘게 말하면, 한번에 처리할 수 있는 요청은 쓰레드의 수로 제한되어있는데, 해당 쓰레드들이 요청을 처리하면서 알차게 일하지 않는다는 게 된다.
Spring WebFlux가 요청을 처리하는 방식은?
WebFlux의 경우는
이벤트 루프 기반으로 작동하기때문에 사용자의 요청이 들어왔을 때 아주 적은 수로 작동하고있는 이벤트루프에서 해당 요청을 핸들링할 쓰레드를 할당하여 작업을 처리하게된다.
리액티브 매니페스토
이쯤에서 읽어보는 리액티브 매니페스토
*매니페스토 : 특정한 주제나 이념에 대한 선언이나 공표문
어떤 앱이 리액티브하다고 말하려면 아래의 4가지 속성을 모두 만족해야 할 것이다.
- Responsive (응답성) : 가능한 한 즉각적으로 응답하는 것을 말한다. 사용자의 입장에서 가장 중요한 항목이 될 것. 소프트웨어는 당연히 사용자가 필요로 할 때 빠르게 응답해야 합니다. 응답성은 Reactive Manifesto의 4가지 속성 중 가장 중요하고, 나머지 아래 3가지 요소는 응답성을 위하여 존재하는 하위 요소이다.
- Resilient (탄력성) : 시스템이 장애에 직면하더라도 응답성을 유지하는 것입니다. Fault Tolerence(장애 허용)와 밀접한 개념. 부분적인 장애나 고장이 시스템 전체를 망가뜨리지 않아야 한다는 말이 된다.시스템 고장을 예외가 아닌 시스템 기능의 일부로 받아들여 정상 작동하도록 한다.
- Elastic (유연성) : 시스템의 현재 작업량이 변하더라도 응답성을 유지하는 것입니다. 리액티브 시스템은 트래픽에 따라 Scale Up과 Scala Down이 쉬워야 합니다. 리액티브 매니페스토의 초기 버전에는 Scalable 이라는 말을 썼지만, 이것은 Scale Up에만 해당하는 용어라, Scale Up과 Down을 동시에 표현하는 Elastic 이라는 용어로 바뀌었다.
- Message Driven (메시지 구동) : 소프트웨어 내부에서 의사소통을 하는 방식이 메시지를 전달하는 방식으로 구성되어있어야 한다.. 소프트웨어를 구성하는 각 부분들은 비동기적으로 메시지를 Fire-and-Forget 방식으로 주고받는다.
Spring WebFlux의 Reactive Programming을 제공하기 위한 3가지 개념
Spring Webflux가 Reactive Programming 을 가능하게하는 중요한 3가지 개념이 있다.
- Non-Blocking I/O : 논 블로킹 I/O는 DB I/O 작업을 커널이 수행할 때 응답이 오기전까지 쓰레드가 다른 작업을 할 수 있도록 한다. 우리 Spring WebFlux에선 R2DBC가 해줄거다.
- Reactive Stream : Reactive Stream은 Asyncronous, Non-blocking으로 작동하는 Stream이다. 함수형 프로그래밍을 통해서 스트림을 처리한다. 함수형 프로그래밍에 대해선 후술, 리액티브 스트림은 기존의 Stream과 사용하는 방법이 비슷하지만, 리액티브하게 작동하는 리액터 라이브러리의 Mono, Flux가 해준다.
- Back Pressure : 백프레셔의 목적은 publisher가 subscriber를 압도하지 못하 게 하는 것이 주 목적이다. 그 말인 즉슨 데이터를 생산하는 publisher가 데이터를 소비하는 subscriber가 처리할 수 있는 양보다 많은 양의 데이터를 생산하지 못하는 것과 같이 작동하도록 한다는 것. FluxSink의 OverflowStrategy로 이러한 백프레셔 전략을 publisher 단에서 선택할 수 있다.
저 3개가 중요한 WebFlux의 요소가 된 데에는 아래의 배경이 있다.
Spring WebFlux는 적은 수의 쓰레드, 더 적은 하드웨어 리소스로 앱이 동작하기 위해서 논 블로킹 웹스택이 필요했고,
기존의 서블릿 3.1이 논블로킹 I/O를 위한 api를 제공했지만, 기존의 Spring mvc 패턴에서 I/O만 논블로킹으로 작업한다고 능사가 아니다, 왜냐하면 springmvc패턴에서, Controller에 닿기 전까지 Filter, Servlet이 작동하던 동기식과 그안에서 이루어지는 블로킹 방식 때문인데, 이때문에 논 블로킹 실행환경에서 Spring mvc를 선택할 수 없었다.
이미 짜여진 비동기 로직은 사람이 읽었을 때 이해되기 쉬운 동기 로직과 달리 서술식으로 작성되어있지 않은데, java 8 에서 추가된 람다 표현식을 이용한 java의 함수형 api와 같이 사용되면 서술식으로 비동기 로직을 작성할 수 있다. 이렇게 람다 표현식을 활용하여 Reactive Stream을 서술식으로 작성할 수 있게 되었다.
함수형 프로그래밍
리액티브 스트림을 설명할 때 함수형 프로그래밍에 대한 언급이 있었는데, 이미 다들 경험해봤을 법한 예제로 설명함
builder 패턴이나 Java Stream을 사용해본 경험이 있다면 아래와 같은 코드가 익숙할 것이다.
ffmpeg 명령어를 build하는 코드
setInputStreamPathVariable(email)
.printFFmpegBanner(false)
.setLoggingLevel(LOGGING_LEVEL_INFO)
.printStatistic(true)
.setVideoCodec(VIDEO_H264)
.setAudioCodec(AUDIO_AAC)
.useTempFileWriting(true)
.setSegmentUnitTime(10)
.setSegmentListSize(2)
.timeStampFileNaming(true)
.setOutputType(OUTPUT_TYPE_HLS)
.createVTTFile(false)
.setM3U8FileName(streamerName)
.createThumbnailBySeconds(10)
.setThumbnailQuality(2)
.setThumbnailCreatePath(streamerName)
.build();
Stream 예시가 눈에 안보여서 방금 작성한 장난치는 코드
String toString = "문자열로 매핑하기";
Stream.of(1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22)
.filter(i-> i%2==0)
.map(i-> toString+i )
.filter(s -> s.length() == toString.length() + 2)
.sorted((s1,s2) ->s2.compareTo(s1))
.forEach(System.out::println);
위처럼 코딩이 가능하고 제대로 동작할 수 있는 이유는,
위에서 본 체이닝 된 함수들이 아래와 같이 자기 자신을 리턴하기 때문이다.(당연히 꼭 자기 자신을 리턴해야할 필요는 없고, 같은 형식의 클래스 혹은 다른 형식의 클래스도 리턴해도 된다, 요지는 함수를 일급 객체로 사용할 수 있기 때문이라는 것)
public FFmpegCommandBuilder createThumbnailBySeconds(int second){
command.append("-vf fps=").append("1/").append(second).append(' ');
return this;
}
public FFmpegCommandBuilder setThumbnailQuality(int quality){
command.append("-q:v").append(' ').append(quality).append(' ');
return this;
}
public FFmpegCommandBuilder setThumbnailCreatePath(String streamerName){
command.append(getOrCreateThumbnailPath(streamerName));
return this.setThumbnailFileName(streamerName);
}
Publisher (클라이언트 ,데이터베이스) 에서 변경이 생기면 Subscriber에 변경된 데이터들을 Stream으로 전달한다. Stream으로 프로그래밍하는 패러다임이 Reactive Programming이다.
한마디로 모든 것을 스트림을로 처리하는것.
Spring WebFlux에서 Client의 요청 처리하기
Annotated Controller / Functional Endpoints
spring mvc 환경이 아닌 webflux 환경에서는 그러면 client의 요청을 어떻게 처리할까?
Controller 계층 이후로 살펴보자면 아래의 두가지의 선택지가 있다.
- Annotated Controller
- Functional Endpoints
anntated controller 방식으로 컨트롤러 계층을 작업한다면 컨트롤러는 spring mvc 기존의 방식과 비슷하게 사용가능하다. 물론 매개변수나 응답에 Mono나 Flux를 사용할 수 있고, 사용해야만 진정 요청→응답 이 비동기로 동작한다고 할 수 있겠다.
@RestController
@RequestMapping("/api")
public class MyController {
@GetMapping("/resource")
public Mono<Resource> getResource() {
// 비동기 작업 수행
}
}
Controller Mapping이 필요한 부분이기때문에 프로그래머가 직접 사용자의 요청을 처리하는 것 보다 많은 리소스를 사용하게 되고, 이 부분에서 성능 저하가 아예 안생긴다고 할 수 없다.
정말 webflux의 적은 리소스 사용, 고성능을 원한다면 또, 컨트롤러만 springmvc와 비슷하게 작성했다는 것 때문에 오히려 webflux를 이해하는데 방해가 된다면 Functional Endpoints를 사용해야한다.
Functional Endpoints
Functional Endpoints는 함수형 프로그래밍 스타일로 엔드포인트를 정의하는 것을 말한다. RouterFunction과 HandlerFunction을 이용해서 client의 요청을 처리함
먼저 예제 코드를 보자
@Configuration
public class MyRouterConfig {
@Bean
public RouterFunction<ServerResponse> route(MyHandler handler) {
return RouterFunctions.route()
.GET("/api/resource", handler::getResource)
.build();
}
}
@Component
public class MyHandler {
public Mono<ServerResponse> getResource(ServerRequest request) {
// 비동기 작업 수행
}
}
Functional Endpoints 전략으로 client의 요청을 처리한다면, RouterFunction과 Handler를 사용해야하는데 여기서 Handler의 역할은 사실 Controller지만 spring mvc에서 Filter와 비슷한 생김새를 하고있다.(이렇게 얘기해도 되는지는 모르겠습니다. 이렇게 이해하니까 쉽긴 했어요)
그리고 RouterFunction은 기존의 디스패쳐 서블렛이 하던 일을 대신하게된다.
명시적으로 Annotated Controller를 사용하는 대신, spring mvc의 HttpServletRequest와 비슷하게 ServerRequest를 요청받아서 해당 요청에 대한 응답을 Reactive Stream으로 응답하는 것이다.
ServerRequest를 받아 비즈니스 로직을 처리하고 다시 Reactive Stream을 반환하는 Handler 함수를 정의하고, RouterFunction에서 어떤 요청에 대해서 어떤 함수가 Handling할것인지 지정해줄 수 있다.
Java 8의 람다식을 통해 간결하고 명시적인 코드를 작성할 수 있습니다. 또한, 함수형 프로그래밍 스타일을 지원하여 비동기 및 논블로킹 코드를 작성할 수 있음
Functional Endpoints는 Annotated Controller 전략으로 client의 요청을 처리하는 것 보다 더 적은 리소스와 더 빠른 반응성을 제공할 수 있다.