Rootable의 개발일기
스프링 MVC 검증 - Bean Validation 본문
📌 Bean Validation이란
검증 기능을 매번 코드로 작성하는 것은 번거롭고, 검증 로직은 대부분 범위나 빈값을 체크하는 일반적인 로직이다.
애노테이션으로 일반적인 검증 로직을 모든 프로젝트에 적용할 수 있도록 공통화하고, 표준화한 것
특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)이라는 기술 표준이다. 즉, 검증 애노테이션과 여러 인터페이스의 모음이다. Bean Validation을 구현한 기술 중에 일반적으로 사용하는 구현체는 하이버네이트 Validator이다.
📌 스프링에 Bean Validation 적용
Bean Validation 의존 관계를 추가해야 한다.
//build.gradle
implementation 'org.springframework.boot:spring-boot-starter-validation'
🔎 스프링 MVC는 어떻게 Bean Validator를 사용하는가
스프링 부트가 spring-boot-starter-validation 라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합한다.
- 스프링 부트는 LocalValidatorFactoryBean을 글로벌 Validator로 등록
- 원하는 컨트롤러에서 검증 대상에 @Validated 또는 @Valid 적용
- LocalValidatorFactoryBean은 타깃의 검증 애노테이션을 보고 검증을 수행한다.
LocalValidatorFactoryBean은 검증 오류가 발생하면 FieldError 또는 ObjectError를 생성하여 BindingResult에 담아준다.
💡 @Validated vs @Valid
@Validated는 스프링 전용 검증 애노테이션이고, @Valid는 자바 표준 검증 애노테이션이다. 그래서 @Valid는 build.gradle에 의존관계 추가가 필요하고, groups라는 기능이 포함되지 않았다.
@ModelAttribute를 통해 바인딩에 성공한 필드만 Bean Validation 적용
ex)typeMismatch 등으로 바인딩에 실패하는 경우 FieldError가 추가되고 Bean Validation은 적용되지 않음
🔎 검증 애노테이션
Bean Validation을 적용하려면 검증 대상 도메인에 검증 애노테이션을 붙여야 한다.
@Data
//@ScriptAssert(lang = "javascript", script = "_this.price" * "_this.quantity" >= 10000)
public class Item {
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(9999)
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
- @NotBlank : 빈값 + 공백만 있는 경우를 허용하지 않는다.
- @NotNull : null을 허용하지 않는다.
- @Range(min = 1000, max = 1000000) : 범위 안의 값이어야 한다.
- @Max(9999) : 최대 9999까지만 허용한다.
💡 @ScriptAssert
@ScriptAssert를 통해 ObjectError도 애노테이션으로 자동화할 수 있다. 하지만 위 코드처럼 필드와 값을 직접 지정하기 때문에 검증 애노테이션의 목적인 보편화 및 표준화에 제약이 많아 활용도가 떨어진다. 따라서, 기존에 했던 것처럼 BindingResult를 통해 컨트롤러 안에 작성하는 것이 더 유연한 운영이 될 것이다.
이러한 기본 메시지를 좀 더 자세하게 변경하려면 어떻게 해야 할까?
이것도 마찬가지로 MessageCodesResolver에서 다양한 오류 메시지를 생성하여 해결한다.
📌 Bean Validation 에러 코드 변경
Bean Validation을 적용하면 오류 코드가 애노테이션 이름으로 등록된다.
MessageCodesResolver를 통해 아래와 같은 메시지 코드가 순서대로 생성된다.
이러한 규칙을 이용하여 MessageSource가 참고하는 파일에 에러 메시지를 작성할 수 있다.
이를 위해 application.properties에 errors.properties를 등록한다.
spring.messages.basename=messages,errors
#Bean Validation 추가
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}
{0}에는 필드명이 들어 갔고, {1}부터는 각 검증 애노테이션의 규칙에 따라 정의되었다.
📌 Bean Validation 메시지 찾는 순서(1 ~ 3 순서)
1. MessageCodesResolver에 의해 생성된 메시지 코드 순서대로 MessageSource에서 찾기
2. 검증 애노테이션의 message 속성 확인
@NotBlank(message = "공백 X")
private String name;
3. 라이브러리가 제공하는 기본 메시지
📌 컨트롤러에 Bean Validation 적용 - 등록 & 수정
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
//특정 필드 예외가 아닌 전체 예외
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000,
resultPrice}, null);
}
}
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "validation/v3/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v3/items/{itemId}";
}
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @Validated @ModelAttribute Item item, BindingResult bindingResult) {
//특정 필드 예외가 아닌 전체 예외
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000,
resultPrice}, null);
}
}
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "validation/v3/editForm";
}
itemRepository.update(itemId, item);
return "redirect:/validation/v3/items/{itemId}";
}
@Validated만 사용하면 간단하게 컨트롤러 작성이 끝난다. 그런데, 등록과 수정 요구 사항이 다르다면 어떻게 될까?
등록과 수정에서 검증 조건의 충돌이 발생한다. 즉, 등록과 수정은 같은 Bean Validation을 적용할 수 없다. 이를 해결하기 위해 Bean Validation은 "groups "라는 기능을 제공한다.
📌 Bean Validation groups
상황에 따른 검증 기능을 각각 그룹으로 나누어 적용한다.
저장용 groups 생성
public interface SaveCheck {
}
수정용 groups 생성
public interface UpdateCheck {
}
이를 Item에 적용해 보자
@Data
public class Item {
@NotNull(groups = UpdateCheck.class) //수정 시에만 적용
private Long id;
@NotBlank(groups = {SaveCheck.class, UpdateCheck.class})
private String itemName;
@NotNull(groups = {SaveCheck.class, UpdateCheck.class})
@Range(min = 1000, max = 1000000, groups = {SaveCheck.class, UpdateCheck.class})
private Integer price;
@NotNull(groups = {SaveCheck.class, UpdateCheck.class})
@Max(value = 9999, groups = SaveCheck.class) //등록 시에만 적용
private Integer quantity;
...
}
변경된 등록 및 수정 컨트롤러는 다음과 같다.
...
@PostMapping("/add")
public String addItem2(@Validated(SaveCheck.class) @ModelAttribute Item item,
BindingResult bindingResult, RedirectAttributes redirectAttributes) {
...
}
@PostMapping("/{itemId}/edit")
public String editV2(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item item,
BindingResult bindingResult) {
...
}
groups 기능을 사용하여 등록과 수정 시 각각 다른 검증 스펙을 적용할 수 있었다. 하지만 Item은 물론이고, 전반적으로 복잡도가 올라갔다.
사실 groups 기능은 잘 사용하지 않는데, 그 이유는 실무에서는 주로 다음에 등장하는 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문이다.
📌 Form 전송 객체 분리
🔎 groups 문제점
실무에서는 폼 데이터에 수 많은 부가 데이터가 함께 포함되는 등 복잡한 형태가 될 수 있다. groups처럼 고정된 도메인에 검증 애노테이션을 상황에 따라 운영하는 것보다 예측하기 힘든 폼 데이터가 들어와도 필요한 데이터만 담을 수 있도록, 즉 원하는 폼을 정의한 클래스 객체를 사용하는 것이 바람직하다.
🔎 폼 데이터 전달에 사용할 객체에 따른 장단점
✔ 폼 데이터 전달에 도메인 객체 사용
HTML Form ➡ Domain ➡ Controller ➡ Domain ➡ Repository
- 장점 : 도메인 객체를 컨트롤러, 리포지토리까지 직접 전달해서 중간에 도메인 객체를 만드는 과정이 없다.
- 단점 : 간단한 경우에만 적용할 수 있고, 검증 중복이 발생할 수 있어 groups를 사용해야 한다.
✔ 폼 데이터 전달에 별도의 객체 사용
HTML Form ➡ Class for Form ➡ Controller ➡ Domain 생성 ➡ Repository
- 장점 : 폼 데이터가 아무리 복잡해도 거기에 맞춘 별도의 폼 객체를 사용해서 데이터를 받을 수 있고, 검증 중복이 없다.
- 단점 : 폼 데이터를 기반으로 컨트롤러에서 도메인 객체를 생성하는 과정이 추가된다.
검증 중복이 없고 유연하게 데이터를 받을 수 있는 별도 폼 객체를 사용하자
🔎 저장용 폼 객체
@Data
public class ItemSaveForm {
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(9999)
private Integer quantity;
}
🔎 수정용 폼 객체
@Data
public class ItemUpdateForm {
@NotNull
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
//수정에서는 수량은 자유롭게 변경할 수 있다.
private Integer quantity;
}
🔎 컨트롤러 - 등록 & 수정
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
//특정 필드 예외가 아닌 전체 예외
if (form.getPrice() != null && form.getQuantity() != null) {
int resultPrice = form.getPrice() * form.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
//검증에 실패하면 다시 입력 폼으로
if (bindingResult.hasErrors()) {
log.info("errors = {}", bindingResult);
return "validation/v4/addForm";
}
//성공 로직
Item item = new Item();
item.setItemName(form.getItemName());
item.setPrice(form.getPrice());
item.setQuantity(form.getQuantity());
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v4/items/{itemId}";
}
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) {
//특정 필드 예외가 아닌 전체 예외
if (form.getPrice() != null && form.getQuantity() != null) {
int resultPrice = form.getPrice() * form.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "validation/v4/editForm";
}
Item itemParam = new Item();
itemParam.setItemName(form.getItemName());
itemParam.setPrice(form.getPrice());
itemParam.setQuantity(form.getQuantity());
itemRepository.update(itemId, itemParam);
return "redirect:/validation/v4/items/{itemId}";
}
@ModelAttribute는 폼 객체에 맞춰 바인딩을 수행한다. 그리고 @Validated를 통해 검증을 수행한 후, BindingResult로 검증 결과를 받는다.
폼 객체를 사용했기 때문에 도메인 객체를 생성하는 작업이 추가되었다.
📌 API에 Bean Validation 적용
@RequestBody처럼 HttpMessageConverter를 사용하는 애노테이션에도 Bean Validation을 적용할 수 있다.
API 요청 결과는 다음 3가지로 나뉜다.
- 성공 요청 ➡ 성공
- 실패 요청 ➡ 바인딩 실패로 컨트롤러 자체가 호출되지 않고 Validator도 실행되지 않는다.
- 검증 오류 요청 ➡ 바인딩은 성공했지만 검증 실패
🔎 코드 및 테스트
@RequestBody는 HttpMessagaeConverter를 통해 HTTP 요청 바디를 읽어 ItemSaveForm에 바인딩한다.
@Slf4j
@RestController
@RequestMapping("/validation/api/items")
public class ValidationItemApiController {
@PostMapping("/add")
public Object addItem(@RequestBody @Validated ItemSaveForm form, BindingResult bindingResult) {
log.info("API 컨트롤러 호출");
if (bindingResult.hasErrors()) {
log.info("검증 오류 발생 errors={}", bindingResult);
return bindingResult.getAllErrors();
}
log.info("성공 로직 실행");
return form;
}
}
검증 오류 발생 시, 모든 에러를 출력하도록 했다. bindingResult의 getAllErrors()는 ObjectError와 FieldError를 반환한다. 스프링은 ObjectError와 FieldError를 JSON으로 변환하여 클라이언트로 내린다. 결과는 다음과 같다.
HttpMessageConverter를 사용하는 @RequestBody도 검증을 적용할 수 있다는 것을 알 수 있었고, BindingResult를 통해 오류 정보도 받을 수 있었다.
💡 @ModelAttribute vs @RequestBody
HTTP 파라미터를 처리하는 @ModelAttribute는 필드 단위로 정교하게 바인딩이 적용된다. 그래서 특정 필드가 바인딩되지 않아도 나머지 필드는 정상 바인딩되고, Validator를 사용한 검증도 가능하다.
HTTP body를 읽는 @RequestBody는 HttpMessageConverter 단계에서 바인딩에 실패하면 아무 기능도 수행하지 않는다. 그 이유는 HttpMessageConverter는 필드 단위가 아니라, 객체 단위로 적용되기 때문이다.
Reference:
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 인프런 | 강의
웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습할 수 있
www.inflearn.com
'Spring' 카테고리의 다른 글
서블릿 HTTP 세션 (0) | 2023.07.27 |
---|---|
MessageCodesResolver (0) | 2023.07.25 |
스프링 MVC 검증 - Validation (0) | 2023.07.22 |
요청 매핑 핸들러 어뎁터 구조 (0) | 2023.07.18 |
HTTP 메시지 컨버터(HTTP Message Converter) (0) | 2023.07.17 |