- 이러한 개발 방법론은 크게 명령형 프로그래밍(Imperative Programming)과 선언형 프로그래밍(Declarative Programming)으로 나뉩니다. - 이 중에서 대중적으로 많이 사용되는 명령형 프로그래밍 방식과 가장 현대적인 개발 방법인 ‘선언형’ 프로그래밍 중 ‘함수형 프로그래밍’에 대해 알아봅니다.
- 프로그램의 상태를 변경하는 문장들을 순차적으로 작성하는 프로그래밍 패러다임을 의미합니다.
- 프로그램이 어떻게 실행되어야 하는지 단계별로 명시적으로 지시하고 상태 변경과 데이터 변경이 명시적으로 이루어지며, 순차적으로 실행 흐름을 가지며, 각 단계가 이전 단계에 의존적 방식입니다. - 기존의 Spring MVC를 이용하여서 명령형 프로그래밍이 구현되어 왔습니다.
💡 아래와 같이 명령형 프로그래밍을 나타내는 대표적인 for문입니다.
- for문은 for(A;B;C;)에서 A는 조건화 식으로 반복문이 시작되기 전의 초기값을 지정하고, B는 true라는 조건식이 만족될 때까지 반복됩니다, C는 증감식으로 하나의 반복이 끝날 때마다 변화가 발생합니다.
- 이를 통해서 초기값에서 시작하여 순차적으로 실행 흐름을 가지고 최종결과를 얻어내는 명령형 프로그래밍의 대표적인 for문입니다.
💡명령형 프로그래밍(Spring MVC)과 반응형 프로그래밍(Spring WebFlux) 특징 비교
1. 명령형 프로그래밍(Spring MVC) 특징 - Imperative logic, simple to write and debug: 명령형 로직으로, 코드를 순차적으로 작성하고 디버깅하기 쉬운 특성을 가집니다. - JDBC, JPA, blocking deps: 블로킹 방식의 데이터베이스 접근 방식을 사용합니다.
2. 반응형 프로그래밍(Spring WebFlux) 특징 - Functional endpoints: 함수형 프로그래밍 스타일의 엔드포인트를 제공합니다. - Event loop concurrent model: 이벤트 루프를 사용한 동시성 모델을 채택하여 적은 수의 스레드로 많은 동시 연결을 처리할 수 있습니다. - Netty: Spring WebFlux의 기본 웹 서버로, 비동기식 이벤트 기반 서버를 구성하는 데 사용됩니다.
3. 공통 특징 - @Controller: 두 프레임워크 모두 컨트롤러 애노테이션을 사용할 수 있습니다. - Reactive clients: WebClient와 같은 반응형 클라이언트를 지원합니다. - Tomcat, Jetty, Undertow: 다양한 서버 환경을 지원합니다.
💡 반응형 프로그래밍(Reactive Programming)과 명령형 프로그래밍(Imperative Programming) 코드
- 명령형 프로그래밍 코드와 반응형 프로그래밍 코드를 통해서 서로의 처리방식에 대해 이해합니다.
💡 명령형 프로그래밍(Imperative Programming) 예시
- 해당 코드는 위에서 아래로 순차적으로 실행되며, 각 단계는 이전 단계에 의존적으로 수행이 됩니다. - 해당 과정은 변수를 초기화 → 반복문을 순차적 수행 → 각 단계에서 덧셈 연산을 수행하는 단계별로 명시적으로 지시하고 있습니다.
// 명령형 프로그래밍(Imperative Programming) 예시intsum=0;
for (inti=0; i < array.length; i++) {
sum += array[i];
}
💡반응형 프로그래밍(Reactive Programming) 예시
- 해당 코드는 명령형 프로그래밍과 동일하게 수행되는 코드입니다. - 일련의 스트림을 통한 작업으로 진행하기에 fromArray()를 통해서 배열을 반응형 스트림으로 변환하며, 스트림의 모든 요소를 reduce()를 통해서 0부터 순차적으로 값을 더합니다. - 이러한, 작업의 결과를 성공, 에러, 완료 시에 대한 콜백함수로 피드백을 받습니다.
- 일련의 발생하는 데이터 스트림을 처리하고 스트림이 변경될 때마다, 이에 따르는 반응을 하는 프로그래밍 방식을 의미합니다. 이를 기반으로 ‘이벤트 기반의 비동기식 애플리케이션’을 구축할 수 있습니다.
- 반응형 프로그래밍에서 발생하는 이벤트는 ‘비동기적’으로 처리가 되고, 여러 이벤트들을 하나의 스트림으로 생성이 됩니다. - 이렇게 생성된 스트림에 대해 구독(Subscribe)하여 전달받은 스트림을 일괄 처리할 수 있습니다.
💡 반응형 프로그래밍 예시
- 아래의 예시에서는 데이터를 발행하는 Publisher 측과 데이터를 구독하는 Subscriber 측이 있습니다. - 데이터를 발행하는 측에서는 배열을 Flux로 변환하고 map을 통해서 값을 2배로 변환합니다. 또한 Filter 연산자로 5보다 큰 값에 대해 필터링하여 전달(emit)합니다. - 데이터를 수신하는 측에서는 onNext, onError, onComplete을 통해서 전달받은 값을 순차적으로 출력하고, 에러 발생 시 메시지 출력, 모든 처리 완료 시 "Completed!"를 출력합니다.
- 시간에 따라 연속적으로 발생하는 데이터의 흐름을 의미합니다. 이러한 스트림들은 여러 이벤트들을 시간 순서대로 묶은 하나의 데이터 흐름을 의미합니다. - 예를 들어서, 네트워크 요청과 응답, 데이터베이스 변경사항과 같은 하나의 이벤트적인 것들을 묶은 것을 의미합니다. - 반응형 프로그래밍에서는 데이터가 준비되면 즉시 처리되거나, 하나의 데이터가 아닌 시간에 따르는 연속된 데이터를 의미합니다.
💡 이벤트(Event)
- 시스템에서 발생하는 의미 있는 상태 변화나 사건을 의미합니다. - 예를 들어서 사용자의 마우스 클릭이나 키보드 입력과 같은 단일한 상태 변화등을 의미합니다. - 반응형 프로그래밍에서는 발생하는 이벤트들은 비동기적으로 처리되며 연속적으로 발생할 수 있습니다. 이를 묶어서 하나의 스트림으로 처리가 됩니다.
💡 스트림과 이벤트의 관계
- 스트림은 여러 이벤트들을 시간 순서대로 묶어서 하나의 데이터 흐름으로 처리하는 방식입니다. 즉, 이벤트들은 스트림을 구성하는 요소가 됩니다.
💡 반응형 프로그래밍에서 처리 이후 onComplete와 onError에 대한 피드백을 받는데 그러면 동기식으로 처리되는 거 아닌가?
- 비동기 처리에서도 작업의 완료나 에러 상태를 알려주는 콜백은 필수적으로 됩니다. 즉, 데이터 처리는 비동기적으로 진행되어서 메인 스레드를 차단하지 않습니다(Non-Block) - 해당 작업은 별도의 스레드에서 실행되며, 콜백은 단순한 결과를 통지하는 역할을 합니다. - onComplete/onError 콜백은 작업의 "결과"를 알려주는 것이지, 작업 자체를 동기식으로 만드는 것은 아닙니다.
💡 반응형 프로그래밍 구조 : Project Reactor 라이브러리 - Spring WebFlux 간의 관계
- 반응형 프로그래밍 전반적인 구조에 대해서 확인해 봅니다.
1. 반응형 프로그래밍(Reactive Programming)을 구축하기 위해 다양한 언어를 선택하여 구성할 수 있습니다. 그중 Spring framework를 기반으로 사용되는 Java/Kotlin 언어에서는 Project Reactor 라이브러리를 사용합니다.
2. Project Reactor 내의 Spring WebFlux를 기반으로 애플리케이션을 구축합니다.
3. 애플리케이션의 구축은 데이터의 송신 - 수신 관계에서는 발행자-구독자 간의 관계인 Publisher-Subscriber 패턴을 이용하고, 이를 수행하기 위해서 네트워크 애플리케이션 프레임워크로 웹 서버로 사용되는 Netty를 활용하며 외부 통신의 경우는 WebClient의 클래스를 활용하여 비동기적 통신을 수행합니다.
- 반응형 프로그래밍(Reactive Programming)을 구현하기 위한 Reactor는 Reactive 라이브러리 중 하나입니다. 이는 리액티브 스트림 사양을 구현하며, 비동기 데이터 처리를 위한 강력한 도구를 제공합니다.
- Publisher-Subscriber 패턴을 중심으로 동작하며, 발행자(Publisher)는 스트림을 생성하고 방출(emit)을 하는 역할을 수행하며, 생성된 데이터는 Mono, Flux 타입으로 구성되어서 데이터를 구독자(Subscriber)가 처리할 수 있을양 만큼 전달되도록 백프레셔(backpressure)를 통해 제어됩니다 - 발행자는 데이터를 스트림으로 생성하고 가공하고 구독자에게 전달하는 역할을 합니다.
- 발행자(Publisher)는 데이터 스트림을 생성하고 방출(emit)하는 역할을 수행하며, Mono, Flux의 주요 타입을 제공합니다. - 구독자(Subscriber)는 발행자(Publisher)가 방출한 데이터를 수신하고 처리하는 역할을 수행합니다. - 해당 처리되는 과정을 통해 ‘데이터의 흐름이 제어’되고, 백프레셔(backpressure)를 통해 Subscriber가 처리할 수 있는 만큼의 데이터만 받을 수 있도록 보장됩니다.
1. Subscriber → Publisher : subscribe - 데이터를 받고자 하는 Subscriber는 Publisher에게 구독(subscribe)을 신청하는 단계입니다.
2. Subscription → Subscriber : onSubscribe - Publisher가 구독(subscribe) 요청을 수락하고, Subscription 객체를 통해 Subscriber에게 구독이 성공했음을 알립니다. 3. Subscriber → Subscription : request(n)/cancel - Subscriber가 처리할 수 있는 데이터의 양(n)을 지정하여 요청하거나, 필요한 경우 구독을 취소할 수 있습니다. 4. Subscription → Subscriber : onNext(data) - 요청받은 데이터를 Subscriber에게 하나씩 전달합니다.
5. Subscription → Subscriber : onComplete/onError - 모든 데이터 처리가 완료되었거나 오류가 발생했을 때 Subscriber에게 알립니다.
💡 백프레셔(Backpressure) - 데이터 스트림에서 데이터 발행자(Publisher)와 구독자(Subscriber) 사이의 데이터 처리 속도 차이를 조절하기 위한 메커니즘입니다.
- 아래의 예시와 같이 데이터 발행자(Publisher)가 데이터를 데이터 구독자(Subscriber)에게 전달(emit)을 하는 경우 발행자의 데이터 속도가 구독자 속도보다 빠를 때 발생하는 문제에 대해서 백프레셔가 이를 속도를 조절하여 처리합니다. - 이는 구독자가 처리할 수 있는 만큼의 데이터만 요청하여 시스템 과부하를 방지하며 메모리의 사용량을 효율적으로 관리하고 시스템의 안정성을 보장합니다.
💡 아래의 예시에서는 데이터 구독자(Subsciber)는 데이터 발행자(Publisher)를 구독(Subscribe)하고 있습니다.
- 이때 데이터 전달(emit)을 통해서 데이터 1, 데이터 2, 데이터 3, 데이터 4, 데이터 N 형태로 데이터를 전달을 받는데, 데이터 구독자(Subsciber) 보다 많은 데이터가 전달이 되는 경우 시스템 과부하가 발생할 수 있습니다. - 이에 따라서 백프레셔에서 이를 관리합니다.
- onSubscribe: 구독이 시작될 때 "Subscribed!" 메시지를 출력합니다. - onNext: 1부터 5까지의 각 숫자가 도착할 때마다 "Received: [숫자]"를 출력합니다. - onComplete: 모든 데이터 처리가 끝나면 "Completed!" 메시지를 출력합니다. - onError: 에러가 발생했을 경우 에러 메시지를 출력합니다 (위 예시에서는 에러가 발생하지 않습니다).
// Subscriber 예시
Flux<Integer> numbers = Flux.just(1, 2, 3, 4, 5);
numbers.subscribe(
// onNext - 각 데이터 처리
data -> System.out.println("Received: " + data),
// onError - 에러 처리
error -> System.err.println("Error occurred: " + error),
// onComplete - 완료 처리
() -> System.out.println("Completed!"),
// onSubscribe - 구독 시작 시 처리
subscription -> {
System.out.println("Subscribed!");
subscription.request(Long.MAX_VALUE);
}
);
// 실행 결과:// Subscribed!// Received: 1// Received: 2// Received: 3// Received: 4// Received: 5// Completed!
- Spring 기반의 네트워크 애플리케이션 프레임워크로 ‘비동기식 이벤트 기반 서버’를 구성하는 데 사용이 되는 네트워크 애플리케이션 프레임워크입니다. - Spring WebFlux를 이용하는 경우에는 기본 웹 서버로 사용이 됩니다. 이벤트 루프 기반으로 동작하여 단일 스레드로 여러 요청을 비동기적으로 처리할 수 있습니다. - 이벤트가 발생하면 이벤트 큐에 저장되고, 이벤트 루프가 순차적으로 처리합니다. 높은 성능과 확장성을 갖추고 있으며, TCP, UDP 등 다양한 프로토콜을 지원하고 있어서 네트워크 애플리케이션 개발에 매우 유용합니다.
https://netty.io/
💡 전통적인 Spring Web 기반의 애플리케이션을 수행하였을 때 Tomcat이 수행됨을 확인할 수 있습니다.
💡 Spring WebFlux로 구성한 애플리케이션을 수행하였을때 Netty 서버가 수행됨을 확인할 수 있습니다.
- 반응형(Reactive) 및 비동기적인 웹 애플리케이션 개발을 지원하는 웹 프레임워크를 의미합니다. - 이는 비동기-논블로킹 웹 스택으로, 적은 수의 스레드로 동시성을 처리할 수 있습니다. - 해당 Spring Webflux의 경우는 Spring Framework 5.0, Spring Boot 2.0 이상에서 지원을 합니다.
- Spring WebFlux에서 각각 처리하는 계층 구조(Layer)에 대해 알아봅니다.
계층(Layer)
주요 특징
표현 계층(Presentation Layer)
- 함수형 방식의 경우는 Router Functions와 Handler Functions이 위치합니다. - 주석 기반 방식(annotation-based)에서는 Controller가 해당 계층에 위치합니다. - HTTP 요청을 받아 적절한 비즈니스 로직으로 전달 - 클라이언트와의 직접적인 통신 처리를 수행합니다.
비즈니스 계층(Service Layer)
- 실제 비즈니스 로직 구현 - 트랜잭션 관리 및 도메인 규칙 적용 - Reactive 스트림을 활용한 비동기 처리
영속성 계층(Persistence Layer)
- R2DBC나 Reactive MongoDB 등을 통한 데이터 접근 - Reactive 리포지토리 패턴 구현- 비동기 데이터 CRUD 연산 처리
도메인 계층(Domain Layer)
- 비즈니스 엔티티와 값 객체 정의 - 도메인 이벤트와 규칙 관리 - Reactive 스트림과 호환되는 도메인 모델 구현
[ 더 알아보기 ]
💡 Router Function과 Handler Function을 분리하는 이유는?
1. 단일 책임 원칙(SRP) 준수 - Router의 경우는 요청 경로와 HTTP 메서드 매핑에만 집중하며, Handler의 경우는 실제 비즈니스 로직 처리에만 집중할 수 있습니다. 2. 테스트 용이성 - Router와 Handler를 독립적으로 테스트 가능하며, 각 컴포넌트의 단위 테스트가 더 명확하고 간단해짐
3. 코드 재사용성 향상 - Handler 함수는 여러 Router에서 재사용 가능하며 동일한 로직을 다른 엔드포인트에서도 활용 가능합니다.
- 해당 사용예시에서는 애플리케이션이 신규 런칭이 되었다는 가정하에, 애플리케이션을 가입하는 사용자가 급증을 하게 됩니다. 이때에, 회원가입을 하면 축하 메일을 전송해 주는 비즈니스 로직이 포함되어 있습니다.
- 동기식으로 처리를 한다면 하나의 사용자 아이디 조회 → 사용자 등록 → 이메일 전송이라는 트랜잭션 내에 처리를 기다리고 있어야 합니다. - 그러나 Spring WebFlux를 이용한다면, 이를 비동기 병렬적으로 처리하여서 트랜잭션의 순차적인 대기 없이 수행되도록 하는 예시입니다.
- 해당 시나리오는 비동기 병렬 처리를 위한 비즈니스 로직 처리 과정입니다. - 시나리오에서는 임의의 사용자를 10명을 등록하여 비동기 API 통신을 확인합니다. - 해당 등록을 위해서 사용자 아이디 조회 → 사용자 등록 → 이메일 전송 처리를 수행합니다.
1. UserServiceTest → RouterConfig - Test 클래스 내에서는 WebClient를 통해 사용자를 등록하는 API 호출을 수행합니다. 해당 과정에서 총 10번의 사용자 등록을 하도록 호출하였습니다.
2. RouterConfig → UserHandler - 호출된 엔드포인트를 기반으로 처리를 위한 UserHandler의 registerUser() 메서드를 호출합니다.
3. UserHandler → UserService - UserHandler에서는 UserService를 호출하고 처리과정 중 성공을 하면 “1”을 반환하고 에러가 발생하면 “0”을 반환합니다.
4. UserSerivce → UserServiceImpl - UserServiceImpl 구현체에서 인터페이스의 비즈니스 로직을 처리합니다. 비즈니스 로직은 사용자 아이디를 기반으로 사용자 조회 → 중복 체크를 통과하면 사용자 등록 → 사용자 등록이 완료되면 이메일을 전송하는 처리과정을 비동기로 처리를 수행합니다.
5. UserServiceImpl → UserRepository - 비즈니스 로직 처리 중 데이터 처리를 위해서 UserRepository를 호출합니다.
6. UserRepository → ReactiveCrudRepository - ReactiveCrudRepository로부터 상속을 받아서 구현합니다. 이를 통해 데이터베이스에 접근하여 비동기적 데이터를 조회합니다.
- 주요한 라이브러리로 spring-boot-starter-webflux로 반응형 프로그래밍을 구현하였고, spring-boot-starter-data-r2dbc, org.postgresql:r2dbc-postgresql를 통해서 반응형 데이터베이스를 구현하였습니다 - 또한, 이를 테스트하기 위해 JUnit, reactor-test를 주입하였습니다.
- 로그를 보면 여러 개의 서로 다른 actor-tcp-nio 스레드(1부터 10까지)가 동시에 같은 SQL 문을 실행하고 있음을 확인할 수 있습니다. - 이는 Spring WebFlux의 비동기-논블로킹 특성을 보여주는 것으로, 하나의 요청이 순차적으로 처리되는 것이 아니라 여러 스레드가 동시에 병렬로 처리되고 있다는 것을 의미합니다.
4.2. 비동기 병렬 처리 수행
💡 비동기 병렬 처리 수행
- 아래의 로그를 확인하더라도 ‘일괄적’으로 SELECT 작업을 수행하고 INSERT 작업을 수행하는 하고 있지는 않습니다. - 각각 스레드별로 맞게 SELECT를 수행하고 INSERT를 수행하고 있음을 확인할 수 있습니다.