@ControllerAdvice로 Validation 예외 처리하기

kindof

·

2022. 3. 27. 22:53

0. 들어가면서

아래 링크는 최근에 JPA를 공부하면서 참고하고 있는 Github 레포지토리입니다. 

 

 

GitHub - cheese10yun/spring-jpa-best-practices: spring-jpa best practices

:octocat: spring-jpa best practices. Contribute to cheese10yun/spring-jpa-best-practices development by creating an account on GitHub.

github.com

이번 포스팅은 위 레포지토리에서 다루고 있는 효과적인 Validation, Exception 처리에 관한 내용을 제 프로젝트에 적용시켜보고, 실제로 코드로 작성해보면서 생각을 정리해보려고 합니다.

 

 

1. Validation 처리, @Valid

아래 코드는 특정 아이템을 판매하는 게시물을 생성할 때 사용하는 DTO입니다.

ProductDTO.java

public class ProductDTO {
  	...
    
    @Data
    public static class SaveDto {
        @NotNull
        private String writer;

        @NotBlank
        private String title;

        @NotBlank
        private String description;

        private Integer price;

        @NotNull
        private ProductCategory productCategory;

        private List<MultipartFile> images = new ArrayList<>();
    }
    
    ...
}

글 작성자(writer)는 @NotNull, 글의 제목과 설명 등은 @NotBlank 어노테이션을 추가했습니다.

 

그리고 아래와 같이 글 작성 요청을 처리하는 컨트롤러 부분을 작성했습니다.

ProductController.java

@RestController
@RequiredArgsConstructor
@RequestMapping("/products")
public class ProductController {
    private final ProductService productService;

	...
	
    // 생성
    @PostMapping("")
    public ProductDTO.DetailResponseDTO createProduct(@ModelAttribute @Valid ProductDTO.SaveDto productSaveDto) throws IOException{
        return new ProductDTO.DetailResponseDTO(productService.save(productSaveDto));
    }
    ...
    
}

 

@Valid 어노테이션을 추가하고 테스트 코드를 작성해보겠습니다.

ProductTest.java

@ExtendWith(MockitoExtension.class)
@WebMvcTest(controllers = ProductController.class,
        excludeFilters = { @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = SecurityConfig.class) })
@MockBean(JpaMetamodelMappingContext.class)
@AutoConfigureMockMvc(addFilters = false)
public class ProductTest {

    @Autowired
    private ObjectMapper objectMapper;

    @Autowired
    MockMvc mvc;

    @MockBean
    private ProductService productService;

    @Test
    public void 중고장터_게시물_생성() throws Exception {
        // given
        String content = objectMapper.writeValueAsString(
                new ProductDTO.SaveDto("josunghyeon", "냉장고 팔아요",
                        "아이템 설명입니다.", 1000000, ProductCategory.CLOTHES, null)
        );

        MockHttpServletRequestBuilder builder =
                post("/products")
                        .param("writer", "sunghyeon")
                        .param("title", "냉장고 팔아요")
                        .param("description", "최신 냉장고!")
                        .param("price", String.valueOf(1000000))
                        .contentType(MediaType.APPLICATION_JSON);

        MvcResult result = mvc.perform(builder).andReturn();
        String message = result.getResolvedException().getMessage();
        System.out.println("message = " + message);

        assertThat(HttpStatus.BAD_REQUEST);
    }
}

위 코드에서는 의도적으로 "productCategory"값을 null로 주었습니다. 그러면 아래와 같이 @NotNull Validation에 위배된다는 메시지를 받을 수 있습니다.

2022-03-27 22:19:07.553  WARN 3561 --- [           main] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.validation.BindException: org.springframework.validation.BeanPropertyBindingResult: 1 errors<EOL>Field error in object 'saveDto' on field 'productCategory': rejected value [null]; codes [NotNull.saveDto.productCategory,NotNull.productCategory,NotNull.daangnmarket.daangn.project.domain.product.ProductCategory,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [saveDto.productCategory,productCategory]; arguments []; default message [productCategory]]; default message [must not be null]]
message = org.springframework.validation.BeanPropertyBindingResult: 1 errors
Field error in object 'saveDto' on field 'productCategory': rejected value [null]; codes [NotNull.saveDto.productCategory,NotNull.productCategory,NotNull.daangnmarket.daangn.project.domain.product.ProductCategory,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [saveDto.productCategory,productCategory]; arguments []; default message [productCategory]]; default message [must not be null]

 

마찬가지로, 실제로 서버를 실행시킨 뒤 위와 동일한 요청을 Postman으로 했을 때 아래와 같은 결과를 받을 수 있습니다. 

Postman 응답

응답을 보면 "org.springframework.validation.BindException"이라는 예외가 발생했다는 것을 알 수 있는데요. 그리고 에러의 메시지는 "Validation failed for object='saveDto'. Error count:1" 이라고 써져 있습니다. 그 외에도 아래는 어떤 부분에서 Validation이 실패했는지를 알 수 있는 내용들이 있죠.

 

문제는 여기에 있습니다. 프론트에 해당 예외의 내용을 리턴해줄 때 이 예외를 적절히 처리해주지 않으면 정확히 필요한 정보를 전달하기 어렵고, 매 번 try-catch 등으로 예외 처리를 위임하는 방식은 코드의 유지 보수 등에서 어려움을 겪을 수 있습니다.

 

