Spring WebFlux Functional Endpoints — RouterFunction·HandlerFunction 완전 정리

2026-05-03AWS SAA-C03 스터디

Spring WebFlux Functional Endpoints 핵심 정리 — RouterFunction·HandlerFunction·ServerRequest·ServerResponse 빌더 패턴, 라우팅과 핸들러 분리, 조건부 라우팅·중첩 라우팅·라우트별 필터까지. @RestController와 비교·선택 기준, pathVariable 타입 변환 함정, bodyValue vs body 구분을 코드와 비유로 풀어 정리.

📚 Spring WebFlux 핵심 정리 · 7편 / 14편 — RouterFunction·HandlerFunction 완전 정리

이 글은 Spring WebFlux 핵심 정리 시리즈의 일곱 번째 편입니다. 지금까지 @RestController·@GetMapping 같은 어노테이션 방식으로 API를 만들었다면, 이번 7편은 같은 WebFlux 위에서 완전히 다른 스타일로 API를 만드는 방법 — Functional Endpoints입니다.

어노테이션 방식이 나쁜 게 아니에요. 대부분의 상황에서 @RestController가 더 간결하고 친숙합니다. Functional Endpoints는 라우팅이 코드로 보여야 할 때, 라우트 그룹에 특정 필터만 적용해야 할 때, 조건부 라우팅을 세밀하게 제어해야 할 때 빛을 발하는 대안이에요. 이번 편의 핵심 질문은 세 가지입니다. "RouterFunction + HandlerFunction 구조가 어노테이션 방식과 어떻게 다른가, 언제 선택하는가, 어떻게 동작하는가" — 이 세 가지만 잡아도 충분합니다.

본문 흐름은 주문서 라우팅 책 비유를 따라 풀어 가요. Functional Endpoints = "주문서 라우팅 책" — 어떤 요청이 어떤 핸들러로 가는지가 한 페이지(RouterConfig)에 깔끔하게 정리돼 있고, 핸들러 파일만 열면 처리 로직만 보이는 구조예요.

학습 노트

이 시리즈는 Spring 공식 문서, Project Reactor 공식 문서, Reactive Streams 명세, 여러 공개 학습 자료를 참고해 한국어 학습 노트로 풀어쓴 자료입니다.

Functional Endpoints는 spring-boot-starter-webflux만 있으면 바로 시작할 수 있어요. 어노테이션 컨트롤러와 같은 프로젝트에 공존도 가능합니다.

Functional Endpoints가 처음엔 왜 낯설게 느껴질까요

이유는 네 가지예요.

첫째, 어노테이션 방식이 이미 충분히 잘 동작하는데 굳이? 라는 의문이 듭니다. @GetMapping("/products/{id}")가 직관적이고 IDE 지원도 풍부한데, RouterFunction이라는 새 개념을 왜 배워야 하는지 처음엔 이유가 안 보여요.

둘째, 라우팅과 핸들러가 완전히 분리됩니다. 어노테이션 방식에서는 @RestController 클래스 안에 @GetMapping이 붙은 메서드가 직접 처리까지 했는데, Functional Endpoints에서는 라우팅 정의(RouterConfig)와 실제 처리 로직(RequestHandler)이 별도 클래스예요.

셋째, @PathVariable이 자동으로 타입 변환되지 않습니다. 어노테이션 방식에서 @PathVariable Integer id라고 쓰면 Spring이 자동으로 String을 Integer로 바꿔줬어요. Functional Endpoints에서 request.pathVariable("id")는 항상 String을 반환해요. 직접 Integer.parseInt()를 호출해야 합니다.

넷째, 응답 생성이 빌더 패턴입니다. return dto; 한 줄 대신 ServerResponse.ok().contentType(APPLICATION_JSON).bodyValue(dto)처럼 메서드를 이어 써야 해요. 간단한 API에서는 어노테이션 방식보다 코드량이 많아 보입니다.

해결법은 하나예요. Functional Endpoints를 "주문서 라우팅 책"으로 잡고 풀면 갑자기 명확해집니다. 라우팅 책(RouterConfig) 한 장에서 URL → 담당 핸들러 매핑이 한눈에 보이고, 핸들러 파일(RequestHandler)만 열면 처리 로직만 보여요. 어노테이션 방식은 라우팅 정보가 클래스 여기저기 흩어져 있는데, Functional 방식은 한 페이지에 모입니다.

Functional Endpoints 구조 — RouterFunction + HandlerFunction

구성 요소 두 가지만 잡으면 됩니다.

RouterConfig (@Configuration)
    └── RouterFunction<ServerResponse> Bean
            └── RouterFunctions.route()
                    ├── .GET("/path", handler::method)
                    ├── .POST("/path", handler::method)
                    ├── .filter((req, next) -> ...)
                    └── .build()

