스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - sec05
출처 : 스프링 MVC 2편
@NotBlank //빈값+공백만 있는 경우를 허용X
private String itemName;
@NotNull //null 허용X
@Range(min = 1000, max = 1000000) //범위 값을 지정
private Integer price;
@NotNull
@Max(9999) //최대값을 지정
private Integer quantity;
javax.validation.constraints.NotNull
org.hibernate.validator.constraints.Range
javax.validation
으로 시작하면 특정 구현에 관계없이 제공되는 표준 인터페이스
org.hibernate.validator
로 시작하면 하이버네이트 validator 구현체를 사용할 때만 제공되는 검증 기능
@NotNull VS @NotBlank VS @NotEmpty
특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)
이라는 기술 표준
=> 검증 애노테이션과 여러 인터페이스의 모음
ex) JPA가 표준 기술이고 그 구현체인 하이버네이트
<인터페이스>jakarta.validation-api와 그의 구현체인 hybernate.validator를 자동으로 추가해줌
이름이 하이버네이트가 붙어서 그렇지 ORM과는 관련이 없다
이 친구를 사용하기 위해선 의존관계를 추가해야 함
build.gradle
부분에 다음과 같이 문구를 작성하여 라이브러리를 추가시키자
implementation 'org.springframework.boot:spring-boot-starter-validation'
@Test
void beanValidation() {
//검증기 생성 코드(스프링 통합시 미작성)
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
Item item = new Item();
item.setItemName(" "); //공백
item.setPrice(0);
item.setQuantity(10000);
Set<ConstraintViolation<Item>> violations = validator.validate(item);
//검증 대상(item)을 직접 검증기에 넣고 그 결과를 받음, Set 에는 ConstraintViolation 이라는 검증 오류가 담김 => 결과가 비어있으면 검증 오류가 없는 것
for (ConstraintViolation<Item> violation : violations) {
System.out.println("violation=" + violation);
System.out.println("violation.message=" + violation.getMessage());
//검증 오류가 발생한 객체, 필드, 메시지 정보등 다양한 정보 확인 가능
}
/* 실행 결과
violation={interpolatedMessage='공백일 수 없습니다', propertyPath=itemName, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{javax.validation.constraints.NotBlank.message}'}
violation.message=공백일 수 없습니다
*/
스프링 부트가 spring-boot-starter-validation
라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합
LocalValidatorFactoryBean
을 글로벌 Validator로 등록한다. 이 Validator는 @NotNull 같은 애노테이션을 보고 검증을 수행 => 이렇게 글로벌 Validator가 적용되어 있기 때문에, @Valid ,@Validated 만 적용하면 됨
=> @validated가 있으면 bean validation이 자동으로 적용됨
검증 오류가 발생하면, FieldError , ObjectError 를 생성해서 BindingResult 에 담아줌
직접 글로벌 Validator를 등록하면 스프링 부트는 Bean Validator를 글로벌
Validator 로 등록하지 않는다. => 애노테이션 기반의 빈 검증기 동작X
검증시 @Validated @Valid 둘다 사용 가능
javax.validation.@Valid
를 사용하려면build.gradle
의존관계 추가가 필요 (implementation 'org.springframework.boot:spring-boot-starter-validation'
)
@Validated 는 스프링 전용 검증 애노테이션, @Valid 는 자바 표준 검증 애노테이션
동일하게 작동하지만, @Validated 는 내부에 groups 라는 기능을 포함하고 있다.
@ModelAttribute
각각의 필드에 타입 변환 시도Bean Validation을 적용하고 bindingResult 에 등록된 검증 오류 코드를 보면,
오류 코드가 애노테이션 이름으로 등록됨 => typeMismatch 와 유사
NotBlank 라는 오류 코드를 기반으로 MessageCodesResolver
를 통해 다양한 메시지 코드가 순서대로 생성됨
ex)
@NotBlank
1. NotBlank.item.itemName
2. NotBlank.itemName
3. NotBlank.java.lang.String
4. NotBlank
얘네 또한 이 코드명을 이용해서 errors.properties에 메시지 등록이 가능함
메시지를 등록할 때, {0}을 넣어주면 필드명이 들어가고, {1} || {2} 같은 경우는 각 어노테이션마다 다름
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000")
public class Item {...}
메시지 코드도 ScriptAssert.item
,ScriptAssert
이 순으로 생성됨
실제 사용해보면 제약이 많고 복잡 and 실무에서는 검증 기능이 해당 객체의 범위를
넘어서는 경우들도 종종 등장하는데, 그런 경우 대응이 어렵
오브젝트 오류(글로벌 오류)의 경우 @ScriptAssert를 쓰기보단 관련 부분만 직접 자바 코드로 작성하는 것이 더 좋음
만약에, 등록(우린 따로 작성 안해줘)과 수정(id값 필수)이 두 부분에서 각자 원하는 기능이 다른 경우, 상품 수정을 위해서 id값을 @notnull로 지정해놓으면 안되는게 우리는 등록할 때 id값을 등록을 안해서 서버에서는 얘가 id값 없는데 뭐하는거지 하면서 id가 들어오지 않았다고 다음 단계를 실행해주지 않음
=> 여기서는 빨간 상자가 나오지 않아서 그렇지 해당폼으로 리턴된거임 => 검증 조건의 충돌 발생
등록할 때와 수정할 때 각자 다르게 검증할 수 있도록 분리하게 해줌
@Valid에는 groups 기능 없음
1. 저장용과 수정용의 groups를 생성해줌(인터페이스로)
2. 어노테이션에 groups를 써줌으로써 각자 원하는 곳에서 쓰일 수 있도록 해줌
@NotNull(groups = UpdateCheck.class) //수정시에만 적용
private Long id;
@NotBlank(groups = {SaveCheck.class, UpdateCheck.class})
private String itemName;
실무에서는 groups 를 잘 사용하지 않음.
why? 등록시 폼에서 전달하는 데이터가 Item 도메인 객체와 딱 맞지 않기 때문
실무에서는 회원 등록시 회원과 관련된 데이터만 전달받는 것이 아니라, 약관 정보도 추가로 받는 등 Item 과 관계없는 수 많은 부가 데이터가 넘어오니깐
폼 데이터 전달에 Item 도메인 객체 사용
HTML Form -> Item -> Controller -> Item -> Repository
장점: Item 도메인 객체를 컨트롤러, 리포지토리 까지 직접 전달해서 중간에 Item을 만드는 과정이 없어서 간단
단점: 간단한 경우에만 적용할 수 있음, 수정시 검증이 중복될 수 있고, groups를 사용해야 함
groups의 불편한 점들을 해결 할 수 있도록 전용 객체들을 만들어 분리 시킴
보통 Item 을 직접 전달받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달
ex)ItemSaveForm 이라는 폼을 전달받는 전용 객체를 생성 => @ModelAttribute 로 사용 => 이것을 통해 컨트롤러에서 폼 데이터를 전달 받음 => 컨트롤러에서 필요한 데이터를 사용해서 Item 을 생성
폼 데이터 전달을 위한 별도의 객체 사용
HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repository
장점: 전송하는 폼 데이터가 복잡해도 거기에 맞춘 별도의 폼 객체를 사용해서 데이터를 전달 받을 수 있음 + 보통 등록과, 수정용으로 별도의 폼 객체를 만들기 때문에 검증이 중복되지 않음
단점: 폼 데이터를 기반으로 컨트롤러에서 Item 객체를 생성하는 변환 과정이 추가
등록과 수정은 완전히 다른 데이터가 넘어옴 => 데이터의 범위에 차이가 존재 + 검증 로직도 많이 달라짐
실무에서는 Item의 데이터에 무수한 추가 데이터까지 넘어옴 + Item 을 생성하는데 필요한 추가 데이터를 데이터베이스나 다른 곳에서 찾아와야 할 가능성 존재
=> 폼 데이터 전달을 위한 별도의 객체를 사용하고, 등록, 수정용 폼 객체를 나누면 등록, 수정이 완전히 분리되기 때문에 groups 를 적용할 일은 ⬇️
//수정용 객체
@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("/{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);
}
}
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}";
//폼 객체의 데이터를 기반으로 Item 객체를 생성 => 중간에 다른 객체가 추가되면 변환하는 과정이 추가됨
@ModelAttribute("item")
에 item 이름을 넣어준 부분을 주의!
if) 넣지 않으면 규칙에 의해 itemSaveForm 이라는 이름으로 MVC Model에 담기게 됨 => 뷰 템플릿에서 접근하는 th:object
이름도 함께 변경해주어야 함
@Valid, @Validated는 HttpMessageConverter(@RequestBody)에도 적용 가능
@ModelAttribute
는 HTTP 요청 파라미터(URL 쿼리 스트링, POST Form)를 다룰 때 사용
@RequestBody
는 HTTP Body의 데이터를 객체로 변환할 때 사용, 주로 API JSON 요청을 다룰 때 사용
@Slf4j
@RestController //자동으로 @ResponseBody를 붙여줌
@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;
}
}
API의 경우 3가지 경우를 나누어 생각해야 함
1. 성공 요청: 성공
2. 실패 요청: JSON을 객체로 생성하는 것 자체가 실패함
=> 컨틀롤러 자체가 호출되지 않음 + Validator도 실행 안됨
3. 검증 오류 요청: JSON을 객체로 생성하는 것은 성공했고, 검증에서 실패함
@ModelAttribute(HTTP 요청 파리미터를 처리하는)
는 각각의 필드 단위로 세밀하게 적용됨 => 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리(정상 바인딩 되고, Validator를 사용한 검증도 적용) 가능HttpMessageConverter
는 전체 객체 단위로 적용 => 메시지 컨버터의 작동이 성공해서 ItemSaveForm 객체를 만들어야 @Valid , @Validated 가 적용됨