본문 바로가기

Backend/Spring

[Spring/SpringBoot] RestController에서 Validation Api 사용하기 + MethodArgumentNotValidException 예외 처리하기

안녕하세요. 오늘은 Validation(유효성 검사)의 예외처리에 대해 살펴보겠습니다.

 

이번에 스프링으로 프로젝트를 진행하게 되었는데, Account 관련 처리를 맡게 되어 회원가입에 관한 Validation 인증을 구현해야 하는 상황이었습니다.

 

ControllerAdvice에서 class를 받아 구현하려 하니, 잘 작동이 되지 않아 제가 사용한 방법을 포스팅하려 합니다.

 

제가 사용한 SignUpForm은 다음과 같이 정의하였습니다.

 

package com.SCAR.Account;

import lombok.Data;
import org.hibernate.validator.constraints.Length;

import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;

@Data
public class SignUpForm {

    @NotBlank
    @Length(min=3, max=20)
    @Pattern(regexp="^[ㄱ-ㅎ가-힣a-z0-9_-]{3,20}$")
    private String nickname;

    @Email
    @NotBlank
    private String email;

    @Email
    @NotBlank
    private String SKKEmail;

    @NotBlank
    @Length(min=8, max=50)
    private String password;
}

 

두 개의 Email을 전달받는데, 모두 Blank가 아니어야 하고 Email 형식에 맞추기를 기대합니다.

nickname의 경우 3자~20자를 사용할 수 있으며 위의 정규식에 따른 인풋을 기대합니다.

password 역시 8자 이상만을 받도록 하겠습니다.

 

    @PostMapping("/account")
    public ResponseEntity<Account> submitSignUp(@Valid @RequestBody SignUpForm signupForm) {

        Account newAccount = accountService.processNewAccount(signupForm);
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}")
                .buildAndExpand(newAccount.getId())
                .toUri();

		return ResponseEntity.created(location).build();
    }

그리고 Controller에서 @RequestBody 앞에 @Valid를 사용하여 데이터의 유효성을 검증하겠습니다.

 

이제 유효성 검증의 예외처리를 살펴보겠습니다.

저는 기본적으로 모든 ExceptionResponse의 공통 부모 클래스를 다음과 같이 사용하였습니다.

 

package com.SCAR.Exception;

import lombok.*;
import lombok.experimental.SuperBuilder;
import net.bytebuddy.implementation.bind.annotation.Super;

import java.time.LocalDateTime;

@Data
@SuperBuilder
public class ExceptionResponse {
    private String title;
    private Integer status;
    private LocalDateTime timestamp;
    private String developerMessage;
}

 

title이라는 필드명이 적절하지 않은 것 같긴 합니다. 간략히 용도에 대해 설명드리면

 

  • title은 에러의 발생 원인을 간략하게 설명하려고 생성했습니다.
  • status는 HttpStatus를 표기하기 위해 선언했습니다.
  • timestamp는 에러가 발생한 시간을 표시하기 위해 사용하였습니다.
  • developerMessage는 에러가 발생한 class명을 개발자에게 알려주기 위해 사용하였습니다.

그다음 ExceptionResponse를 상속받은 NotValidExceptionResponse를 선언하여 주었습니다.

@SuperBuilder : 상속받은 부모 객체도 Builder에 사용하고 싶어서 선언하였습니다.

package com.SCAR.Exception;

import lombok.*;
import lombok.experimental.SuperBuilder;

import java.util.List;

@Getter
@SuperBuilder
public class NotValidExceptionResponse extends ExceptionResponse{
    private final List<String> err;
}

 

package com.SCAR.Exception;

import com.SCAR.Account.AccountNotFoundException;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
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.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

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

@RestController
@ControllerAdvice
public class CustomizedGlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {

        List<String> errorList = ex
                .getBindingResult()
                .getFieldErrors()
                .stream()
                .map(DefaultMessageSourceResolvable::getDefaultMessage)
                .collect(Collectors.toList());

        return new ResponseEntity<>(
                NotValidExceptionResponse.builder()
                        .timestamp(LocalDateTime.now())
                        .status(HttpStatus.BAD_REQUEST.value())
                        .title("Arguments Not Valid")
                        .developerMessage(ex.getClass().getName())
                        .err(errorList)
                        .build(), HttpStatus.BAD_REQUEST
        );
    }
}

 

