타임리프, 메시지, Validation

Seung jun Cha·2022년 6월 19일
0

1. 입력 폼

1-1 th:object, th:field

  • th:field는 Thymeleaf에서 폼 필드와 모델 객체의 필드를 바인딩할 때 사용한다. th:field 사용시 태그의 value, name, id가 객체의 필드값으로 정해지므로 th:value와 중복될 수 없다.
  • th:value : th:value 속성은 일반적으로 사용자의 입력이 필요한 요소의 값을 설정하는 데 사용됩니다. 입력 필드, 체크박스, 라디오 버튼, 드롭다운과 같은 요소가 이 범주에 속한다.
  • Thymeleaf는 렌더링 시점에 th:field로 지정된 필드의 현재 값과 th:value로 설정된 값을 비교한다. 만약 th:field로 바인딩된 필드에 이미 th:value에 설정된 값이 포함되어 있다면, 체크박스를 자동으로 선택(checked) 상태로 렌더링한다.
  1. @GetMapping 컨트롤러에서 Model로 객체를 넘겨줌
등록폼 : model.addAttribute("item", new Item());
수정폼 : Item item = itemRepository.findById(itemId);
 		model.addAttribute("item", item);
  1. Model를 통해 넘어온 객체가 뷰 랜더링하는 곳에서 th:object=${객체}로 인식됨
  2. th:field=*{변수명}th:object 객체의 변수를 지정할 수 있다.
    • th:field 는 id , name , value 속성을 모두 자동으로 만들어서 설정해줌
      id, name는 변수명, value는 화면에서 입력받은 변수의 값
  3. 뷰 랜더링을 하면서 만들어진 객체는 @PostMapping 컨트롤러의 @ModelAttribute에서 사용됨
    ==> 커맨드 객체 방식이다.
 <form action="item.html" th:action method="post" th:object="${item}">
        <div>
            <label for="itemName">상품명</label>
            <input type="text" id="itemName" th:field="*{itemName}" class="form-control" placeholder="이름을 입력하세요">
        </div>
        <div>
            <label for="price">가격</label>
            <input type="text" id="price" th:field="*{price}" class="form-control" placeholder="가격을 입력하세요">
        </div>
        <div>
            <label for="quantity">수량</label>
            <input type="text" id="quantity" th:field="*{quantity}" class="form-control" placeholder="수량을 입력하세요">
        </div>

2. 체크박스

2-1. 체크박스 단일

<div class="form-check">
 <input type="checkbox" id="open" name="open" class="form-check-input">
 <input type="hidden" name="_open" value="on"/> <!-- 히든 필드 추가 -->
 <label for="open" class="form-check-label">판매 오픈</label>
</div>
  1. 체크 박스를 체크하면 Item의 Boolean open변수에 open=on 이라는 값이 넘어간다. 스프링타입 컨버터는 on 이라는 문자를 true로 변환해준다.

  2. 체크 박스를 선택하지 않고 폼을 전송하면 open 이라는 필드 자체가 서버로 전송되지 않는다. 따라서 이후에 open 값을 수정하려고 할 때 문제가 발생할 수 있다.

  3. 히든 필드는 항상 전송된다. 따라서 체크를 해제한 경우, 여기에서 open은 전송되지 않고, _open만 전송되는데, 이 경우 스프링 MVC는 체크를 해제했다고 판단한다. 이 경우, open의 값은 false로 나온다 히든 필드를 설정하지 않고, 체크를 해제한 후 버튼을 누르면 open은 null로 설정된다.

  • 체크 박스를 체크하면 스프링 MVC가 open에 값이 있는 것을 확인하고 on으로 설정한다. 이때 _open도 on으로 설정되어 넘어가지만 무시한다.
 open=on&_open=on
  • 체크 박스를 체크하지 않으면 스프링 MVC가 _open만 있는 것을 확인하고, open의 값이 체크되지 않았다고 인식한다. 그래서 open값을 null이 아니라 false로 설정한다.
 _open=on
<div>판매 여부</div>
        <div>
            <div class="form-check">
                <input type="checkbox" id="open" name="open" class="form-check-input">
                <input type="hidden" id="open" name="_open" value ="on"/>
                <label for="open" class="form-check-label">판매 오픈</label>
            </div>
        </div>
  1. th:field=${item.open}을 사용하면 hidden필드 처리까지 자동으로 생성해준다. 그리고 ${item.open}에 값이 들어가있어서 true라면 checked 속성까지 넣어줘서 체크표시를 해준다.
