WebFlux를 이해하기 위해서는 먼저 리액티브 프로그래밍과 기존 서블릿 API의 차이를 이해하는 것이 중요합니다.
리액티브 프로그래밍은 데이터 스트림과 변화의 전파에 중점을 둔 비동기 프로그래밍 패러다임입니다. 쉽게 말해서, 데이터가 생성되거나 변경될 때 이를 관찰하고 있다가 반응하는 방식으로 동작합니다.
리액티브 프로그래밍의 핵심 특징은 다음과 같습니다:
서블릿 API는 Java에서 웹 애플리케이션을 개발할 때 사용하는 표준 인터페이스입니다. 클라이언트의 요청을 받아 처리하고 응답을 생성하는 서버 측 컴포넌트를 개발하기 위한 규약입니다.
서블릿 API의 주요 특징은 다음과 같습니다:
Spring WebFlux는 Spring 5에서 도입된 리액티브 웹 프레임워크입니다. 전통적인 서블릿 API를 사용하는 Spring MVC와 달리, WebFlux는 비동기-논블로킹 방식으로 동작하며 Project Reactor를 기반으로 합니다.
Project Reactor는 Spring WebFlux의 핵심 기반이 되는 리액티브 프로그래밍 라이브러리입니다. JVM에서 동작하는 완전한 비동기-논블로킹 리액티브 프로그래밍 기반을 제공합니다.
리액티브 스트림 구현체:
핵심 타입:
Mono<T>: 0 또는 1개의 결과를 표현하는 리액티브 타입Flux<T>: 0에서 N개의 결과를 표현하는 리액티브 타입풍부한 연산자:
map, flatMap, reduce 등filter, distinct, take 등zip, merge, concat 등onErrorResume, retry, timeout 등스케줄러:
Schedulers.immediate(): 현재 스레드에서 실행Schedulers.single(): 단일 재사용 가능한 스레드에서 실행Schedulers.elastic(): 신축적인 스레드 풀에서 실행 (deprecated)Schedulers.boundedElastic(): 제한된 신축적 스레드 풀에서 실행Schedulers.parallel(): 고정 크기 워커 풀에서 실행간단한 Project Reactor 예시:
// 간단한 데이터 스트림 생성 및 처리
Flux<String> names = Flux.just("홍길동", "김철수", "이영희");
// 이 부분은 세 개의 문자열("홍길동", "김철수", "이영희")을 포함하는 Flux 스트림을 생성합니다.
// Flux는 0개에서 N개의 데이터를 발행할 수 있는 Publisher입니다.
names.map(String::toUpperCase)
.filter(name -> name.length() > 2)
.subscribe(
name -> System.out.println("Name: " + name),
error -> System.err.println("Error: " + error),
() -> System.out.println("Completed")
);
// map 연산자는 스트림의 각 항목을 변환합니다.
// 여기서는 각 이름을 대문자로 변환합니다.
// filter 연산자는 조건을 만족하는 항목만 통과시킵니다.
// 여기서는 길이가 2보다 큰 이름만 선택합니다. 모든 이름이 2글자 이상이므로 모두 통과될 것입니다.
// subscribe 메서드는 세 개의 콜백을 받습니다:
// 첫 번째 콜백: 각 데이터 항목이 발행될 때 호출됨 (onNext)
// 두 번째 콜백: 오류가 발생했을 때 호출됨 (onError)
// 세 번째 콜백: 스트림이 정상적으로 완료되었을 때 호출됨 (onComplete)
// 중요한 점:
// 지연 실행: 위 코드에서 map과 filter 연산자를 호출해도 실제 처리는 일어나지 않습니다.
// subscribe가 호출될 때까지 실제 데이터 처리는 지연됩니다.
// 선언적 스타일: 무엇을 처리할지 명령적으로 지시하는 대신, 데이터 스트림을 어떻게 변환하고 처리할지 선언적으로 정의합니다.
WebFlux는 다음과 같은 특징을 가지고 있습니다:
Publisher, Subscriber, Subscription, Processor 인터페이스 기반Spring MVC와 WebFlux의 주요 차이점을 알아보겠습니다:
| 특징 | Spring MVC | Spring WebFlux |
|---|---|---|
| I/O 모델 | 동기-블로킹 | 비동기-논블로킹 |
| 기반 기술 | 서블릿 API | 리액티브 스트림 |
| 서버 | 톰캣, 제티 등 | 네티, 언더토우, 톰캣, 제티 등 |
| 프로그래밍 모델 | 명령형 | 함수형, 리액티브 |
| 데이터 처리 | 컬렉션, 단일 값 | Flux<T>, Mono<T> |
Spring MVC는 요청당 하나의 스레드를 할당하는 모델로, 대량의 동시 요청을 처리할 때는 스레드 수가 증가하여 컨텍스트 스위칭 비용과 메모리 사용량이 증가합니다. 반면 WebFlux는 이벤트 루프 모델을 사용하여 적은 수의 스레드로 많은 요청을 처리할 수 있습니다.
[Spring MVC]
클라이언트 요청 -> 서블릿 스레드 할당 -> 블로킹 처리 -> 응답 -> 스레드 반환
[Spring WebFlux]
클라이언트 요청 -> 이벤트 루프에 등록 -> 비동기 처리 -> 콜백으로 응답
초기 HTTP/1.0에서는 기본적으로 요청마다 새로운 TCP 연결을 열고 응답 후 즉시 닫는 방식이었습니다. 이는 매 요청마다 TCP 연결 설정(3-way handshake)과 종료 과정이 필요해 오버헤드가 컸습니다.
HTTP/1.1(1997년)부터 Keep-Alive가 기본 기능으로 도입되었습니다. 이는 하나의 TCP 연결을 여러 HTTP 요청/응답에 재사용하는 기술입니다.
Client Server
| |
|------- 요청 1 -------->|
|<------ 응답 1 ---------|
| | (연결 유지)
|------- 요청 2 -------->|
|<------ 응답 2 ---------|
| | (연결 유지)
|------- 요청 3 -------->|
|<------ 응답 3 ---------|
HTTP/1.1 요청 헤더:
Connection: keep-alive
HTTP/1.1에서는 이 설정이 기본값이므로 명시적으로 지정하지 않아도 됩니다.
HTTP/1.1은 연결 유지를 지원했지만, "Head-of-Line Blocking" 문제가 있었습니다. 하나의 연결에서 여러 요청을 보내도 응답은 순서대로 받아야 했기 때문에, 앞선 요청의 처리가 지연되면 뒤의 요청도 함께 지연되었습니다.
HTTP/2(2015년)는 연결 유지 개념을 더 발전시켰습니다. 단일 TCP 연결 내에서 여러 요청과 응답을 동시에 처리할 수 있는 멀티플렉싱(multiplexing)을 도입했습니다.
Client 한 TCP 연결 Server
| |
|------- 요청 1 ------------------->|
|------- 요청 2 ------------------->| (동시 처리)
|------- 요청 3 ------------------->|
| |
|<-------- 응답 2 ------------------|
|<-------- 응답 1 ------------------|
|<-------- 응답 3 ------------------|
WebFlux는 이러한 HTTP 프로토콜의 특성을 활용하여 비동기-논블로킹 방식으로 요청을 처리합니다:
따라서, HTTP 연결 유지 기능은 오래전부터 존재했던 개념이며, WebFlux는 이런 기존 프로토콜 기능을 최대한 활용하여 효율적인 비동기 웹 애플리케이션을 구현할 수 있게 해줍니다.
비동기 스트리밍은 데이터를 하나의 큰 덩어리로 처리하는 것이 아니라, 작은 조각으로 나누어 처리하는 방식입니다. 이를 통해 메모리 효율성을 높이고 응답성을 개선할 수 있습니다.
전통적인 접근 방식:
1. 클라이언트가 요청 보냄
2. 서버가 전체 데이터를 메모리에 로드
3. 데이터 처리 완료 후 전체 응답 반환
4. 클라이언트가 응답 받음
클라이언트 ---- 요청 ----> 서버
<--- 대기 ----
<--- 전체 응답 ----
비동기 스트리밍 방식:
1. 클라이언트가 요청 보냄
2. 서버가 데이터를 조금씩 처리하면서 바로 전송
3. 클라이언트가 데이터를 수신하는 동시에 처리 시작
4. 모든 데이터가 전송 완료될 때까지 반복
클라이언트 ---- 요청 ----> 서버
<--- 데이터 조각 1 ----
<--- 데이터 조각 2 ----
<--- 데이터 조각 3 ----
...
<--- 데이터 조각 N ----
<--- 완료 신호 ----
비동기 스트리밍은 이벤트 루프 모델을 기반으로 합니다:
+-------------+
| 이벤트 루프 |
+-------------+
|
+------------------------+
| |
+-------+ +-------+ +-------+
| 작업 1 | | 작업 2 | | 작업 3 |
+-------+ +-------+ +-------+
리액티브 스트림은 비동기 스트림 처리를 위한 표준으로, 다음 네 가지 인터페이스를 정의합니다:
// 데이터를 생성하고 발행하는 역할
public interface Publisher<T> {
void subscribe(Subscriber<? super T> subscriber);
}
// 발행된 데이터를 구독하는 역할
public interface Subscriber<T> {
void onSubscribe(Subscription s);
void onNext(T t);
void onError(Throwable t);
void onComplete();
}
// 발행자와 구독자 사이의 연결을 제어
public interface Subscription {
void request(long n); // 백프레셔 구현의 핵심
void cancel();
}
// 데이터를 변환하는 역할
public interface Processor<T, R> extends Subscriber<T>, Publisher<R> {
}
백프레셔는 구독자가 처리할 수 있는 속도보다 발행자가 데이터를 빠르게 생성할 때, 구독자가 처리 가능한 양을 제어하는 메커니즘입니다. 이를 통해 시스템 과부하를 방지할 수 있습니다.
[빠른 생산자]
|
| (초당 1000개 생성)
v
[백프레셔 메커니즘]
| Subscription.request(100) // 100개만 요청
v
[느린 소비자]
| (초당 100개 처리)
백프레셔가 없는 경우:
백프레셔 전략:
WebFlux는 Project Reactor 라이브러리를 사용하며, Mono와 Flux 두 가지 핵심 타입이 있습니다.
Mono는 0 또는 1개의 결과를 표현하는 리액티브 타입입니다. Optional과 유사하지만, 비동기-논블로킹 방식으로 동작합니다.
// 단일 값을 가지는 Mono 생성
Mono<String> mono = Mono.just("Hello");
// 이것은 정상적으로 하나의 값을 발행하는 Mono입니다
// 구독하면 onNext("Hello")가 호출되고 이어서 onComplete()가 호출됩니다
// 비어있는 Mono 생성
Mono<String> emptyMono = Mono.empty();
// 이것은 값 없이 즉시 완료되는 Mono입니다
// 구독하면 onNext() 없이 바로 onComplete()가 호출됩니다
// API 호출 결과가 없거나, 조건에 맞는 데이터가 없을 때 사용합니다
// 에러를 포함하는 Mono 생성
Mono<String> errorMono = Mono.error(new RuntimeException("에러 발생"));
// 이것은 오류를 발행하는 Mono입니다
// 구독하면 onNext() 없이 바로 onError(exception)가 호출됩니다
// 비즈니스 로직 오류나 네트워크 문제 등을 표현할 때 사용합니다
// Mono 구독하기
mono.subscribe(
value -> System.out.println("값: " + value),
error -> System.err.println("에러: " + error.getMessage()),
() -> System.out.println("완료")
);
Flux는 0에서 N개의 결과를 표현하는 리액티브 타입입니다. Java의 Stream과 유사하지만, 비동기-논블로킹 방식으로 동작하며 백프레셔를 지원합니다.
// 여러 값을 가지는 Flux 생성
Flux<String> flux = Flux.just("사과", "바나나", "오렌지");
// 이 방식은 명시적으로 지정한 요소들로 구성된 Flux를 생성합니다.
// 구독하면 "사과", "바나나", "오렌지"를 순서대로 발행한 후 완료됩니다.
// 고정된 데이터 집합으로 작업할 때 유용합니다.
// 범위의 값을 생성하는 Flux
Flux<Integer> rangeFlux = Flux.range(1, 5); // 1, 2, 3, 4, 5
// 이 방식은 지정된 범위의 정수값을 순서대로 발행하는 Flux를 생성합니다.
// 시작값(1)부터 시작해서 개수(5)만큼의 값을 생성합니다.
// 구독하면 1, 2, 3, 4, 5를 순서대로 발행한 후 완료됩니다.
// 페이지네이션이나 반복적인 작업에 유용합니다.
// 시간 간격으로 값을 생성하는 Flux
Flux<Long> intervalFlux = Flux.interval(Duration.ofSeconds(1)); // 매 초마다 값 생성
// 이 방식은 일정 시간 간격으로 값을 발행하는 Flux를 생성합니다.
// 시작부터 매 초마다 0, 1, 2, 3, ... 등의 증가하는 Long 값을 발행합니다.
// 중요한 점은 이 Flux는 자동으로 완료되지 않습니다(무한 스트림).
// 주기적인 작업이나 폴링, 타이머 기반 이벤트에 유용합니다.
// Flux 구독하기
flux.subscribe(
value -> System.out.println("값: " + value),
error -> System.err.println("에러: " + error.getMessage()),
() -> System.out.println("완료")
);
Mono와 Flux는 다양한 연산자를 제공합니다:
Flux<String> names = Flux.just("홍길동", "김철수", "이영희", "박영수");
// 필터링
Flux<String> filteredNames = names.filter(name -> name.length() > 2);
// 변환
Flux<Integer> nameLengths = names.map(String::length);
// 데이터 조합
Flux<String> combined = Flux.zip(
names,
nameLengths,
(name, length) -> name + ": " + length + "글자"
);
// 에러 처리
Flux<String> withErrorHandling = names
.flatMap(name -> {
if (name.equals("김철수")) {
return Mono.error(new RuntimeException("김철수는 거부됨"));
}
return Mono.just(name);
})
.onErrorResume(e -> Mono.just("알 수 없음"));
이제 간단한 WebFlux 애플리케이션을 만들어보겠습니다. 먼저 의존성을 추가해야 합니다:
// Gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-webflux'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.projectreactor:reactor-test'
}
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/api/books")
public class BookController {
private final BookRepository bookRepository;
public BookController(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
@GetMapping
public Flux<Book> getAllBooks() {
return bookRepository.findAll();
}
@GetMapping("/{id}")
public Mono<Book> getBookById(@PathVariable String id) {
return bookRepository.findById(id)
.switchIfEmpty(Mono.error(new BookNotFoundException(id)));
}
@PostMapping
public Mono<Book> createBook(@RequestBody Book book) {
return bookRepository.save(book);
}
@DeleteMapping("/{id}")
public Mono<Void> deleteBook(@PathVariable String id) {
return bookRepository.deleteById(id);
}
}
WebFlux는 애노테이션 기반 컨트롤러 외에도 함수형 엔드포인트 스타일을 지원합니다.
함수형 엔드포인트 스타일은 WebFlux에서만 제공하는 독특한 기능이며, Spring WebFlux를 사용하는 경우에만 이용할 수 있습니다.
Spring MVC에서는 애노테이션 기반 방식만 사용 가능합니다.
리액티브 모델에서 함수형 엔드포인트는 비동기 이벤트 흐름을 더 잘 표현하기 위한 선택지입니다.
WebFlux에서 애노테이션 방식도 여전히 사용할 수 있습니다.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.server.*;
@Configuration
public class BookRouter {
@Bean
public RouterFunction<ServerResponse> bookRoutes(BookHandler bookHandler) {
return RouterFunctions
.route(RequestPredicates.GET("/functional/books"), bookHandler::getAllBooks)
.andRoute(RequestPredicates.GET("/functional/books/{id}"), bookHandler::getBookById)
.andRoute(RequestPredicates.POST("/functional/books"), bookHandler::createBook)
.andRoute(RequestPredicates.DELETE("/functional/books/{id}"), bookHandler::deleteBook);
}
}
// Handler 클래스
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.*;
import reactor.core.publisher.Mono;
@Component
public class BookHandler {
private final BookRepository bookRepository;
public BookHandler(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
public Mono<ServerResponse> getAllBooks(ServerRequest request) {
return ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(bookRepository.findAll(), Book.class);
}
public Mono<ServerResponse> getBookById(ServerRequest request) {
String id = request.pathVariable("id");
return bookRepository.findById(id)
.flatMap(book -> ServerResponse.ok().bodyValue(book))
.switchIfEmpty(ServerResponse.notFound().build());
}
// 다른 핸들러 메서드들...
}
WebFlux 애플리케이션에서는 비동기-논블로킹 데이터 액세스가 중요합니다. Spring Data R2DBC와 Spring Data MongoDB Reactive를 살펴보겠습니다.
R2DBC(Reactive Relational Database Connectivity)는 관계형 데이터베이스를 위한 리액티브 API입니다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc'
implementation 'io.r2dbc:r2dbc-h2' // H2 데이터베이스 사용 시
}
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
public interface BookRepository extends ReactiveCrudRepository<Book, String> {
Flux<Book> findByAuthor(String author);
Mono<Book> findByIsbn(String isbn);
}
MongoDB는 리액티브 드라이버를 제공하여 WebFlux와 잘 통합됩니다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb-reactive'
}
import org.springframework.data.mongodb.repository.ReactiveMongoRepository;
public interface BookRepository extends ReactiveMongoRepository<Book, String> {
Flux<Book> findByAuthor(String author);
Mono<Book> findByIsbn(String isbn);
}
WebFlux를 학습하면서 파악한 장단점을 정리해보겠습니다.
Spring WebFlux와 리액티브 프로그래밍은 현대 애플리케이션 개발에 중요한 개념이라고 생각합니다. 특히 동시성이 중요한 서비스에서 리액티브 접근 방식은 큰 이점을 제공할 수 있습니다.
그러나 모든 상황에 WebFlux가 최선은 아니며, 적절한 사용 사례를 판단하는 것이 중요합니다.