함수형 Spring WebFlux

Spring WebFlux는 어노테이션 기반(@RestController)과 함수형 라우터 두 가지 방식을 지원한다. 함수형 라우터는 라우팅 로직을 코드로 명시적으로 표현하는 방식으로, FP의 “함수를 값으로 다루기” 원칙에 가깝다.


RouterFunction — 함수형 라우터

// 어노테이션 방식
@RestController
@RequestMapping("/api/orders")
class OrderController {
    @GetMapping("/{id}")
    fun getOrder(@PathVariable id: Long) = ...
}
 
// 함수형 라우터 방식
@Configuration
class OrderRouter {
 
    @Bean
    fun orderRoutes(handler: OrderHandler): RouterFunction<ServerResponse> =
        router {
            "/api/orders".nest {
                GET("/{id}", handler::getOrder)
                POST("/", handler::createOrder)
                PUT("/{id}/cancel", handler::cancelOrder)
                GET("/", handler::listOrders)
            }
        }
}

라우터는 어디로 라우팅할지를 선언하고, Handler가 실제 로직을 담당한다.


Handler 구현

@Component
class OrderHandler(private val orderService: OrderService) {
 
    suspend fun getOrder(request: ServerRequest): ServerResponse {
        val id = request.pathVariable("id").toLong()
 
        return orderService.findOrder(OrderId(id))
            .fold(
                ifLeft  = { error -> error.toServerResponse() },
                ifRight = { order -> ServerResponse.ok().bodyValueAndAwait(order.toResponse()) }
            )
    }
 
    suspend fun createOrder(request: ServerRequest): ServerResponse {
        val command = request.awaitBody<CreateOrderCommand>()
 
        return orderService.createOrder(command).fold(
            ifLeft  = { it.toServerResponse() },
            ifRight = { order ->
                ServerResponse.status(201)
                    .bodyValueAndAwait(order.toResponse())
            }
        )
    }
 
    suspend fun listOrders(request: ServerRequest): ServerResponse {
        val userId = request.queryParam("userId")
            .map { UserId(it.toLong()) }
            .orElse(null)
            ?: return ServerResponse.badRequest().buildAndAwait()
 
        val orders = orderService.getOrdersFlow(userId)
            .map { it.toResponse() }
            .toList()
 
        return ServerResponse.ok().bodyValueAndAwait(orders)
    }
}

Mono/Flux를 Either처럼 다루기

WebFlux의 Mono<T>는 Arrow의 Either<E, A>와 유사한 개념이다. 둘 다 “값이 있거나 없거나(에러)“를 표현한다.

// Mono — 성공 또는 에러 시그널
Mono.just(order)           // 성공
Mono.error(NotFoundException())  // 에러
 
// Either — Right 또는 Left
order.right()
OrderError.NotFound.left()

서비스가 Either를 반환한다면 Mono로 변환할 수 있다.

fun <E : AppError, T> Either<E, T>.toMono(): Mono<T> = fold(
    ifLeft  = { Mono.error(it.toException()) },
    ifRight = { Mono.just(it) }
)
 
fun <E : AppError, T> Either<E, T>.toServerResponseMono(
    successStatus: HttpStatus = HttpStatus.OK
): Mono<ServerResponse> = fold(
    ifLeft  = { error ->
        ServerResponse.status(error.httpStatus())
            .bodyValue(ErrorResponse(error.message))
    },
    ifRight = { value ->
        ServerResponse.status(successStatus).bodyValue(value)
    }
)

Flux — 스트리밍 응답

@Bean
fun orderStreamRoutes(handler: OrderStreamHandler): RouterFunction<ServerResponse> =
    router {
        GET("/api/orders/stream", handler::streamOrders)
        GET("/api/events/orders", handler::orderEventStream)
    }
 
@Component
class OrderStreamHandler(private val orderService: OrderService) {
 
    // Server-Sent Events 스트리밍
    suspend fun streamOrders(request: ServerRequest): ServerResponse {
        val userId = UserId(request.queryParam("userId").orElseThrow().toLong())
 
        return ServerResponse.ok()
            .contentType(MediaType.TEXT_EVENT_STREAM)
            .bodyAndAwait(
                orderService.getOrdersFlow(userId).map { it.toResponse() }.asFlux()
            )
    }
 
