검증 - Bean Validation
- 검증 로직은 대부분 빈 값인지 아닌지, 특정 크기를 넘는지 아닌지와 같이 매우 일반적인 로직이다.
- Bean Validation : 일반적인 검증 로직들을 모든 프로젝트에 적용할 수 있게 공통화하고 표준화한 것
- 어노테이션 하나로 검증 로직을 매우 편리하게 적용할 수 있다.
- 구현체가 아닌 검증 어노테이션과 여러 인터페이스의 모음이다.
- 일반적으로 사용하는 구현체는 하이버네이트 Validator
implementation 'org.springframework.boot:spring-boot-starter-validation'
- Bean Validation을 사용하기 위한 build.gradle에 라이브러리 추가
- 해당 라이브러리가 추가되면 스프링 부트가 자동으로 Bean Validator를 인식하고 스프링에 통합한다.
//검증 어노테이션 사용 예시
@Data
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;
}
- 검증 어노테이션
- @NotBlank : 빈값 + 공백만 있는 경우를 허용하지 않는다.
- @NotNull : null을 허용하지 않는다.
- @Range(min=~, max=~) : 범위 안의 값이어야 한다.
- @Max(max) : 최대 max 값까지만 허용한다.
- javax.validation으로 시작하면 특정 구현에 관계없이 제공되는 표준 인터페이스
- org.hibernate.validator로 시작하면 하이버네이트 validator 구현체를 사용할 때만 제공되는 검증 기능
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
- 검증기 생성 코드
- 스프링과 통합 시 직접 이런 코드를 작성하지 않아도 된다.
Set<ConstraintViolation<Item>> violations = validator.validate(item);
- 검증 대상을 검증기에 넣고 결과를 받는다.
- Set에는 검증 오류가 담긴다.
- 결과가 비어있다면 검증 오류가 없다는 의미.
- 스프링 부트는 자동으로 글로벌 Validator를 등록한다.
- 검증을 위해 @Valid / @Validated만 적용하면 된다.
- 검증 오류가 발생하면 FieldError, ObjectError를 생성해서 BindingResult에 담아준다.
- @Valid vs @Validated
- @Valid는 자바 표준 검증 어노테이션으로 build.gradle에 별도의 의존관계 추가가 필요하다.
- @Validated는 스프링 전용 검증 어노테이션이며 내부에 groups라는 기능을 포함하고 있다.
- 둘 중 아무거나 사용해도 동일하게 작동한다. (groups 기능 제외)
- 검증 순서
- @ModelAttribute 객체의 각 필드에 타입 변환 후 값을 넣어준다.
- 실패하면 typeMismatch로 FieldError 생성
- Validator 적용
- @ModelAttribute 객체의 각 필드에 타입 변환 후 값을 넣어준다.
- 바인딩에 성공한 필드만 Bean Validation을 적용한다.
- 바인딩에 실패한 필드는 값이 들어가지 않기 때문에 검증을 할 필요가 없다.
Bean Validation - 에러 코드
- 오류 코드가 어노테이션 이름으로 등록된다. (typeMismatch와 유사)
- 어노테이션 이름의 오류 코드를 기반으로 MessageCodesResolver 를 통해 다양한 메시지 코드가 순서대로 생성된다.
- ex) @NotBlank 어노테이션에 대한 오류
- NotBlank.item.itemName
- NotBlank.itemName
- NotBlank.java.lang.String
- NotBlank
#Bean Validation 추가
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}
- 마찬가지로 메시지를 레벨 별로 등록할 수 있다. (구체적 ~ 덜 구체적)
- {0}은 필드명, {1}, {2} ...는 어노테이션마다 다르다.
- BeanValidation 메시지 찾는 순서
- 생성된 메시지 코드 순서대로 messageSource(error.application과 같은 파일)에서 메시지 찾기
- 어노테이션 message 속성 찾기
- ex) @NotBlank(message = "공백! {0}")
- 라이브러리가 제공하는 기본 값 사용
Bean Validation - 오브젝트 오류
- 특정 필드 (FieldError)가 아닌 오브젝트 오류 (ObjectError)는 @ScriptAssert()를 사용해서 처리한다.
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000", message ="총합이 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;
}
- @ScriptAssert 어노테이션을 사용해 ObjectError 처리
- 실제 사용하면 제약이 많고 복잡하고 대응이 어려운 경우가 많다.
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//ObjectError 관련 부분
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("bindingResult={}", bindingResult);
return "validation/v3/addForm";
}
//성공 로직
...
}
- @ScriptAssert를 사용하기보다 ObjectError 관련된 부분만 직접 자바 코드로 작성하는 것을 권장한다.
Bean Validation - 한계
- 등록과 수정에서 검증 조건의 충돌이 발생한다.
- 등록과 수정은 같은 Validation을 적용할 수 없다.
- ex) 등록할 때는 DB를 통해서 id가 만들어진다. 수정 시 id 값을 필수로 기입해야한다.
=> id에 @NotNull 검증 조건을 걸면 등록 시에는 id 값을 따로 입력하지 않기 때문에 충돌이 발생하게 된다.
- id 값이 항상 들어가도록 로직이 구성되어 있더라도 검증은 필수!!
- HTTP 요청은 언제든 악의적으로 변경해서 요청할 수 있기에 서버에서 항상 검증해야한다.
Bean Validation - groups
- 동일한 모델 객체를 등록할 때와 수정할 때 각각 다르게 검증하는 2가지 방법
- BeanValidation의 groups 기능을 사용한다.
- Item을 직접 사용하지 않고 ItemSaveForm, ItemUpdateForm 같은 폼 전송을 위한 별도의 모델 객체를 만들어 사용한다.
- BeanValidation groups 기능
- ex) 등록 시 검증할 기능과 수정 시 검증할 기능을 각각 그룹으로 나눠 적용할 수 있다.
//groups 사용 예제
public interface SaveCheck {} //등록용 그룹
public interface UpdateCheck {} //수정용 그룹
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 addItemV2(@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 사용 방법
- groups를 적용하기 위해 인터페이스를 생성
- 인터페이스는 그룹을 나누기 위한 용도로 일반적인 인터페이스만 생성하면 된다.
- 검증 어노테이션을 그룹 별로 나눈다.
- 어노테이션의 groups 속성을 통해 적용할 그룹을 지정
- groups를 적용하기 위해 @Validated의 value 속성으로 적용할 그룹을 지정해준다.
- ex) 등록 로직은 등록용 그룹과 관련있기에 SaveCheck를 지정
- groups를 적용하기 위해 인터페이스를 생성
- groups 기능은 @Validated에만 있기에 @Valid는 사용할 수 없다.
- groups 기능을 사용하면 등록, 수정 시 각각 다르게 검증할 수 있지만 복잡도가 올라가기에 실제로 잘 사용되지 않는다.
- 실무에서는 주로 등록용 폼 객체와 수정용 폼 객체를 분리하는 방식을 사용한다.
Form 전송 객체 분리
- 실무에서 groups 기능을 잘 사용하지 않는 이유
- 등록, 수정 시 폼에서 전달하는 데이터가 도메인 객체와 딱 맞지 않기 때문이다.
- 복잡한 폼 데이터를 컨트롤러까지 전달할 별도의 전용 객체를 만들어서 @ModelAttribute로 사용한다.
- 이후 컨트롤러에서 필요한 데이터를 사용해서 도메인 객체를 생성한다.
- 폼 데이터 전달에 도메인 객체 사용
- HTML Form -> Domain -> Controller -> Domain -> Repository
- 장점 - 도메인 객체를 컨트롤러, 레포지토리까지 직접 전달해 중간에 도메인 객체를 만들지 않아도 되서 간단하다.
- 단점 - 간단한 경우에만 적용할 수 있으며 groups를 사용해야 한다.
- HTML Form -> Domain -> Controller -> Domain -> Repository
- 폼 데이터 전달을 위한 별도의 객체 사용
- HTML Form -> 별도의 전용 객체(DTO) -> Controller -> Domain 생성 -> Repository
- 장점 - 전송하는 폼의 데이터가 복잡해도 그것에 맞춘 별도의 폼 객체를 사용해서 데이터를 받을 수 있다.
별도의 폼 객체를 만들기에 검증이 중복되지 않는다. - 단점 - 폼 데이터를 기반으로 컨트롤러에서 도메인 객체를 생성하는 변환 과정이 추가된다.
- 장점 - 전송하는 폼의 데이터가 복잡해도 그것에 맞춘 별도의 폼 객체를 사용해서 데이터를 받을 수 있다.
- HTML Form -> 별도의 전용 객체(DTO) -> Controller -> Domain 생성 -> Repository
- 폼 데이터 전달을 위한 별도의 객체 이름은 의미있고 일관성 있게 지어야한다.
- ex) ItemSave, ItemSaveForm, ItemSaveRequest, ItemSaveDto ...
- 등록, 수정용 뷰 템플릿이 비슷하더라도 합치는 것은 권장하지 않는다.
- 나중에 유지보수할 때 힘들기에 별도의 객체를 만들어서 사용하는 것이 좋다.
//등록용 폼
@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) {
//특정 필드가 아닌 복합 룰 검증
...
//검증에 실패하면 다시 입력 폼으로
...
//성공 로직
//별도의 폼 객체를 도메인 객체로 변환
Item item = new Item();
item.setItemName(form.getItemName());
item.setQuantity(form.getQuantity());
item.setPrice(form.getPrice());
Item savedItem = itemRepository.save(item);
...
}
- 각각 용도의 폼 객체를 만들고 필요한 검증 어노테이션을 사용
- 등록, 수정 로직에서 @ModelAttribute 객체를 도메인 객체에서 별도의 폼 객체로 변경
- @ModelAttribute name 속성을 별도로 지정하지 않으면 클래스명에서 앞글자만 소문자로 바꾼 형태가 되기에 뷰 템플릿에서 사용하는 Model 데이터 이름으로 지정해줘야한다.
- name 속성을 별도로 지정하지 않을거라면 뷰 템플릿에서 접근하는 th:object 이름을 변경해야한다.
- ex) ItemSaveForm => itemSaveForm
- 각각의 폼 객체의 데이터를 기반으로 도메인 객체를 생성하는 과정이 필요하다.
- @ModelAttribute name 속성을 별도로 지정하지 않으면 클래스명에서 앞글자만 소문자로 바꾼 형태가 되기에 뷰 템플릿에서 사용하는 Model 데이터 이름으로 지정해줘야한다.
Bean Validation - HTTP 메시지 컨버터
- @Valid / @Validated는 HttpMessageConverter (@RequestBody)에도 적용할 수 있다.
- @ModelAttribute vs @RequestBody
- @ModelAttribute는 필드 단위로 정교하게 바인딩이 적용된다.
=> 특정 필드가 바인딩 되지 않아도 나머지 필드는 정상 바인딩되고 Validator로 검증도 적용할 수 있다. - @RequestBody는 각각의 필드 단위로 적용되는 것이 아닌 전체 객체 단위로 적용된다.
HttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계가 진행되지 못하고 예외가 발생한다.
=> 컨트롤러가 호출되지 않고 Validator로 검증도 적용하지 못한다. - ex) 숫자 타입에 문자 입력 시 @ModelAttribute는 해당 필드만 바인딩이 되지 못하고 나머지는 정상적으로 바인딩이 되서 Validator로 검증을 할 수 있다.
@RequestBody는 하나라도 타입이 맞지 않으면 컨버터가 객체로 만들 수 없기에 오류가 발생하고 컨트롤러가 호출되지 않으며 Validator로 검증을 하지 못한다.
- @ModelAttribute는 필드 단위로 정교하게 바인딩이 적용된다.
출처 : [인프런 김영한 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술]
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 > [인프런 김영한 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술]' 카테고리의 다른 글
[인프런 김영한 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술] 로그인 처리 - 필터, 인터셉터 (6) | 2024.10.16 |
---|---|
[인프런 김영한 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술] 로그인 처리 - 쿠키, 세션 (4) | 2024.10.15 |
[인프런 김영한 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술] 검증 - Validation (4) | 2024.10.14 |
[인프런 김영한 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술] 메시지, 국제화 (8) | 2024.10.11 |
[인프런 김영한 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술] 타임리프 - 스프링 통합과 폼 (2) | 2024.10.11 |