devoong2
devoong2
devoong2
전체 방문자
오늘
어제

인기 글

최근 글

  • Category (35)
    • Java (4)
    • Spring (13)
    • JPA (4)
    • DesignPattern (1)
    • 동시성 (Concurrency) (4)
    • 회고 (1)
    • Redis (1)
    • Network (3)
    • Kafka (2)
    • Spring Batch (2)

최근 댓글

반응형
hELLO · Designed By 정상우.
devoong2

devoong2

Spring @Valid Annotation을 이용한 유효성 검증과 예외처리
Spring

Spring @Valid Annotation을 이용한 유효성 검증과 예외처리

2022. 8. 17. 20:00
반응형

 

예제 및 테스트 코드는 github 에서 확인 가능합니다.

Valid 사용하기

Spring 에서는 유효성 체크를 위하여 @Valid annotation 을 지원합니다.
Valid는 JSR-303(Bean Validation) 표준 스펙으로서 제약조건이 있는 객체에게 Bean Validation 을 이용해 조건을 검증하는
어노테이션입니다.

사용 예제

환경

  • Spring boot 2.6.2
  • java11

build.gradle

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

valid 를 사용하기 위해 위 의존성을 추가합니다. spring boot 2.3 이상부터는 spring-boot-starter-web 의존성 내부에 있던 validation 이 사라져서 2.3 이상이라면 위처럼 선언해서 사용해야 합니다.

 

모든 어노테이션을 여기서 다루진 않겠습니다. 우선 간략한 사용방법만 다루겠습니다.

 

UserRequest.java

@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
public class UserRequest {
    @Email(message = "이메일 형식이 맞지 않습니다.")
    @NotBlank(message = "이메일을 입력해주세요.")
    private String email;

    @NotBlank(message = "이름을 입력해주세요.")
    @Size(min = 2 , max = 10 , message = "이름은 2자 이상 , 5자 이하여야 합니다.")
    private String name;

    @NotBlank(message = "나이를 입력해주세요.")
    @Size(min = 20 , max = 100 , message = "나이는 20~100세 사이의 사용자만 가입이 가능합니다.")
    private int age;
}
  • @Email: Email 형식인지 확인
  • @NotBlank: null , 공백을 허용하지 않음
  • @Size: 길이를 제한할때 사용(min: 최소 , max: 최대)
  • @max: 지정한 값 이하인지
  • @min: 지정한 값 이상인지

UserController.java

@RestController
@RequiredArgsConstructor
@RequestMapping("/user")
public class UserController {
    private final UserService userService;

