스프링 유효성 체크 및 예외처리

Belluga·2021년 5월 6일
4

클라이언트 요청부터 서버의 응답을 받기까지의 과정을 정리하면 아래와 같습니다.

번호이름설명
1프론트 → 백엔드 호출RESTful로 프론트에서 백엔드를 호출합니다. 이때 json으로 data를 넘깁니다.
2JSON → DTOdata를 객체에 옮겨 담으면서 유효성(길이, 형식 등)을 체크합니다.
3컨트롤러 요청 처리요청에 따라 컨트롤러에서 어떤 동작을 할지 결정합니다.
4서비스 로직 처리동작에 따른 서버단의 로직을 처리합니다.
5DB 정보 요청DB에 접근해야할 경우 Repository를 통해 결과를 받습니다.
6DB 접근DB에서 CRUD의 결과값을 넘겨줍니다.
7요청값 반환리턴한 값을 JSON 형태로 만들어 리턴합니다.
8예외 리턴사용자의 실수, 서버의 오류등으로 인한 요청이 실패한 경우 에러를 리턴합니다.

✨ 유효성 체크

@Valid

이전 포스팅에서 프론트에서 백엔드로 요청하면서 json data를 넘겨주는 과정을 살펴보았습니다.

요청 본문(body)에 들어있는 json 데이터를 HttpMessageConverter를 통해 변환한 Java Object로 받아올 수 있었는데요

이 때 @Valid 어노테이션을 사용해서 값을 검증할 수 있습니다.
+ json 데이터 뿐 아니라 파라미터 검증도 가능합니다.

왜 필요할까?

물론 Validator를 사용하지않고 직접 Exception을 발생하여 유효성을 체크할 수 있습니다.
그러나 이 경우 한 필드에 대해서만 Exception이 발생하기 때문에 사용자 경험이 떨어질 수 있습니다.
예를들어 이름, 생년월일, 전화번호 모든 데이터에 대해 유효하지 않은 데이터라고 가정해봅시다.
사용자는 한번에 이름, 생년월일, 전화번호에 대한 유효성 검사 결과를 확인하고 싶지만 이름에 대한 Exception이 발생 이후 생년월일에 대한 유효성을 확인할 수 있고, 생년월일에 대한 Exception이 발생 이후에야 전화번호에 대한 유효성을 확인할 수 있습니다.

의존성 추가

compile group: 'org.springframework.boot', name: 'spring-boot-starter-validation', version: '2.4.2'

먼저 build.gradle 파일에 위와 같은 의존성을 추가합니다.
해당 라이브러리는 hibernate-validator:6.1.7.Final 라이브러리를 의존하고 있습니다.

Hibernate Validator는 어노테이션 기반으로 제약사항을 표현하고 입증할 수 있으며 애플리케이션 레이어에 구애받지 않는 검증 처리를 진행할 수 있습니다.
Hibernate Validator 6.x는 Bean Validation 2.0을 구현한다고 설명되어있습니다.1

Validation

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Member {

    private Long id;

    @NotBlank(message = "이메일은 빈 값일 수 없습니다")
    @Email(message = "올바른 형식의 이메일 주소어야 합니다")
    private String email;

    @NotBlank(message = "비밀번호는 빈 값일 수 없습니다")
    private String password;

    @NotBlank(message = "이름은 빈 값일 수 없습니다")
    private String name;
}

Validation 어노테이션을 이용하면 간단히 검증 조건을 추가할 수 있습니다.
먼저 Member 클래스에 제약조건을 추가하였습니다. 📌

javax.validation.constraints 패키지에 더 많은 유효성 검증 애노테이션이 존재하며
https://www.baeldung.com/javax-validation 에서 애노테이션에 대한 간략한 설명을 확인하실 수 있습니다.

헷갈리는 애노테이션에 대해서만 정리하도록 하겠습니다.
@NotNull : null만 허용하지 않으며 "" or " "는 허용한다.
@NotEmpty : null과 ""을 허용하지 않으며 " "는 허용한다.
@NotBlank는 null과 "", " " 모두 허용하지 않는다.

 @PostMapping("/members")
 public ResponseEntity join(@Valid @RequestBody Member member, BindingResult bindingResult) {
    if (bindingResult.hasErrors()) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(bindingResult.getAllErrors());
    }
    memberService.join(member);   
      
    return ResponseEntity.ok(member);
 }

위와 같이 컨트롤러에서 @Valid 어노테이션과 BindingResult 아규먼트를 사용해 위와 같이 컨트롤러에서 직접 에러를 처리해줄 수 있습니다.
유효성 검증에 실패하는 경우 에러 정보가 BindingResult 변수에 담겨집니다.

✨ 8. 예외 처리

@ControllerAdvice

{
    "email" : "akem",
    "password":"",
    "name":"mm"
}

여기까지 진행했다면 위와 같은 json 데이터를 넘겨받는 경우 바인딩시 유효성 체크에 걸려
MethodArgumentNotValidException 이 발생하고 400 Bad Request 응답을 받게됩니다.

