어떠한 서비스 로직을 처리하기 전의 데이터의 유효성 검사를 위해 관련 로직을 모두 작성하다보면 코드도 길어지고 검증 과정에서 나타날 수 있는 다양한 에러에 대해 모두 작성하기 는 어려운 일이다.
이를 위해서 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}$"
- 유효성 검증 코드의 길이가 너무 길다
- 서비스로직에 대해 방해가 된다
- 흩어져있는 경우(여러명이 작업하는 경우)어디에서 검증되었는지 찾기 어렵다
- 검증 로직이 변경되는 경우, 테스트코드 등 전체 로직이 흔들릴 수 있다.


****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);
}
}
이렇게 수정하면,
- 비즈니스 로직을 처리하는 클래스에 요청이 들어왔다===해당 요청의 값은 유효하므로 비즈니스 로직만 처리하고 응답 반환
- 클라이언트 측에서도 어떤 요청에 어떻게 에러가 발생했는지(어떤 값이 유효하지 않아 요청이 반려되었는지) 알 수 있다.