    // 이벤트 스트림 (Flux 직접 사용)
    fun orderEventStream(request: ServerRequest): Mono<ServerResponse> {
        val events: Flux<OrderEvent> = orderEventService.subscribe()
            .filter { it.type != OrderEventType.HEARTBEAT }
            .map { it.toSseEvent() }
 
        return ServerResponse.ok()
            .contentType(MediaType.TEXT_EVENT_STREAM)
            .body(events, OrderEvent::class.java)
    }
}

에러 처리 — onErrorResume vs Either

WebFlux에서 에러 처리 방법이 두 가지 있다.

방법 1: onErrorResume (리액티브 방식)

fun getOrder(orderId: OrderId): Mono<Order> =
    Mono.fromCallable { orderRepository.findById(orderId) }
        .subscribeOn(Schedulers.boundedElastic())
        .onErrorResume(EntityNotFoundException::class.java) { e ->
            Mono.error(OrderNotFoundException(orderId))
        }
        .onErrorResume(DataAccessException::class.java) { e ->
            Mono.error(DatabaseException("DB 오류", e))
        }

방법 2: Either와 조합 (타입 안전)

// 서비스 — Either 반환
suspend fun getOrder(orderId: OrderId): Either<OrderError, Order>
 
// Handler — Either를 ServerResponse로 변환
suspend fun getOrder(request: ServerRequest): ServerResponse {
    val id = OrderId(request.pathVariable("id").toLong())
    return orderService.getOrder(id).fold(
        ifLeft  = { error ->
            when (error) {
                is OrderError.NotFound -> ServerResponse.notFound().buildAndAwait()
                else -> ServerResponse.status(500).buildAndAwait()
            }
        },
        ifRight = { order ->
            ServerResponse.ok().bodyValueAndAwait(order.toResponse())
        }
    )
}

Either 방식은 에러 타입이 컴파일 타임에 확정되어 처리 누락이 없다. onErrorResume은 런타임 예외 타입에 의존한다.


WebFilter — 함수형 미들웨어

@Component
@Order(1)
class RequestLoggingFilter : WebFilter {
 
    override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
        val request = exchange.request
        val requestId = UUID.randomUUID().toString()
 
        return chain.filter(exchange)
            .doFirst {
                log.info("→ {} {} [{}]", request.method, request.path, requestId)
            }
            .doOnSuccess {
                log.info("← {} {} [{}]", exchange.response.statusCode, request.path, requestId)
            }
    }
}
// 코루틴 WebFilter
@Component
class JwtAuthFilter(private val jwtService: JwtService) : CoWebFilter() {
 
    override suspend fun filter(exchange: ServerWebExchange, chain: CoWebFilterChain) {
        val token = exchange.request.headers.getFirst("Authorization")
            ?.removePrefix("Bearer ")
 
        if (token != null) {
            jwtService.validateToken(token).fold(
                ifLeft  = { /* 무시 — Security가 처리 */ },
                ifRight = { claims ->
                    val auth = UsernamePasswordAuthenticationToken(
                        claims.subject, null, claims.authorities()
                    )
                    val context = SecurityContextImpl(auth)
                    exchange.attributes[SecurityContext::class.java.name] = context
                }
            )
        }
 
        chain.filter(exchange)
    }
}

함수형 라우터 vs 어노테이션 방식

어노테이션 (@RestController)함수형 라우터
라우팅 방식어노테이션으로 선언코드로 명시
가독성간결 (소규모)라우팅 구조 한눈에 파악
테스트@WebFluxTestRouterFunctions.toHttpHandler()로 단독 테스트
유연성제한적동적 라우팅 가능
Spring 통합자동수동 설정

소규모 API는 어노테이션, 라우팅 구조가 복잡하거나 미들웨어 조합이 필요한 경우 함수형 라우터가 적합하다. 두 방식을 혼합해서 써도 된다.


함수형 라우터 테스트

class OrderRouterTest {
 
    private val orderService = mockk<OrderService>()
    private val handler = OrderHandler(orderService)
    private val router = OrderRouter().orderRoutes(handler)
 
    private val client = WebTestClient
        .bindToRouterFunction(router)
        .build()
 
    @Test
    fun `GET /api/orders/{id} — 존재하는 주문 반환`() {
        val order = Order(OrderId(1L), OrderStatus.Pending, ...)
        coEvery { orderService.findOrder(OrderId(1L)) } returns order.right()
 
        client.get().uri("/api/orders/1")
            .exchange()
            .expectStatus().isOk
            .expectBody<OrderResponse>()
            .value { response ->
                assertThat(response.id).isEqualTo(1L)
            }
    }
}

@SpringBootTest 없이도 라우터를 테스트할 수 있다.