스프링에서 유효성 검증을 하는 여러가지 방법을 알아보겠습니다.
- BindingResult를 사용하는 방법
- 새 어노테이션을 만들고 ConstraintValidator를 상속받아 유효성 검증 필요한 어노테이션을 커스텀하기
- Exception으로 처리하기
- ControllerAdvice 사용하기
Validation은 프로그래밍에 있어 가장 필요한 부분입니다.
예를 들면 이름이 NULL인경우 NPE를 방지하거나 나이를 입력할 자리에 음수가 들어온 경우를 방지하는 과정을 Validation이라고 합니다.
public void valid(String username, String password, int age) {
if (username == null | password == null) {
return;
}
if (age < 1) {
return;
}
}
단순하게는 위와 같은 코드입니다. 불필요하게 비즈니스 로직과 상관없는 코드가 사용됩니다.
Validation
Annotation | Valid |
@Size | 문자 길이 측정 |
@Max | 최대 값 |
@Min | 최소 값 |
@AssertTrue / False | 별도 Logic 적용 |
@Valid | 해당 Object validation 실행 |
@NotNull | NULL 불허 |
@NotEmpty | NULL, "" 불허 |
@NotBlank | NULL, "", " " 불허 |
@Past | 과거 날짜 |
@PastOrPresent | 오늘이거나 과거 날짜 |
@Future | 미래 날짜 |
@FutureOrPresent | 오늘이거나 미래 날짜 |
예제
UserDTO를 raw-json으로 받아서 그대로 출력해주는 간단한 API를 만들어보겠습니다.
User
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
@ToString
public class User {
private String name;
private int age;
private String email;
private String phoneNumber;
}
ApiController
@RestController
@RequestMapping("/api")
public class ApiController {
@PostMapping("/user")
public User user(@RequestBody User user) {
System.out.println(user);
return user;
}
}
API 개발이 완료되었고 아래와 같은 정보를 가지고 요청을 해봅니다.
{
"name" : "김김태태우우",
"age" : -2381,
"email" : "abcde",
"phoneNumber" : "0101111122222"
}
Conosle: User(name=김김태태우우, age=-2381, email=abcde, phoneNumber=0101111122222)
클라이언트가 서버에 정상적인 데이터를 보내지 않았는데 정상적으로 처리가 됐습니다.
이번엔 Validation을 사용해 데이터 유효성을 검증해보겠습니다.
ApiController
@RestController
@RequestMapping("/api")
public class ApiController {
@PostMapping("/user")
public ResponseEntity<?> user(@Valid @RequestBody User user, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
StringBuilder sb = new StringBuilder();
for (ObjectError error : bindingResult.getAllErrors())
sb.append(((FieldError) error).getField() + ": " + error.getDefaultMessage() + "\n");
return ResponseEntity.badRequest().body(sb);
}
return ResponseEntity.ok().body(user);
}
}
@Valid 어노테이션을 붙이고 bindingResult를 매개변수로 받으면 bindingResult.hasErrors() 메서드를 통해 유효성 검증 통과 여부를 알 수 있습니다.
만약 에러가 있다면 필드와 메세지까지 가져올 수 있습니다.
위는 보여주기 위한 코드이고 서비스 로직이 아니더라도 Controller에 검증 로직이 들어가는 건 올바른 코드가 아닙니다.
또한 이러한 처리를 매번 해주는 것은 필드 검증 방식과 다를 바가 없습니다.
그래서 재 사용이 가능한 커스텀 검증 어노테이션을 만들어줍니다.
DTO를 아래와 같이 수정합니다.
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
@ToString
public class User {
@Size(min = 2, max = 4, message = "이름이 너무 길거나 짧습니다. ")
private String name;
@Min(value = 1, message = "나이가 0 또는 음수입니다.")
private int age;
@Pattern(regexp = "^[_a-zA-Z0-9-\\.]+@[\\.a-zA-Z0-9-]+\\.[a-zA-Z]+$", message = "이멜의 포맷이 \"example@example.xxx과 다릅니다.\"")
private String email;
@Pattern(regexp = "^\\d{3}-\\d{4}-\\d{4}$", message = "전화번호의 포맷이 \"xxx-xxxx-xxxx\"과 다릅니다. ")
private String phoneNumber;
@YearMonth
private String birthDay;
}
birthDay의 유효성 검사를 책임질 어노테이션을 만들어봅니다.
YearMonth
@Constraint(validatedBy = {YearMonthValidator.class})
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})
@Retention(RUNTIME)
public @interface YearMonth {
String message() default "yyyyMM 형식에 맞지 않습니다.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
String pattern() default "yyyyMMdd";
}
Validation 어노테이션의 기본 속성 네개를 선언합니다.
YearMonthValidator
public class YearMonthValidator implements ConstraintValidator<YearMonth, String> {
private String pattern;
@Override
public void initialize(YearMonth constraintAnnotation) {
this.pattern = constraintAnnotation.pattern();
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
try {
LocalDate localDate = LocalDate.parse(value + "01", DateTimeFormatter.ofPattern(this.pattern));
} catch (Exception e) {
return false;
}
return true;
}
}
YearMonthValidator를 ConstraintValidator<CustomAnnotatinClassName, value Type>에 상속시킵니다.
{
"name" : "김태우",
"age" : 20,
"email" : "gimgau0218@naver.com",
"phoneNumber" : "010-2222-3333",
"birthDay" : "200302"
}
위와같은 정상적인 요청에 200 OK 가떨어집니다.
{
"name" : "김태우",
"age" : 20,
"email" : "gimgau0218@naver.com",
"phoneNumber" : "010-2222-3333",
"birthDay" : "11112222"
}
위와같이 잘못된 요청운 400 BADREQUEST와 메세지로 birthDay: yyyyMM 형식에 맞지 않습니다. 가 반환됩니다.
마지막으로 하위 속성에 대한 Validation을 알아보겠습니다.
유저 domain을 아래와 같이 수정해줍니다. List<Car>를 추가했습니다.
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
@ToString
public class User {
@Size(min = 2, max = 4, message = "이름이 너무 길거나 짧습니다. ")
private String name;
@Min(value = 1, message = "나이가 0 또는 음수입니다.")
private int age;
@Pattern(regexp = "^[_a-zA-Z0-9-\\.]+@[\\.a-zA-Z0-9-]+\\.[a-zA-Z]+$", message = "이메일의 포맷이 \"example@example.xxx과 다릅니다.\"")
private String email;
@Pattern(regexp = "^\\d{3}-\\d{4}-\\d{4}$", message = "전화번호의 포맷이 \"xxx-xxxx-xxxx\"과 다릅니다. ")
private String phoneNumber;
@YearMonth
private String birthDay;
List<Car> cars;
}
Car
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
@JsonNaming(value = PropertyNamingStrategy.SnakeCaseStrategy.class)
public class Car {
@NotBlank
private String name;
@NotBlank
private String model;
@NotBlank
private String number;
}
Car 클래스는 모든 속성이 NotBlank입니다.
자 그럼 이렇게 요청을 해보겠습니다.
{
"name" : "김태우",
"age" : 20,
"email" : "gimgau0218@naver.com",
"phoneNumber" : "010-2222-3333",
"birthDay" : "200302",
"cars": [
{"name" : "A4"},
{"name" : "A6"},
{"name" : "R8"}
]
}
Car 리스트의 model, number가 들어가지 않아 null입니다.
하지만 Validation은 200 OK를 내려줬습니다.
해결 하는 방법은 해당 프로퍼티에도 @Valid 어노테이션을 달아주는 것입니다.
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
@ToString
public class User {
@Size(min = 2, max = 4, message = "이름이 너무 길거나 짧습니다. ")
private String name;
@Min(value = 1, message = "나이가 0 또는 음수입니다.")
private int age;
@Pattern(regexp = "^[_a-zA-Z0-9-\\.]+@[\\.a-zA-Z0-9-]+\\.[a-zA-Z]+$", message = "이메일의 포맷이 \"example@example.xxx과 다릅니다.\"")
private String email;
@Pattern(regexp = "^\\d{3}-\\d{4}-\\d{4}$", message = "전화번호의 포맷이 \"xxx-xxxx-xxxx\"과 다릅니다. ")
private String phoneNumber;
@YearMonth
private String birthDay;
@Valid
List<Car> cars;
}
{
"name" : "김태우",
"age" : 20,
"email" : "gimgau0218@naver.com",
"phoneNumber" : "010-2222-3333",
"birthDay" : "200302",
"cars": [
{"name" : "A4"},
{"name" : "A6"},
{"name" : "R8"}
]
}
다시 아까와 같이 요청을 해본다면 400이 떨어지고 에러 메세지가 나오는 모습을 볼 수 있습니다.