    @PostMapping(value = "")
    public ResponseEntity<UserDTO> create(@Valid UserRequest userRequest) {

        return new ResponseEntity<>(HttpStatus.CREATED);
    }

@Valid 만 체크하기 위해 별다른 로직 없이 바로 201 code 를 리턴하도록 만들었습니다.
그렇다면 @Valid를 확인해보기 위해 테스트 코드를 작성해보겠습니다.

@Test
@DisplayName("Valid 조건에 맞지 않는 파라미터를 넘기면 실패해야 한다")
void validTest() throws Exception {
    // given
    UserRequest userRequest = UserRequest.builder()
            .email("drogba02")
            .name("woobeen")
            .age(29)
            .build();

    // then
    mockMvc.perform(get("/user")
            .param("email" , userRequest.getEmail())
            .param("name" , userRequest.getName())
            .param("age" , Integer.toString(userRequest.getAge())))
            .andExpect(status().isCreated());
}

파라미터값을 보시면 이름과 나이는 validation에 알맞는 값이지만 email 값은 형식에 맞지 않습니다.
그렇다면 이 테스트코드는 실패해야 정상입니다. 실행해보겠습니다

 

valid-test-code-1valid-test-code-2

예상대로 실패하였습니다.

그렇다면 이번엔 모든 파라미터가 정상적인 상황에서의 테스트를 진행해보겠습니다.

@Test
@DisplayName("Valid 조건에 맞는 파라미터를 넘기면 성공해야 한다")
void validTest2() throws Exception {
    // given
    UserRequest userRequest = UserRequest.builder()
            .email("drogba02@naver.com")
            .name("woobeen")
            .age(29)
            .build();

    // then
    mockMvc.perform(get("/user")
                    .param("email" , userRequest.getEmail())
                    .param("name" , userRequest.getName())
                    .param("age" , Integer.toString(userRequest.getAge())))
            .andExpect(status().isCreated());
}

위의 파라미터를 확인해보면 email , name , age 모두 validation 형식에 맞는 값임을 확인할 수 있습니다.
그렇다면 이 테스트코드는 성공해야 정상입니다.

valid-test-code-3

정상적으로 테스트가 성공한것을 확인할 수 있습니다.

Controller 에서 에러 메시지 처리하기

위의 예시에서 @Valid 옵션에 따라 파라미터를 검증하는것을 확인했습니다.
하지만 코드에 기재해놓은 message에 대해서는 전혀 찾아볼 수 없습니다.
@Valid 에 대한 예외를 확인하려면 BindingResult 라는 객체가 필요합니다.

GetMapping

@GetMapping(value = "/v2")
public ResponseEntity create(@Valid UserRequest userRequest , BindingResult bindingResult) {
    if (bindingResult.hasErrors()) {
        List<FieldError> list = bindingResult.getFieldErrors();
        for(FieldError error : list) {
            return new ResponseEntity<>(error.getDefaultMessage() , HttpStatus.BAD_REQUEST);
        }
    }

    return new ResponseEntity<>(HttpStatus.CREATED);
}

validation 에 맞지 않는 값이 있으면 즉시 return 하도록 작성했습니다.
테스트 코드를 통해 확인해보겠습니다.

@DisplayName("BindResult Valid 테스트")
class BindResultValidTest {

    static final String EXPECTED_EMAIL_ERR_MESSAGE = "이메일 형식이 맞지 않습니다.";

    @Test
    @DisplayName("Valid 조건에 맞지 않는 파라미터를 Get으로 넘기면 status 400, 에러메시지를 응답받아야 한다")
    void validTest_get() throws Exception {
        // given
        UserRequest userRequest = UserRequest.builder()
                .email("drogba02")
                .name("woobeen")
                .age(29)
                .build();

        // then
        mockMvc.perform(get("/user/v2")
                .param("email" , userRequest.getEmail())
                .param("name" , userRequest.getName())
                .param("age" , Integer.toString(userRequest.getAge())))
                .andExpect(status().isBadRequest())
                .andExpect(content().string(EXPECTED_EMAIL_ERR_MESSAGE));
    }
}

valid-test-code-4

status 400 에 "이메일 형식이 맞지 않습니다." 라는 문자열을 리턴해야 합니다.
정상적으로 테스트가 통과된것을 확인할 수 있습니다.

PostMapping

이번엔 Post 방식으로도 한번 테스트해보겠습니다.

@PostMapping(value = "/v2")
public ResponseEntity createForPost(@Valid @RequestBody UserRequest userRequest , BindingResult bindingResult) {
    if (bindingResult.hasErrors()) {
        List<FieldError> list = bindingResult.getFieldErrors();
        for(FieldError error : list) {
            return new ResponseEntity<>(error.getDefaultMessage() , HttpStatus.BAD_REQUEST);
        }
    }

    return new ResponseEntity<>(HttpStatus.CREATED);
}


@Test
@DisplayName("Valid 조건에 맞는 파라미터를 Post로 넘기면 성공해야 한다")
void validTest_post_ok() throws Exception {
    // given
    UserRequest userRequest = UserRequest.builder()
            .email("drogba02@naver.com")
            .name("woobeen")
            .age(29)
            .build();

    String jsonData = objectMapper.writeValueAsString(userRequest);

    // then
    mockMvc.perform(post("/user/v2")
            .content(jsonData)
            .contentType(MediaType.APPLICATION_JSON)
            .accept(MediaType.APPLICATION_JSON))
            .andExpect(status().isCreated())
            .andExpect(content().string(""));
}

valid-test-code-5

Post 방식도 동일합니다.

advice 를 이용한 exception handling 적용

위에서의 BindResult 를 선언해서 에러를 처리하다보니 몇가지 문제점이 보였습니다.

