검증2 - Bean Validation

유동우·2023년 12월 24일
0
post-thumbnail

에러코드

NotBlank

  • NotBlank.item.itemName
  • NotBlank.itemName
  • NotBlank.java.lang.String
  • NotBlank

Range

  • Range.item.price
  • Range.price
  • Range.java.lang.Integer
  • Range

오류코드가 애노테이션이름으로 등록되고, typeMismatch 와 유사하다

@NotBlank, @Range 라는 오류 코드를 기반으로 MessageCodeResolver를 통해 다양한 메시지 코드가 순서대로 생성된다

이제 errors.properties에 메시지를 등록해보자

#Bean Validation 추가
NotBlank = {0} 공백X
Range = {0}, {2} ~ {1} 허용
Max = {0}, 최대 {1}

{0} : 필드명
{1}, {2} .... : 각 애노테이션마다 다름 (Range {2} ~ {1} 에 주의)

BeanValidation이 메시지를 찾는 순서 (중요)

  1. 생성된 메시지 코드 순서대로 messageSource에서 메시지를 찾는다
  2. 애노테이션의 message 속성에서 찾는다 (@NotBlank(message = "공백은 입력할 수 없습니다")
  3. 라이브러리가 제공하는 기본 메시지 사용한다

오브젝트 오류

BeanValidation에서 특정필드 (itemName, quantity)가 아닌 오브젝트 관련 오류는 어떻게 처리할까

엔티티 위에다 @ScirptAssert 애노테이션을 추가하면 되지만 한계점이 있어 많이 사용하는 방식은 아니므로 생략한다

따라서 오브젝트 오류 관련 부분만 직접 자바코드로 넣는것이 유용하다

@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BidingResult bidingresult, 
						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);
        }
    }
    ...
}
    

수정에 적용

지금까지는 등록할 때에만 검증을 사용했지만 수정할 때에도 검증이 되어야 한다.

위 복합 룰 검증 코드와 bindingResult에 에러가 있는지 검사하는 로직을
@PostMapping("{itemId}/edit")에 넣어주면 된다

이렇게 하면 수정할 때에도 BeanValidation이 동작해서 수정폼에 적당한 값을 넣지 않으면 페이지가 넘어가지 않는
것을 볼 수 있다.

이를 가시적으로 확인하기 위해 addForm.html에 있던 코드를 적당히 editForm.html로 가져와서 수정할 수 있다

한계

이제 지금까지 했던 BeanValidation의 한계점에 대해 알아보자.
기획자의 요구사항이 등록할 때와 수정할 때의 필드에 대한 조건이 달라질 수 있다

  • 등록할 때에는 quantity의 값을 9999까지 허용하지만, 수정시에는 신뢰가 있음을 가정하여
    수량을 무제한으로 변경할 수 있다
  • 등록시에는 id에 값이 없어도 되지만 수정시에는 id값을 찾아와야 하기 때문에 필수이다

위 요구사항을 만족하기 위해 item 도메인에서 적당히 수정해보자

@Data
public class Item{
	
    @Notnull // 수정 요구사항 위해 추가
    private Long id;
    
    ...
    
    @Notnull
    //@Max(9999) //수정 요구사항 위해 제거
    private Integer quantity;
    
    ...
    
}

위 코드 변경을 통해 수정시에는 기획자의 요구사항에 맞게 잘 동작하지만,
등록할 때 수량 필드에 문제점이 생긴다는 사실을 알 수 있다
(id에도 값이 없으므로 화면이 넘어가지 않음)

'id" : rejected value [null];

위와 같은 오류를 확인할 수 있는데, id를 @NotNull로 설정했지만 값이 없어서다시 폼 화면으로 넘어오는 것이다.

결론은, 등록과 수정에서의 요구사항이 충돌하여 한 쪽에서는 원할한 진행이 되지 않는것을 알 수 있다
이후 챕터에서 어떻게 해결할 수 있는지 알아보자

groups

  1. BeanValidation의 groups 기능을 사용 (현재챕터)
  2. Item을 직접 사용하지 않고 ItemServiceForm, ItemUpdateForm 같은 폼을 위한 별도의 모델 객체 만들어 사용 (이후챕터)

저장용 groups 와 수정용 groups를 각각 인터페이스로 만들고, Item에 각각 적용하면 된다

 package hello.itemservice.domain.item;

  public interface SaveCheck {
  
  }
  
package hello.itemservice.domain.item;

  public interface UpdateCheck{
  
  }
public class Item{
	  @NotNull(groups=UpdateCheck.class)//수정시에만 적용 
      private Long id;
      