@ControllerAdvice : 모든 컨트롤러가 실행되기 전에 사전에 실행되는 Controller입니다.

 

ResponseEntityExceptionHandler class에 handleMethodArgumentNotValid라는 메서드가 사전에 정의되어 있습니다. 즉 저희는 ControllerAdvice를 통하여 모든 예외에 대하여 처리하는 클래스를 선언하였고, 기존에는 MethodArgumentNotValidException이 발생하게 되면 사전에 정의된 handleMethodArgumentNotValid가 호출되는데, 이를 Override 하여 저희가 정의한 메서드를 호출하게 만드는 것입니다.

 

Exception의 BindingResult의 FieldErrors를 stream을 활용하여 List에 매핑해주었습니다.

 

그리고 저희가 정의한 NotValidExceptionResponse를 Builder로 생성하여 호출하여 주었습니다.

 

이렇게 되면 정상적으로 유효하지 않은 인풋 값이 전달되었을 때 적절한 error message와 함께 Client에게 보내집니다.

 

Custom Validator

저는 추가적으로

Email값이나 Nickname값은 유저마다 고유하게 설정하고 싶습니다. 즉 중복 이메일이나 중복 닉네임이 signUpForm으로 들어온 경우 이를 사전에 차단하고 싶습니다.

 

이를 처리하기 위해서 몇 가지 작업이 필요합니다.

 

1. AccountRepository에 기존에 이메일 값이 존재하는지, 닉네임 값이 존재하는지 알려주는 메서드가 필요합니다.

2. 1에서 정의한 메서드를 활용하여 org.springframework.validation.Validator의 Validator를 구현한 저희의 SignUpForm Validator를 구현해야 합니다.

3. Controller에서 SignUp 요청이 왔을 때 메인 서비스 로직에 넘겨주기 전에 검증을 진행합니다.

 

우선 1번부터 빠르게 만들어 보겠습니다.

 

package com.SCAR.Account;

import com.SCAR.Domain.Account;
import org.springframework.data.jpa.repository.JpaRepository;

public interface AccountRepository extends JpaRepository<Account, Long> {
    boolean existsByEmail(String email);
    boolean existsByNickname(String nickname);
}

 

JpaRepository에 다음과 같이 선언을 해주면 사용할 수 있습니다. (이런 거 보면 참 신기합니다...)

 

2. SignUpFormValidator를 만들어보겠습니다.

 

package com.SCAR.Account;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import org.springframework.validation.Errors;
import org.springframework.validation.Validator;

@Component
@RequiredArgsConstructor
public class SignUpFormValidator implements Validator {
    private final AccountRepository accountRepository;

    @Override
    public boolean supports(Class<?> clazz) {
        return clazz.isAssignableFrom(SignUpForm.class);
    }

    @Override
    public void validate(Object target, Errors errors) {
        SignUpForm signUpForm = (SignUpForm) target;
        if(accountRepository.existsByEmail(signUpForm.getEmail())) {
            errors.reject(signUpForm.getEmail(), "이미 사용중인 이메일입니다.");
        }
        if(accountRepository.existsByNickname(signUpForm.getNickname())) {
            errors.reject(signUpForm.getNickname(), "이미 사용중인 닉네임입니다.");
        }
    }
}

@RequiredArgsConstructor : 이 어노테이션을 사용하면 private final 변수에 대한 빈을 자동으로 주입해줍니다.

@Component : 빈으로 등록하기 위해 Component 어노테이션을 추가하였습니다.

 

Validator를 구현하였기 때문에 supports라는 메서드와 validate라는 메서드를 Override 해줘야 합니다.

 

  • supports를 통해 SignUpForm class에 사용할 validator라는 것을 지정해 줍니다.
  • validate를 통해 저희가 진행할 validate 로직을 설정합니다.

여기서 errors.reject를 하게 되면 GlobalErrors로 반환되기 때문에 Controller에서도 이 값을 사용할 수 있습니다.

