WebFlux 예외 처리 완전 정복 — 검증·RFC 7807

2026-05-03AWS SAA-C03 스터디

Spring WebFlux 핵심 정리 시리즈 5편. @Valid + Bean Validation, WebFlux 전용 WebExchangeBindException, @ControllerAdvice 리액티브 모드, ResponseStatusException, RFC 7807 ProblemDetail — 공항 보안검색·통합 안내데스크 비유로 풀어가는 에러 처리 완전 가이드.

📚 Spring WebFlux 핵심 정리 · 5편 / 14편 — 검증·RFC 7807

이 글은 Spring WebFlux 핵심 정리 시리즈의 다섯 번째 편입니다. 4편에서 성공 경로(Happy Path) CRUD를 만들었다면, 이번 편에서는 잘못된 요청과 예외 상황을 우아하게 처리하는 법을 다룹니다. 프로덕션 수준의 API는 에러 처리가 절반이에요.

이번 편 핵심 목표는 두 가지입니다. "어떻게 입력값을 검증하는가""발생한 예외를 일관된 형식으로 클라이언트에게 돌려주는가" — 이 두 가지를 WebFlux 방식으로 풀어 갑니다.

📚 학습 노트

이 시리즈는 Spring 공식 문서, Project Reactor 공식 문서, 여러 리액티브 백엔드 학습 자료를 참고해 한국어 학습 노트로 풀어쓴 자료입니다.

Spring 6.x `ProblemDetail` 클래스로 RFC 7807 표준 에러 응답을 손쉽게 구현할 수 있습니다.

왜 WebFlux 예외 처리가 처음엔 헷갈릴까요

이유는 네 가지예요.

첫째, @Valid를 붙여도 검증이 안 된다는 착각이 생깁니다. Spring MVC에서는 @RequestBody CustomerDto dto@Valid를 붙이면 바로 검증됩니다. WebFlux에서 Mono 타입에 붙이는 방식이 약간 다르고, 검증 실패 예외 타입도 달라요.

둘째, WebExchangeBindException이 낯섭니다. Spring MVC에서는 검증 실패 시 MethodArgumentNotValidException이 발생합니다. WebFlux에서는 WebExchangeBindException이에요. 클래스 계층 구조는 비슷하지만 타입이 다르기 때문에 @ControllerAdvice에서 잘못된 예외 타입을 잡으면 처리가 안 됩니다.

셋째, 빈 Mono를 그냥 반환하면 404가 아니라 204가 됩니다. 4편에서 잠깐 언급한 함정인데, 이번 편에서 switchIfEmpty(Mono.error(...)) 패턴으로 완전히 해결합니다.

넷째, 에러 처리가 파이프라인 곳곳에 분산됩니다. try-catch로 잡던 방식이 아니라 @ControllerAdvice 전역 처리와 switchIfEmpty 파이프라인 내 처리가 서로 어떤 역할인지 처음엔 헷갈려요.

비유 두 개로 잡겠습니다. 검증 = "공항 보안검색" — 탑승구(컨트롤러) 들어오기 전에 짐(요청 데이터)을 검사해요. 금지 물품(잘못된 데이터)은 여기서 걸립니다. 예외 처리 = "고장 난 짐 발견 시 통합 안내데스크로 안내" — 탑승 후 문제가 생기면(서비스 예외) 공항 안내데스크(@ControllerAdvice)에서 통일된 안내(표준 에러 응답)를 해 줍니다.

Bean Validation — DTO 검증 어노테이션

의존성부터 추가합니다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

DTO에 검증 어노테이션을 붙입니다:

public record CustomerDto(
    Integer id,

    @NotBlank(message = "이름을 입력해 주세요")
    @Size(min = 2, max = 50, message = "이름은 2~50자 사이여야 합니다")
    String name,

    @NotEmpty(message = "이메일은 필수입니다")
    @Email(message = "올바른 이메일 형식이 아닙니다")
    String email
) {}

주요 어노테이션 비교:

