특정 필드 검증은 빈 값, 길이, 크기, 형식과 같은 간단한 로직입니다. 이런 로직들을 모든 프로젝트에 적용할 수 있도록 표준화한 것이 BeanValidation 입니다.
@PostMapping("/v3/member")
public String createMemberV3(
// 1. @ModelAttribute 뒤에 2. BindingResult가 위치한다.
@ModelAttribute MemberCreateRequestDto request,
BindingResult bindingResult,
Model model
) {
System.out.println("/V3/member API가 호출되었습니다.");
// 3. Validation
if (request.getAge() == null || request.getAge() < 0) {
// BindingResult FieldError 추가
bindingResult.addError(
new FieldError("request", "age", "age 필드는 필수이며 0 이상의 값이어야 합니다.")
);
}
// error 처리
if (bindingResult.hasErrors()) {
System.out.println("Error를 처리하는 로직");
// error 페이지 반환
return "error";
}
// Model에 저장
model.addAttribute("point", request.getPoint());
model.addAttribute("name", request.getName());
model.addAttribute("age", request.getAge());
return "complete";
}
age에 -1을 넣고 요청을 보내게 되면, error 페이지로 이동하게 됩니다.
하지만 이런 식으로 검증 기능을 BindingResult처럼 구현하는 것은 번거롭습니다. 게다가 Controller의 크기가 커지고 단일 책임 원칙에 위배가 됩니다.
객체의 필드나 메서드에 제약 조건을 설정합니다. 그리고 그 제약조건에 맞는지 요청된 값을 검증하는 표준화된 방법입니다.
기술 표준 인터페이스에 해당하며, 다양한 annotation들과 여러 가지 interface로 구성이 되어 있습니다.
구현체로는 Hibernate Validator가 있으며, 이를 주로 사용합니다.
Hibernate?
ORM JPA Hibernate도 존재하는데 여기서 말하는 Hibernate와는 이름만 같은 별개의 기술입니다.
@Getter
public class SignUpRequestDto {
@NotBlank
private String name;
@NotNull
@Range(min = 1, max = 120)
private Integer age;
}
@Controller
public class BeanValidationController {
@PostMapping("/model-attribute")
public String beanValidationV1(
@Validated @ModelAttribute SignUpRequestDto dto
) {
// 로직
// ViewName
return "complete";
}
}
@RestController
public class BeanValidationRestController {
@PostMapping("/request-body")
public String beanValidationV2(
@Validated @RequestBody SignUpRequestDto dto
) {
// 로직
// 문자 Data 반환
return "회원가입 완료";
}
}
위 코드처럼 annotation을 적용시키는 것만으로도 validation을 적용시킬 수 있습니다. Controller에 개발자가 기본적인 검증 로직을 작성할 필요도 없어졌으며, RestController의 @RequestBody에도 사용할 수 있습니다.
특정 필드에 대한 유효성 검증을 진행했을 때, 검증 조건을 만족하지 않으면 발생하는 오류입니다.
| annoatation | 설명 | 허용 대상 |
|---|---|---|
| @NotNull | null이 아니어야 합니다. | 모든 타입 |
| @NotBlank | null, 공백, 빈 문자열 모두 불가능합니다. | CharSequence |
| @NotEmpty | null과 빈 값이 불가능합니다. | CharSequence, Collection, Map, Array |
| @Size | 크기를 제한합니다. | 문자열, Collection |
| @Min, @Max | 최솟값, 최댓값 제한 | 숫자형 |
여기서 각 annotation은 message 속성으로 error message를 커스터마이즈 할 수 있습니다.
validator의 import
import가
org.hibernate.validator로 시작하면, hibernate를 사용할 때에만 제공되는 검증기능입니다. 만약 다른 구현체로 validator를 교체할 경우 동작하지 않습니다.
그래도 보통org.hibernate.validator를 사용합니다.
단순히 annotation을 선언해주기만 해도 검증이 되는 이유는 Validator가 존재하기 때문입니다. Spring Boot에서 validation 라이브러리를 설정하면, 자동으로 Bean Validator를 Spring에 통합되도록 설정해줍니다.
LocalValidatorFactoryBean을 Global Validator로 등록합니다.LocalValidatorFactoryBean을 등록하지 않습니다.@Valid, @Validated만 적용하면 됩니다.FieldError, ObjectError를 생성하여 BindingResult에 담아둡니다.@Valid는 Java 표준이고 @Validated는 Spring에서 제공하는 annotation입니다.@Validated를 통해서 Group Validation 또는 Controller 이외의 계층에서 Validation이 가능합니다.@Valid는 MethodArgumentNotValidException 예외를 발생시킵니다.@Valid는 ConstraintViolationException 예외를 발생시킵니다.@ModelAttribute가 각각의 필드 타입에 맞추어 바인딩을 시도합니다.
여기서 바인딩에 성공하게 되면 Controller를 정상 호출하고, 실패한다면 TypeMismatch FieldError를 발생시킵니다.
@ModelAttribute가 각 필드를 바인딩하고, 바인딩에 성공한 필드만 Bean Validation을 적용합니다.
여기서 바인딩에 성공하게 되면 성공한 필드에 Bean Validation을 적용하게 되고, 실패한 필드가 있다면, BindingResult에 TypeMismatch FieldError를 추가합니다. 여기서, 바인딩에 실패한 필드는 값이 존재하지 않게 되며 Bean Validation 적용 대상이 아니게 됩니다.
위에 annotation에 대해 이야기하며 잠깐 언급을 하였지만, Bean Validation에서 제공하는 Message들은 각각 존재하며, 커스터마이징이 가능합니다.
Bean Validation을 적용하고 BindingResult에 등록된 검증 오류를 확인해보면 오류가 annotation 이름으로 등록이 되어있습니다.
Spring에서의 오류 메세지
Spring에서는 오류 메세지 코드 관리를 위해
MessageCodesResolver인터페이스의 구현체인DefaultMessageCodesResolver를 기본적으로 사용합니다.
오류 메세지의 우선순위는 아래와 같습니다. 우선순위가 높은 것들이 정의되어 있지 않으면, 그 다음 우선순위를 가진 message들이 반환됩니다.
Annotation의 message 속성을 사용합니다.
// 예시 코드
@Data
public class TestDto {
@NotBlank(message = "메세지 수정 가능")
private String stringField;
}
필드명에 맞춘 사용자 정의 Message입니다.
필드 타입에 맞춘 사용자 정의 Message입니다.
annotation 자체에 포함된 default message입니다.
필드 단위가 아닌, 객체 전체에 대한 오류를 나타냅니다. 두 필드 간의 관계를 검증한다고 하면, ObjectError를 통해 해당 오류를 BindingResult에 기록할 수 있습니다.
@Data
@ScriptAssert(lang = "javascript", script = "_this.password === _this.confirmPassword", message = "Passwords do not match")
public class UserRegistrationDto {
@NotBlank(message = "Password is required")
private String password;
@NotBlank(message = "Confirm password is required")
private String confirmPassword;
}
실제로 @ScriptAssert는 제약사항 때문에 사용하지는 않습니다. 훨씬 복잡한 Validation이 필요한 경우 대응이 불가능합니다.
그렇기 때문에 보통 Object Error의 경우, Java 코드로 직접 Validation을 합니다.
@Getter
@AllArgsConstructor
public class OrderRequestDto {
@NotNull
@Range(min = 1000)
private Integer price;
@NotNull
@Range(min = 1)
private Integer count;
}
@Slf4j
@RestController
public class BeanValidationController {
@PostMapping("/object-error")
public String objectError(
@Validated @ModelAttribute OrderRequestDto requestDto,
BindingResult bindingResult
) {
// 합이 10000원 이상인지 확인
int result = requestDto.getPrice() * requestDto.getCount();
if (result < 10000) {
// Object Error
bindingResult.reject("totalMin", new Object[]{10000, result}, "총 합이 10000 이상이어야 합니다.");
}
// Error가 있으면 출력
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return bindingResult.getAllErrors().get(0).getDefaultMessage();
}
// 성공로직 ...
return "성공";
}
}
이런 식으로 Object Error는 로직으로 구현하면 됩니다.
저장, 수정 API에서 각각 다른 Validation이 적용된다면 어떻게 될까요?
예를 들어서, 상품을 저장할 때는 id가 필요없지만, 상품을 수정할 때에는 id가 필요하고 상품의 가격의 범위 제한도 다를 수가 있습니다.
이런 상황에서 하나의 DTO에 모든 검증 annotation을 적용하게 되면 각각의 요구사항들이 충돌하게 됩니다. 여기서, 이를 해결하기 위한 두가지 방법으로 groups 기능과 DTO의 분리입니다.
annotation의 groups 속성에 그룹을 지정하여 상황 별로 다른 validation 규칙을 적용할 수 있습니다.
// 그룹 인터페이스 정의
public interface SaveCheck {}
public interface UpdateCheck {}
// DTO에 groups 지정
@Data
public class ProductRequestDtoV2 {
@NotBlank(groups = {SaveCheck.class, UpdateCheck.class})
private String name;
@NotNull
@Range(min = 10, max = 10000, groups = SaveCheck.class) // 등록시에만 범위 제한
private Integer price;
@NotNull
@Range(min = 1, max = 999)
private Integer count;
}
Controller에서 group을 지정합니다.
@PostMapping("/v2/product")
public String save(@Validated(SaveCheck.class) @ModelAttribute ProductRequestDtoV2 dto) { ... }
@PutMapping("/v2/product/{id}")
public String update(@PathVariable Long id, @Validated(UpdateCheck.class) @ModelAttribute ProductRequestDtoV2 dto) { ... }
저장 시에는 SaveCheck 그룹에 해당하는 validation만, 수정 시에는 UpdateCheck에 해당하는 validation만 동작합니다.
이런 식으로 저장 또는 수정 요구사항을 하나의 DTO에서 상황에 맞게 다르게 적용할 수 있습니다.
저장용 DTO와 수정용 DTO를 분리합니다. 그리고 각각에 맞는 검증 annotation을 지정합니다.
@Data
public class ProductSaveRequestDto {
@NotBlank
private String name;
@NotNull
@Range(min = 10, max = 10000)
private Integer price;
@NotNull
@Range(min = 1, max = 999)
private Integer count;
}
@Data
public class ProductUpdateRequestDto {
@NotNull
private Long id;
@NotBlank
private String name;
@NotNull
private Integer price; // 범위 제한 없음
@NotNull
@Range(min = 1, max = 999)
private Integer count;
}
Controller에서 각각 사용합니다.
@PostMapping("/product")
public String save(@Validated @ModelAttribute ProductSaveRequestDto dto) { ... }
@PutMapping("/product/{id}")
public String update(@Validated @ModelAttribute ProductUpdateRequestDto dto) { ... }
각 API에 맞는 DTO를 사용해 검증 충돌이 발생하지 않습니다. 보통 DTO 방식이 더 명확하고 유지보수에 유리하다고 합니다.
Groups의 경우 하나의 DTO에서 상황별로 검증이 가능하며, 코드의 중복이 적습니다.
하지만, 가독성이 저하되고 복잡성이 증가하여 실무에서는 잘 사용하지 않습니다.
DTO 분리의 경우 DTO 별로 명확하게 책임을 분리하며 유지보수가 용이하다는 점에서 실무에서 선호하는 편입니다. 하지만, 비슷한 필드가 많으면 코드가 중복된다는 단점이 있으며, 어설프게 하나로 합칠 경우 유지보수할 때 매우 복잡해집니다.
@Validated는 Spring에서 제공하며, groups 기능을 지원합니다. 그리고 속성값을 지정할 수 있습니다. 이에 반해 @Valid는 Java 표준이며, groups 기능을 지원하지 않고 속성값을 지정할 수 없습니다.
따라서 위에 소개한 groups 기능을 사용하려면 @Validated를 사용해야 합니다.
@Valid와 @Validated는 @ModelAttribute 뿐만 아니라 @RequestBody에도 사용할 수 있습니다.
여기서 @ModelAttribute는 요청 파라미터 혹은 Form Data(x-www-urlencoded)를 다룰 때 사용하며 @RequestBody는 HTTP Body Data(JSON, XML 등)를 Object로 변환할 때 사용합니다.
@Data
public class ExampleRequestDto {
@NotBlank
private String field1;
@NotNull
@Range(min = 1, max = 150)
private Integer field2;
}
@Slf4j
@RestController
public class RequestBodyController {
@PostMapping("/example")
public Object save(
@Validated @RequestBody ExampleRequestDto dto,
BindingResult bindingResult
) {
log.info("RequestBody Controller 호출");
if (bindingResult.hasErrors()) {
log.info("validation errors={}", bindingResult);
// Field, Object Error 모두 JSON으로 반환
return bindingResult.getAllErrors();
}
// 성공 시 RequestDto 반환(의미 없음)
return dto;
}
}
@ModelAttribute는 각각의 필드 단위로 바인딩하고, 특정 필드의 바인딩이 실패하더라도 나머지 필드에 대한 검증을 정상적으로 처리할 수 있습니다.
@RequestBody의 검증은 필드 별로 적용되는 것이 아닌, 객체 단위로 적용이 됩니다. MessageConverter가 정상적으로 동작하여 Object로 변환하여야만 Validation이 동작합니다. @ModelAttribute와 다르게 특정 필드에 대한 변환이 실패하는 경우, 컨트롤러가 호출되지 않으며 Validation이 적용되지 않습니다.
+@
bindingResult.getAllErrors()는 FieldError, ObjectError 모두 반환합니다.
Spring은 MessageConverter를 이용해 Error 객체들을 변환하여 응답을 합니다.
RequestDTO는 생성, 수정, 삭제 등 상황 별로 분리하여 사용하는 것이 좋습니다.
실제 운영 환경에서는 단순히 error object 배열을 반환하는 대신, API 명세에 맞는 에러 응답 포맷을 만들어야 합니다. 이를 위하여 @ControllerAdvice와 같은 예외 처리 handler를 사용합니다.
자료 및 코드 출처: 스파르타 코딩클럽