<div class="form-check">
 <input type="checkbox" id="open" th:field="*{open}" class="form-check input">
 <label for="open" class="form-check-label">판매 오픈</label>
</div>

 <input type="hidden" id="open" name="_open" value ="on"/> 이것까지 자동으로 생성해준다.

2-2 체크박스 멀티

  • @ModelAttribute의 특별한 사용법
    등록 폼, 상세화면, 수정 폼에서 모두 서울, 부산, 제주라는 체크 박스를 반복해서 보여주어야 한다. 이렇게 하려면 각각의 컨트롤러에서 model.addAttribute(...) 을 사용해서 체크 박스를 구성하는 데이터를 반복해서 넣어주어야 한다.
    @ModelAttribute 는 이렇게 컨트롤러에 있는 별도의 메서드에 적용할 수 있다.
    이렇게하면 해당 컨트롤러의 모든 model에 담긴다.
    여기서는 model.addAttribute("regions", regions) 이 코드가 자동으로 추가되는 것이다.
@ModelAttribute("regions")
public Map<String, String> regions() {
 Map<String, String> regions = new LinkedHashMap<>();
 regions.put("SEOUL", "서울");
 regions.put("BUSAN", "부산");
 regions.put("JEJU", "제주");
 return regions;
}

  @GetMapping
    public String items(Model model) {
        List<Item> items = itemRepository.findAll();
        model.addAttribute("items", items);
        return "form/items";
    }

    @GetMapping("/{itemId}")
    public String item(@PathVariable long itemId, Model model) {
        Item item = itemRepository.findById(itemId);
        model.addAttribute("item", item);
        return "form/item";
    }

    @GetMapping("/add")
    public String addForm(Model model) {
        model.addAttribute("item", new Item());
        return "form/addForm";
    }

    @PostMapping("/add")
    public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes) {
        log.info("item.open = {}", item.getOpen());
        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/form/items/{itemId}";
    }

    @GetMapping("/{itemId}/edit")
    public String editForm(@PathVariable Long itemId, Model model) {
        Item item = itemRepository.findById(itemId);
        model.addAttribute("item", item);
        return "form/editForm";
    }

    @PostMapping("/{itemId}/edit")
    public String edit(@PathVariable Long itemId, @ModelAttribute Item item) {
        itemRepository.update(itemId, item);
        return "redirect:/form/items/{itemId}";
    }
  • th:for="${#ids.prev('regions')}" : th:field="*{regions}" 에서 생성된 id값을 그대로 가져와서 사용
    =>멀티 체크박스는 같은 이름의 여러 체크박스가 생성된다. name 속성은 모두 같아도 되지만 id는 유일해야하기에 모두 달라야 한다.
    타임리프는 체크박스를 each 루프 안에서 반복해서 만들 때 id에 임의로 1 , 2 , 3 숫자를 뒤에 붙여준다
    label은 체크박스의 id를 알아야하는데, id가 타임리프에 의해 동적으로 만들어지기 때문에 label for="id 값" 으로 id 값을 임의로 지정하는 것은 곤란하다.
<div>
 <div>등록 지역</div>
   <div th:each="region : ${regions}" class="form-check form-check-inline">
     <input type="checkbox" th:field="*{regions}" th:value="${region.key}"
    class="form-check-input">
     <label th:for="${#ids.prev('regions')}"
     th:text="${region.value}" class="form-check-label">서울</label>
 </div>
</div>
  • 해당 체크박스를 선택하면, th:value 속성에 설정된 값이 th:field와 매핑된 Item 객체의 regions 필드에 저장된다.
  • th:for는 <label> 요소가 연결될 폼 필드의 ID를 설정한다. th:field는 id를 자동으로 생성해주는데, 타임리프는 반복문 안에서는 id의 중복을 막기위해 1,2,3 순서대로 숫자를 붙여준다. #ids.prev('regions')는 앞서 정의된 체크박스의 ID를 자동으로 참조한다.

