Spring Boot

Spring WebFlux 입문 - 리액티브 프로그래밍의 모든 것

백엔드 개발자 김승원 2026. 4. 2. 15:49

들어가며

전통적인 Spring MVC는 요청당 하나의 스레드를 할당하는 블로킹 방식으로 동작합니다. 대부분의 서비스에서는 이 모델이 충분하지만, 동시 접속자가 매우 많거나 외부 API 호출이 빈번한 환경에서는 스레드 풀이 고갈되는 문제가 발생합니다. Spring WebFlux는 논블로킹 리액티브 방식으로 이 문제를 해결합니다. 이 글에서는 리액티브 프로그래밍의 핵심 개념부터 WebFlux 실전 코드까지 단계적으로 살펴보겠습니다.

1. 리액티브 프로그래밍이란

리액티브 프로그래밍은 데이터 스트림과 변화의 전파를 중심으로 하는 비동기 프로그래밍 패러다임입니다. Reactive Streams 사양은 다음 4가지 인터페이스를 정의합니다.

  • Publisher - 데이터를 생성하고 발행
  • Subscriber - 데이터를 구독하고 소비
  • Subscription - Publisher-Subscriber 간 연결, 백프레셔 제어
  • Processor - Publisher이면서 동시에 Subscriber

Spring WebFlux는 내부적으로 Project Reactor 라이브러리를 사용하며, 핵심 타입은 MonoFlux입니다.

구분 Mono<T> Flux<T>
데이터 개수 0 또는 1개 0~N개
용도 단일 응답 (findById 등) 컬렉션 응답 (findAll, 스트리밍)
비유 Optional<T>의 비동기 버전 List<T>의 비동기 스트림 버전

2. Mono와 Flux 기본 사용법

Mono 기본

// 값 생성
Mono<String> mono = Mono.just("Hello WebFlux");

// 빈 Mono
Mono<String> empty = Mono.empty();

// 에러 Mono
Mono<String> error = Mono.error(new RuntimeException("에러 발생"));

// 변환
Mono<Integer> length = Mono.just("Hello")
    .map(String::length);           // 5

// 비동기 체이닝 (flatMap)
Mono<User> user = Mono.just(userId)
    .flatMap(id -> userRepository.findById(id));  // Mono<User> 반환

// 기본값 설정
Mono<String> withDefault = Mono.empty()
    .defaultIfEmpty("기본값");

// 에러 처리
Mono<String> recovered = mono
    .onErrorReturn("에러 시 기본값")
    .onErrorResume(e -> Mono.just("대체 로직"));

Flux 기본

// 값 생성
Flux<String> flux = Flux.just("A", "B", "C");

// 범위 생성
Flux<Integer> range = Flux.range(1, 10); // 1~10

// 리스트에서 생성
Flux<String> fromList = Flux.fromIterable(List.of("X", "Y", "Z"));

// 변환 및 필터링
Flux<String> result = Flux.just("apple", "banana", "cherry", "avocado")
    .filter(s -> s.startsWith("a"))
    .map(String::toUpperCase);  // APPLE, AVOCADO

// flatMap - 비동기 변환 (순서 보장 X)
Flux<Order> orders = Flux.fromIterable(userIds)
    .flatMap(id -> orderRepository.findByUserId(id));

// concatMap - 비동기 변환 (순서 보장 O)
Flux<Order> orderedOrders = Flux.fromIterable(userIds)
    .concatMap(id -> orderRepository.findByUserId(id));

// 배치 처리
Flux<List<String>> buffered = flux.buffer(2); // [A,B], [C]

3. WebFlux vs MVC 비교

항목 Spring MVC Spring WebFlux
스레드 모델 요청당 1스레드 (블로킹) 이벤트 루프 (논블로킹)
기본 서버 Tomcat (서블릿) Netty (논블로킹)
반환 타입 T, ResponseEntity<T> Mono<T>, Flux<T>
DB 드라이버 JDBC (블로킹) R2DBC (논블로킹)
적합한 상황 일반 CRUD, 동기 작업 높은 동시성, I/O 집약
학습 곡선 낮음 높음 (리액티브 사고 필요)
디버깅 쉬움 (콜스택 명확) 어려움 (비동기 체인)