어노테이션null""" "설명
@NotNull실패통과통과null만 거부
@NotEmpty실패실패통과null·빈 문자열 거부
@NotBlank실패실패실패null·빈 문자열·공백만 거부

문자열 필드는 대부분 @NotBlank가 가장 적합해요. @NotNull" "(공백) 을 통과시키기 때문에 의도와 다른 경우가 많습니다.

한 줄 정리 — Bean Validation 어노테이션은 DTO에. @NotBlank가 문자열 필드 기본 선택.

@Valid와 WebExchangeBindException

컨트롤러에서 @Valid를 붙입니다:

@PostMapping
public Mono<ResponseEntity<CustomerDto>> saveCustomer(
        @RequestBody @Valid Mono<CustomerDto> dtoMono) {
    return customerService.saveCustomer(dtoMono)
            .map(dto -> ResponseEntity.status(HttpStatus.CREATED).body(dto));
}

@PutMapping("/{id}")
public Mono<ResponseEntity<CustomerDto>> updateCustomer(
        @PathVariable Integer id,
        @RequestBody @Valid Mono<CustomerDto> dtoMono) {
    return customerService.updateCustomer(id, dtoMono)
            .map(ResponseEntity::ok);
}

@Valid를 붙이면 Mono 안의 DTO가 역직렬화될 때 Bean Validation 검증이 실행됩니다. 검증 실패 시 WebExchangeBindException이 발생하고, 이것이 @ControllerAdvice로 전달됩니다.

여기서 시험 함정이 하나 있어요. WebExchangeBindException은 WebFlux 전용 예외입니다. Spring MVC의 MethodArgumentNotValidException과 다릅니다. @ControllerAdvice에서 MethodArgumentNotValidException을 잡도록 설정하면 WebFlux에서 검증 실패를 처리하지 못하고 500 에러가 납니다. WebFlux라면 반드시 WebExchangeBindException을 잡아야 해요.

커스텀 예외 계층 구조

서비스에서 발생시킬 예외를 정의합니다:

// 앱 전용 예외 부모
public class ApplicationException extends RuntimeException {
    private final String errorCode;

    public ApplicationException(String message, String errorCode) {
        super(message);
        this.errorCode = errorCode;
    }

    public String getErrorCode() { return errorCode; }
}

// 404 Not Found
public class CustomerNotFoundException extends ApplicationException {
    public CustomerNotFoundException(Integer id) {
        super("Customer with id [" + id + "] could not be found.", "CUSTOMER_NOT_FOUND");
    }
}

// 409 Conflict
public class DuplicateEmailException extends ApplicationException {
    public DuplicateEmailException(String email) {
        super("Customer with email [" + email + "] already exists.", "DUPLICATE_EMAIL");
    }
}

switchIfEmpty — 404 처리 표준 패턴

서비스에서 데이터가 없을 때 예외를 발생시킵니다:

@Service
@RequiredArgsConstructor
public class CustomerService {

    private final CustomerRepository customerRepository;

    // GET 단건 조회 — 없으면 404
    public Mono<CustomerDto> getCustomerById(Integer id) {
        return customerRepository.findById(id)
                .map(EntityDtoMapper::toDto)
                .switchIfEmpty(Mono.error(new CustomerNotFoundException(id)));
    }

    // PUT 수정 — 없으면 404
    public Mono<CustomerDto> updateCustomer(Integer id, Mono<CustomerDto> dtoMono) {
        return customerRepository.findById(id)
                .switchIfEmpty(Mono.error(new CustomerNotFoundException(id))) // 먼저 존재 확인
                .flatMap(existingCustomer -> dtoMono)
                .map(EntityDtoMapper::toEntity)
                .doOnNext(entity -> entity.setId(id))
                .flatMap(customerRepository::save)
                .map(EntityDtoMapper::toDto);
    }

    // DELETE — 없으면 404
    public Mono<Void> deleteCustomer(Integer id) {
        return customerRepository.findById(id)
                .switchIfEmpty(Mono.error(new CustomerNotFoundException(id)))
                .flatMap(customer -> customerRepository.deleteById(id));
    }

    // POST — 이메일 중복 체크
    public Mono<CustomerDto> saveCustomer(Mono<CustomerDto> dtoMono) {
        return dtoMono
                .map(EntityDtoMapper::toEntity)
                .flatMap(entity -> customerRepository.findByEmail(entity.getEmail())
                    .flatMap(existing -> Mono.<Customer>error(
                        new DuplicateEmailException(entity.getEmail())
                    ))
                    .switchIfEmpty(customerRepository.save(entity))
                )
                .map(EntityDtoMapper::toDto);
    }
}

switchIfEmpty(Mono.error(...)) 패턴이 핵심이에요. 빈 Mono가 흘러오면 에러 신호로 교체합니다. 이 에러 신호가 onError로 변환되어 @ControllerAdvice로 전달됩니다.

여기서 시험 함정이 하나 있어요. Mono.error()는 에러를 즉시 발생시키는 게 아닙니다. 에러 신호를 방출할 Publisher를 만드는 것이에요. switchIfEmpty는 빈 Mono가 도착했을 때 이 Publisher를 대신 사용합니다. Cold Publisher의 특성 — 구독되어야 실행된다는 원칙이 여기도 그대로 적용돼요.

한 줄 정리 — switchIfEmpty(Mono.error(...)) = "비어있으면 에러로 전환". 404 처리의 리액티브 표준 패턴.

@ControllerAdvice — 전역 예외 처리

모든 예외를 한 곳에서 처리합니다. RFC 7807 표준 응답 형식을 씁니다.

@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    // 404 Not Found
    @ExceptionHandler(CustomerNotFoundException.class)
    public ResponseEntity<ProblemDetail> handleCustomerNotFound(
            CustomerNotFoundException ex,
            ServerWebExchange exchange) {  // WebFlux: HttpServletRequest 대신 ServerWebExchange

        log.warn("Customer not found: {}", ex.getMessage());

        ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        pd.setTitle("Customer Not Found");
        pd.setInstance(exchange.getRequest().getURI());
        pd.setProperty("errorCode", ex.getErrorCode());
        pd.setProperty("timestamp", Instant.now().toString());

        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(pd);
    }

    // 409 Conflict — 중복 이메일
    @ExceptionHandler(DuplicateEmailException.class)
    public ResponseEntity<ProblemDetail> handleDuplicateEmail(
            DuplicateEmailException ex,
            ServerWebExchange exchange) {

        ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
        pd.setTitle("Duplicate Email");
        pd.setInstance(exchange.getRequest().getURI());

        return ResponseEntity.status(HttpStatus.CONFLICT).body(pd);
    }

    // 400 Bad Request — Bean Validation 실패 (WebFlux 전용 예외)
    @ExceptionHandler(WebExchangeBindException.class)
    public ResponseEntity<ProblemDetail> handleValidationErrors(WebExchangeBindException ex) {

        List<String> errors = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
                .collect(Collectors.toList());

        ProblemDetail pd = ProblemDetail.forStatusAndDetail(
            HttpStatus.BAD_REQUEST, "입력값 검증에 실패했습니다."
        );
        pd.setTitle("Validation Failed");
        pd.setProperty("errors", errors);

        return ResponseEntity.badRequest().body(pd);
    }

    // 500 Internal Server Error — 예상치 못한 예외
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ProblemDetail> handleGenericException(
            Exception ex, ServerWebExchange exchange) {

        // 스택 트레이스는 내부 로그에만! 클라이언트에게 절대 노출 금지
        log.error("Unexpected error occurred", ex);

        ProblemDetail pd = ProblemDetail.forStatusAndDetail(
            HttpStatus.INTERNAL_SERVER_ERROR, "예기치 않은 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."
        );
        pd.setTitle("Internal Server Error");

        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(pd);
    }
}

실제 에러 응답 예시:

// 없는 고객 조회 → 404
{
  "type": "about:blank",
  "title": "Customer Not Found",
  "status": 404,
  "detail": "Customer with id [999] could not be found.",
  "instance": "/customers/999",
  "errorCode": "CUSTOMER_NOT_FOUND",
  "timestamp": "2026-05-03T10:00:00Z"
}

// 잘못된 데이터 → 400
{
  "type": "about:blank",
  "title": "Validation Failed",
  "status": 400,
  "detail": "입력값 검증에 실패했습니다.",
  "errors": [
    "name: 이름을 입력해 주세요",
    "email: 올바른 이메일 형식이 아닙니다"
  ]
}

여기서 시험 함정이 하나 있어요. @RestControllerAdvice도 WebFlux에서 동작하고, @ExceptionHandler 메서드가 Mono를 반환할 수 있습니다. @ControllerAdvice + ResponseEntity 반환과 @RestControllerAdvice + Mono 반환 둘 다 유효한 WebFlux 패턴이에요. 시험에서 "@ControllerAdvice는 WebFlux에서 동작하지 않는다"는 선택지가 나오면 틀린 것입니다.

한 줄 정리 — @ControllerAdvice + @ExceptionHandler + ProblemDetail 조합이 WebFlux 전역 에러 처리 표준.

리액티브 파이프라인 안에서의 에러 처리

@ControllerAdvice로 올라오기 전, 파이프라인 안에서 에러를 처리하는 연산자들도 있어요.

// onErrorResume: 에러 발생 시 다른 Publisher로 대체
public Mono<CustomerDto> getCustomerSafely(Integer id) {
    return customerRepository.findById(id)
            .map(EntityDtoMapper::toDto)
            .onErrorResume(e -> {
                log.warn("Error fetching customer {}: {}", id, e.getMessage());
                return Mono.empty(); // 빈 Mono로 대체
            });
}

// onErrorReturn: 에러 발생 시 기본값 반환
public Mono<CustomerDto> getCustomerWithDefault(Integer id) {
    return customerRepository.findById(id)
            .map(EntityDtoMapper::toDto)
            .onErrorReturn(new CustomerDto(null, "Unknown", ""));
}

// Mono.error + onErrorResume 조합
public Flux<CustomerDto> getCustomersResilent() {
    return customerRepository.findAll()
            .map(EntityDtoMapper::toDto)
            .onErrorComplete(); // 에러를 완료 신호로 처리 (부분 성공 허용)
}

에러 처리 방식 비교:

방식특징적합한 상황
@ControllerAdvice전역 처리, 일관된 형식공통 예외, HTTP 에러 응답
onErrorResume파이프라인 내 대체 스트림에러 시 fallback 로직
onErrorReturn에러 시 기본값단순 대체값
onErrorComplete에러를 완료로 처리부분 실패 허용 스트리밍
switchIfEmpty빈 Mono 처리데이터 없음 → 에러 전환

여기서 시험 함정이 하나 있어요. @RequestParam@Valid는 동작하지 않습니다. Bean Validation은 @RequestBody의 객체 검증에 적용됩니다. @RequestParam, @PathVariable 같은 단일 값 검증은 클래스 레벨에 @Validated를 붙이고 메서드 파라미터에 @Min, @NotBlank 같은 제약 어노테이션을 직접 붙여야 해요.

@RestController
@RequestMapping("/customers")
@Validated  // 클래스 레벨에 붙여야 @PathVariable, @RequestParam 검증 동작
public class CustomerController {

    @GetMapping("/{id}")
    public Mono<CustomerDto> getById(
            @PathVariable @Positive(message = "ID는 양수여야 합니다") Integer id) {
        return customerService.getCustomerById(id);
    }
}

WebTestClient로 에러 응답 테스트

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class CustomerControllerErrorTest {

    @Autowired
    private WebTestClient webTestClient;

    @Test
    void testGetCustomerNotFound() {
        webTestClient.get()
                .uri("/customers/999")
                .exchange()
                .expectStatus().isNotFound()
                .expectBody(ProblemDetail.class)
                .value(pd -> {
                    Assertions.assertEquals(404, pd.getStatus());
                    Assertions.assertEquals("Customer Not Found", pd.getTitle());
                    Assertions.assertTrue(pd.getDetail().contains("999"));
                });
    }

    @Test
    void testCreateWithInvalidData() {
        CustomerDto invalid = new CustomerDto(null, "", "not-an-email");

        webTestClient.post()
                .uri("/customers")
                .bodyValue(invalid)
                .exchange()
                .expectStatus().isBadRequest()
                .expectBody(ProblemDetail.class)
                .value(pd -> Assertions.assertEquals(400, pd.getStatus()));
    }
}

자주 만나는 함정 — 시험 직전 압축 노트

  • spring-boot-starter-validation 의존성 추가 필수
  • @ValidMono 위에@RequestBody @Valid Mono 형식
  • WebExchangeBindException — WebFlux 검증 실패 전용 예외. MVC의 MethodArgumentNotValidException과 다름
  • @ControllerAdvice는 WebFlux에서도 동작 — 리액티브 스트림의 onError@ExceptionHandler가 처리
  • @RestControllerAdvice도 OK@ExceptionHandler 반환 타입으로 Mono 가능
  • switchIfEmpty(Mono.error(...)) — 빈 Mono → 에러 신호 변환. 404 처리 표준 패턴
  • ServerWebExchange — WebFlux @ExceptionHandler에서 요청 정보 접근. MVC의 HttpServletRequest 대체
  • RFC 7807 ProblemDetail — Spring 6.x 내장. forStatusAndDetail(), setTitle(), setProperty()
  • 스택 트레이스 절대 노출 금지log.error() 내부 기록. 클라이언트에게는 일반 메시지만
  • @NotNull vs @NotEmpty vs @NotBlank" " 공백 허용 여부 다름. 문자열은 @NotBlank 권장
  • @RequestParam 검증 — 클래스 레벨 @Validated + 파라미터에 제약 어노테이션 직접 붙임
  • @RequestBody 검증@Valid + DTO 필드에 Bean Validation 어노테이션
  • onErrorResume — 파이프라인 내 에러 시 다른 Publisher로 대체 (fallback)
  • onErrorComplete — 에러를 완료 신호로 처리. 스트리밍에서 부분 성공 허용
  • ExceptionHandler 순서 — 구체적 예외(CustomerNotFoundException) 먼저, 광범위(Exception) 나중
  • 에러 처리 흐름Mono.error()onError 신호 → @ControllerAdviceProblemDetail 응답
  • then() vs thenReturn()then() 다음 Publisher, thenReturn(value) 즉시 값 반환
  • 이중 구독 방지@ControllerAdvice가 구독 처리. 컨트롤러에서 .subscribe() 직접 호출 금지
  • Happy Path + 에러 처리 조합 — 4편 Happy Path 구조에 5편 switchIfEmpty + @ControllerAdvice 추가

시리즈 다른 편

같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!