3. 라디오 버튼

  • 여러 선택지 중, 하나만 선택할 수 있을 때 사용
    이번에는 ENUM을 사용해서 개발
@ModelAttribute("itemTypes")
public ItemType[] itemTypes() {
 return ItemType.values();
}

ItemType.values() 를 사용하면 해당 ENUM의 모든 정보를 배열로 반환한다.
예) [BOOK, FOOD, ETC]

<div>
 <div>상품 종류</div>
  <div th:each="type : ${itemTypes}" class="form-check form-check-inline">
 <input type="radio" th:field="*{itemType}" th:value="${type.name()}"
class="form-check-input">
 <label th:for="${#ids.prev('itemType')}" th:text="${type.description}"
class="form-check-label">
 BOOK
 </label>
 </div>
</div>

라디오 버튼은 이미 선택이 되어 있다면, 수정시에도 항상 하나를 선택하도록 되어 있으므로 체크박스와 달리 별도의 히든 필드를 사용할 필요가 없다.

4. 셀렉트 박스

  • 여러 선택지 중, 하나만 선택할 수 있을 때 사용
    이번에는 자바 객체를 사용해서 개발
@ModelAttribute("deliveryCodes")
   public List<DeliveryCode> deliveryCodes() {
	 List<DeliveryCode> deliveryCodes = new ArrayList<>();
	 deliveryCodes.add(new DeliveryCode("FAST", "빠른 배송"));
	 deliveryCodes.add(new DeliveryCode("NORMAL", "일반 배송"));
	 deliveryCodes.add(new DeliveryCode("SLOW", "느린 배송"));
	 return deliveryCodes;
}

컨트롤러가 호출 될 때 마다 사용되므로 deliveryCodes 객체도 계속 생성된다. 이런 부분은 미리 생성해두고 재사용하는 것이 더 효율적이다(static 등을 사용)

<div>
 <div>배송 방식</div>
 <select th:field="*{deliveryCode}" class="form-select">
 <option value="">==배송 방식 선택==</option>
 <option th:each="deliveryCode : ${deliveryCodes}" th:value="$
{deliveryCode.code}"
 th:text="${deliveryCode.displayName}">FAST</option>
 </select>
</div>

th:field="*{deliveryCode}" 와 th:value="${deliveryCode.code}"를 비교해서 값이 같으면 selected="selected"를 자동으로 넣어줌

5. 메시지, 국제화

  • 스프링 부트를 사용하면 스프링 부트가 MessageSource 를 자동으로 스프링 빈으로 등록한다. 따라서 별다른 설정없이 메시지와 국제화기능 사용가능
    spring.messages.basename=messages : 스프링부트 메시지 기본설정
    =>messages.properties, messages_en.properties 등을 가지고 옴
  • String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale);
    =>Object[] args : 매개변수를 전달해서 치환할 수 있음 ({0}, {1}, {2}...)
    ex) String result = ms.getMessage("hello.name", new Object[]{"Spring"}, null);

  • locale 정보가 없으면 basename 에서 설정한 기본 이름 메시지 파일을 조회한다.

  • 타임리프의 메시지 표현식 #{...} 를 사용하면 message.properties의 값을 가져와서 스프링의 메시지를 편리하게 조회할 수 있다.

<div th:text="#{label.item}"></h2> => <div>상품</h2>

6. Validation

  • 검증에 실패한 경우 고객에게 다시 상품 등록 폼을 보여주고, 어떤 값을 잘못 입력했는지 친절하게 알려주어야 한다.
  • Bean validation이 가장 간편한 방법으로 클라이언트로부터 들어오는 값(requestDTO)에 Bean validation을 설정한다. controller에서 검증하려는 객체 앞에 @Vaild를 넣으면 검증을 한다.
    DTO는 다양한 계층에서 사용되는데 Bean validation을 사용하면 계층에 따라 유동적인 조건 설정이 불가능하다는 단점이 있다. 따라서 가장 기본적은 조건은 Bean validation으로 설정하고 if절을 사용해 BindingResult와 @Vaild를 함께 설정하는 것이 나을듯