4. 어노테이션 기반 컨트롤러

기존 Spring MVC와 거의 동일한 형태로 WebFlux 컨트롤러를 작성할 수 있습니다.

@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {

    private final UserRepository userRepository;
    private final WebClient webClient;

    @GetMapping
    public Flux<UserResponse> getAllUsers() {
        return userRepository.findAll()
            .map(UserResponse::from);
    }

    @GetMapping("/{id}")
    public Mono<ResponseEntity<UserResponse>> getUser(@PathVariable Long id) {
        return userRepository.findById(id)
            .map(user -> ResponseEntity.ok(UserResponse.from(user)))
            .defaultIfEmpty(ResponseEntity.notFound().build());
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Mono<UserResponse> createUser(@RequestBody @Valid Mono<UserRequest> request) {
        return request
            .map(UserRequest::toEntity)
            .flatMap(userRepository::save)
            .map(UserResponse::from);
    }

    @DeleteMapping("/{id}")
    public Mono<ResponseEntity<Void>> deleteUser(@PathVariable Long id) {
        return userRepository.findById(id)
            .flatMap(user -> userRepository.delete(user)
                .then(Mono.just(ResponseEntity.noContent().<Void>build())))
            .defaultIfEmpty(ResponseEntity.notFound().build());
    }
}

5. Router Functions (함수형 엔드포인트)

WebFlux만의 고유한 방식인 Router Functions를 사용하면 라우팅 로직을 함수형으로 작성할 수 있습니다.

Handler 정의

@Component
@RequiredArgsConstructor
public class UserHandler {

    private final UserRepository userRepository;

    public Mono<ServerResponse> getAllUsers(ServerRequest request) {
        Flux<UserResponse> users = userRepository.findAll()
            .map(UserResponse::from);
        return ServerResponse.ok()
            .contentType(MediaType.APPLICATION_JSON)
            .body(users, UserResponse.class);
    }

    public Mono<ServerResponse> getUserById(ServerRequest request) {
        Long id = Long.parseLong(request.pathVariable("id"));
        return userRepository.findById(id)
            .flatMap(user -> ServerResponse.ok()
                .bodyValue(UserResponse.from(user)))
            .switchIfEmpty(ServerResponse.notFound().build());
    }

    public Mono<ServerResponse> createUser(ServerRequest request) {
        return request.bodyToMono(UserRequest.class)
            .map(UserRequest::toEntity)
            .flatMap(userRepository::save)
            .flatMap(user -> ServerResponse
                .created(URI.create("/api/users/" + user.getId()))
                .bodyValue(UserResponse.from(user)));
    }
}

Router 정의

@Configuration
public class UserRouter {

    @Bean
    public RouterFunction<ServerResponse> userRoutes(UserHandler handler) {
        return RouterFunctions.route()
            .path("/api/users", builder -> builder
                .GET("", handler::getAllUsers)
                .GET("/{id}", handler::getUserById)
                .POST("", handler::createUser)
            )
            .build();
    }
}

6. WebClient - 논블로킹 HTTP 클라이언트

WebFlux 환경에서는 RestTemplate 대신 WebClient를 사용합니다. RestTemplate은 블로킹 방식이므로 리액티브 파이프라인의 장점을 상쇄합니다.

@Configuration
public class WebClientConfig {

    @Bean
    public WebClient webClient() {
        return WebClient.builder()
            .baseUrl("https://api.example.com")
            .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
            .filter(ExchangeFilterFunctions.basicAuthentication("user", "password"))
            .build();
    }
}

@Service
@RequiredArgsConstructor
public class ExternalApiService {

    private final WebClient webClient;

    // GET 요청
    public Mono<ProductResponse> getProduct(Long id) {
        return webClient.get()
            .uri("/products/{id}", id)
            .retrieve()
            .onStatus(HttpStatusCode::is4xxClientError,
                response -> Mono.error(new NotFoundException("상품을 찾을 수 없습니다.")))
            .onStatus(HttpStatusCode::is5xxServerError,
                response -> Mono.error(new ServiceException("외부 서비스 오류")))
            .bodyToMono(ProductResponse.class)
            .timeout(Duration.ofSeconds(5))
            .retryWhen(Retry.backoff(3, Duration.ofSeconds(1)));
    }

    // POST 요청
    public Mono<OrderResponse> createOrder(OrderRequest request) {
        return webClient.post()
            .uri("/orders")
            .bodyValue(request)
            .retrieve()
            .bodyToMono(OrderResponse.class);
    }

    // 여러 API를 병렬 호출
    public Mono<AggregatedResponse> getAggregatedData(Long userId) {
        Mono<UserProfile> profile = webClient.get()
            .uri("/users/{id}", userId)
            .retrieve().bodyToMono(UserProfile.class);

        Mono<List<Order>> orders = webClient.get()
            .uri("/users/{id}/orders", userId)
            .retrieve().bodyToFlux(Order.class).collectList();

        return Mono.zip(profile, orders)
            .map(tuple -> new AggregatedResponse(tuple.getT1(), tuple.getT2()));
    }
}

7. R2DBC 연동 (리액티브 DB 접근)

WebFlux에서 JDBC를 사용하면 블로킹이 발생하므로, 논블로킹 DB 드라이버인 R2DBC를 사용해야 합니다.

의존성

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc'
    runtimeOnly 'io.asyncer:r2dbc-mysql:1.1.0'  // MySQL
    // runtimeOnly 'org.postgresql:r2dbc-postgresql'  // PostgreSQL
}

