Spring Boot - Validation

ys·2024년 5월 23일

Spring공부

목록 보기
7/14
  • 우리가 개발을 하다보면, 여러 요청을 받을 때 다음과 같이 요청이 오는 경우가 있다
  • 그런데, 이름이 빈문자열이던지... , 이메일이 이메일 형식에 맞지 않다던지... , snake_case, camel_case로 인해 null 값이 들어올 수 있다
  • 이를 해결하기 위해서는 if(name != null) 이렇게 검증을 하는 로직을 서비스 로직에 넣어줘야 한다
  • 그런데 요청받는 변수가 많으면? -> 복잡하다...
  • 이런 검증 과정이 길어지면, 실질적으로 중요한 서비스 로직에 대해서는 많이 누락되게 된다
  • 가장 중요한 이렇게 검증하고 다시 응답받고, 다시 요청을 받으려면 통신 시간이 길어지게 된다...

그렇기에 Spring boot validation의 dependency를 통해, 손쉽게 검증을 진행할 수 있다!

gradle dependecies

implemetation("org.springframework.book:spring-boot-starter-validation")

bean validation spec

http://beanvalidation.org/2.0-jsr380/

    1. Built-in Constraint definitions 부분에, 어노테이션의 설명들이 나와있다
  • 또한 정규식을 이용할 수도 있다
  • 핸드폰 번호 정규식 : "^\\d{2,3}-\\d{3,4}-\\d{4}$"

1. SpringBoot Validation을 하는 이유

  • 위의 내용을 다시 정리해 보자!
  1. 유효성 검증 하는 코드의 길이가 너무 길어지게 된다
  2. service logic에 대해서 방해가 된다(요청 request의 요청...)
  3. 만약 검증로직이 DTO에 대해서 흩어져 있다면, 어디서 검증되었는지 찾기가 어렵다(일관성..)
  4. 검증 로직이 변경되는 경우, 테스트 코드 등 전체 로직이 흔들릴 수 있다

Validation 어노테이션

  • 추가적으로 필요한 어노테이션은 공식문서를 참고하자!

2. Validation 코드

  • 간략하게 요청받는 데이터를 검정하기 위한 api를 하나 만들어준다
@Slf4j
@RestController
@RequestMapping("/api/user")
public class UserApiController {
    @PostMapping("")
    public UserRegisterRequest register(
            @RequestBody UserRegisterRequest userRegisterRequest
    ){
        log.info("init : {}", userRegisterRequest);
        return userRegisterRequest;
    }
}
  • 그리고 다음 json 형태의 데이터를 요청했다고 가정해보자
{
  "name" : "",
  "password" : "",
  "age" : 20,
  "email" : "",
  "phone_number" : "010-1111-2222",
  "register_at" : ""
}  
  • 결과는 잘 들어오긴했지만, 이럴 경우 비즈니스 로직이 잘 작동되지 않을 것이다...
  • 심지어 db에 저장하는 경우에는 문제가 더 커질 것이다
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
public class UserRegisterRequest {

    @NotNull  // != null
    @NotEmpty // != null && name != ""
    @NotBlank // != null && name != "" && name != "   "
    private String name;

    @NotBlank
    @Size(min = 1, max = 12)
    private String password;

    @NotNull
    @Min(1)
    @Max(120)
    private Integer age;

    @Email
    private String email;

    @Pattern(regexp = "^\\d{2,3}-\\d{3,4}-\\d{4}$", message = "휴대폰 번호 양식과 맞지 않습니다")
    private String phoneNumber;

    @FutureOrPresent // 현재 or 미래
    private LocalDateTime registerAt;
}
  • 그렇기에 다음과 같이 validation 로직을 DTO에 넣어준다
  • ✅ Entity와 분리된 DTO기 때문에, 검증 로직을 넣어줘도 Entity가 오염되지 않는다!
  • 핸드폰 번호를 검증하는 어노테이션이 특별히 없기 때문에 @Pattern 어노테이션을 이용해 넣어주었다

@Valid

