Spring Boot에서의 Validation

MONA·2024년 3월 3일

나혼공

목록 보기
10/92

Spring Boot에서의 Validation

어떠한 서비스 로직을 처리하기 전의 데이터의 유효성 검사를 위해 관련 로직을 모두 작성하다보면 코드도 길어지고 검증 과정에서 나타날 수 있는 다양한 에러에 대해 모두 작성하기 는 어려운 일이다.
이를 위해서 spring boot에서는 spring-boot-starter-validation이라는 dependency를 이용할 수 있다.
https://jakarta.ee/specifications/bean-validation/3.0/jakarta-bean-validation-spec-3.0.html#builtinconstraints

또는 정규식을 이용하는 방법도 있다.
ex)휴대폰 번호 정규식
"^\d{2,3}-\d{3,4}-\d{4}$"

Validaion을 사용하는 이유

  1. 유효성 검증 코드의 길이가 너무 길다
  2. 서비스로직에 대해 방해가 된다
  3. 흩어져있는 경우(여러명이 작업하는 경우)어디에서 검증되었는지 찾기 어렵다
  4. 검증 로직이 변경되는 경우, 테스트코드 등 전체 로직이 흔들릴 수 있다.

Validation 적용해보기

서버 측 검증


****package com.example.validation.model;

import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
public class UserRegisterRequest {

    private String name;
    private Integer age;
    private String email;
    private String phoneNumber;

    private LocalDateTime registerAt;
}

package com.example.validation.controller;

import com.example.validation.model.UserRegisterRequest;
import lombok.extern.slf4j.Slf4j;
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;

@Slf4j
@RestController
@RequestMapping("/api/user")
public class UserApiController {

    @PostMapping("")
    public UserRegisterRequest register(
            @RequestBody
            UserRegisterRequest userRegisterRequest
    ){
        log.info("init : {}", userRegisterRequest);

        return userRegisterRequest;
    }
}

해당 url로 다음과 같은 내용의 POST 요청을 보낸다

{
  "name": "",
  "age": 20,
  "email": "",
  "phone_number": "",
  "register_at": "2023-01-02T14:04:10"
}

결과

(생략) init : UserRegisterRequest(name=, age=20, email=, phoneNumber=, registerAt=2023-01-02T14:04:10)

값이 null로 정상적으로 서버에 전송됨을 확인할 수 있다.
여기서 validation을 이용해 각 필드에 대해 검증과정을 추가할 수 있다.

    @NotNull // != null  : null 불가
    @NotEmpty // != null && != ""  : null, 빈 문자열 불가
    @NotBlank // != null && !="" && != " "  : null, 빈 문자열, 공백 문자열 불가

위의 RequestDTO는 다음과 같이 수정할 수 있다.

package com.example.validation.model;

import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import jakarta.validation.constraints.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
public class UserRegisterRequest {

    @NotBlank
    private String name;

    @NotBlank
    @Size(min = 1, max = 12) // 문자열의 최소, 최대 길이 설정
    private String password;

    @NotNull
    @Min(1) // 최솟값
    @Max(100) // 최댓값
    private Integer age; // 문자가 아니라 NotBlank 사용 불가

    @Email
    private String email;

    // 휴대폰은 관련 validation이 없어 정규식 활용
    @Pattern( regexp = "^\\d{2,3}-\\d{3,4}-\\d{4}$")
    private String phoneNumber;

    @FutureOrPresent
    private LocalDateTime registerAt;
}

수정 후 ApitController도 다음과 같이 수정해준다.

package com.example.validation.controller;

import com.example.validation.model.UserRegisterRequest;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
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;

@Slf4j
@RestController
@RequestMapping("/api/user")
public class UserApiController {

    @PostMapping("")
    public UserRegisterRequest register(
            @Valid // 요청이 들어올 때 자동으로 해당 클레스에 붙어있는 어노테이션 기반으로 검증을 실행함
            @RequestBody
            UserRegisterRequest userRegisterRequest
    ){
        log.info("init : {}", userRegisterRequest);

        return userRegisterRequest;
    }
}

그리고 위의 json양식 그대로 POST 요청을 보내면 400 error가 발생한다.

{
  "timestamp": "2024-03-03T13:00:23.926+00:00",
  "status": 400,
  "error": "Bad Request",
  "path": "/api/user"
}