따라서, 아래에서 @ControllerAdvice를 이용해서 위와 같은 Exception을 보다 효율적으로 처리하는 방법에 대해 알아보려고 합니다.

 

 

2. Exception 처리하기 - @ControllerAdvice

@ControllerAdvice 어노테이션은 @Controller 전역에서 터지는 예외를 처리해주는 역할을 합니다. 아래는 해당 어노테이션에 대한 설명입니다.

@ControllerAdvice

설명을 간략히 요약하면, @ControllerAdvice는 기본적으로 모든 패키지에 있는 컨트롤러들에 대하여 @ExceptionHandler로 처리하고 싶은 예외를 위임받아 처리하는 역할을 합니다.

 

이를 통해 각 컨트롤러에서 비슷한 패턴으로 발생하는 예외를 전역적으로 관리할 수 있겠죠.

 

이제 @ExceptionHandler와 @ControllerAdvice를 바탕으로 위에서 작성했던 코드를 수정해보겠습니다.

 

먼저 ErrorResponse 클래스를 아래와 같이 작성하겠습니다. 해당 클래스는 발생한 예외에서 필요한 정보들 중 예외 코드, 예외 메시지, 응답 Status, 예외 내용들을 Wrapping하는 역할을 합니다.

 

ErrorResponse.java

@Getter
public class ErrorResponse {

    private String message;
    private String code;
    private int status;
    private List<FieldError> errors = new ArrayList<>();

    @Builder
    public ErrorResponse(String message, String code, int status, List<FieldError> errors) {
        this.message = message;
        this.code = code;
        this.status = status;
        this.errors = initErrors(errors);
    }

    private List<FieldError> initErrors(List<FieldError> errors) {
        return (errors == null) ? new ArrayList<>() : errors;
    }

    @Getter
    public static class FieldError {
        private String field;
        private String value;
        private String reason;

        @Builder
        public FieldError(String field, String value, String reason) {
            this.field = field;
            this.value = value;
            this.reason = reason;
        }
    }
}

 

그리고 각종 에러 코드들을 커스터마이징하여 정리해둘 수 있는 ErrorCode.java 파일입니다.

ErrorCode.java

@Getter
public enum ErrorCode {
    INPUT_VALUE_INVALID("CM_001", "입력값이 올바르지 않습니다.", 400);

    private final String code;
    private final String message;
    private final int status;

    ErrorCode(String code, String message, int status){
        this.code = code;
        this.message = message;
        this.status = status;
    }
}

 

마지막으로 ErrorExceptionController.java 입니다. 해당 컨트롤러를 통해 전역에 존재하는 모든 컨트롤러에서 발생하는 Exception들을 관리할 수 있습니다. 현재 상황에서는 BindException을 처리하고 있기 때문에 해당 부분을 주목해서 봐주세요.

 

ErrorExceptionController.java

package daangnmarket.daangn.project.exception;

import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindException;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;

import java.util.List;
import java.util.stream.Collectors;

@ControllerAdvice
@ResponseBody
@Slf4j
public class ErrorExceptionController {
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    protected ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        final List<ErrorResponse.FieldError> fieldErrors = getFieldErrors(e.getBindingResult());
        return buildFieldErrors(ErrorCode.INPUT_VALUE_INVALID, fieldErrors);
    }

    @ExceptionHandler(BindException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    protected ErrorResponse handleBindException(org.springframework.validation.BindException e) {
        final List<ErrorResponse.FieldError> fieldErrors = getFieldErrors(e.getBindingResult());
        return buildFieldErrors(ErrorCode.INPUT_VALUE_INVALID, fieldErrors);
    }


    private List<ErrorResponse.FieldError> getFieldErrors(BindingResult bindingResult) {
        final List<FieldError> errors = bindingResult.getFieldErrors();
        return errors.parallelStream()
                .map(error -> ErrorResponse.FieldError.builder()
                        .reason(error.getDefaultMessage())
                        .field(error.getField())
                        .value((String) error.getRejectedValue())
                        .build())
                .collect(Collectors.toList());
    }


    private ErrorResponse buildFieldErrors(ErrorCode errorCode, List<ErrorResponse.FieldError> errors) {
        return ErrorResponse.builder()
                .code(errorCode.getCode())
                .status(errorCode.getStatus())
                .message(errorCode.getMessage())
                .errors(errors)
                .build();
    }
}

참고로 BindException은 org.springframework.validation.BindException으로 임포트해주셔야 합니다. 저는 처음에 이 부분을 java.net.BindException으로 잘못써서 한참을 고생했습니다...

 

아무튼, 이제 @Valid 검증이 실패하면 BindException이 발생하고 이 예외는 ErrorExceptionController에서 처리할 것입니다. 아래 결과를 보겠습니다.

결과

 

이전에 불필요했던 정보들이 사라졌고, 사용자에게 필요한 정보만을 JSON으로 리턴해주고 있습니다.

 

 

3. 나가면서

이번 포스팅에서는 @ControllerAdvice, @ExceptionHandler 어노테이션을 통해 예외 처리를 하는 방법에 대해 공부해봤습니다.

 

이제 이렇게 만들어둔 클래스들을 통해 다양한 ErrorCode 등을 커스텀화하여 더할수도 있고, Custom Exception도 작성하여 같은 로직으로 처리할 수 있게 된 것 같습니다.

 

감사합니다.

 

 

Reference

https://github.com/cheese10yun/spring-jpa-best-practices/blob/master/doc/step-02.md