6-1 BindingResult

  1. 파라미터로 넣을 때, 위치가 중요하다. @ModelAttribute에 의해 객체에 바인딩을 시도했지만 검증오류가 발생한 내용이 BindingrResult에 들어가므로, 반드시 @ModelAttribute + 객체, 뒤에 BindingResult를 넣어준다.

    • BindingResult가 있으면 @ModelAttribute에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 정상호출된다
    • BindingResult의 내용은 자동으로 Model에 담겨서 view로 넘어감
  2. bindingResult.addError(
    new FieldError(String objectName, String field, String defaultMessage)
    )
    bindingResult.addError(new ObjectError(String objectName, String defaultMessage))

if (!StringUtils.hasText(item.getItemName())) {
 bindingResult.addError(new FieldError("item", "itemName", "상품 이름은
필수입니다."));
 }
  1. 뷰 처리
  • #fields : BindingResult가 제공하는 검증 오류에 접근할 수 있다.

  • 글로벌 에러 : 글로벌 에러가 2개 이상일 수도 있으니 반복문 처리

글로벌 에러
<div th:if="${#fields.hasGlobalErrors()}">
 <p class="field-error" th:each="err : ${#fields.globalErrors()}"
th:text="${err}">글로벌 오류 메시지</p>
  • 필드 에러
    • th:errors="*{itemName}" -> if문까지 자동으로 만들어서 처리해주기 때문에, 해당 필드에 에러가 있으면 >...< 태그가 출력되고 없으면 안나옴
    • th:errorclass="field-error" -> th:field에서 지정한 필드에 오류가 있는지 확인하고, 있으면 class 정보를 추가(CSS 스타일링)
      ex)뒤에 있는 class에 "field-error"까지 추가해서 class="form-control", "field-error"
<div>
 <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
 <input type="text" id="itemName" th:field="*{itemName}"
 th:errorclass="field-error" class="form-control"
placeholder="이름을 입력하세요">
 <div class="field-error" th:errors="*{itemName}">
 상품명 오류
 </div>
</div>
  • 사용자 입력값 유지 : rejectedValue
    • 타임리프의 th:field 는 매우 똑똑하게 동작하는데, 정상 상황에는 모델 객체의 값을 사용하지만, 오류가 발생하면 FieldError 에서 보관한 값을 사용해서 값을 출력한다.

7. 오류코드와 메시지 처리

7-1 오류코드와 메시지 처리 1, 2

  • 오류 메시지를 구분하기 쉽게 errors.properties를 만들고, application.properties에 spring.messages.basename=messages,errors를 등록

    • codes : 메시지 코드 -> new String[]{"required.item.itemName"}
    • arguments : 메시지에서 사용하는 인자 -> new Object[]{1000, 1000000}
  • 컨트롤러에서 BindingResult는 검증해야 할 객체인 target 바로 다음에 온다. 따라서
    BindingResult는 이미 본인이 검증해야 할 객체인 target을 알고 있다.

  • BindingResult가 제공하는 rejectValue() -> 필드오류 , reject() -> 전체 를 사용하면 FieldError , ObjectError를 직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있다.

bindingResult.rejectValue("itemName", "required"); ->오류코드의 앞부분만 입력
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);   메시지코드에서 {0}, {1}..로 치환

7-2 오류코드와 메시지 처리 3, 4

MessageCodesResolver가 다음과 같은 규칙으로 codes를 만들어주고, 앞부분의 code만 입력하더라도 우선순위가 높은 것부터 적용됨

  • 객체 오류
    객체 오류의 경우 다음 순서로 2가지 생성
    1.: code + "." + object name
    2.: code
    예) 오류 코드: required, object name: item
    1.: required.item
    2.: required

  • 필드 오류
    필드 오류의 경우 다음 순서로 4가지 메시지 코드 생성
    1.: code + "." + object name + "." + field
    2.: code + "." + field
    3.: code + "." + field type
    4.: code

8. Validator 구현

  • 컨트롤러와 분리 : 검증코드가 너무 많아지면 보기 불편하므로 컨트롤러와 코드를 분리
  1. Validator 인터페이스 구현 : 해당 클래스에 검증로직을 입력
    상대적으로 복잡한 검증이 가능하지만 검증로직을 찾기 어렵고 데이터 검증이 아닌 비즈니스 로직 검증이 들어가는 경우도 있어서 다른 검증과 겹치는 검증 중복이 일어날 수 있다.