      @NotBlank(groups = {SaveCheck.class, UpdateCheck.class})
      private String itemName;

마지막으로 Controller의 저장로직과 수정로직에서 @Validated 속성만 각각 다르게 추가해주면 된다

 @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) {
//...
}

위 방식은 이전에 요구사항별로 서로 충돌하는 문제점을 보완할 수 있지만 직접 Item 객체 수정은 물론이고 복잡도가 올라가서 많이 사용하는 방식은 아니다.

위에 소개했던 2번 방식을 다음챕터에서 알아보자

Form 전송 객체 분리

지금까지 했던 방식은 폼 데이터 전달에 Item 도메인 객체를 사용한 방식이고 간략히 나타내면 다음과 같다

  • HTML Form -> Item -> Controller -> Item -> Repository

그러나 이것보다 앞으로 소개할 방식이 좀 더 효율적이다

  • HTML form -> ItemSaveForm -> Controller -> Item 생성 -> Repository

요구사항에 맞는 별도의 폼 객체를 사용하므로 검증이 중복되지 않는다는 장점이 있다

Item 객체에 추가했던 검증 애노테이션을 모두 제거하고
ItemSaveForm, ItemUpdateForm 객체를 만들어 각각의 객체에 생성과 수정에 있어서 다른 요구사항을 다시 검증 애노테이션으로 적용시키면 된다

@Data
  public class ItemSaveForm {
      @NotBlank
      private String itemName;
      
      @NotNull
      @Range(min = 1000, max = 1000000)
      private Integer price;
      ...
}

이제 컨트롤러를 수정해야한다

@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form,
		BindingResult bindingResult, RedirectAttributes redirectAttributes)

@Validated에 group을 적용했던 것을 지우고, Item item -> ItemSaveForm form 으로 수정함에 따라 edit 함수내 item을 모두 form으로 수정해준다.

이 과정에서 @ModelAttribute의 특성상 itemSaveForm 이라는 이름으로 MVC Model에 담기기 때문에 뷰 템플릿(타임리프)에서 접근하는 html파일의 변수도 수정을 해줘야 한다.

따라서 @ModelAttribute("item") 으로 기존 사항을 유지해줄 수 있고,
Item 객체를 파라미터에서 받지 않으므로 따로 생성해줘야 한다

Item item = new Item(); 
item.setItemName(form.getItemName());
item.setPrice(form.getPrice());
item.setQuantity(form.getQuantity());

Item savedItem = itemRepository.save(item);

수정로직도 저장과 똑같이 진행하면 문제가 없을 것이다

Bean Validation - HTTP 메시지 컨버터

우선 @ModelAttribute는 HTTP 요청 파라미터를 다룰 때 사용하고
@RequestBody는 HTTP Body의 데이터를 객체로 변환할 때 사용한다 (주로 API JSON 요청)

(위 내용은 정확히 이해가 안돼서 추후 포스트에 기술 할 생각이다)

RestController와 Postman을 사용해서 Api를 테스트 해보자

@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;
    }
}

위와같이 컨트롤러를 구성하고 Postman을 보내게 되면 3가지 결과로 나뉜다

  1. 성공
  2. 실패 (JSON을 객체로 생성하는 것 자체가 실패)
  3. 검증 오류 (JSON -> 객체 성공, 검증 실패)
POST http://localhost:8080/validation/api/items/add
{"itemName":"hello", "price":"A", "quantity": 10}

이 양식으로 POST 요청을 하게되면 price의 데이터타입이 맞지 않아 요청한 JSON을 ItemSaveForm으로 만드는 것에 실패하게 된다.

따라서 컨트롤러도 호출되지 않고 당연히 검증로직도 실행되지 않는다

이번에는 데이터타입은 동일하게하고 제한된 범위를 벗어나 검증에서 오류가 발생되게 해보자

POST http://localhost:8080/validation/api/items/add
{"itemName":"hello", "price":1000, "quantity": 10000}

quantity가 10000이되어 @Max(9999)보다 큰 값이 들어가 검증에서 오류가 발생하게 된다.

이때 오류 메시지는 return bindingResult.getAllErrors(); 에서 ObjectError 와 FieldError를 반환하고, 스프링이 이 객체를 JSON으로 변환해서 보여준다.

오류 메시지는 필요한 데이터만 뽑아서 별도의 API 스펙을 적용하여 적절하게 사용해야 하는데
이 과정은 추후 챕터에서 알아보자

Reference
김영한 님 - 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술

profile
효율적이고 꾸준하게

0개의 댓글