일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- 의존관계
- 코딩테스트
- 백준
- 추상클래스
- 다형성
- fielderror
- 티스토리챌린지
- 서블릿
- 코드트리조별과제
- equals()
- html form
- 참조변수
- http 메시지 컨버터
- 인터페이스
- HttpServletResponse
- DI
- 오버라이딩
- 오블완
- java
- 프록시
- 스프링
- 테스트코드
- 싱글톤
- objecterror
- JSON
- 김영한
- ocp
- @configuration
- 스프링컨테이너
- 코드트리
- Today
- Total
minOS
Bean Validation - groups,Form 전송 객체 분리, HTTP 메시지 컨버터 본문
Bean Validation - groups,Form 전송 객체 분리, HTTP 메시지 컨버터
minOE 2024. 9. 18. 10:11Bean Validation - groups
동일한 모델 객체를 등록할 때와 수정할 때 각각 다르게 검증하는 방법을 알아보자.
1) BeanValidation의 groups 기능을 사용한다.
2) Item을 직접 사용하지 않고, ItemSaveForm, ItemUpdateForm 같은 폼 전송을 위한 별도의 모델 객체를 만들 어서 사용한다.
1) BeanValidation의 groups 기능을 사용
이런 문제를 해결하기 위해 Bean Validation은 groups라는 기능을 제공한다.
예를 들어서 등록시에 검증할 기능과 수정시에 검증할 기능을 각각 그룹으로 나누어 적용할 수 있다.저장용 groups 생성
public interface SaveCheck { }
수정용 groups 생성
public interface UpdateCheck { }
item - groups 적용@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; public Item() { } public Item(String itemName, Integer price, Integer quantity) { this.itemName = itemName; this.price = price; this.quantity = quantity; } }
ValidationItemControllerV3 - 저장 로직에 SaveCheck Groups 적용
@PostMapping("/add") //`BindingResult bindingResult` 파라미터의 위치는 `@ModelAttribute Item item` 다음에 와야 한다. public String addItem1(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult bindingResult , RedirectAttributes redirectAttributes, Model model) { //특정 필드가 아난 복합 룰 검증 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("error ={}", bindingResult); //model.addAttribute("errors",errors); BindResult는 자동으로 view로 넘어감 return "/validation/v3/addForm"; } // 성공 로직 Item savedItem = itemRepository.save(item); redirectAttributes.addAttribute("itemId", savedItem.getId()); redirectAttributes.addAttribute("status", true); return "redirect:/validation/v3/items/{itemId}"; }
ValidationItemControllerV3 - 수정 로직에 UpdateCheck Groups 적용
@PostMapping("/{itemId}/edit") public String edit2(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item item, BindingResult bindingResult) { itemRepository.update(itemId, item); 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("error ={}", bindingResult); return "/validation/v3/editForm"; } return "redirect:/validation/v3/items/{itemId}"; }
참고: `@Valid` 에는 groups를 적용할 수 있는 기능이 없다. 따라서 groups를 사용하려면 `@Validated를 사 용해야 한다.
정리
groups 기능을 사용해서 등록과 수정시에 각각 다르게 검증을 할 수 있었다. 그런데 groups 기능을 사용하니 `Item` 은 물론이고, 전반적으로 복잡도가 올라갔다.
사실 groups 기능은 실제 잘 사용되지는 않는데, 그 이유는 실무에서는 주로 다음에 등장하는 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문이다.
Form 전송 객체 분리 - 소개
실무에서는 `groups` 를 잘 사용하지 않는데, 그 이유가 다른 곳에 있다. 바로 등록시 폼에서 전달하는 데이터가 `Item` 도메인 객체와 딱 맞지 않기 때문이다. 소위 "Hello World" 예제에서는 폼에서 전달하는 데이터와 `Item` 도메인 객체가 딱 맞는다. 하지만 실무에서는 회원 등록시 회원과 관련된 데이터만 전달 받는 것이 아니라,약관 정보도 추가로 받는 등 `Item` 과 관계 없는 수 많은부가 데이터가 넘어온다. 그래서 보통 `Item` 을 직접 전달받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다. 예를 들면 `ItemSaveForm` 이라는 폼을 전달받는 전용 객체를 만들어서 `@ModelAttribute` 로 사용한다. 이것을 통해 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 `Item` 을 생성한다.
폼 데이터 전달에 Item 도메인 객체 사용
HTML Form -> Item -> Controller -> Item -> Repository`
장점: Item 도메인 객체를 컨트롤러, 리포지토리 까지 직접 전달해서 중간에 Item을 만드는 과정이 없어서 간단하다.
단점: 간단한 경우에만 적용할 수 있다. 수정시 검증이 중복될 수 있고, groups를 사용해야 한다.
폼 데이터 전달을 위한 별도의 객체 사용
`HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repository`
장점: 전송하는 폼 데이터가 복잡해도 거기에 맞춘 별도의 폼 객체를 사용해서 데이터를 전달 받을 수 있다. 보통 등록과, 수정용으로 별도의 폼 객체를 만들기 때문에 검증이 중복되지 않는다.단점: 폼 데이터를 기반으로 컨트롤러에서 Item 객체를 생성하는 변환 과정이 추가된다.
수정의 경우 등록과 수정은 완전히 다른 데이터가 넘어온다. 생각해보면 회원 가입시 다루는 데이터와 수정시 다루는 데 이터는 범위에 차이가 있다. 예를 들면 등록시에는 로그인id, 주민번호 등등을 받을 수 있지만, 수정시에는 이런 부분이 빠진다. 그리고 검증 로직도 많이 달라진다. 그래서 `ItemUpdateForm` 이라는 별도의 객체로 데이터를 전달받는 것이 좋다.
`Item` 도메인 객체를 폼 전달 데이터로 사용하고, 그대로 쭉 넘기면 편리하겠지만, 앞에서 설명한 것과 같이 실무에서 는 `Item` 의 데이터만 넘어오는 것이 아니라 무수한 추가 데이터가 넘어온다. 그리고 더 나아가서 `Item` 을 생성하는데
필요한 추가 데이터를 데이터베이스나 다른 곳에서 찾아와야 할 수도 있다. 따라서 이렇게 폼 데이터 전달을 위한 별도의 객체를 사용하고, 등록, 수정용 폼 객체를 나누면 등록, 수정이 완전히 분 리되기 때문에 `groups` 를 적용할 일은 드물다.
Q: 등록, 수정용 뷰 템플릿이 비슷한데 합치는게 좋을까요?
한 페이지에 그러니까 뷰 템플릿 파일을 등록과 수정을 합치는게 좋을지 고민이 될 수 있다. 각각 장단점이 있으므로 고 민하는게 좋지만, 어설프게 합치면 수 많은 분기문(등록일 때, 수정일 때) 때문에 나중에 유지보수에서 고통을 맛본다. 이런 어설픈 분기문들이 보이기 시작하면 분리해야 할 신호이다.
Form 전송 객체 분리 - 개발
ITEM 원복
이제 `Item` 의 검증은 사용하지 않으므로 검증 코드를 제거해도 된다.
package hello.itemservice.domain.item; import lombok.Data; import org.hibernate.validator.constraints.Range; import javax.validation.constraints.Max; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; @Data public class Item { // @NotNull(groups = {UpdateCheck.class}) // 수정 요구사항 추가 private Long id; // @NotBlank(message = "공백x",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; public Item() { } public Item(String itemName, Integer price, Integer quantity) { this.itemName = itemName; this.price = price; this.quantity = quantity; } }
ItemSaveForm - ITEM 저장용 폼@Data public class ItemSaveForm { @NotBlank private String itemName; @NotNull @Range(min = 1000, max = 1000000) private Integer price; @NotNull @Max(value = 9999) private Integer quantity; }
ItemUpdateForm - ITEM 수정용 폼
@Data public class ItemUpdateForm { @NotNull private Long id; @NotBlank private String itemName; @NotNull @Range(min = 1000, max = 1000000) private Integer price; //수정시 수량은 자유롭게 변경할 수 있다. private Integer quantity; }
등록, 수정용 폼 객체를 사용하도록 컨트롤러를 수정
package hello.itemservice.web.validation; import hello.itemservice.domain.item.Item; import hello.itemservice.domain.item.ItemRepository; import hello.itemservice.domain.item.SaveCheck; import hello.itemservice.domain.item.UpdateCheck; import hello.itemservice.web.validation.form.ItemSaveForm; import hello.itemservice.web.validation.form.ItemUpdateForm; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.validation.BindingResult; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.support.RedirectAttributes; import java.util.List; @Slf4j @Controller @RequestMapping("/validation/v4/items") @RequiredArgsConstructor public class ValidationItemControllerV4 { private final ItemRepository itemRepository; @GetMapping public String items(Model model) { List<Item> items = itemRepository.findAll(); model.addAttribute("items", items); return "/validation/v4/items"; } @GetMapping("/{itemId}") public String item(@PathVariable long itemId, Model model) { Item item = itemRepository.findById(itemId); model.addAttribute("item", item); return "/validation/v4/item"; } @GetMapping("/add") public String addForm(Model model) { model.addAttribute("item", new Item()); return "/validation/v4/addForm"; } @PostMapping("/add") public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult , RedirectAttributes redirectAttributes, Model model) { //특정 필드가 아난 복합 룰 검증 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("error ={}", bindingResult); //model.addAttribute("errors",errors); BindResult는 자동으로 view로 넘어감 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}"; } @GetMapping("/{itemId}/edit") public String editForm(@PathVariable Long itemId, Model model) { Item item = itemRepository.findById(itemId); model.addAttribute("item", item); return "/validation/v4/editForm"; } @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("error ={}", 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}"; } }
폼 객체 바인딩
@PostMapping("/add") public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes) { //... }
`Item` 대신에 `ItemSaveform` 을 전달 받는다. 그리고 `@Validated` 로 검증도 수행하고, `BindingResult` 로 검증 결과도 받는다.
주의
`@ModelAttribute("item")` 에 `item` 이름을 넣어준 부분을 주의하자. 이것을 넣지 않으면 `ItemSaveForm` 의 경우 규칙에 의해 `itemSaveForm` 이라는 이름으로 MVC Model에 담기게 된다. 이렇게 되면 뷰 템플릿에서 접근하 는 `th:object` 이름도 함께 변경해주어야 한다.
폼 객체를 Item으로 변환
//성공 로직 Item item = new Item(); item.setItemName(form.getItemName()); item.setPrice(form.getPrice()); item.setQuantity(form.getQuantity()); Item savedItem = itemRepository.save(item);
폼 객체의 데이터를 기반으로 Item 객체를 생성한다. 이렇게 폼 객체 처럼 중간에 다른 객체가 추가되면 변환하는 과정 이 추가된다.
수정
@PostMapping("/{itemId}/edit") public String edit(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) { //... }
수정의 경우도 등록과 같다. 그리고 폼 객체를 Item 객체로 변환하는 과정을 거친다.
Bean Validation - HTTP 메시지 컨버터
참고
`@ModelAttribute` 는 HTTP 요청 파라미터(URL 쿼리 스트링, POST Form)를 다룰 때 사용한다. `@RequestBody` 는 HTTP Body의 데이터를 객체로 변환할 때 사용한다. 주로 API JSON 요청을 다룰 때 사용한다.
@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; } }
API의 경우 3가지 경우를 나누어 생각해야 한다.
1)성공 요청: 성공
2)실패 요청: JSON을 객체로 생성하는 것 자체가 실패함
3)검증 오류 요청: JSON을 객체로 생성하는 것은 성공했고, 검증에서 실패함
1)성공 요청
로그
2)실패 요청
로그
`HttpMessageConverter` 에서 요청 JSON을 `ItemSaveForm` 객체로 생성하는데 실패한다. 이 경우는 `ItemSaveForm` 객체를 만들지 못하기 때문에 컨트롤러 자체가 호출되지 않고 그 전에 예외가 발생한다. 물론 Validator도 실행되지 않는다.
3)검증 오류 요청
로그
@ModelAttribute vs @RequestBody
HTTP 요청 파리미터를 처리하는 `@ModelAttribute` 는 각각의 필드 단위로 세밀하게 적용된다. 그래서 특정 필드 에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있었다.
`HttpMessageConverter` 는 `@ModelAttribute` 와 다르게 각각의 필드 단위로 적용되는 것이 아니라, 전체 객체 단위로 적용된다.
따라서 메시지 컨버터의 작동이 성공해서 `ItemSaveForm` 객체를 만들어야 `@Valid` , `@Validated` 가 적용된다.`@ModelAttribute` 는 필드 단위로 정교하게 바인딩이 적용된다. 특정 필드가 바인딩 되지 않아도 나머지 필드 는 정상 바인딩 되고, Validator를 사용한 검증도 적용할 수 있다.
`@RequestBody` 는 HttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계 자 체가 진행되지 않고 예외가 발생한다. 컨트롤러도 호출되지 않고, Validator도 적용할 수 없다.
'TIL > 김영한의 스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술' 카테고리의 다른 글
로그인 처리하기1 - 세션 동작 방식 , 세션 만들기, 세션 적용 (0) | 2024.09.24 |
---|---|
로그인 - 요구사항, 쿠키 사용 (1) | 2024.09.24 |
Bean Validation - 오브젝트 오류,수정에 적용,한계 (0) | 2024.09.18 |
Bean Validation - Bean Validation 애노테이션 적용,스프링 적용,에러 코드 (0) | 2024.09.18 |
Validator 분리 (1) | 2024.09.14 |