  • valid 를 사용하는 controller 마다 직접 에러처리 로직을 작성해야 한다.
  • 코드의 중복이 발생한다.
  • 일관성 있는 예외처리를 보장할 수 없다.

이와 같은 문제점들을 해결하기 위해 Spring의 ControllerAdvice 를 이용하여 공통 예외처리를 만들어보겠습니다.

advice 를 이용한 handling 방법

@Valid 에서 발생한 예외를 캐치해서 응답하는 로직을 만들어보겠습니다.

우선 Advice class 를 만들어보겠습니다.

 

ExceptionAdvice.class

@RestControllerAdvice
public class ExceptionAdvice extends ResponseEntityExceptionHandler {
     @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {
        BindingResult result = ex.getBindingResult();
        StringBuilder errMessage = new StringBuilder();

        for (FieldError error : result.getFieldErrors()) {
            errMessage.append("[")
                    .append(error.getField())
                    .append("] ")
                    .append(":")
                    .append(error.getDefaultMessage());
        }

        return new ResponseEntity<>(errMessage , HttpStatus.BAD_REQUEST);
    }
}

@Valid 에 대한 예외는 MethodArgumentNotValidException 를 발생시킵니다.
그래서 ResponseEntityExceptionHandler 의 handleMethodArgumentNotValid method 를 오버라이딩하여

재정의해서 예외를 캐치한 다음 응답하는 로직을 작성했습니다.

@PostMapping(value = "")
public ResponseEntity createForPost(@Valid @RequestBody UserRequest userRequest) {

    return new ResponseEntity(HttpStatus.CREATED);
}

테스트할 Controller 는 다음과 같습니다.
예외를 advice에서 처리할테니 더 이상 controller는 BindingResult 객체가 필요없겟죠?

PostMapping 에 대한 테스트 코드를 먼저 작성해보겠습니다.

@Nested
@DisplayName("Valid Advice 테스트")
class ValidAdviceTest {

    static final String EMAIL_EXCEPTION_MESSAGE = "이메일 형식이 맞지 않습니다.";
        static final String AGE_EXCEPTION_MESSAGE = "나이는 20~100세 사이의 사용자만 가입이 가능합니다.";

    @Test
    @DisplayName("PostMapping시 Valid 예외가 Advice 에서 정상적으로 처리되어야 한다")
    void advice_post_test() throws Exception {
        // given
        UserRequest userRequest = UserRequest.builder()
                .email("drogba02")
                .name("woobeen")
                .age(18)
                .build();

        String jsonData = objectMapper.writeValueAsString(userRequest);

        // then
        mockMvc.perform(post("/user")
                .content(jsonData)
                .contentType(MediaType.APPLICATION_JSON)
                .accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isBadRequest())
                .andExpect(content().string(containsString(EMAIL_EXCEPTION_MESSAGE)))
                .andExpect(content().string(containsString(AGE_EXCEPTION_MESSAGE)))
                .andDo(print());
    }
}

일부러 email , age가 Valid에 걸리게끔 테스트 코드를 작성했습니다.
예상대로라면 응답받은 메시지는 email , age 에 대한 메시지가 포함되어있어야 정상입니다. 실행해볼까요?

valid-test-code-6

정상적으로 ControllerAdvice 가 동작하는것을 확인할 수 있습니다.

그렇다면 이제 GetMapping에 대한 테스트도 진행해보겠습니다.

@Nested
@DisplayName("Valid Advice 테스트")
class ValidAdviceTest {

