Spring WebMvc.fn 함수형 라우팅

Spring WebMvc.fn이란?

WebMvc.fn은 Spring MVC에서 함수형 프로그래밍 스타일로 HTTP 엔드포인트를 정의하는 방식이다. 기존 @Controller + @RequestMapping 어노테이션 기반과 달리, RouterFunctionHandlerFunction을 조합하여 라우팅과 핸들러를 코드로 구성한다.

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();
    }
}

어노테이션 방식과 혼용

기존 @ControllerRouterFunction은 같은 애플리케이션에서 공존할 수 있다. 점진적 마이그레이션에 유용하다.

// 기존 어노테이션 컨트롤러 (유지)
@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 심화와 비교하면 요청 파라미터 처리 방식의 차이를 명확히 알 수 있다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux