스프링 WebFlux를 활용한 고급 반응형 에러 처리

스프링 WebFlux란 무엇인가?

스프링 WebFlux는 스프링 5에서 새롭게 추가된 모듈 중 하나로, 반응형 프로그래밍을 위한 기능을 제공합니다. 이전까지 스프링에서 제공되던 기술들은 주로 동기식 방식으로 동작하였으나, 스프링 WebFlux는 비동기식 방식으로 동작하면서도 높은 성능과 확장성을 제공합니다.

스프링 WebFlux는 Reactive Streams API를 기반으로 하며, Reactor라는 라이브러리를 사용하여 구현됩니다. Reactor는 Java 8의 함수형 프로그래밍 기능을 활용하여 Reactive Streams API를 구현한 라이브러리로, 스프링 WebFlux에서도 이를 활용하여 비동기식으로 HTTP 요청을 처리하고 응답합니다.

스프링 WebFlux는 기존 스프링 MVC와 마찬가지로 RESTful 웹 서비스를 개발하기 위한 기능을 제공합니다. 또한, 스프링 WebFlux는 스프링의 다른 기능들과 연동하여 사용할 수 있으며, 스프링 부트와 함께 사용하면 더욱 쉽게 개발할 수 있습니다.

반응형 에러 처리의 필요성과 이점

반응형 프로그래밍에서는 비동기식 방식으로 동작하기 때문에, 에러 처리는 매우 중요한 문제입니다. 만약 에러 처리가 제대로 이루어지지 않는다면, 프로그램이 예측할 수 없는 동작을 하거나 부정확한 결과를 반환할 수 있습니다.

스프링 WebFlux에서는 Reactive Streams API를 기반으로 하기 때문에, 에러 처리도 Reactive Streams API의 규약을 따라야 합니다. 예를 들어, Reactive Streams API에서는 Subscriber가 Exception을 던져서는 안 되고, instead, onError() 메서드를 호출하여 에러를 처리해야 합니다.

반응형 에러 처리를 제대로 수행하기 위해서는, 에러가 발생하는 시점에서 어떤 작업이 수행되었는지에 따라 다르게 처리해야 합니다. 예를 들어, HTTP 요청을 처리하는 도중 에러가 발생했다면, 클라이언트에게 적절한 HTTP 응답 코드를 반환해야 합니다.

반응형 에러 처리를 제대로 수행하면, 다음과 같은 이점을 얻을 수 있습니다.

  • 신속한 대응: 에러가 발생하면 즉시 대응할 수 있기 때문에, 문제점을 빠르게 해결할 수 있습니다.
  • 예측 가능한 동작: 에러 처리를 제대로 수행하면, 프로그램이 예측 가능한 동작을 하게 됩니다. 이는 프로그램의 신뢰성을 높이고, 유지 보수를 용이하게 만듭니다.
  • 사용자 경험 개선: 에러 처리를 적절하게 수행하면, 사용자가 프로그램을 보다 쉽게 이해하고 사용할 수 있게 됩니다.

고급 반응형 에러 처리를 위한 스프링 WebFlux 활용 방법

스프링 WebFlux에서는 다양한 방식으로 반응형 에러 처리를 할 수 있습니다. 다음은 스프링 WebFlux에서 고급 반응형 에러 처리를 위한 방법입니다.

1. Global Error Handler 구현하기

Global Error Handler는 스프링에서 제공하는 기능으로, 애플리케이션 전역에서 발생하는 에러를 처리하는 데 사용됩니다. Global Error Handler는 다음과 같이 구현할 수 있습니다.

@ControllerAdvice
public class GlobalErrorHandler {

    @ExceptionHandler(Exception.class)
    public Mono<ResponseEntity> handleException(Exception ex) {
        return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                                     .body("Internal Server Error"));
    }
}

위 예제는 @ControllerAdvice 어노테이션을 사용하여 Global Error Handler를 구현한 것입니다. handleException() 메서드는 Exception이 발생하면 호출되며, Mono<ResponseEntity> 형태의 결과를 반환합니다. 이 결과는 HTTP 응답으로 전송됩니다.

2. WebExceptionHandler 구현하기

WebExceptionHandler는 Global Error Handler와 비슷한 기능을 제공하지만, 더욱 세밀한 제어가 가능합니다. WebExceptionHandler는 다음과 같이 구현할 수 있습니다.

@Component
public class CustomWebExceptionHandler implements WebExceptionHandler {

    @Override
    public Mono handle(ServerWebExchange exchange, Throwable ex) {
        if (ex instanceof NotFoundException) {
            return handleNotFoundException((NotFoundException) ex, exchange);
        } else if (ex instanceof BadRequestException) {
            return handleBadRequestException((BadRequestException) ex, exchange);
        } else if (ex instanceof ForbiddenException) {
            return handleForbiddenException((ForbiddenException) ex, exchange);
        } else {
            return handleOtherException(ex, exchange);
        }
    }

    private Mono handleNotFoundException(NotFoundException ex, ServerWebExchange exchange) {
        exchange.getResponse().setStatusCode(HttpStatus.NOT_FOUND);
        return exchange.getResponse().setComplete();
    }

    private Mono handleBadRequestException(BadRequestException ex, ServerWebExchange exchange) {
        exchange.getResponse().setStatusCode(HttpStatus.BAD_REQUEST);
        return exchange.getResponse().setComplete();
    }

    private Mono handleForbiddenException(ForbiddenException ex, ServerWebExchange exchange) {
        exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
        return exchange.getResponse().setComplete();
    }

    private Mono handleOtherException(Throwable ex, ServerWebExchange exchange) {
        exchange.getResponse().setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR);
        return exchange.getResponse().setComplete();
    }
}

위 예제는 WebExceptionHandler를 구현한 것으로, handle() 메서드에서 처리할 에러를 분류하고, 각각의 에러에 대한 처리를 수행합니다. 예를 들어, NotFoundException이 발생하면 404 Not Found HTTP 응답을 반환합니다.

3. 함수형 엔드포인트에서의 에러 처리

스프링 WebFlux에서는 함수형 엔드포인트를 사용하여 더욱 간결하고 가독성 좋은 코드를 작성할 수 있습니다. 함수형 엔드포인트에서의 에러 처리는 다음과 같이 수행할 수 있습니다.

RouterFunction routes() {
    return route(GET("/foo"), this::handleFoo)
            .andRoute(GET("/bar"), this::handleBar);
}

Mono handleFoo(ServerRequest request) {
    return service.getFoo()
            .map(foo -> ServerResponse.ok().body(foo))
            .onErrorResume(NotFoundException.class, ex -> ServerResponse.status(HttpStatus.NOT_FOUND).build())
            .onErrorResume(BadRequestException.class, ex -> ServerResponse.status(HttpStatus.BAD_REQUEST).build())
            .onErrorResume(ForbiddenException.class, ex -> ServerResponse.status(HttpStatus.FORBIDDEN).build())
            .onErrorResume(ex -> ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
}

Mono handleBar(ServerRequest request) {
    throw new RuntimeException("Internal Server Error");
}

위 예제는 RouterFunction을 사용하여 엔드포인트를 정의하고, handleFoo()와 handleBar()에서 각각의 에러에 대한 처리를 수행합니다. handleFoo()에서는 onErrorResume() 메서드를 사용하여 NotFoundException, BadRequestException, ForbiddenException 및 그 외의 예외에 대한 처리를 수행하며, handleBar()에서는 예외를 직접 던져서 에러 처리를 수행합니다.

예제와 함께 배우는 스프링 WebFlux를 활용한 고급 반응형 에러 처리

다음은 예제와 함께 스프링 WebFlux를 활용한 고급 반응형 에러 처리를 배워보겠습니다.

1. 예제 소개

우리는 레스트랑이라는 음식점에서 주문을 받는 웹 애플리케이션을 개발하고 있습니다. 이 애플리케이션은 다음과 같은 요구사항을 가지고 있습니다.

  • 사용자는 메뉴를 선택하여 주문할 수 있습니다.
  • 주문이 완료되면 사용자에게 주문 번호를 알려줍니다.
  • 주문이 실패한 경우, 사용자에게 적절한 에러 메시지를 보여줍니다.
  • 주문이 성공하면, 주문 내역을 관리하는 시스템에 주문 정보를 저장합니다.

위 요구사항을 충족하기 위해, 스프링 WebFlux를 사용하여 다음과 같은 코드를 작성할 수 있습니다.

@Configuration
public class RestaurantConfig {

    @Bean
    public RouterFunction routes(RestaurantHandler handler) {
        return route(GET("/menu"), handler::getMenu)
                .andRoute(POST("/order"), handler::placeOrder);
    }
}

@Service
public class RestaurantHandler {

    private final MenuService menuService;
    private final OrderService orderService;

    public RestaurantHandler(MenuService menuService, OrderService orderService) {
        this.menuService = menuService;
        this.orderService = orderService;
    }

    public Mono getMenu(ServerRequest request) {
        return menuService.getMenu()
                .map(menu -> ServerResponse.ok().body(menu))
                .onErrorResume(ex -> ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
    }

    public Mono placeOrder(ServerRequest request) {
        return request.bodyToMono(Order.class)
                .flatMap(order -> orderService.placeOrder(order)
                        .map(orderNumber -> {
                            order.setOrderNumber(orderNumber);
                            return order;
                        }))
                .flatMap(order -> ServerResponse.ok().body(order))
                .onErrorResume(NotFoundException.class, ex -> ServerResponse.status(HttpStatus.NOT_FOUND).build())
                .onErrorResume(BadRequestException.class, ex -> ServerResponse.status(HttpStatus.BAD_REQUEST).build())
                .onErrorResume(ForbiddenException.class, ex -> ServerResponse.status(HttpStatus.FORBIDDEN).build())
                .onErrorResume(ex -> ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
    }
}

@Service
public class MenuService {

    public Mono getMenu() {
        // ...
    }
}

@Service
public class OrderService {

    public Mono placeOrder(Order order) {
        // ...
    }
}

public class Order {

    private String menuId;
    private String customerName;
    private String customerPhone;
    private String customerEmail;
    private Integer quantity;
    private String orderNumber;

    // getters and setters
}

public class Menu {

    private List items;

    // getters and setters
}

public class MenuItem {

    private String id;
    private String name;
    private String description;
    private Integer price;

    // getters and setters
}

위 코드에서는 RestaurantConfig 클래스에서 RouterFunction을 정의하고, RestaurantHandler 클래스에서 실제 요청을 처리하는 메서드를 구현합니다. getMenu() 메서드에서는 MenuService를 사용하여 메뉴 정보를 반환하며, placeOrder() 메서드에서는 OrderService를 사용하여 주문을 처리합니다. 이때, placeOrder() 메서드에서는 onErrorResume() 메서드를 사용하여 각각의 에러에 대한 처리를 수행합니다.

2. 에러 처리 테스트

우리는 이제 위에서 작성한 코드를 사용하여 실제로 주문을 처리하고, 에러 처리가 제대로 동작하는지 테스트해보겠습니다. 먼저, getMenu() 메서드를 테스트하기 위한 코드는 다음과 같습니다.

@Test
public void testGetMenu() {
    MenuService menuService = mock(MenuService.class);
    when(menuService.getMenu()).thenReturn(Mono.just(new Menu()));

    WebTestClient client = WebTestClient.bindToRouterFunction(new RestaurantConfig().routes(new RestaurantHandler(menuService, null))).build();
    client.get().uri("/menu").exchange()
            .expectStatus().isOk()
            .expectBody(Menu.class).isEqualTo(new Menu());
}

위 코드에서는 getMenu() 메서드에서 반환하는 Menu 객체를 mock 객체로 대체하여 테스트합니다. 이후, WebTestClient를 사용하여 /menu 엔드포인트에 GET 요청을 보내고, HTTP 응답 코드와 응답 본문이 예상대로 반환되는지 검증합니다.

이번에는 placeOrder() 메서드를 테스트하기 위한 코드를 작성해보겠습니다.

@Test
public void testPlaceOrder() {
    OrderService orderService = mock(OrderService.class);
    when(orderService.placeOrder(any())).thenReturn(Mono.just("123"));

    WebTestClient client = WebTestClient.bindToRouterFunction(new RestaurantConfig().routes(new RestaurantHandler(null, orderService))).build();
    Order request = new Order();
    request.setMenuId("1");
    request.setCustomerName("John Doe");
    request.setCustomerPhone("123-4567");
    request.setCustomerEmail("john.doe@example.com");
    request.setQuantity(2);
    client.post().uri("/order").body(Mono.just(request), Order.class).exchange()
            .expectStatus().isOk()
            .expectBody(Order.class).isEqualTo(request.withOrderNumber("123"));
}

위 코드에서는 placeOrder() 메서드에서 반환하는 orderNumber를 mock 객체로 대체하여 테스트합니다. 이후, WebTestClient를 사용하여 /order 엔드포인트에 POST 요청을 보내고, HTTP 응답 코드와 응답 본문이 예상대로 반환되는지 검증합니다.

마지막으로, placeOrder() 메서드에서 발생하는 각각의 에러에 대한 처리가 제대로 동작하는지 테스트해보겠습니다.


@Test
public void testPlaceOrderNotFound() {
    OrderService orderService = mock(OrderService.class);
    when(orderService.placeOrder(any())).thenReturn(Mono.error(new NotFoundException("Menu not found")));

    WebTestClient client = WebTestClient.bindToRouterFunction(new RestaurantConfig().routes(new RestaurantHandler(null, orderService))).build();
    Order request = new Order();
    request.setMenuId("1");
    request.setCustomerName("John Doe");
    request.setCustomerPhone("123-4567");
    request.set