Spring WebFlux Functional Endpoints 핵심 정리 — RouterFunction·HandlerFunction·ServerRequest·ServerResponse 빌더 패턴, 라우팅과 핸들러 분리, 조건부 라우팅·중첩 라우팅·라우트별 필터까지. @RestController와 비교·선택 기준, pathVariable 타입 변환 함정, bodyValue vs body 구분을 코드와 비유로 풀어 정리.
이 글은 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 대응도 깔끔해요.
| 컴포넌트 | 역할 | 어노테이션 방식 대응 |
|---|---|---|
RouterFunction | URL + HTTP 메서드를 핸들러에 매핑 | @GetMapping, @PostMapping |
HandlerFunction | 실제 요청 처리 함수 | @RestController 메서드 본문 |
ServerRequest | HTTP 요청 정보 (불변) | @PathVariable, @RequestBody |
ServerResponse | HTTP 응답 빌더 (불변) | 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 위에서 동작하고, 같은 프로젝트에서 혼용도 가능합니다.
| 항목 | @RestController | Functional Endpoints |
|---|---|---|
| API 정의 위치 | 각 컨트롤러 메서드 | RouterConfig에 중앙 집중 |
| 경로 변수 | @PathVariable Integer id (자동 변환) | Integer.parseInt(request.pathVariable("id")) |
| 요청 본문 | @RequestBody Mono | request.bodyToMono(CustomerDto.class) |
| 응답 생성 | return dto; 또는 ResponseEntity | ServerResponse.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)은 항상 String —Integer.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로 우선순위 지정 가능
시리즈 다른 편
같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.