Web Application의 입장에서 바라 보았을 때, 에러를 내려줄 수 있는 방법은 많지 않습니다.
- 에러 페이지 사용하기,
- 4xx or 5xx
- Client가 200 외에 처리를 하지 못 할 때는 200을 내려주고 에러 Message를 전달하기.
그래서 우리는 서버 어디에서 예외가 발생하던 Exception으로 처리하여 response를 내려주는 방법을 배워야합니다.
@ContrllerAdvice | Global Exception 처리 및 특정 Package / Controller 예외 처리 |
@ExceptionHandler | 특정 Controller의 예외 처리 |
프로젝트를 새로 만듭니다. ( Spring Web, Lombok, Validation ) 을 추가 해주세요.
User
package com.example.exception.dto;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
@ToString
public class User {
@NotEmpty
@Size(min = 1, max = 10)
private String name;
@Min(1)
@NotNull
private Integer age;
}
name은 NotEmpty, Size가 걸려있습니다.
age는 최소 1에 NotNull입니다.
ApiController
@RestController
@RequestMapping("/api")
public class ApiController {
@GetMapping("/user")
public User get(@RequestParam(required = false) String name, @RequestParam(required = false) Integer age) {
int raise = 10 + age;
return User.builder()
.name(name)
.age(age)
.build();
}
@PostMapping("")
public User post(@Valid @RequestBody User user) {
System.out.println(user);
return user;
}
}
Get요청에는 파라미터로 name과 age를 받습니다.
required 속성을 사용해서 필수 여부를 false로 바꿔줍니다.
만약 age를 null로 주고 Get 요청을 타게된다면 raise에서 NPE가 발생할 것입니다. ( null + 10 )
다음과 같이 요청해보겠습니다.
http://localhost:8080/api/user?name&age
클라이언트에 내려준 답변은
{
"timestamp": "2022-08-13T16:50:47.818+00:00",
"status": 400,
"error": "Bad Request",
"path": "/api/user"
}
이렇습니다. 서버에는
java.lang.NullPointerException: null
at com.example.exception.contorller.ApiController.get(ApiController.java:22) ~[main/:na]
당연히 NPE가 발생합니다.
다음과 같이 요청하겠습니다.
http://localhost:8080/api/user
{
"name" : "",
"age" : 0
}

