[SpringBoot]Validation 에러 처리하기

Validation Check 하기

스프링에서 받는 request들의 validation check를 하는 방법은 아래와 같이 두 가지 방법이 있다.

  1. 수동으로 하나씩 코드를 작성해서 맞는지 확인하는 방법
  2. JSR-303 Validator 를 사용하는 방법

1. 수동으로 하나씩 코드를 작성해서 맞는지 확인하는 방법

첫번째 방법인 수동으로 개발자가 작성하여서 validation check를 한다면 아래와 같은 방법으로 만들수가 있다.

장점 : 여러가지 자신이 원하는 값들이 들어오지않으면 명확하게 세분화해서 작성 할 수 있었다.

단점 : null 값 체크, 빈값, 최소값, 최대값, 패턴 등 다양한 방법을 하기 위해서는 시간과 노력이 많이 든다.

@PostMapping
public String saveSchool(@RequestBody RequestSchool requestSchool) {

    validationCheck(requestSchool);

    schoolService.save(requestSchool);

    return "school saved";
}

private void validationCheck(RequestSchool requestSchool) {
    Optional.ofNullable(requestSchool.getAccount()).orElseThrow(RuntimeException::new);
    Optional.ofNullable(requestSchool.getPhoneNumber()).orElseThrow(RuntimeException::new);
    Optional.ofNullable(requestSchool.getEstablishedYear()).orElseThrow(RuntimeException::new);
    Optional.ofNullable(requestSchool.getNumberOfStudent()).orElseThrow(RuntimeException::new);
}

2. Bean Validation를 사용

Bean Validation 초기에는 JSR 303으로 처음 제안이 되어 만들어 지기 시작해서 JSR-349 거처 현재는 Bean Validation JSR-380 까지 지원을 한다.

JSR-380을 사용하려면 최소 자바 버전은 8 을 사용해야한다.

380에서 추가 지원하는 것들

  • Support for validating container elements by annotating type arguments of parameterized types, e.g. List<@Positive Integer> positiveNumbers; this also includes:
    • More flexible cascaded validation of collection types; e.g. values and keys of maps can be validated now: Map<@Valid CustomerType, @Valid Customer> customersByType
    • Support for java.util.Optional
    • Support for the property types declared by JavaFX
    • Support for custom container types by plugging in additional value extractors
  • Support for the new date/time data types for @Past and @Future; fine-grained control over the current time and time zone used for validation
  • New built-in constraints: @Email, @NotEmpty, @NotBlank, @Positive, @PositiveOrZero, @Negative, @NegativeOrZero, @PastOrPresent and @FutureOrPresent
  • All built-in constraints are marked as repeatable now
  • Parameter names are retrieved using reflection
  • ConstraintValidator#initialize() is a default method

Bean Validation 을 간단히 말하면 @javax.validation.Valid 어노테이션을 사용하여 내부적으로 검증이 수행 하는 것이다.

참고 : https://en.wikipedia.org/wiki/Bean_Validation

간단히 jsr-380에서 알아봤으면 Spring 에서 적용 하는방법을 알아보자

Spring에서 JSR-380 사용하기(정리)

springboot에서 jsr-380 validation을 사용하기 위해서는 아래의 패키지를 추가 해주어야한다

//maven

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

//gradle
implementation 'org.springframework.boot:spring-boot-starter-validation:2.5.4'

org.springframework.boot:spring-boot-starter-validation:2.5.4 를 살펴 보면

<dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter</artifactId>
      <version>2.5.4</version>
      <scope>compile</scope>
    </dependency>
    <dependency>
      <groupId>org.apache.tomcat.embed</groupId>
      <artifactId>tomcat-embed-el</artifactId>
      <version>9.0.52</version>
      <scope>compile</scope>
    </dependency>
    <dependency>
      <groupId>org.hibernate.validator</groupId>
      <artifactId>hibernate-validator</artifactId>
      <version>6.2.0.Final</version>
      <scope>compile</scope>
    </dependency>
  </dependencies>

hibername-validator가 추가가 된것을 볼수 있다.