log를 확인해보면,

 WARN 3160 --- [nio-8080-exec-1] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public com.example.validation.model.UserRegisterRequest com.example.validation.controller.UserApiController.register(com.example.validation.model.UserRegisterRequest) with 4 errors: [Field error in object 'userRegisterRequest' on field 'phoneNumber': rejected value []; codes [Pattern.userRegisterRequest.phoneNumber,Pattern.phoneNumber,Pattern.java.lang.String,Pattern]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [userRegisterRequest.phoneNumber,phoneNumber]; arguments []; default message [phoneNumber],[Ljakarta.validation.constraints.Pattern$Flag;@50b113b9,^\d{2,3}-\d{3,4}-\d{4}\$]; default message ["^\d{2,3}-\d{3,4}-\d{4}\$"와 일치해야 합니다]]
 [Field error in object 'userRegisterRequest' on field 'password': rejected value [null]; codes [NotBlank.userRegisterRequest.password,NotBlank.password,NotBlank.java.lang.String,NotBlank]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [userRegisterRequest.password,password]; arguments []; default message [password]]; default message [공백일 수 없습니다]]
 [Field error in object 'userRegisterRequest' on field 'registerAt': rejected value [2023-01-02T14:04:10]; codes [FutureOrPresent.userRegisterRequest.registerAt,FutureOrPresent.registerAt,FutureOrPresent.java.time.LocalDateTime,FutureOrPresent]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [userRegisterRequest.registerAt,registerAt]; arguments []; default message [registerAt]]; default message [현재 또는 미래의 날짜여야 합니다]]
 [Field error in object 'userRegisterRequest' on field 'name': rejected value []; codes [NotBlank.userRegisterRequest.name,NotBlank.name,NotBlank.java.lang.String,NotBlank]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [userRegisterRequest.name,name]; arguments []; default message [name]]; default message [공백일 수 없습니다]] ]

엄청 긴 내용이다. 살펴보면 내가 필드별로 설정한 validation 조건에 맞지않는다는 내용이다.

요청 내용을 validation에 맞게 수정해서 다시 POST 요청을 보낸다.

{
  "name": "김치볶음밥",
  "password": "admin123",
  "age": 20,
  "email": "le@gmail.com",
  "phone_number": "010-1100-0011",
  "register_at": "2024-11-02T14:04:10"
}

200 OK 반환,

{
  "name": "김치볶음밥",
  "password": "admin123",
  "age": 20,
  "email": "le@gmail.com",
  "phone_number": "010-1100-0011",
  "register_at": "2024-11-02T14:04:10"
}

양식이 맞지 않은 경우 message를 설정해 출력해줄 수도 있다.

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

이렇게 할 경우 콘솔에 출력되는 휴대폰 번호 양식 관련 에러메시지는 "양식이 맞지 않습니다."라고 출력된다.

하지만 이렇게 처리할 경우 클라이언트 측에서는 에러가 발생했는지 알 수 없다.

클라이언트 측에 결과 전달하기

에러가 발생했다는 것을 응답으로 전달해 클라이언트 측에서도 정상 처리가 되지 않았음을 알려줄 수 있다.

model package에 Api라는 java class를 하나 생성하고, 다음과 같이 작성한다.

package com.example.validation.model;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.List;

@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
public class Api<T> {

    private String resultCode;

    private String resultMessage;

    private  T data;

    private Error error;

    // 에러 담을 객체 생성
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    @Builder
    @JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
    public static class Error{

        private List<String> errorMessage;
    }
}

그리고 앞서 작성한 controller를 다음과 같이 수정해준다.

package com.example.validation.controller;

import com.example.validation.model.Api;
import com.example.validation.model.UserRegisterRequest;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
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;

@Slf4j
@RestController
@RequestMapping("/api/user")
public class UserApiController {

    @PostMapping("")
    // 반환값을 api 스펙을 통해 리턴함
    public Api<UserRegisterRequest> register(
            @Valid
            @RequestBody
            // 요청도 api 스펙으로 요청함
            Api<UserRegisterRequest> userRegisterRequest
    ){
        log.info("init : {}", userRegisterRequest);

        return userRegisterRequest;
    }
}

이렇게 수정하면 요청을 보낼 때도, 받을 때도 작성한 Api 스펙으로 주고받을 수 있다.
그러므로 json은 다음과 같이 수정된다.

{
  "result_code": "",
  "result_message": "",
  "data": {
    "name": "김치볶음밥",
    "password": "admin123",
    "age": 20,
    "email": "le@gmail.com",
    "phone_number": "010-1100-0011",
    "register_at": "2024-11-02T14:04:10"
  },
  "error": {
    "error_message": []
  }
}

한데 이 경우 data 안의 내용이 잘못되어도 200 OK 가 반환된다.
이는 요청을 보낼 때 data의 내용이 검증과정을 거치지 않기 때문이다.

package com.example.validation.model;

import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.List;

@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)
    public static class Error{

        private List<String> errorMessage;
    }
}

이렇게 수정하면 양식이 잘못된 경우 다시 400 에러가 발생한다.

resultCode, resultMessage 전달하기

package com.example.validation.controller;