Entity와 Repository

@Table("users")
public record User(
    @Id Long id,
    String email,
    String name,
    LocalDateTime createdAt
) {}

public interface UserRepository extends ReactiveCrudRepository<User, Long> {

    Flux<User> findByNameContaining(String name);

    @Query("SELECT * FROM users WHERE email = :email")
    Mono<User> findByEmail(String email);

    @Query("SELECT * FROM users ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
    Flux<User> findAllPaged(int limit, long offset);
}

8. 언제 WebFlux를 써야 하는가

WebFlux 도입 여부는 다음 기준으로 판단하세요.

WebFlux가 적합한 경우

  • 동시 접속자가 매우 많은 서비스 (실시간 알림, 채팅 등)
  • 외부 API 호출이 빈번한 게이트웨이/BFF 서비스
  • SSE(Server-Sent Events)나 WebSocket 기반 스트리밍
  • 마이크로서비스 간 비동기 통신이 핵심인 구조

MVC를 유지하는 것이 나은 경우

  • 기존 JDBC/JPA를 그대로 사용해야 하는 경우
  • 팀원 대부분이 리액티브에 익숙하지 않은 경우
  • 단순 CRUD 위주의 어드민/백오피스 시스템
  • 트래픽이 보통 수준이고, Tomcat 스레드 풀로 충분한 경우

주의: WebFlux와 MVC는 같은 프로젝트에서 혼용하지 않는 것을 권장합니다. 한쪽으로 통일하되, 블로킹 코드가 섞이면 리액티브의 장점이 사라집니다.

마치며

Spring WebFlux는 높은 동시성과 효율적인 리소스 사용이 필요한 환경에서 강력한 선택지입니다. 핵심 정리는 다음과 같습니다.

  • Mono는 0~1개, Flux는 0~N개의 비동기 데이터 스트림을 표현합니다.
  • 어노테이션 컨트롤러 방식과 Router Functions 방식 중 팀 스타일에 맞게 선택하세요.
  • HTTP 클라이언트는 반드시 WebClient를 사용하고, DB는 R2DBC를 사용해야 논블로킹의 이점을 유지합니다.
  • 모든 서비스에 WebFlux가 필요하지는 않습니다. 트래픽 패턴과 팀 역량을 고려해서 도입 여부를 결정하세요.

다음 글에서는 WebFlux 환경에서의 에러 핸들링, 테스트(WebTestClient), 그리고 Spring Cloud Gateway 연동을 다루겠습니다.