BeanValidation은 명세만 한것이지 구현한 코드가 아니기 때문에 hibernate.validator의 코드로 구현된것을 사용한다.

구조를 살펴 보면 아래와 같다 .

hibernate.validator.contraints 패키지 내부에 보면 아래와 같이 미리 정의된 메시지들과, 클래스들을 확인할수 있다. 자세한 내용은 다음에 ...

hibernate에 미리 정의된 나라별 메시지들

실습

실습 환경 : SpringBoot

School 이라는 정보를 생성하기 위해 RequestSchool 이라는 객체로 받을 때

RequestSchool.java

package com.morris.validation;

import lombok.*;
import org.hibernate.validator.constraints.Range;

import javax.validation.constraints.Email;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;

@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class RequestSchool {

    @NotNull(message = "이름이 비어 있습니다.")
    private String name;

    @Pattern(regexp = "^\\d{3}-\\d{3,4}-\\d{4}$", message = "000-0000-0000 의 형식이 맞지 않습니다.")
    private String phoneNumber;

    @Range(min = 1800, max= 2200, message = "설립 연도는 1800년 부터, 2200년도까지 등록이 가능합니다.")
    private Integer establishedYear;

    @Email(message="이메일 형식이 아닙니다.")
    private String account;

    @Min(value =  0 ,message = "학생의 수는 0이 아니여야 합니다.")
    private Integer numberOfStudent;
}

SchoolController.java

RequestBody에는 @Valid 어노테이션을 붙여서 사용한다.

@Valid 어노테이션을 사용하면 RequestBody를 객체로 변환할때 validation check를 하게 된다.

package com.morris.validation;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.Valid;

@RestController
@RequestMapping("/school")
public class SchoolController {

    private final SchoolService schoolService;

    public SchoolController(SchoolService schoolService) {
        this.schoolService = schoolService;
    }

        @PostMapping
    public ResponseEntity<?> saveSchool(@RequestBody @Valid RequestSchool requestSchool) {

        schoolService.save(requestSchool);

        return ResponseEntity.ok("school saved");
    }

}

테스트

package com.morris.validation;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class SchoolControllerTest {

    @Autowired
    private SchoolController schoolController;

    @Autowired
    private MockMvc mockMvc;

    private ObjectMapper objectMapper;

    @BeforeAll
    public void setup() {
        objectMapper = new ObjectMapper();
    }

    @Test
    void createSchoolValidationCheck() throws Exception {

        RequestSchool requestSchool = RequestSchool.builder()
                .build();

        mockMvc.perform( post("/school")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(requestSchool))
        ).andDo(print())
        .andExpect(status().isBadRequest())
        ;
    }

}

테스트의 결과는 아래와 같다.

Body에 보면 모두 null 값이 들어갔기 때문에 status().isBadRequest() 아래와 같이 예상결과로 두었다.

결과를 보면 org.springframework.web.bind.MethodArgumentNotValidException 이 발생한 것을 알수 있다.

MockHttpServletRequest:
  HTTP Method = POST
  Request URI = /school
   Parameters = {}
      Headers = [Content-Type:"application/json;charset=UTF-8", Content-Length:"93"]
         Body = {"name":null,"phoneNumber":null,"establishedYear":null,"account":null,"numberOfStudent":null}
Session Attrs = {}

Handler:
         Type = com.morris.validation.SchoolController
       Method = com.morris.validation.SchoolController#saveSchool(RequestSchool)

Async:
Async started = false
 Async result = null

Resolved Exception:
         Type = org.springframework.web.bind.MethodArgumentNotValidException

하지만 내가 원하는 MethodArgumentNotValidException에 대한 자세한 에러 메시지를 확인하기 위해서는

mockMvc로 테스트 한값을 result로 받아서 에러 메시지를 출력 받기 위해 코드를 추가 를 했다.

@Test
void createSchoolValidationCheck() throws Exception {

    RequestSchool requestSchool = RequestSchool.builder()
            .build();

    MvcResult result = mockMvc.perform(post("/school")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(requestSchool))
    ).andDo(print())
            .andExpect(status().isBadRequest())
            .andReturn();

    String errorMessage = result.getResolvedException().getMessage();

    System.out.println(errorMessage);

}