RequestHandler (@Component)
    ├── ServerRequest  ← HTTP 요청 정보 (불변)
    └── ServerResponse ← HTTP 응답 생성 (빌더 패턴)

어노테이션 방식과 1:1 대응도 깔끔해요.

컴포넌트역할어노테이션 방식 대응
RouterFunctionURL + HTTP 메서드를 핸들러에 매핑@GetMapping, @PostMapping
HandlerFunction실제 요청 처리 함수@RestController 메서드 본문
ServerRequestHTTP 요청 정보 (불변)@PathVariable, @RequestBody
ServerResponseHTTP 응답 빌더 (불변)ResponseEntity

비유로 한 줄 정리 — Functional Endpoints = "주문서 라우팅 책". 어떤 요청이 어디로 가는지 한 페이지에 정리되어 있고, 담당자(HandlerFunction)는 자신에게 온 요청만 처리하는 구조예요.

RouterConfig — 라우팅 정의 한 곳에 모으기

@Configuration 클래스에서 RouterFunction Bean을 정의합니다.

@Configuration
public class RouterConfig {

    @Bean
    public RouterFunction<ServerResponse> customerRoutes(RequestHandler handler) {
        return RouterFunctions.route()
                .GET("/customers", handler::allCustomers)
                .GET("/customers/{id}", handler::getCustomerById)
                .POST("/customers", handler::saveCustomer)
                .PUT("/customers/{id}", handler::updateCustomer)
                .DELETE("/customers/{id}", handler::deleteCustomer)
                .build();
    }

    // 여러 RouterFunction Bean을 별도로 정의 가능
    @Bean
    public RouterFunction<ServerResponse> productRoutes(ProductHandler handler) {
        return RouterFunctions.route()
                .GET("/products", handler::allProducts)
                .GET("/products/{id}", handler::getProductById)
                .build();
    }
}

여기서 시험 함정이 하나 있어요. 라우트 순서가 중요합니다. /customers/{id}/customers/search보다 먼저 정의하면, /customers/search 요청이 {id}에 매핑돼 버려요. 더 구체적인 경로를 먼저 정의해야 합니다.

// 잘못된 순서
.GET("/customers/{id}", handler::getById)
.GET("/customers/search", handler::search)  // 절대 매핑 안 됨!

// 올바른 순서
.GET("/customers/search", handler::search)  // 구체적인 경로 먼저
.GET("/customers/{id}", handler::getById)

RequestHandler — 요청 처리 로직

@Component로 등록되고 실제 처리 로직을 담습니다.

@Component
@RequiredArgsConstructor
@Slf4j
public class RequestHandler {

    private final CustomerService customerService;

    // GET /customers — 전체 조회 (Flux 스트리밍 응답)
    public Mono<ServerResponse> allCustomers(ServerRequest request) {
        return ServerResponse.ok()
                .contentType(MediaType.APPLICATION_JSON)
                .body(customerService.allCustomers(), CustomerDto.class);
    }

    // GET /customers/{id} — ID 조회
    public Mono<ServerResponse> getCustomerById(ServerRequest request) {
        Integer id = Integer.parseInt(request.pathVariable("id"));  // 직접 변환 필요

        return customerService.getCustomerById(id)
                .flatMap(dto -> ServerResponse.ok()
                        .contentType(MediaType.APPLICATION_JSON)
                        .bodyValue(dto))
                .switchIfEmpty(ServerResponse.notFound().build());  // 비어있으면 404
    }

    // POST /customers — 생성
    public Mono<ServerResponse> saveCustomer(ServerRequest request) {
        return request.bodyToMono(CustomerDto.class)  // 요청 본문 역직렬화
                .flatMap(dto -> customerService.saveCustomer(Mono.just(dto)))
                .flatMap(saved -> ServerResponse
                        .status(HttpStatus.CREATED)
                        .contentType(MediaType.APPLICATION_JSON)
                        .bodyValue(saved));
    }

    // DELETE /customers/{id} — 삭제 (응답 본문 없음)
    public Mono<ServerResponse> deleteCustomer(ServerRequest request) {
        Integer id = Integer.parseInt(request.pathVariable("id"));

        return customerService.deleteCustomer(id)
                .then(ServerResponse.noContent().build());  // .then(): 완료 후 204 응답
    }
}

여기서 시험 함정이 하나 있어요. pathVariable은 항상 String을 반환합니다. 어노테이션 방식의 @PathVariable Integer id처럼 자동 변환이 없어요. Integer.parseInt(request.pathVariable("id"))로 직접 변환해야 하고, 숫자가 아닌 값이 들어오면 NumberFormatException이 발생하므로 에러 처리도 함께 넣어야 합니다.