컨트롤러에서 직접 에러를 처리할 수도 있지만 스프링에서 @ControllerAdvice@ExceptionHandler 어노테이션을 이용하여 컨트롤러의 메서드 수행 중 발생하는 예외를 일괄적으로 처리할 수 있습니다.

@ControllerAdvice
public class GlobalExceptionController {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public void handleValidationExceptions(BindingResult bindingResult) {
       
    }
}

컨트롤러에서 예외가 발생하는 순간 @ExceptionHandler 어노테이션으로 지정한 메서드가 수행됩니다. 이때, 어떤 예외가 발생했느냐에 따라 다른 메서드가 수행됩니다.

위 코드에서는 MethodArgumentNotValidException 예외 발생시 handleValidationExceptions 메서드가 호출됩니다.

BindingResult 클래스의 getAllErrors() 메서드 호출시 List<ObjectError> 를 반환합니다.
유효성에 걸린 모든 필드에 대한 에러를 갖고 있습니다.

@ControllerAdvice
public class GlobalExceptionController {

  @ExceptionHandler(MethodArgumentNotValidException.class)
  public ResponseEntity<Map<String, String>> handleValidationExceptions(BindingResult bindingResult) {
      Map<String, String> errors = new HashMap<>();
      bindingResult.getAllErrors().forEach(c -> errors.put(((FieldError)c).getField() , c.getDefaultMessage()));

      return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errors);
  }
}

ObjectError 객체가 갖고있는 field, defaultMessage를 추출하여 Map에 담아 클라이언트에 반환합니다.

{
    "password": "비밀번호는 빈 값일 수 없습니다",
    "email": "올바른 형식의 이메일 주소어야 합니다"
}

이렇게 클라이언트는 400 상태 코드, Bad Request 상태 문구, json 메시지를 받을 수 있습니다. 👏

DTO를 사용한 API 서버 공통 응답 포맷

handleValidationExceptions 메서드에서 리턴 타입으로 Map<String, String>을 사용하는데
Map 타입의 경우 타입이 명료하지 않아 return 받았을 때 어떤 키에 어떤 값을 갖는지 확인하기 어렵습니다.

즉 한번에 바로 알아볼 수 있는 '명확성'이 필요합니다.

Map 대신 DTO를 사용하여 API 서버 공통 응답 포맷을 만들어보도록 하겠습니다.

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(BindingResult bindingResult) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ErrorResponse.of(bindingResult));
}

최종적으로 위 코드처럼 Map<String, String> 대신 ErrorResponse 타입을 사용하여 좀 더 명료한 값을 반환할 것 입니다.

@Getter
@NoArgsConstructor
public class ErrorResponse {

    private List<ErrorField> errors;

    public ErrorResponse(List<ErrorField> errors) {
        this.errors = errors;
    }

    public static ErrorResponse of(BindingResult bindingResult) {
        return new ErrorResponse(ErrorField.of(bindingResult));
    }

    @Getter
    @AllArgsConstructor
    @NoArgsConstructor
    public static class ErrorField {
        private String field;
        private String value;
        private String reason;

        public static List<ErrorField> of(BindingResult bindingResult) {
            List<ErrorField> errorFields = bindingResult.getAllErrors().stream().map(error ->
                    new ErrorField(((FieldError) error).getField(), ((FieldError) error).getRejectedValue().toString(),
                            ((FieldError) error).getDefaultMessage())).collect(Collectors.toList());

            return errorFields;
        }
    }
}

ErrorResponse는 email, password, name와 같은 필드에 대한
에러 정보(필드명, 사용자 입력값, 유효성 오류 원인)를 담는
ErrorField의 List를 갖습니다.

👀 참고로 내부 클래스를 만들때 웬만하면 static 클래스로 만드는 것이 좋습니다.
static 내부 클래스는 바깥 클래스 인스턴스의 참조를 유지하지 않아
static이 아닌 내부 클래스에 비해 메모리 누수를 예방할 수 있고 더 적은 메모리를 사용한다는 장점이 있습니다.

여기까지 진행했다면 위와 같은 json 데이터를 넘겨받는 경우
클라이언트에게 아래와 같은 정보를 반환할 수 있습니다.

{
    "errors": [
        {
            "field": "name",
            "value": "",
            "reason": "이름은 빈 값일 수 없습니다"
        },
        {
            "field": "password",
            "value": "",
            "reason": "비밀번호는 빈 값일 수 없습니다"
        },
        {
            "field": "email",
            "value": "aaa",
            "reason": "올바른 형식의 이메일 주소어야 합니다"
        }
    ]
}

주석

1: https://hibernate.org/validator/

References

https://codinghack.tistory.com/

https://meetup.toast.com/posts/223

1개의 댓글

comment-user-thumbnail
2021년 5월 16일

잘봤습니다^^

답글 달기