@Component
public class ItemValidator implements Validator {
 @Override
 public boolean supports(Class<?> clazz) {
 return Item.class.isAssignableFrom(clazz);
 }  // 검증하고자 하는 클래스와 동일한지 판단하는 메서드
 @Override
 public void validate(Object target, Errors errors) {
 Item item = (Item) target;
  1. 컨트롤러 : @InitBinder의 기능으로 컨트롤러가 작동하기 전에, @Validated 대상을 검증하는 검증기 사용
    Validator가 다수일 경우, 어떤 것이 사용되는지는 supports메서드의 return값과 @Validated가 적용된 객체를 비교하여 선택
@InitBinder
public void init(WebDataBinder dataBinder) {
 log.info("init binder {}", dataBinder);
 dataBinder.addValidators(itemValidator);
}
  1. 메서드 : 검증대상 앞에 @Validated
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult 
bindingResult, RedirectAttributes redirectAttributes)

9. Bean Validation

9-1 에러코드, 글로벌 오류

@NotBlank
NotBlank.item.itemName
NotBlank.itemName
NotBlank.java.lang.String
NotBlank
-> 다른 에러코드와 마찬가지로 MessageCodesResolver가 우선순위를 자동으로 만들어 두었음.
-> 글로벌 오류(오브젝트 오류)는 자바 코드로 작성하는 것을 추천(if, BindingResult)

9-2 객체를 수정할 때

  • 객체를 등록할 때와 수정할 때의 조건이 다르다면, 각각 다르게 검증하는 방법이 2가지 있다.

1. Bean Validation - groups : 그룹별로 나누어 검증기능 수행

@Valid에는 groups를 적용할 수 있는 기능이 없다. 따라서 groups를 사용하려면 @Validated를 사용해야 한다.
폼에서 전달하는 데이터가 Item 도메인 객체와 딱 맞지 않기 때문에 실무에서는 잘 사용되지 않는다.

(1)구분을 위한 기본 인터페이스 생성 : interface SaveCheck, interface UpdateCheck

(2)Entity에 적용

@NotNull(groups = {SaveCheck.class, UpdateCheck.class})
 @Max(value = 9999, groups = SaveCheck.class) //등록시에만 적용
 private Integer quantity;

(3)컨트롤러에 적용

@PostMapping("/add")
public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) 

2. Form 전송 객체 분리

  • 실무에서는 도메인 객체의 정보뿐만아니라 수많은 부가데이터가 넘어오기 때문에,
    그래서 보통 Item을 직접 전달받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다

  • 폼 데이터 전달을 위한 별도의 객체 사용 : 전송하는 폼 데이터가 복잡해도 거기에 맞춘 별도의 폼 객체를 사용해서 데이터를 전달 받을 수 있다
    HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repository
    (1)ITEM 원복 : Item의 검증은 사용하지 않으므로 검증 코드를 제거
    (2)ItemSaveForm, ItemUpdateForm 등 필요한 form을 Bean Validation을 사용하여 만듦
    (3)컨트롤러에서 적용

addItem(@Validated @ModelAttribute("item") ItemSaveForm form
->"item" 이라는 이름으로 th:object에 넘어감

edit(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult)

(4)폼 객체를 Item으로 변환

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

3. HTTP 메시지 컨버터

  • API의 경우 3가지 경우를 나누어 생각해야 한다.
    • 성공 요청: 성공
    • 실패 요청: @RequestBody에 의해 JSON을 객체로 생성하는 것 자체가 실패함
      ->객체가 만들어지지 않았기 때문에 컨트롤러로 못 넘어감
    • 검증 오류 요청: JSON을 객체로 생성하는 것은 성공했고, 검증에서 실패함
@PostMapping("/add")
 public Object addItem(@RequestBody @Validated ItemSaveForm form,
 BindingResult bindingResult)
  • @ModelAttribute vs @RequestBody
    • @ModelAttribute는 특정 필드가 바인딩 되지 않아도 컨트롤러가 호출되며, 나머지 필드는 정상 바인딩되고, Validator를 사용한 검증도 적용할 수 있다.
    • @RequestBody 는 HttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계 자체가 진행되지 않고 예외가 발생한다. 컨트롤러도 호출되지 않고, Validator도 적용할 수 없다.

0개의 댓글