th:field
는 Thymeleaf에서 폼 필드와 모델 객체의 필드를 바인딩할 때 사용한다. th:field 사용시 태그의 value, name, id가 객체의 필드값으로 정해지므로 th:value와 중복될 수 없다.th:value
: th:value 속성은 일반적으로 사용자의 입력이 필요한 요소의 값을 설정하는 데 사용됩니다. 입력 필드, 체크박스, 라디오 버튼, 드롭다운과 같은 요소가 이 범주에 속한다.등록폼 : model.addAttribute("item", new Item());
수정폼 : Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
*{변수명}
로 th:object 객체의 변수를 지정할 수 있다. <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>
<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>
체크 박스를 체크하면 Item의 Boolean open변수에 open=on
이라는 값이 넘어간다. 스프링타입 컨버터는 on 이라는 문자를 true로 변환해준다.
체크 박스를 선택하지 않고 폼을 전송하면 open 이라는 필드 자체가 서버로 전송되지 않는다. 따라서 이후에 open 값을 수정하려고 할 때 문제가 발생할 수 있다.
히든 필드는 항상 전송된다. 따라서 체크를 해제한 경우, 여기에서 open은 전송되지 않고, _open
만 전송되는데, 이 경우 스프링 MVC는 체크를 해제했다고 판단한다. 이 경우, open의 값은 false로 나온다 히든 필드를 설정하지 않고, 체크를 해제한 후 버튼을 누르면 open은 null로 설정된다.
open=on&_open=on
_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>
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"/> 이것까지 자동으로 생성해준다.
@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값을 그대로 가져와서 사용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>
<label>
요소가 연결될 폼 필드의 ID를 설정한다. th:field는 id를 자동으로 생성해주는데, 타임리프는 반복문 안에서는 id의 중복을 막기위해 1,2,3 순서대로 숫자를 붙여준다. #ids.prev('regions')는 앞서 정의된 체크박스의 ID를 자동으로 참조한다.@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>
라디오 버튼은 이미 선택이 되어 있다면, 수정시에도 항상 하나를 선택하도록 되어 있으므로 체크박스와 달리 별도의 히든 필드를 사용할 필요가 없다.
@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"를 자동으로 넣어줌
spring.messages.basename=messages
: 스프링부트 메시지 기본설정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>
Bean validation
이 가장 간편한 방법으로 클라이언트로부터 들어오는 값(requestDTO)에 Bean validation을 설정한다. controller에서 검증하려는 객체 앞에 @Vaild를 넣으면 검증을 한다.파라미터로 넣을 때, 위치가 중요하다. @ModelAttribute에 의해 객체에 바인딩을 시도했지만 검증오류가 발생한 내용이 BindingrResult에 들어가므로, 반드시 @ModelAttribute + 객체, 뒤에 BindingResult를 넣어준다.
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", "상품 이름은
필수입니다."));
}
#fields : BindingResult가 제공하는 검증 오류에 접근할 수 있다.
글로벌 에러 : 글로벌 에러가 2개 이상일 수도 있으니 반복문 처리
글로벌 에러
<div th:if="${#fields.hasGlobalErrors()}">
<p class="field-error" th:each="err : ${#fields.globalErrors()}"
th:text="${err}">글로벌 오류 메시지</p>
(CSS 스타일링)
<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>
오류 메시지를 구분하기 쉽게 errors.properties를 만들고, application.properties에 spring.messages.basename=
messages,errors를 등록
new String[]
{"required.item.itemName"}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}..로 치환
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
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;
@InitBinder
public void init(WebDataBinder dataBinder) {
log.info("init binder {}", dataBinder);
dataBinder.addValidators(itemValidator);
}
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult
bindingResult, RedirectAttributes redirectAttributes)
@NotBlank
NotBlank.item.itemName
NotBlank.itemName
NotBlank.java.lang.String
NotBlank
-> 다른 에러코드와 마찬가지로 MessageCodesResolver가 우선순위를 자동으로 만들어 두었음.
-> 글로벌 오류(오브젝트 오류)는 자바 코드로 작성하는 것을 추천(if, BindingResult)
@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)
실무에서는 도메인 객체의 정보뿐만아니라 수많은 부가데이터가 넘어오기 때문에,
그래서 보통 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);
@PostMapping("/add")
public Object addItem(@RequestBody @Validated ItemSaveForm form,
BindingResult bindingResult)