Spring WebMvc.fn이란?
WebMvc.fn은 Spring MVC에서 함수형 프로그래밍 스타일로 HTTP 엔드포인트를 정의하는 방식이다. 기존 @Controller + @RequestMapping 어노테이션 기반과 달리, RouterFunction과 HandlerFunction을 조합하여 라우팅과 핸들러를 코드로 구성한다.
Spring WebFlux의 함수형 엔드포인트가 먼저 도입되었고, Spring 5.2부터 서블릿 기반 MVC에서도 동일한 패턴을 사용할 수 있게 되었다. 이 글에서는 RouterFunction 구성, 핸들러 분리, 필터 체이닝, 검증, 예외 처리, 그리고 어노테이션 방식과의 혼용 패턴을 심화하여 다룬다.
핵심 개념: RouterFunction과 HandlerFunction
// HandlerFunction: 요청 → 응답 변환 (Controller 메서드에 해당)
@FunctionalInterface
public interface HandlerFunction<T extends ServerResponse> {
T handle(ServerRequest request) throws Exception;
}
// RouterFunction: 요청 → 핸들러 매핑 (RequestMapping에 해당)
@FunctionalInterface
public interface RouterFunction<T extends ServerResponse> {
Optional<HandlerFunction<T>> route(ServerRequest request);
}
기본 라우터 구성
import static org.springframework.web.servlet.function.RouterFunctions.route;
import static org.springframework.web.servlet.function.RequestPredicates.*;
@Configuration
public class UserRouterConfig {
@Bean
public RouterFunction<ServerResponse> userRoutes(UserHandler handler) {
return route()
.GET("/api/users", handler::getAll)
.GET("/api/users/{id}", handler::getById)
.POST("/api/users", handler::create)
.PUT("/api/users/{id}", handler::update)
.DELETE("/api/users/{id}", handler::delete)
.build();
}
}
Handler 클래스
@Component
public class UserHandler {
private final UserService userService;
public UserHandler(UserService userService) {
this.userService = userService;
}
public ServerResponse getAll(ServerRequest request) {
List<UserDto> users = userService.findAll();
return ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(users);
}
public ServerResponse getById(ServerRequest request) {
Long id = Long.parseLong(request.pathVariable("id"));
return userService.findById(id)
.map(user -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(user))
.orElse(ServerResponse.notFound().build());
}
public ServerResponse create(ServerRequest request) throws Exception {
CreateUserRequest body = request.body(CreateUserRequest.class);
UserDto created = userService.create(body);
URI location = URI.create("/api/users/" + created.getId());
return ServerResponse.created(location)
.contentType(MediaType.APPLICATION_JSON)
.body(created);
}
public ServerResponse update(ServerRequest request) throws Exception {
Long id = Long.parseLong(request.pathVariable("id"));
UpdateUserRequest body = request.body(UpdateUserRequest.class);
UserDto updated = userService.update(id, body);
return ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(updated);
}
public ServerResponse delete(ServerRequest request) {
Long id = Long.parseLong(request.pathVariable("id"));
userService.delete(id);
return ServerResponse.noContent().build();
}
}
중첩 라우팅 (path + nest)
공통 경로 접두사를 nest로 묶어 가독성을 높인다.
@Bean
public RouterFunction<ServerResponse> apiRoutes(
UserHandler userHandler,
OrderHandler orderHandler,
ProductHandler productHandler) {
return route()
.path("/api", builder -> builder
.path("/users", userBuilder -> userBuilder
.GET("", userHandler::getAll)
.GET("/{id}", userHandler::getById)
.POST("", userHandler::create)
.PUT("/{id}", userHandler::update)
.DELETE("/{id}", userHandler::delete)
.path("/{userId}/orders", orderBuilder -> orderBuilder
.GET("", orderHandler::getByUser)
.POST("", orderHandler::createForUser)
)
)
.path("/products", productBuilder -> productBuilder
.GET("", productHandler::getAll)
.GET("/{id}", productHandler::getById)
.GET("/search", productHandler::search)
)
)
.build();
}
RequestPredicate: 고급 매칭
@Bean
public RouterFunction<ServerResponse> advancedRoutes(UserHandler handler) {
return route()
// Content-Type 매칭
.POST("/api/users",
contentType(MediaType.APPLICATION_JSON),
handler::createFromJson)
.POST("/api/users",
contentType(MediaType.MULTIPART_FORM_DATA),
handler::createFromForm)
// Accept 헤더 매칭
.GET("/api/users",
accept(MediaType.APPLICATION_JSON),
handler::getAllJson)
.GET("/api/users",
accept(MediaType.TEXT_EVENT_STREAM),
handler::getAllSse)
// 커스텀 Predicate (헤더 기반)
.GET("/api/users",
request -> "admin".equals(request.headers().firstHeader("X-Role")),
handler::getAllAdmin)
// Query Parameter 기반 분기
.GET("/api/users",
request -> request.param("export").isPresent(),
handler::exportCsv)
.GET("/api/users", handler::getAll)
.build();
}
Filter: 요청/응답 가공
어노테이션 방식의 HandlerInterceptor에 해당한다. 라우터 레벨에서 필터를 체이닝한다.
@Bean
public RouterFunction<ServerResponse> filteredRoutes(UserHandler handler) {
return route()
.path("/api/users", builder -> builder
.GET("", handler::getAll)
.POST("", handler::create)
// 이 경로 그룹에만 필터 적용
.filter(loggingFilter())
.filter(authFilter())
)
.build();
}
// 로깅 필터
private HandlerFilterFunction<ServerResponse, ServerResponse> loggingFilter() {
return (request, next) -> {
long start = System.currentTimeMillis();
String method = request.method().name();
String path = request.path();
ServerResponse response = next.handle(request);
long elapsed = System.currentTimeMillis() - start;
log.info("{} {} → {} ({}ms)", method, path,
response.statusCode().value(), elapsed);
return response;
};
}
// 인증 필터
private HandlerFilterFunction<ServerResponse, ServerResponse> authFilter() {
return (request, next) -> {
String token = request.headers().firstHeader("Authorization");
if (token == null || !token.startsWith("Bearer ")) {
return ServerResponse.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Missing or invalid token"));
}
// 토큰 검증 로직
try {
Claims claims = jwtParser.parseClaimsJws(
token.substring(7)).getBody();
// request attribute에 사용자 정보 저장
ServerRequest enriched = ServerRequest.from(request)
.attribute("userId", claims.getSubject())
.build();
return next.handle(enriched);
} catch (JwtException e) {
return ServerResponse.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid token"));
}
};
}
예외 처리
@Bean
public RouterFunction<ServerResponse> routesWithErrorHandling(
UserHandler handler) {
return route()
.GET("/api/users/{id}", handler::getById)
.POST("/api/users", handler::create)
// 글로벌 예외 핸들러
.onError(UserNotFoundException.class, (error, request) ->
ServerResponse.status(HttpStatus.NOT_FOUND)
.body(Map.of(
"error", "User not found",
"message", error.getMessage(),
"path", request.path()
))
)
.onError(IllegalArgumentException.class, (error, request) ->
ServerResponse.badRequest()
.body(Map.of(
"error", "Bad request",
"message", error.getMessage()
))
)
.onError(Exception.class, (error, request) -> {
log.error("Unexpected error on {}", request.path(), error);
return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Internal server error"));
})
.build();
}
검증 (Validation)
함수형 엔드포인트에서는 @Valid 어노테이션이 자동 적용되지 않으므로, 직접 Validator를 호출해야 한다.
@Component
public class UserHandler {
private final Validator validator;
private final UserService userService;
public ServerResponse create(ServerRequest request) throws Exception {
CreateUserRequest body = request.body(CreateUserRequest.class);
// 수동 검증
Errors errors = new BeanPropertyBindingResult(body, "user");
validator.validate(body, errors);
if (errors.hasErrors()) {
List<Map<String, String>> fieldErrors = errors.getFieldErrors()
.stream()
.map(e -> Map.of(
"field", e.getField(),
"message", e.getDefaultMessage()
))
.toList();
return ServerResponse.badRequest()
.body(Map.of("errors", fieldErrors));
}
UserDto created = userService.create(body);
return ServerResponse.created(
URI.create("/api/users/" + created.getId()))
.body(created);
}
}
// 검증 유틸 헬퍼
public class ValidationHelper {
private final Validator validator;
public <T> Optional<ServerResponse> validate(T body) {
Errors errors = new BeanPropertyBindingResult(body, "request");
validator.validate(body, errors);
if (errors.hasErrors()) {
return Optional.of(ServerResponse.badRequest()
.body(Map.of("errors", errors.getFieldErrors().stream()
.map(e -> Map.of("field", e.getField(),
"message", e.getDefaultMessage()))
.toList())));
}
return Optional.empty();
}
}
어노테이션 방식과 혼용
기존 @Controller와 RouterFunction은 같은 애플리케이션에서 공존할 수 있다. 점진적 마이그레이션에 유용하다.
// 기존 어노테이션 컨트롤러 (유지)
@RestController
@RequestMapping("/api/legacy")
public class LegacyController {
@GetMapping("/users")
public List<UserDto> getUsers() { ... }
}
// 새로운 API는 함수형으로
@Configuration
public class NewApiRouterConfig {
@Bean
public RouterFunction<ServerResponse> newApiRoutes(UserHandler handler) {
return route()
.path("/api/v2/users", b -> b
.GET("", handler::getAll)
.POST("", handler::create)
)
.build();
}
}
// 두 방식 모두 동시에 동작!
테스트
@WebMvcTest
@Import({UserRouterConfig.class, UserHandler.class})
class UserRoutesTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
void shouldReturnUsers() throws Exception {
when(userService.findAll()).thenReturn(
List.of(new UserDto(1L, "Alice", "alice@test.com")));
mockMvc.perform(get("/api/users")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].name").value("Alice"));
}
@Test
void shouldReturn404ForMissingUser() throws Exception {
when(userService.findById(999L)).thenReturn(Optional.empty());
mockMvc.perform(get("/api/users/999"))
.andExpect(status().isNotFound());
}
// RouterFunction 직접 테스트 (MockMvc 없이)
@Test
void shouldMatchRoute() {
RouterFunction<ServerResponse> routes = new UserRouterConfig()
.userRoutes(new UserHandler(userService));
MockServerRequest request = MockServerRequest.builder()
.method(HttpMethod.GET)
.uri(URI.create("/api/users"))
.build();
Optional<HandlerFunction<ServerResponse>> handler =
routes.route(request);
assertThat(handler).isPresent();
}
}
어노테이션 vs 함수형: 언제 뭘 쓸까?
| 기준 | @Controller | RouterFunction |
|---|---|---|
| 라우팅 가시성 | 클래스에 분산 | 한 곳에 집중 |
| 검증 | @Valid 자동 | 수동 호출 필요 |
| Swagger/OpenAPI | 자동 생성 | 별도 설정 필요 |
| 동적 라우팅 | 어려움 | 코드로 자유롭게 |
| 테스트 | MockMvc 중심 | RouterFunction 직접 테스트 가능 |
| 학습 곡선 | 낮음 | 함수형 패러다임 이해 필요 |
마무리
Spring WebMvc.fn은 라우팅을 코드로 선언하여 전체 API 구조를 한 눈에 파악할 수 있게 한다. 어노테이션 방식과 공존 가능하므로, 새로운 API부터 점진적으로 도입할 수 있다.
실무 적용 핵심:
- nest()로 경로 그룹핑: REST 리소스별 깔끔한 구조
- Filter 체이닝: 경로 그룹별 인증·로깅 분리
- Validation 헬퍼: @Valid 대체 유틸 필수 구현
- onError: 예외 타입별 응답 매핑
Spring DispatcherServlet 동작 원리를 이해하면 RouterFunction이 내부적으로 어떻게 처리되는지 파악할 수 있고, Spring ArgumentResolver 심화와 비교하면 요청 파라미터 처리 방식의 차이를 명확히 알 수 있다.