서버 로그에선 어떤 에러인지 보이지만 요청하는 클라이언트는
{
"timestamp": "2022-08-13T16:49:34.126+00:00",
"status": 400,
"error": "Bad Request",
"path": "/api/user"
}
기본적인 정보밖에 볼 수 없습니다.
그렇다면 에러가 발생할 때 좀더 다양한 정보를 클라이언트에 제공할 수 있도록 만들어보겠습니다.
새로운 클래스를 만듭니다.
package com.example.exception.advice;
import java.util.*;
@RestControllerAdvice
public class GlobalControllerAdvice {
@ExceptionHandler(value = Exception.class)
public ResponseEntity exception(Exception e) {
System.out.println(e.getLocalizedMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("");
}
}
@ExceptionHandler의 value 속성으로 Exception.class를 넣었습니다.
이러면 Spring server에서 일어나는 모든 예외를 여기서 처리하겠다는 뜻입니다.
body없이 INTERNAL_SERVER_ERROR로 response 하는 코드입니다.
다시 한번 잘못된 요청해보겠습니다.
GET : http://localhost:8080/api/user?name&age
위 처럼 요청을 보내면 500 INTERNAL SERVER ERROR가 내려오고 아까와 다르게 body에 아무것도 없는 것을 확인할 수 있습니다.
만약 특정 예외를 잡고 싶다면 아래와 같이 만들면 됩니다.
@ExceptionHandler(value = MethodArgumentNotValidException.class)
public ResponseEntity methodArgsNotValidException(MethodArgumentNotValidException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
}
body
Validation failed for argument [
0
] in public com.example.exception.dto.User com.example.exception.contorller.ApiController.post(com.example.exception.dto.User) with 3 errors: [Field error in object 'user' on field 'name': rejected value []; codes [Size.user.name,Size.name,Size.java.lang.String,Size
]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [user.name,name
]; arguments []; default message [name
],
10,
1
]; default message [크기가 1에서 10 사이여야 합니다
]
] [Field error in object 'user' on field 'name': rejected value []; codes [NotEmpty.user.name,NotEmpty.name,NotEmpty.java.lang.String,NotEmpty
]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [user.name,name
]; arguments []; default message [name
]
]; default message [비어 있을 수 없습니다
]
] [Field error in object 'user' on field 'age': rejected value [
0
]; codes [Min.user.age,Min.age,Min.java.lang.Integer,Min
]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [user.age,age
]; arguments []; default message [age
],
1
]; default message [
1 이상이어야 합니다
]
]
이렇게 Response될 메세지를 꾸미는 것은 각 클라이언트와 협의해서 보기쉽게 ErrorResponse를 해주면 될 것같습니다.
래퍼런스
https://hibernate.org/validator/
The Bean Validation reference implementation. - Hibernate Validator
Express validation rules in a standardized way using annotation-based constraints and benefit from transparent integration with a wide variety of frameworks.
hibernate.org
'Spring > Spring Boot' 카테고리의 다른 글
[게시판 RESTful API] - domain을 만들며 Entity를 알아보자. (2) (0) | 2022.08.20 |
---|---|
[게시판 RESTful API] - 프로젝트 세팅 (1) (0) | 2022.08.20 |
[Spring Boot, RIOT API] - Unirest로 RIOT API를 요청하고 ObjectMapper를 배워보자. (0) | 2022.08.10 |
[Spring Boot] Transactional 어노테이션에 대해 알아보자. (0) | 2022.04.30 |
[Spring Data Jpa] (JUNIT, DDL) 외래키 제약조건에 NOT NULL을 추가해보자. (0) | 2022.04.23 |