(관련 내용을

https://stackoverflow.com/questions/44755789/spring-validation-how-to-retrieve-errors-rejectvalue-in-my-contoller

 

Spring Validation - How to retrieve errors.rejectValue in my Contoller?

I have my validate method in my TestValidator as follows @Override public void validate(Object target, Errors errors) { Test test = (Test) target; String testTitle = test.getTestTitle(); ...

stackoverflow.com

에서 찾을 수 있었습니다.)

 

부수적인 작업으로 저희가 Custom 한 Validator를 사용하는 동안 사용할 예외처리 클래스와 예외처리 Object를 선언해주겠습니다.

 

package com.SCAR.Exception;

import lombok.Getter;
import lombok.experimental.SuperBuilder;

import java.util.List;

@Getter
@SuperBuilder
public class CustomizedNotValidResponse extends ExceptionResponse{
    private final List<String> CustomValidError;
}

 

포스팅의 위쪽에 있는 ExceptionResponse를 상속받아 구현하였습니다. SignUpFormValidator에서 error.reject의 결과를 이 객체에 List형태로 담아서 Client에게 전달할 예정입니다!

 

package com.SCAR.Account;

import lombok.Getter;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.ResponseStatus;

import java.util.List;

@ResponseStatus(HttpStatus.BAD_REQUEST)
@Getter
public class AccountNotValidException extends RuntimeException{
    private final List<String> errorList;
    public AccountNotValidException(List<String> errList, String m) {
        super(m);
        this.errorList = errList;
    }
}

 

예외처리 클래스입니다. 위에서 말했듯이 errList를 Controller로부터 받아올 것이기 때문에 파라미터로 전달받겠습니다.

 

package com.SCAR.Exception;

import com.SCAR.Account.AccountNotFoundException;
import com.SCAR.Account.AccountNotValidException;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
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.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

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

@RestController
@ControllerAdvice
public class CustomizedGlobalExceptionHandler extends ResponseEntityExceptionHandler {

 	...
    
    @ExceptionHandler(AccountNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ResponseEntity<CustomizedNotValidResponse> handleCustomizedNotValidException(AccountNotValidException ex, WebRequest webRequest) {
        return new ResponseEntity<>(
                CustomizedNotValidResponse.builder()
                        .CustomValidError(ex.getErrorList())
                        .timestamp(LocalDateTime.now())
                        .status(HttpStatus.BAD_REQUEST.value())
                        .title("Email Already Exists Or NickName Already Exists")
                        .developerMessage(ex.getClass().getName())
                        .build(), HttpStatus.BAD_REQUEST
        );
    }
}

 

그리고 마지막으로 앞서 사용했던 ControllerAdvice에 위에서 처리한 예외처리 클래스에 대한 예외처리 메서드를 정의해줍니다.

 

이제 모든 사전작업은 끝났습니다. 마지막으로 Controller에서 Validate만 진행해주겠습니다.

 

    @PostMapping("/account")
    public ResponseEntity<Account> submitSignUp(@Valid @RequestBody SignUpForm signupForm, BindingResult bindingResult) {
        signUpFormValidator.validate(signupForm, bindingResult);
        if(bindingResult.hasErrors()) {
            List<String> errorList =
                    bindingResult.getGlobalErrors()
                    .stream()
                    .map(DefaultMessageSourceResolvable::getDefaultMessage)
                    .collect(Collectors.toList());

            throw new AccountNotValidException(errorList, "Custom Validator work");
        }

        Account newAccount = accountService.processNewAccount(signupForm);
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}")
                .buildAndExpand(newAccount.getId())
                .toUri();

        return ResponseEntity.created(location).build();
    }

 

signUpFormValidator는 Controller 선언부에 Bean으로 주입받았습니다.

 

저희가 정의한 validate 메서드를 활용하면 bindingResult에 내용이 담겨서 반환됩니다.

그리고 bindingResult에 에러가 존재한다면 -> Default Message들을 List에 담아서 AccountNotValidException을 던져주겠습니다.

 

Result

 

우선 데이터베이스를 재실행 한 뒤, 기본적인 SignUp을 진행하겠습니다.

 

 

그 뒤로, 저희가 같은 값으로 다시 SignUp을 진행해보겠습니다. 당연히 중복 이메일과 중복 닉네임에 대한 에러가 반환되어야 합니다.

 

정상적으로 반환된 것을 확인할 수 있습니다.

 

마지막으로 이메일만 중복되게 요청을 보내보겠습니다.

네 이경우에는 정상적으로 이메일에 대한 에러 문구만 출력되는 것을 확인할 수 있습니다.

 

오늘은 이렇게 Spring RestController에서 Validator에 대한 기본적인 예외처리, CustomValidator, CustomValidator예외처리까지 알아보았습니다.

 

*저의 글에 대한 피드백이나 지적은 언제나 환영합니다.