결과는 아래로, Request객체에서 정의를 해두었던 이름이 비어 있습니다. 가 제대로 출력 되는 것을 볼수 있다.

Validation failed for argument [0] in public java.lang.String com.morris.validation.SchoolController.saveSchool(com.morris.validation.RequestSchool): [Field error in object 'requestSchool' on field 'name': rejected value [null]; codes [NotNull.requestSchool.name,NotNull.name,NotNull.java.lang.String,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [requestSchool.name,name]; arguments []; default message [name]]; default message [이름이 비어 있습니다.]]

에러 처리가 된것을 확인 했으니 결과 값을 보내려면 에러를 어떻게 핸들링을 해야할까??

컨트롤러의 메서드 뒤에 사용하게 되면 바인딩 할때 BindingResult 를 사용하면 validation의 결과를 받아 확인이 가능하다.

@PostMapping
public ResponseEntity<?> saveSchool(@RequestBody @Valid RequestSchool requestSchool, BindingResult bindingResult) {

    if( bindingResult.hasErrors() ) {

        ObjectError objectError = bindingResult.getAllErrors().stream().findFirst().get();

        return ResponseEntity.badRequest().body(objectError.getDefaultMessage());
    }

    schoolService.save(requestSchool);

    return ResponseEntity.ok("school saved");
}

테스트 코드

이전에는 MvcResult result에서 result.getResolvedException().getMessage(); 를 통해서 결과를 받아왔었지만, body에 default 메시지를 포함해서 보낼 수 있다.

@Test
  void createSchoolValidationCheck() throws Exception {

      RequestSchool requestSchool = RequestSchool.builder()
              .build();

      MvcResult result = mockMvc.perform(post("/school")
              .contentType(MediaType.APPLICATION_JSON)
              .content(objectMapper.writeValueAsString(requestSchool))
      ).andDo(print())
              .andExpect(status().isBadRequest())
              .andReturn();

  }

결과

MockHttpServletResponse:
           Status = 400
    Error message = null
          Headers = [Content-Type:"text/plain;charset=UTF-8", Content-Length:"30"]
     Content type = text/plain;charset=UTF-8
             Body = 이름이 비어 있습니다.
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

하지만 여기서 다시 Controller 에 validation 체크의 코드들을 전부 적어준다면, 코드에 중복된 코드들이 많아진다.

이런 부분들을 @ExceptionHandler를 사용해서 Exception이 발생했을 경우에 ResponseEntity를 custom한 객체로 만들어서 보내줄수 있다.

@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<String> ValidationException(MethodArgumentNotValidException exception) {
    exception.getBindingResult().getAllErrors().forEach((error) -> {
        String fieldName = ((FieldError) error).getField();
        String errorMessage = error.getDefaultMessage();
    });
    ObjectError objectError = exception.getBindingResult().getAllErrors().stream().findFirst().get();
    String errorMsg = "field : " + ((FieldError) objectError).getField() + ", errorMessage" + objectError.getDefaultMessage();
    return ResponseEntity.badRequest().body(errorMsg);
}

참고 사항

Controller의 파라미터에 BindingResult를 사용하면서 내부에 BindingResult에 대한 비지니스 로직이 없으면

BindingResult를 통해서 에러를 찾았다고 해서 그냥 다음의 비지니스 로직으로 실행이 되므로 BindingResult를 사용한다면 반드시 result에 대한 에러처리를 해주어야하고, ExceptionHandler를 통해서 한다고 하면 BindingResult 를 파라미터에 함게 사용하면 안 된다.

참고 사이트

https://medium.com/@SlackBeck/javabean-validation과-hibernate-validator-그리고-spring-boot-3f31aee610f5

https://www.baeldung.com/spring-boot-bean-validation

validation 알아보기

https://beagle-dev.tistory.com/142

https://sanghye.tistory.com/36

https://jeong-pro.tistory.com/195

https://www.popit.kr/javabean-validation과-hibernate-validator-그리고-spring-boot/

+ Recent posts