public UserRegisterRequest register(
            @Valid 
            @RequestBody UserRegisterRequest userRegisterRequest
    ){
  • 이제 UserRegisterRequest를 어노테이션 기반으로 검증을 하겠다는 @Valid 어노테이션을 넣어줘, 요청받는 데이터를 검증한다!
{
  "name" : "홍길동",
  "password" : "",
  "age" : 20,
  "email" : "hong@gmail.com",
  "phone_number" : "010-1111-2222",
  "register_at" : "2024-06-17T09:09:09"
}  
 
  • 이제 다음과 같은 json 데이터를 보내보자
default message [공백일 수 없습니다]]
[Field error in object 'userRegisterRequest' on field 'password': rejected value [];
  • 다음과 같이 오류 메시지가 나온다
  • 이제 검증에 모두 맞는 데이터를 넣어보자!
{
  "name" : "홍길동",
  "password" : "qwer",
  "age" : 20,
  "email" : "hong@gmail.com",
  "phone_number" : "010-1111-2222",
  "register_at" : "2024-06-17T09:09:09"
}  
 
  • 해당로그로 잘 데이터가 들어간 것을 확인할 수 있다

3. 공통 데이터 형식

  • 이제 클라이언트에게 오류가 나거나, 정상 결과가 나와도 동일한 형식의 결과를 내리기 위해 공통 형식을 만들어보자
  • 그렇다면 이제 요청도 🤔공통 Api 스펙으로 받고, 응답도 공통 Api 스펙으로 전달해야 한다
  • 먼저 공통 Api 스펙을 정해보자!

Api

@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
public class Api<T> {
    private String resultCode;
    private String resultMessage;
    @Valid
    private T data;
    private Error error;

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    @Builder
    @JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
    // inner class로 Error 만들기
    public static class Error{

        private List<String> errorMessage;

    }
}
  • 공통 스펙엔 resultCode, resultMessage,
  • 제네릭 타입은 데이터 객체
  • 그리고 Error코드를 List로 가지고 있는 Error 객체 이렇게로 정의하였다
  • 그리고 요청시 data를 검증하기 위해선, 🤔 data 부분에 꼭 @Valid를 붙어줘야지 검증이 완료된다

✅ 여기선 inner class를 사용하였다!

  • 코드를 분리하는 것도 좋은 방법일 것 같다
  • 여기서 Error 클래스는, Api에서만 사용할 것이므로 inner class를 사용했다

요청 Api spec

{
  "result_code" : "",
  "result_message" : "",
  "data" : {
  },
  "error" : {
    "error_message" : [
    ]
  }
}
  • 그렇다면, 클라이언트가 요청하는 Api spec도 다음과 같아진다!
  • data에 요청할 데이터를 채워서 보내주면 된다!
{
  "result_code" : "",
  "result_message" : "",
  "data" : {
    "name" : "홍길동",
    "password" : "qwer",
    "age" : 20,
    "email" : "hong@gmail.com",
    "phone_number" : "010-1111-2222",
    "register_at" : "2024-06-17T09:09:09"
  }  ,
  "error" : {
    "error_message" : [
    ]
  }
}
  • 다음과 같이 데이터를 넣어서 Api로 요청해주면 된다!

4. Error Code Response 공통화

  • 우리가 @Valid를 통해 이제 요청에 대한 검증을 할 수 있었다
  • 지금 우리가 원하는 형식에 대해서는 잘 응답을 줄 수 있게 완성을 했다
  • 🤔 이제 에러가 날 때도, 우리가 공통으로 만들어둔 Api 스펙으로 전달해야 한다
  • 물론, 우리는 Controller에 BindgingResult과 if문을 이용해 에러를 처리할 수 있다
  • https://velog.io/@yys/4.-%EA%B2%80%EC%A6%9D1-Validation#bindingresult 이부분을 참고하자
  • 하지만, 우리는 🤔 @ExceptionHandler를 이용해서, 예외처리하는 방법을 배웠다

ValidationExceptionHandler

@Slf4j
@RestControllerAdvice
public class ValidationExceptionHandler {

    @ExceptionHandler(value = MethodArgumentNotValidException.class)
    public ResponseEntity<Api> validationException(
            MethodArgumentNotValidException exception
    ){
        log.error("", exception);

        List<String> errorMessageList = exception.getFieldErrors().stream()
                .map(it -> {
                    String format = "%s : { %s } 은 %s";
                    String message = String.format(format, it.getField(), it.getRejectedValue(), it.getDefaultMessage());
                    return message;
                }).collect(Collectors.toList());

        Api.Error error = Api.Error
                .builder()
                .errorMessage(errorMessageList)
                .build();

        Api<Object> errorResponse = Api.builder()
                .resultCode(String.valueOf(HttpStatus.BAD_REQUEST.value()))
                .resultMessage(HttpStatus.BAD_REQUEST.getReasonPhrase())
                .error(error)
                .build();

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

}
  • MethodArgumentNotValidException를 처리하는 ExceptionHandler을 하나 만들어줬다
  • 이제 우리가 클라이언트와 약속한 Api 타입으로 만들어줘야 한다
  • 생각을 해보자, 예외는 클라이언트가 보낸 요청에 여러개가 나올 수가 있고, 우리는 inner class를 통해 list로 error들을 받기로 하였다
List<String> errorMessageList = exception.getFieldErrors().stream()
                .map(it -> {
                    String format = "%s : { %s } 은 %s";
                    String message = String.format(format, it.getField(), it.getRejectedValue(), it.getDefaultMessage());
                    return message;
                }).collect(Collectors.toList());
  • 우리는 @RestControllerAdvice@ExceptionHandler에러들을 가져왔다
  • 거기서 fiedleError에 대해, stream으로 변환후, filter을 통해 원하는 format으로 변경후 리스트에 담아준다
  • 이부분은, Api 응답의 error 필드에 들어갈 내용이 된다
Api.Error error = Api.Error
                .builder()
                .errorMessage(errorMessageList)
                .build();
  • Api의 Error부분을 메시지를 넣어서 만들어준다!
  • 이때 builder 패턴을 이용했다
Api<Object> errorResponse = Api.builder()
                .resultCode(String.valueOf(HttpStatus.BAD_REQUEST.value()))
                .resultMessage(HttpStatus.BAD_REQUEST.getReasonPhrase())
                .error(error)
                .build();

        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body(errorResponse);
    }
  • 우리가 지정한 Api타입에 resultCode와 resultMessage 그리고 error부분을 잘 넣어서 만든 후
  • ResponseEntity에 상태코드와 body값에 만들어 넣은 Api를 넣어준다

🤔 틀린 Api 요청

{
  "result_code" : "",
  "result_message" : "",
  "data" : {
    "name" : "",
    "password" : "qwer",
    "age" : 20,
    "email" : "hong@gmail.com",
    "phone_number" : "010-11112222",
    "register_at" : "2024-06-17T09:09:09"
  }  ,
  "error" : {
    "error_message" : [
    ]
  }
}
  • 다음과 같이 이름도 공백이고, 번호양식도 맞추지 않은 Api요청을 보냈다!
  • 우리가 원하는 응답 형식과, 공통 Api 스펙에 맞는 결과가 나온 것을 알 수 있다!

이렇게, ✅ 서버의 스펙을 맞춘 후에 예외를 exception 핸들로로 빼게 되면

  • 해당 비지니스 로직을 처리되는 곳에서는, 요청 안의 값이 유효하다는 장점이 생겨, 비지니스 로직만들 처리하는 기능을 가지게 된다!
  • 또한 exception handler에서 format을 이용해 메시지를 만들어서 보내주므로 클라이언트가 오류를 쉽게 알 수 있는 장점이 있다

5. 복합적인 validation

  • 지금까지 본 validation은 간단한 경우였다...
  • 우리가 개발을 하다보면, 여러 조건들이 합쳐진 복합적인 조건에 대한 validation을 진행하게 된다
  • 예를 들어, 다음의 경우를 살펴보자... 똑같이 UserRegisterRequest다
private String name;
private String nickName;
  • 만약 name, nickName 둘 중에 하나만 있으면 -> 회원가입이 가능하다면??
  • 이런 부분에 대해서 validation을 해주는 어노테이션은 없다...

🤔 @AssertTrue, @AssertFalse

  • 해당 어노테이션들은 리턴값이 true거나 false일 때 실행되는 어노테이션이다
@AssertTrue(message = "name or nickName 중 반듯이 1개가 존재해야 합니다")
    public boolean nameCheck(){
        if (Objects.nonNull(name) && !name.isBlank()){
            return true;
        }
        if (Objects.nonNull(nickName) && !nickName.isBlank()){
            return true;
        }
        return false;
    }
  • 해당과 같이 만들어주고, null값이 아니고, 공백이 아니면 -> true를 반환해 실행되지 않게 하고
  • 만약 name, nickName 둘다 공백이거나 없다면 -> false가 반환되어
  • 파라미터인 message가 errorMessage로 가고, BindingResult에 잡혀서, 예외처리가 되게된다!

Api

{
  "result_code" : "",
  "result_message" : "",
  "data" : {
    "name" : "",
    "nick_name" : "",
    "password" : "qwer",
    "age" : 20,
    "email" : "hong@gmail.com",
    "phone_number" : "010-1111-2222",
    "register_at" : "2024-06-17T09:09:09"
  }  ,
  "error" : {
    "error_message" : [
    ]
  }
}
  • 그런데... 결과가 200 즉 성공되었고, 아무 문제가 없었다...
  • 왜 그러지?

🤔 AssertTrue는 반듯이 is를 붙인 메서드 즉, boolean을 반환하는 메서드와 사용해야 한다고 한다!

@AssertTrue(message = "name or nickName 중 반듯이 1개가 존재해야 합니다") 
    public boolean isNameCheck(){
        if (Objects.nonNull(name) && !name.isBlank()){
            return true;
        }
        if (Objects.nonNull(nickName) && !nickName.isBlank()){
            return true;
        }
        return false;
    }
  • 다음과 같이 메서드명을 수정해줬고 , 실행하였더니
  • 우리가 정한 error 메시지가, Api 공통 스펙으로 잘 실행되었다!
profile
개발 공부,정리

0개의 댓글