타임리프 스프링 통합
타임리프는 스프링 없이도 동작하지만, 스프링과 통합을 위한 다양한 기능을 편리하게 제공한다. 그리고 이런 부분은 스프링으로 백엔드를 개발하는 개발자 입장에서 타임리프를 선택하는 하나의 이유가 된다.
스프링 통합으로 추가되는 기능들
- 스프링의 SpringEL 문법 통합
- ${@myBean.doSomething()} 처럼 스프링 빈 호출 지원
- 편리한 폼 관리를 위한 추가 속성
- th:object (기능 강화, 폼 커맨드 객체 선택)
- th:field , th:errors , th:errorclass
- 폼 컴포넌트 기능
- checkbox, radio button, List 등을 편리하게 사용할 수 있는 기능 지원
- 스프링의 메시지, 국제화 기능의 편리한 통합
- 스프링의 검증, 오류 처리 통합
- 스프링의 변환 서비스 통합(ConversionService)
스프링 부트에서는 대부분 자동화된다.
타임리프를 템플릿 엔진으로 스프링 빈에 등록하려면, 타임리프용 뷰 리졸버를 스프링 빈으로 등록해야 한다.
하지만 스프링 부트에서는 build.gradle에 타임리프 관련 의존성만 추개히주면 관련 설정을 모두 자동으로 스프링빈으로 등록해준다.
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
그리고 대부분의 타임리프 관련 설정은 application.properties에 추가해 설정 및 변경이 가능하다.
⇒ 스프링 부트가 제공하는 타임리프 설정
입력 폼 처리
타임리프의 속성을 이용해 입력 폼을 효율적으로 개선할 수 있다.
- th:object : 커맨드 객체를 지정한다.
- *{...} : 선택 변수 식이라고 한다. th:object 에서 선택한 객체에 접근한다.
- th:field : HTML 태그의 id , name , value 속성을 자동으로 처리해준다.
렌더링 전
<input type="text" th:field="*{itemName}" />
렌더링 후
<input type="text" id="itemName" name="itemName" th:value="*{itemName}" />
예시
등록 폼
th:object 를 적용하려면 먼저 해당 오브젝트 정보를 넘겨주어야 한다. 등록 폼이기 때문에 데이터가 비어있는 빈 오브젝트를 만들어서 뷰에 전달하자.
@GetMapping("/add")
public String addForm(Model model) {
model.addAttribute("item", new Item()); // 빈 객체 전달
return "form/addForm";
}
form/addForm.html 변경 코드 부분
<form action="item.html" th:action th:object="${item}" method="post">
<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>
- th:object="${item}" : <form> 에서 사용할 객체를 지정한다. 선택 변수 식( *{...} )을 적용할 수 있다.
- th:field="*{itemName}"
- *{itemName} 는 선택 변수 식을 사용했는데, ${item.itemName} 과 같다. 앞서 th:object 로 item 을 선택했기 때문에 선택 변수 식을 적용할 수 있다.
- th:field 는 id , name , value 속성을 모두 자동으로 만들어준다.
- id : th:field 에서 지정한 변수 이름과 같다. id="itemName"
- name : th:field 에서 지정한 변수 이름과 같다. name="itemName"
- value : th:field 에서 지정한 변수의 값을 사용한다. value=""
렌더링 전
<input type="text" id="itemName" th:field="*{itemName}" class="form-control" placeholder="이름을 입력하세요">
렌더링 후
<input type="text" id="itemName" class="form-control" placeholder="이름을 입력하세요" name="itemName" value="">
수정 폼은 앞서 설명한 내용과 같다. 수정 폼의 경우 id , name , value 를 모두 신경써야 했는데, 많은 부분이 th:field 덕분에 자동으로 처리되는 것을 확인할 수 있다
사실 이것의 진짜 위력은 뒤에 설명할 검증(Validation)에서 나타난다. 이후 검증 부분에서 폼 처리와 관련된 부분을 더 깊이있게 알아보자.
요구사항 추가
타임리프를 사용해서 폼에서 체크박스, 라디오 버튼, 셀렉트 박스를 편리하게 사용하는 방법을 학습해보자.
기존 상품 서비스에 다음 요구사항이 추가되었다.
- 판매 여부
- 판매 오픈 여부
- 체크 박스로 선택할 수 있다.
- 등록 지역
- 서울, 부산, 제주
- 체크 박스로 다중 선택할 수 있다.
- 상품 종류
- 도서, 식품, 기타
- 라디오 버튼으로 하나만 선택할 수 있다.
- 배송 방식
- 빠른 배송
- 일반 배송
- 느린 배송
- 셀렉트 박스로 하나만 선택할 수 있다.
예시 이미지
코드 추가
ItemType - 상품 종류
package hello.itemservice.domain.item;
public enum ItemType {
BOOK("도서"), FOOD("음식"), ERC("기타");
private final String description;
ItemType(String description) {
this.description = description;
}
}
- 상품 종류는 ENUM 을 사용한다. 설명을 위해 description 필드를 추가했다.
배송 방식 - DeliveryCode
package hello.itemservice.domain.item;
import lombok.AllArgsConstructor;
import lombok.Data;
/**
* FAST : 빠른 배송
* NORMAL : 일반 배송
* SLOW : 느린 배송
*/
@Data
@AllArgsConstructor
public class DeliveryCode {
private String code;
private String displayName;
}
- 배송 방식은 DeliveryCode 라는 클래스를 사용한다. code 는 FAST 같은 시스템에서 전달하는 값이고, displayName 은 빠른 배송 같은 고객에게 보여주는 값이다.
Item - 상품
package hello.itemservice.domain.item;
import java.util.List;
import lombok.Data;
@Data
public class Item {
private Long id;
private String itemName;
private Integer price;
private Integer quantity;
private Boolean open; // 판매 여부
private List<String> regions; // 등록 지역
private ItemType itemType; // 상품 종류
private String deliveryCode; // 배송 방식
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
- ENUM , 클래스, String 같은 다양한 상황을 준비했다. 각각의 상황에 어떻게 폼의 데이터를 받을 수 있는지 하나씩 알아볼 것이다.
체크 박스 - 단일1
HTML checkbox 주의점
우선 결론을 얘기하자면 HTML checkbox의 특징으로 check가 되지 않는다면 클라이언트에서 서버로 값을 보내지 않는다
<!-- single checkbox -->
<div>판매 여부</div>
<div>
<div class="form-check">
<input type="checkbox" id="open" name="open" class="form-check-input">
<label for="open" class="form-check-label">판매 오픈</label>
</div>
</div>
즉 다음과 같은 태그가 있다고 할 때 선택을 하지 않은 경우 서버에서는 open이라는 프로퍼티가 false가 아니라 아예 전송을 하지 않았기 때문에 null상태이다.
※ 참고
추가적으로 check를 하면 on이라는 값이 넘어가는데 스프링에선 이 문자를 true로 변환해주며 해당 역할은 스프링 타입 컨버터가 수행한다
이러한 특징은 수정의 경우에는 상황에 따라서 이 방식이 문제가 될 수 있다. 사용자가 의도적으로 체크되어 있던 값을 체크를 해제해도 저장시 아무 값도 넘어가지 않기 때문에, 서버 구현에 따라서 값이 오지 않은 것으로 판단해서 값을 변경하지 않을 수도 있다.
히든 필드를 이용한 해결
스프링 MVC는 히든 필드를 이용해 이런 문제를 해결한다.
기존 체크박스 태그 앞에 히든 필드를 하나 추가하고 _open 처럼 언더스코어(_)를 붙혀 전송하면 체크를 해제했다고 인식할 수 있다. 히든필드는 항상 전송된다는 특징을 이용한 방식으로 체크를 해제한 경우 open은 전송되지 않고 _open 만 전송되는데 이 경우 스프링 MVC는 체크를 해제했다고 판단한다.
체크 해제를 위한 히든 필드
<input type="hidden" name="_open" value="on">
기존 checkbox 코드에 히든 필드 추가
<input type="hidden" name="_open" value="on"><!-- 히든 필드 추가 -->
<input type="checkbox" id="open" name="open" class="form-check-input" />
이렇게 작성을 하면 체크박스를 선택하지 않고 저장했을 때 서버에서는 null 이 아니라 명확하게 false를 받을 수 있다. 이는 스프링 MVC에서 _open만 존재하는 것을 확인 후, open의 값이 체크되지 않았음을 인식한다.
정리
- 체크 박스 체크
- open=on&_open=on
- 체크 박스를 체크하면 스프링 MVC가 open 에 값이 있는 것을 확인하고 사용한다. 이때 _open 은 무시한다.
- 체크 박스 미체크
- _open=on
- 체크 박스를 체크하지 않으면 스프링 MVC가 _open 만 있는 것을 확인하고, open 의 값이 체크되지 않았다고 인식한다.
체크 박스 - 단일2
개발할 때 마다 이렇게 히든 필드를 추가하는 것은 상당히 번거롭다. 그래서 타임리프의 폼 기능을 사용해 자동으로 생성해줄 수 있다.
결론부터 말하자면 th:field 속성을 이용하면 id, name, value, field뿐 아니라 hidden field와 checked속성까지 자동으로 설정해준다.
렌더링 전 체크박스 태그
<input type="text" th:field="*{open}" />
렌더링 후 체크박스 태그
<input type="text" id="open" name="open" th:value="true" checked="checked"/>
<input type="hidden" name="_open" value="on"><!-- 히든 필드 추가 -->
타임리프의 체크 확인
checked="checked"
체크 박스에서 판매 여부를 선택해서 저장하면, 조회시에 checked 속성이 추가된 것을 확인할 수 있다. 이런 부분을 개발자가 직접 처리하려면 상당히 번거롭다. 타임리프의 th:field 를 사용하면, 값이 true 인 경우 체크를 자동으로 처리해준다.
상품 수정에도 적용해보자.
editForm.html
<hr class="my-4">
<!-- single checkbox -->
<div>판매 여부</div>
<div>
<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>
</div>
상품 수정도 th:object , th:field 를 모두 적용했다.
그런데 실행해보면 체크 박스를 수정해도 반영되지 않는다?? 분면 상품 수정에서 체크박스 값을 변경해주고 서버로 전송했는데 무슨 문제가 있었을까??
해결 방법은 파라미터로 전송된 데이터를 저장 안해줬기 때문에 발생한 문제였다.
ItemRepository - update() 코드를 다음과 같이 수정하자
public void update(Long itemId, Item updateParam) {
Item findItem = findById(itemId);
findItem.setItemName(updateParam.getItemName());
findItem.setPrice(updateParam.getPrice());
findItem.setQuantity(updateParam.getQuantity());
findItem.setOpen(updateParam.getOpen());
findItem.setRegions(updateParam.getRegions());
findItem.setItemType(updateParam.getItemType());
findItem.setDeliveryCode(updateParam.getDeliveryCode());
}
체크 박스 - 멀티
체크 박스를 멀티로 사용해서, 하나 이상을 체크할 수 있도록 해보자.
- 등록 지역
- 서울, 부산, 제주
- 체크 박스로 다중 선택할 수 있다
@ModelAttribute의 특별한 사용법
FormItemController 추가
@ModelAttribute("regions")
public Map<String, String> regions(){
Map<String, String> regions = new LinkedHashMap<>();
regions.put("SEOUL", "서울");
regions.put("BUSAN", "부산");
regions.put("JEJU", "제주");
return regions;
}
등록 폼, 상세화면, 수정 폼에서 모두 서울, 부산, 제주라는 체크 박스를 반복해서 보여주어야 한다. 이렇게 하려면 각각의 컨트롤러에서 model.addAttribute(...) 을 사용해서 체크 박스를 구성하는 데이터를 반복해서 넣어주어야 한다.
@ModelAttribute 는 이렇게 컨트롤러에 있는 별도의 메서드에 적용할 수 있다. 이렇게하면 해당 컨트롤러를 요청할 때 regions 에서 반환한 값이 자동으로 모델( model )에 담기게 된다. 물론 이렇게 사용하지 않고, 각각의 컨트롤러 메서드에서 모델에 직접 데이터를 담아서 처리해도 된다.
멀티 체크 박스 사용법
렌더링 전 타임리프 코드
<!-- multi checkbox -->
<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>
- regions라는 반복가능한 요소를 th:each로 반복하도록 선언한다.
- th.field 속성을 넣으면 th:value에 들어가는 region.key 값과 비교해 값이 포함되있으면 checked가 추가된다. 즉, 자동으로 value와 비교해서 checked 여부를 설정할 수 있다.
- th:for="${#ids.prev('regions')}"
- 멀티 체크박스는 같은 이름의 여러 체크박스를 만들 수 있다. 그런데 문제는 이렇게 반복해서 HTML 태그를 생성할 때 name 속성은 모두 같아도 되지만 id는 유일해야 하기 때문에 모두 달라야 한다.
- 따라서 타임리프는 체크박스를 each 루프 안에서 반복해서 만들 때 임의로 1 , 2 , 3 숫자를 뒤에 붙여준다.
렌더링 후 타임리프 코드
- each로 체크박스가 반복 생성된 결과 - id 뒤에 숫자가 추가
- <label for="id 값"> 에 지정된 id 가 checkbox 에서 동적으로 생성된 regions1 , regions2 , regions3 에 맞추어 순서대로 입력된 것을 확인할 수 있다
<div>
<div>등록 지역</div>
<div class="form-check form-check-inline">
<input type="checkbox" value="SEOUL" class="form-check-input"
id="regions1" name="regions">
<input type="hidden" name="_regions" value="on"/>
<label for="regions1" class="form-check-label">서울</label>
</div>
<div class="form-check form-check-inline">
<input type="checkbox" value="BUSAN" class="form-check-input"
id="regions2" name="regions">
<input type="hidden" name="_regions" value="on"/>
<label for="regions2" class="form-check-label">부산</label>
</div>
<div class="form-check form-check-inline">
<input type="checkbox" value="JEJU" class="form-check-input"
id="regions3" name="regions">
<input type="hidden" name="_regions" value="on"/>
<label for="regions3" class="form-check-label">제주</label>
</div>
</div>
- HTML의 id 가 타임리프에 의해 동적으로 만들어지기 때문에 으로 label 의 대상이 되는 id 값을 임의로 지정하는 것은 곤란하다.
- 그래서 타임리프에서는 편의 목적으로 ids라는 프로퍼티를 제공하며 다음과 같은 메서드도 제공한다. ids는 반복하는 요소의 인덱스로 1,2,3처럼 숫자다.
- ids.prev(...) : 아이디 앞에 인수값을 문자열 결합해준다. (Ex: ids.prev('data')→ data1)
- ids.next(...) : 아이디 뒤에 인수값을 문자열 결합해준다. (Ex: ids.prev('data')→ 1data)
로그 출력
서울, 부산 선택
regions=SEOUL&_regions=on®ions=BUSAN&_regions=on&_regions=on
- 로그: item.regions=[SEOUL, BUSAN]
지역 선택X
_regions=on&_regions=on&_regions=on
- 로그: item.regions=[]
_regions 는 앞서 설명한 기능이다. 웹 브라우저에서 체크를 하나도 하지 않았을 때, 클라이언트가 서버에 아무런 데이터를 보내지 않는 것을 방지한다.
참고로 _regions 조차 보내지 않으면 결과는 null 이 된다.
_regions 가 체크박스 숫자만큼 생성될 필요는 없지만, 타임리프가 생성되는 옵션 수 만큼 생성해서 그런 것이니 무시하자.
item.html - 추가
<!-- multi checkbox -->
<div>
<div>등록 지역</div>
<div th:each="region : ${regions}" class="form-check form-check-inline">
<input type="checkbox" th:field="${item.regions}" th:value="${region.key}" class="form-check-input" disabled>
<label th:for="${#ids.prev('regions')}"
th:text="${region.value}" class="form-check-label">서울</label>
</div>
</div>
- 주의: item.html 에는 th:object 를 사용하지 않았기 때문에 th:field 부분에 ${item.regions} 으로 적어주어야 한다.
- th.field 속성을 넣으면 th:value에 들어가는 region.key 값과 비교해 값이 포함되있으면 checked가 추가된다. 즉, 자동으로 value와 비교해서 checked 여부를 설정할 수 있다.
- disabled 를 사용해서 상품 상세에서는 체크 박스가 선택되지 않도록 했다.
정리
- 다수의 체크박스가 필요한 경우에는 th:each를 이용해 요소를 반복 생성할 수 있다.
- th:field와 th:value를 같이 작성하면 타임리프가 자동으로 두 값을 비교해 checked 설정을 해준다.
- 반복 생성되는 태그의 아이디를 모두 다르게 생성해줘야 하기에 ids라는 편의 프로퍼티를 제공한다.
- ids에는 next, prev라는 메서드가 제공되며 인수로 넘겨주는 값이 id의 앞/뒤로 결합되 아이디가 된다.
라디오 버튼
라디오 버튼은 여러 선택지 중에 하나를 선택할 때 사용할 수 있다. 이번 시간에는 라디오 버튼을 자바 ENUM을 활용해서 개발해보자.
- 상품 종류
- 도서, 식품, 기타
- 라디오 버튼으로 하나만 선택할 수 있다.
사용법
- 표현할 라디오버튼이 하나가 아닌이상 th:each 속성을 이용해 반복한다.
- 작성법 자체는 멀티 체크박스와 동일하다.
예제 코드
<div th:object="${item}">
...
<div th:each="type : ${반복요소}" class="form-check form-check-inline">
<input type="radio" th:field="*{라디오요소}" th:value="${type.비교값}" class="form-check-input">
<label th:for="${#ids.prev('data')}" th:text="${type.description}"
class="form-check-label">
BOOK
</label>
</div>
</div>
- 반복요소의 count만큼 내부 태그가 반복되어 생성된다.
- th:field에 들어간 th:object에서 선언한 item의 요소는 th:value의 값과 비교해 checked가 자동으로 표현된다.
- 멀티 체크박스와 동일하게 th:each속성 내부에서 ids를 이용해 인덱스접근및 인수로 넘겨준 값과 문자열 결합으로 고유한 아이디를 만들 수 있다.
특징
- 라디오버튼은 아무것도 선택하지 않을 경우 아무 값도 넘어가지 않아 Null이 된다.
- 체크박스와는 다르게 별도의 히든필드가 자동으로 생성되진 않는다.
- 라디오버튼은 한번 선택되면 항상 하나를 선택하도록 되어 있어 히든 필드를 사용하지 않는다.
타임리프에서 ENUM 직접 사용하기
타임리프는 SpringEL문법으로 서버에 있는 자바 객체에 직접 접근할 수도 있다. 대신 해당 객체의 FQCN(Fully Qualified Class Name)을 알아야 한다.
${T(FQCN)}
<div th:each="type : ${T(hello.itemservice.domain.item.ItemType).values()}">
- 사용법 : ${T(hello.itemservice.domain.item.ItemType).values()}
- ENUM에 values() 를 호출하면 해당 ENUM의 모든 정보가 배열로 반환된다.
그런데 이렇게 사용하면 ENUM의 패키지 위치가 변경되거나 할때 자바 컴파일러가 타임리프까지 컴파일 오류를 잡을 수 없으므로 추천하지는 않는다.
셀렉트 박스
셀렉트 박스는 여러 선택지 중에 하나를 선택할 때 사용할 수 있다.
- 배송 방식
- 빠른 배송
- 일반 배송
- 느린 배송
- 셀렉트 박스로 하나만 선택할 수 있다.
FormItemController - 추가
@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;
}
- DeliveryCode 라는 자바 객체를 사용하는 방법으로 진행하겠다
- DeliveryCode 를 등록 폼, 조회, 수정 폼에서 모두 사용하므로 @ModelAttribute 의 특별한 사용법을 적용하자
※ 참고
@ModelAttribute 가 있는 deliveryCodes() 메서드는 컨트롤러가 호출 될 때 마다 사용되므로 deliveryCodes 객체도 계속 생성된다. 이런 부분은 미리 생성해두고 재사용하는 것이 더 효율적이다.
사용법
- 반복요소가 있는 셀렉트박스 역시 th:each로 요소를 반복하며 하나하나의 값을 다룬다.
- 수정페이지에서 기본으로 선택되야하는 값이 있다면 th:field 요소를 이용해 가능하다.
예제 코드
<select th:field="${item.deliveryCode}" class="form-select">
<option value="">==배송 방식 선택==</option>
<option th:each="deliveryCode : ${deliveryCodes}"
th:value="${deliveryCode.code}"
th:text="${deliveryCode.displayName}">FAST
</option>
</select>
- select 태그에 선언된 th:field 속성과 option에서 선언된 th:value를 타임리프가 비교해 일치할 경우 자동으로 selected 된다.
렌더링 된 예제 코드는 다음과 같다.
(빠른배송이 선택된 상태라 가정한다.)
<select class="form-select" id="deliveryCode" name="deliveryCode">
<option value="">==배송 방식 선택==</option>
<option value="FAST" selected="selected">빠른 배송</option>
<option value="NORMAL">일반 배송</option>
<option value="SLOW">느린 배송</option>
</select>
- th:field가 빠른배송이기에 option중 빠른 배송의 값과 일치하기에 해당 태그에 selected가 추가되었다.