ServerRequest에서 데이터 꺼내는 방법

ServerRequest가 제공하는 정보 꺼내는 패턴을 한 번 정리해 두면 편해요.

// 경로 변수 — 항상 String, 직접 변환 필요
Integer id = Integer.parseInt(request.pathVariable("id"));

// 쿼리 파라미터 — Optional<String> 반환
int page = Integer.parseInt(request.queryParam("page").orElse("0"));
int size = Integer.parseInt(request.queryParam("size").orElse("10"));

// 헤더
String authToken = request.headers().firstHeader("auth-token");

// 요청 본문 — Mono로 비동기 읽기
Mono<CustomerDto> body = request.bodyToMono(CustomerDto.class);

// 요청 본문 — Flux로 스트리밍 읽기
Flux<CustomerDto> bodies = request.bodyToFlux(CustomerDto.class);

// HTTP 메서드, URI
HttpMethod method = request.method();
URI uri = request.uri();
String path = request.path();

body() vs bodyValue() vs build() — 응답 생성 패턴

ServerResponse 빌더에서 세 가지 메서드를 구분해야 해요.

메서드용도입력 타입
bodyValue(T)단일 객체 직렬화일반 객체 (DTO 등)
body(Publisher, Class)Publisher 스트리밍Mono, Flux
build()응답 본문 없음없음 (notFound, noContent 등)
// 단일 DTO 응답
ServerResponse.ok().bodyValue(customerDto);

// Flux 스트리밍 응답
ServerResponse.ok().body(customerFlux, CustomerDto.class);

// 응답 본문 없음 (204, 404)
ServerResponse.noContent().build();
ServerResponse.notFound().build();

여기서 시험 함정이 하나 있어요. Flux를 bodyValue()에 넣으면 타입 에러가 납니다. bodyValue()는 단일 객체를 직렬화하는 메서드이므로, Flux나 Mono는 반드시 body(publisher, Class) 형태로 전달해야 해요.

라우트별 필터 적용 — Functional Endpoints의 강점

WebFilter는 모든 요청에 전역으로 적용됩니다. 반면 RouterFunction.filter()특정 라우트 그룹에만 필터를 적용할 수 있어요. 이게 Functional Endpoints의 대표적인 강점입니다.

@Bean
public RouterFunction<ServerResponse> securedRoutes(RequestHandler handler) {
    return RouterFunctions.route()
            .GET("/admin/customers", handler::allCustomers)
            .POST("/admin/customers", handler::saveCustomer)
            // 이 RouterFunction의 모든 라우트에만 인증 필터 적용
            .filter((request, next) -> {
                String token = request.headers().firstHeader("auth-token");
                if (token == null || token.isBlank()) {
                    return ServerResponse.status(HttpStatus.UNAUTHORIZED).build();
                }
                return next.handle(request);  // 다음 핸들러로 전달
            })
            .build();
}

@Bean
public RouterFunction<ServerResponse> publicRoutes(RequestHandler handler) {
    return RouterFunctions.route()
            .GET("/public/products", handler::allProducts)
            // 이 RouterFunction에는 필터 없음 — 인증 불필요
            .build();
}

조건부 라우팅과 중첩 라우팅

Functional Endpoints의 또 다른 강점 — RequestPredicates를 조합해 복잡한 조건 라우팅을 깔끔하게 표현할 수 있어요.

// 헤더 버전에 따라 다른 핸들러로 라우팅
@Bean
public RouterFunction<ServerResponse> versionedRoutes(RequestHandler handler) {
    return RouterFunctions.route()
            .GET("/customers",
                 RequestPredicates.headers(h -> "v2".equals(h.firstHeader("API-Version"))),
                 handler::allCustomersV2
            )
            .GET("/customers", handler::allCustomers)  // 기본
            .build();
}

// 중첩 라우팅 — /api/v1 하위 경로 그룹화
@Bean
public RouterFunction<ServerResponse> nestedRoutes(RequestHandler handler) {
    return RouterFunctions.route()
            .nest(RequestPredicates.path("/api/v1"), () ->
                RouterFunctions.route()
                    .GET("/customers", handler::allCustomers)
                    .GET("/customers/{id}", handler::getCustomerById)
                    .POST("/customers", handler::saveCustomer)
                    .build()
            )
            .nest(RequestPredicates.path("/api/v2"), () ->
                RouterFunctions.route()
                    .GET("/customers", handler::allCustomersV2)
                    .build()
            )
            .build();
}

Functional Endpoints에서 @Valid 검증

어노테이션 방식에서는 @Valid가 자동으로 동작했지만, Functional Endpoints에서는 직접 Validator를 주입해서 호출해야 합니다.