import com.example.validation.model.Api;
import com.example.validation.model.UserRegisterRequest;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindingResult;
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 java.util.Objects;
import java.util.stream.Collectors;

@Slf4j
@RestController
@RequestMapping("/api/user")
public class UserApiController {

    @PostMapping("")
    // 2. 와일드카드로 수정해서 모든 값을 generic type으로 리턴하도록 수정했다.
    public Api<? extends Object> register(
            @Valid
            @RequestBody
            Api<UserRegisterRequest> userRegisterRequest,

            BindingResult bindingResult // 해당 Valid가 실행되었을 때 그 결과를 bindingResult에 담아준다.
    ){
        log.info("init : {}", userRegisterRequest);

//        bindingResult.getErrorCount(); // ErrorCount가 0 이상이면 에러가 있다
        if(bindingResult.hasErrors()){ // 만약 에러가 있다면
            var errorMessageList = bindingResult.getFieldErrors().stream() // 에러가 발생한 필드를 가져온다.
                    .map(it -> { // 각각의 값을 매핑하며 format으로 가공해준다.
                        var format = "%s : {%s} 은 %s";
                        var message = String.format(format, it.getField(), it.getRejectedValue(), it.getDefaultMessage());
                            // 1. 이 필드가 2. 이러한 값을 요청해서 3. 요청이 실패했다.
                        return message;
                    }).collect(Collectors.toList()); // 에러 메시지 리스트 생성

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

            var errorResponse = Api
                    .builder()
                    .resultCode(String.valueOf(HttpStatus.BAD_REQUEST.value()))
                    .resultMessage(HttpStatus.BAD_REQUEST.getReasonPhrase())
                    .error(error)
                    .build();
            // 1. body가 없어 UserRegisterRequest에 맞지 않아 클래스의 리턴값을 Object로 수정했음
            return errorResponse;
        }

        var body = userRegisterRequest.getData();

        Api<UserRegisterRequest> response = Api.<UserRegisterRequest>builder()
                .resultCode(String.valueOf(HttpStatus.OK.value()))
                .resultMessage(HttpStatus.OK.getReasonPhrase())
                .data(body)
                .build();
        return response;
    }
}

결과

// 정상 요청일 경우 응답
{
  "result_code": "",
  "result_message": "",
  "data": {
    "name": "김치볶음밥",
    "password": "admin123",
    "age": 20,
    "email": "le@gmail.com",
    "phone_number": "010-1100-0011",
    "register_at": "2024-11-02T14:04:10"
  },
  "error": {
    "error_message": []
  }
}
// 잘못된 요청값일 경우 응답
{
  "result_code": "400",
  "result_message": "Bad Request",
  "data": null,
  "error": {
    "error_message": [
      "data.name : {} 은 공백일 수 없습니다",
      "data.phoneNumber : {010-11000011} 은 양식이 맞지 않습니다."
    ]
  }
}

그런데! 반환된 응답 내의 result_code는 400이지만 응답 자체는 200 OK로 반환되는 문제가 있다.

그렇다고 여기서 또 에러 핸들링 로직을 작성하다보면 코드가 무지하게 길어지는 문제가 또 발생한다.

이럴 경우 앞서 학습했던 global exception handler를 이용해 비즈니스 로직과 에러 핸들링 로직을 분리 작성하는 게 좋다.

// 비즈니스 로직
package com.example.validation.controller;

import com.example.validation.model.Api;
import com.example.validation.model.UserRegisterRequest;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindingResult;
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 java.util.Objects;
import java.util.stream.Collectors;

@Slf4j
@RestController
@RequestMapping("/api/user")
public class UserApiController {

    @PostMapping("")
    public Api<UserRegisterRequest> register(

            @Valid
            @RequestBody
            Api<UserRegisterRequest> userRegisterRequest
    ){
        log.info("init : {}", userRegisterRequest);

        var body = userRegisterRequest.getData();

       var response = Api.<UserRegisterRequest>builder()
                .resultCode(String.valueOf(HttpStatus.OK.value()))
                .resultMessage(HttpStatus.OK.getReasonPhrase())
                .data(body)
                .build();
        return response;
    }
}
// global exception handler
package com.example.validation.exception;

import com.example.validation.model.Api;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.stream.Collectors;

@RestControllerAdvice
@Slf4j
public class ValidationExceptionHandler {

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

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

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

        var 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);

    }
}

이렇게 수정하면,

  1. 비즈니스 로직을 처리하는 클래스에 요청이 들어왔다===해당 요청의 값은 유효하므로 비즈니스 로직만 처리하고 응답 반환
  2. 클라이언트 측에서도 어떤 요청에 어떻게 에러가 발생했는지(어떤 값이 유효하지 않아 요청이 반려되었는지) 알 수 있다.
profile
고민고민고민

0개의 댓글