    static final String EMAIL_EXCEPTION_MESSAGE = "이메일 형식이 맞지 않습니다.";
        static final String AGE_EXCEPTION_MESSAGE = "나이는 20~100세 사이의 사용자만 가입이 가능합니다.";

    @Test
    @DisplayName("GetMapping시 Valid 예외가 Advice 에서 정상적으로 처리되어야 한다")
    void advice_post_get() throws Exception {
        // given
        UserRequest userRequest = UserRequest.builder()
                .email("drogba02")
                .name("woobeen")
                .age(18)
                .build();

        // then
        mockMvc.perform(get("/user")
                .param("email" , userRequest.getEmail())
                .param("name" , userRequest.getName())
                .param("age" , Integer.toString(userRequest.getAge())))
                .andExpect(status().isBadRequest())
                .andExpect(content().string(containsString(EMAIL_EXCEPTION_MESSAGE)))
                .andExpect(content().string(containsString(AGE_EXCEPTION_MESSAGE)))
                .andDo(print());
    }
}

예상대로라면 위와 같이 400 error 를 리턴하고 email , age 에 대한 메시지를 리턴해줘야 정상입니다.
결과보겠습니다.

valid-test-code-7

하지만 실패했습니다. 에러 메시지를 보니 status()는 예상대로 받은것 같지만 에러메시지를 가져오지 못했네요.
왜 Post 시에는 가져오고 Get 으로는 못가져왔을까요?

 

저희가 advice에서 선언한 MethodArgumentNotValidException 는
@RequestBody에 대한 exception 을 처리해주기 때문인데요.

 

위 GetMapping 테스트코드는 ModelAttribute 방식으로 객체에 바인딩되게 됩니다.
ModelAttribute 방식으로 받은 파라미터에 대한 예외를 처리해주기 위해서는 BindException 을 advice에 선언해줘야 합니다.

advice 에 BindException 에 대한 처리를 추가하겠습니다.

@Override
protected ResponseEntity<Object> handleBindException(BindException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {
    BindingResult result = ex.getBindingResult();
    StringBuilder errMessage = new StringBuilder();

    for (FieldError error : result.getFieldErrors()) {
        errMessage.append("[")
                .append(error.getField())
                .append("] ")
                .append(":")
                .append(error.getDefaultMessage());
    }

    log.info("errMsg ### {}" , errMessage);
    return new ResponseEntity<>(errMessage , HttpStatus.BAD_REQUEST);
}

ResponseEntityExceptionHandler 클래스의 handleBindException 메소드를 재정의하여
BindException 에 대한 처리를 작성했습니다.
다시 테스트를 해보겠습니다.

valid-test-code-8

이번엔 정상적으로 테스트가 통과되는것을 확인할수 있습니다.

반응형

'Spring' 카테고리의 다른 글

[Spring] Transactional REQUIRES_NEW 옵션에서 예외 및 Rollback  (2) 2024.01.10
[Spring] 서로 다른 테스트 클래스에서 테스트 데이터를 공유하는 방법  (0) 2023.11.05
SpEL(Spring Expression Langauge) 사용법 + 어노테이션에 SpEL로 값 전달하기  (1) 2023.05.06
스프링 Redis 테스트 환경 구축하기 (Embedded Redis, TestContainer)  (0) 2022.09.17
MapStruct를 이용해 객체를 변환하는 방법  (0) 2022.08.17
    'Spring' 카테고리의 다른 글
    • [Spring] 서로 다른 테스트 클래스에서 테스트 데이터를 공유하는 방법
    • SpEL(Spring Expression Langauge) 사용법 + 어노테이션에 SpEL로 값 전달하기
    • 스프링 Redis 테스트 환경 구축하기 (Embedded Redis, TestContainer)
    • MapStruct를 이용해 객체를 변환하는 방법
    devoong2
    devoong2
    github 주소: https://github.com/limwoobin

    티스토리툴바