MVC2 5th Step

최보현·2022년 8월 15일
0

MVC

목록 보기
12/18
post-thumbnail

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - sec05
출처 : 스프링 MVC 2편

Bean Validation

@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이란?

특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)이라는 기술 표준
=> 검증 애노테이션과 여러 인터페이스의 모음
ex) JPA가 표준 기술이고 그 구현체인 하이버네이트
<인터페이스>jakarta.validation-api와 그의 구현체인 hybernate.validator를 자동으로 추가해줌
이름이 하이버네이트가 붙어서 그렇지 ORM과는 관련이 없다

Bean Validation 적용해보기

이 친구를 사용하기 위해선 의존관계를 추가해야 함
build.gradle 부분에 다음과 같이 문구를 작성하여 라이브러리를 추가시키자
implementation 'org.springframework.boot:spring-boot-starter-validation'

Bean 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=공백일 수 없습니다
*/

Bean Validation 스프링 적용

HOW?

스프링 부트가 spring-boot-starter-validation 라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합

자동 글로벌 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 라는 기능을 포함하고 있다.

검증 순서

  1. @ModelAttribute 각각의 필드에 타입 변환 시도
    1-1. 성공하면 다음으로
    1-2. 실패하면 typeMismatch 로 FieldError 추가
  2. Validator 적용
    바인딩에 성공한 필드만 Bean Validation 적용
    BeanValidator는 바인딩에 실패한 필드는 BeanValidation을 적용X
    생각해보면 타입 변환에 성공해서 바인딩에 성공한 필드여야 BeanValidation 적용이 의미 있다. (모델 객체에 바인딩 받는 값이 정상으로 들어와야 검증도 의미 有)
    ex)
    price 에 문자 "A" 입력 "A"를 숫자 타입 변환 시도 실패 => typeMismatch FieldError 추가 => price 필드는 BeanValidation 적용 X

에러 코드

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} 같은 경우는 각 어노테이션마다 다름

Bean Validation 오브젝트 오류

@ScriptAssert()

@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000")
public class Item {...}

메시지 코드도 ScriptAssert.item,ScriptAssert 이 순으로 생성됨
실제 사용해보면 제약이 많고 복잡 and 실무에서는 검증 기능이 해당 객체의 범위를
넘어서는 경우들도 종종 등장하는데, 그런 경우 대응이 어렵

오브젝트 오류(글로벌 오류)의 경우 @ScriptAssert를 쓰기보단 관련 부분만 직접 자바 코드로 작성하는 것이 더 좋음

Bean Validation 한계

만약에, 등록(우린 따로 작성 안해줘)과 수정(id값 필수)이 두 부분에서 각자 원하는 기능이 다른 경우, 상품 수정을 위해서 id값을 @notnull로 지정해놓으면 안되는게 우리는 등록할 때 id값을 등록을 안해서 서버에서는 얘가 id값 없는데 뭐하는거지 하면서 id가 들어오지 않았다고 다음 단계를 실행해주지 않음
=> 여기서는 빨간 상자가 나오지 않아서 그렇지 해당폼으로 리턴된거임 => 검증 조건의 충돌 발생

Bean Validation groups

등록할 때와 수정할 때 각자 다르게 검증할 수 있도록 분리하게 해줌
@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 이름도 함께 변경해주어야 함

Bean Validation - HTTP 메시지 컨버터

@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 vs @RequestBody

  • @ModelAttribute(HTTP 요청 파리미터를 처리하는)는 각각의 필드 단위로 세밀하게 적용됨 => 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리(정상 바인딩 되고, Validator를 사용한 검증도 적용) 가능
  • HttpMessageConverter 는 전체 객체 단위로 적용 => 메시지 컨버터의 작동이 성공해서 ItemSaveForm 객체를 만들어야 @Valid , @Validated 가 적용됨
    - @RequestBody 는 HttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계 자체가 진행되지 않고 예외가 발생, 컨트롤러도 호출되지 않고, Validator도 적용 불가
profile
Novice Developer's Blog

0개의 댓글