@Component
@RequiredArgsConstructor
public class RequestHandler {

    private final CustomerService customerService;
    private final Validator validator;

    public Mono<ServerResponse> saveCustomer(ServerRequest request) {
        return request.bodyToMono(CustomerDto.class)
                .flatMap(dto -> {
                    Set<ConstraintViolation<CustomerDto>> violations = validator.validate(dto);
                    if (!violations.isEmpty()) {
                        List<String> errors = violations.stream()
                                .map(v -> v.getPropertyPath() + ": " + v.getMessage())
                                .collect(Collectors.toList());
                        return ServerResponse.badRequest().bodyValue(errors);
                    }
                    return customerService.saveCustomer(Mono.just(dto))
                            .flatMap(saved -> ServerResponse
                                    .status(HttpStatus.CREATED)
                                    .bodyValue(saved));
                });
    }
}

여기서 시험 함정이 하나 있어요. Functional Endpoints에서 @Valid는 자동으로 적용되지 않습니다. 이 점 때문에 단순한 CRUD API라면 어노테이션 방식(@RestController)이 훨씬 간결해요. Functional 방식은 복잡한 라우팅이 필요한 경우에 선택하는 게 맞습니다.

@RestController vs Functional Endpoints — 선택 기준

두 방식은 같은 WebFlux 위에서 동작하고, 같은 프로젝트에서 혼용도 가능합니다.

항목@RestControllerFunctional Endpoints
API 정의 위치각 컨트롤러 메서드RouterConfig에 중앙 집중
경로 변수@PathVariable Integer id (자동 변환)Integer.parseInt(request.pathVariable("id"))
요청 본문@RequestBody Monorequest.bodyToMono(CustomerDto.class)
응답 생성return dto; 또는 ResponseEntityServerResponse.ok().bodyValue(dto)
검증@Valid 자동Validator 직접 호출
라우트별 필터AOP 필요, 복잡.filter() 간단
조건부 라우팅어렵RequestPredicates 조합
코드량간결상대적으로 장황

Functional이 유리한 경우:

  • 라우팅 조건이 복잡할 때 (헤더, 미디어 타입, 버전별 라우팅)
  • 라우트 그룹에 특정 필터만 적용해야 할 때
  • 라우팅 로직과 핸들러 로직을 완전히 분리하고 싶을 때

어노테이션이 유리한 경우:

  • 팀 대부분이 Spring MVC 경험자일 때 (빠른 적응)
  • 단순한 CRUD API
  • Swagger/OpenAPI 자동 문서화가 필요할 때

자주 만나는 함정 — 시험 직전 압축 노트

여기까지가 WebFlux 7편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.

  • Functional Endpoints = RouterFunction + HandlerFunction — 라우팅 정의와 처리 로직 분리
  • RouterFunctions.route().GET().POST().build() — 라우팅 체인 빌더
  • handler::method 메서드 참조로 라우팅과 핸들러 연결
  • ServerRequest — 경로 변수, 쿼리 파라미터, 헤더, 본문 모두 포함하는 불변 요청 객체
  • ServerResponse — 빌더 패턴 응답 객체 (상태·Content-Type·본문)
  • pathVariable(name)은 항상 StringInteger.parseInt() 직접 변환 필요
  • request.bodyToMono(Class) — 요청 본문 역직렬화 / ServerResponse.ok().bodyValue(dto) — 응답 본문
  • bodyValue(T)는 단일 객체, body(Publisher, Class)는 Flux/Mono — 혼용 시 타입 에러
  • build() — 본문 없는 응답 (notFound().build(), noContent().build())
  • switchIfEmpty(ServerResponse.notFound().build()) — Mono 비어있을 때 404
  • .then(ServerResponse.noContent().build()) — Mono 완료 후 204 응답
  • 라우트 순서 중요 — 구체적인 경로(/customers/search)를 변수 경로(/customers/{id})보다 먼저
  • @Valid 자동 적용 안 됨Validator 직접 주입하여 validator.validate(dto) 호출
  • .filter((req, next) -> ...) — 특정 RouterFunction에만 필터 적용 (WebFilter와 다름)
  • .nest(RequestPredicates.path("/api/v1"), () -> ...) — 중첩 라우팅으로 경로 그룹화
  • RequestPredicates.headers(h -> ...) — 헤더 조건 라우팅
  • @RestController vs Functional — 둘 다 같은 WebFlux 위, 둘 다 맞음. 복잡한 라우팅이면 Functional
  • 두 방식 같은 프로젝트에서 혼용 가능 — 경로만 겹치지 않으면 OK
  • 여러 RouterFunction Bean이 있을 때 @Order로 우선순위 지정 가능

시리즈 다른 편

같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!