Java Category/Thymeleaf

[Thymeleaf] 스프링 통합과 폼

ReBugs 2024. 3. 9.

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


타임리프 스프링 통합

타임리프 기본 메뉴얼 : https://www.thymeleaf.org/doc/tutorials/3.0/usingthymeleaf.html

스프링 통합 메뉴얼 : https://www.thymeleaf.org/doc/tutorials/3.0/thymeleafspring.html

 

스프링 통합으로 추가되는 기능

  • 스프링의 SpringEL 문법
  • ${@myBean.doSomething()} 처럼 스프링 빈 호출 지원
  • 편리한 폼 관리를 위한 추가 속성
    → th:object (기능 강화, 폼 커맨드 객체 선택)
    → th:field , th:errors , th:errorclass
  • 폼 컴포넌트 기능
    →checkbox, radio button, List 등을 편리하게 사용할 수 있는 기능 지원
  • 스프링의 메시지, 국제화 기능의 편리한 통합
  • 스프링의 검증, 오류 처리 통합
  • 스프링의 변환 서비스 통합(ConversionService)

 

스프링 부트에서 build.gradle에 아래의 한줄을 넣어주면 Gradle은 타임리프와 관련 된 라이브러리를 다운로드 받고, 스프링 부트는 타임리프와 관련된 설정용 스프링 빈을 자동으로 등록해준다.

implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'

 

 

입력 폼 처리

  • 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}" />

 

 

등록 폼

<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=""
  • 참고로  id 속성을 제거해도 th:field 가 자동으로 만들어준다.

 

렌더링 전

<input type="text" id="itemName" th:field="*{itemName}" class="form-control">

 

렌더링 후

<input type="text" id="itemName" class="form-control" name="itemName" value="itemA">

 

체크박스, 라디오 버튼, 셀렉트 박스

 

ItemType - 상품 종류

public enum ItemType {
    BOOK("도서"), FOOD("식품"), ETC("기타");
         private final String description;
         ItemType(String description) {
             this.description = description;
    }
         public String getDescription() {
             return description;
    }
}

 

DeliveryCode - 배송 방식

/**
* FAST: 빠른 배송
* NORMAL: 일반 배송 * SLOW: 느린 배송 */
 @Data
 @AllArgsConstructor
 public class DeliveryCode {
     private String code;
     private String displayName;
 }

 

Item - 상품

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

 

타임리프 - 체크 박스 코드

<!-- 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>

 

타임리프를 사용하면 체크 박스의 히든 필드와 관련된 부분도 함께 해결해준다.

HTML 생성 결과를 보면 히든 필드 부분이 자동으로 생성되어 있다.

따라서 체크박스를 선택하지 않아도 null로 전송되는 것을 해결할 수 있다.

  • item.open=true //체크 박스를 선택하는 경우
  • item.open=false //체크 박스를 선택하지 않는 경우

 

체크 박스 - 멀티

컨트롤러

@ModelAttribute("regions")
 public Map<String, String> regions() {
    Map<String, String> regions = new LinkedHashMap<>(); regions.put("SEOUL", "서울");
    regions.put("BUSAN", "부산");
    regions.put("JEJU", "제주");
	return regions;
}

@ModelAttribute 를 컨트롤러 클래스에서 별도의 메서드에 붙이면 해당 컨트롤러를 요청할 때 regions 에서 반환한 값이 자동으로 모델에 담기게 된다.

 

타임리프

<!-- multi checkbox -->
<div>
    <div>등록 지역</div>
    <div th:each="region : ${regions}" class="form-check form-check-inline"> <!-- ModelAttribute에 담긴 region -->
        <input type="checkbox" th:field="${item.regions}" th:value="${region.key}" class="form-check-input" disabled> <!-- region.key: 서울 -> 부산 -> 제주 -->
        <label th:for="${#ids.prev('regions')}" th:text="${region.value}" class="form-check-label">서울</label>
    </div>
</div>

이 과정에서 중요한 것은 th:field를 사용하여 Model의 특정 필드와 체크박스를 연결하고, th:value를 사용하여 각 체크박스의 값을 지정하는 것이다.

  • th:each를 사용한 반복: regions 컬렉션에 대한 반복을 통해 각 지역에 대한 체크박스를 동적으로 생성한다. region 변수는 현재 반복 중인 지역을 나타낸다.
  • th:field의 사용: th:field="*{regions}"는 폼의 백엔드 모델에 있는 regions 필드와 체크박스 그룹을 바인딩한다. 이를 통해 체크박스의 선택 상태가 해당 모델 필드에 자동으로 반영된다.
  • th:value를 통한 값 지정: 각 체크박스에 th:value="${region.key}"를 사용하여 값을 지정한다. 이 값은 체크박스가 선택될 때 모델의 regions 필드에 저장될 식별 값이다.
  • th:text와 th:label을 사용한 레이블 설정: th:text를 사용하여 체크박스 옆에 표시될 지역 이름을 설정한다. th:label은 체크박스와 연결된 레이블을 지정하는데, th:for="${#ids.prev('regions')}"는 생성된 체크박스의 id를 레이블의 for 속성과 연결하여, 레이블 클릭 시 해당 체크박스가 선택되도록 한다.

 

th:for="${#ids.prev('regions')}"

→ 멀티 체크박스는 같은 이름의 여러 체크박스를 만들 수 있다. 그런데 문제는 이렇게 반복해서 HTML 태그를 생성할 때, 생성된 HTML 태그 속성에서 name 은 같아도 되지만, id 는 모두 달라야 한다. 따라서 타임리프는 체크박스를 each 루프 안에서 반복해서 만들 때 임의로 1 ,2 ,3 숫자를 뒤에 붙여준다.

 <input type="checkbox" value="SEOUL" class="form-check-input" id="regions1" name="regions">
 <input type="checkbox" value="BUSAN" class="form-check-input" id="regions2" name="regions">
 <input type="checkbox" value="JEJU" class="form-check-input" id="regions3" name="regions">

HTML의 id 가 타임리프에 의해 동적으로 만들어지기 때문에 <label for="id 값"> 으로 label 의 대상이 되는 id 값을 임의로 지정하는 것은 곤란하다. 타임리프는 ids.prev(...) , ids.next(...) 을 제공해서 동적으로 생성되는 id 값을 사용할 수 있도록 한다.

 

서울, 부산 선택

regions=SEOUL&_regions=on&regions=BUSAN&_regions=on&_regions=on

 

지역 선택X

_regions=on&_regions=on&_regions=on

웹 브라우저에서 체크를 하나도 하지 않았을 때, 클라이언트가 서버에 아무런 데 이터를 보내지 않는 것을 방지한다. _regions 조차 보내지 않으면 결과는 null 이 된다.
_regions 가 체크박스 숫자만큼 생성될 필요는 없지만, 타임리프가 생성되는 옵션 수 만큼 생성한다.

 

라디오 버튼

DeliveryCode

@Data
@AllArgsConstructor
public class DeliveryCode {
    private String code;
    private String displayName;
}

 

컨트롤러

@ModelAttribute("itemTypes")
 public ItemType[] itemTypes() {
     return ItemType.values();
 }
ItemType.values() 를 사용하면 해당 ENUM의 모든 정보를 배열로 반환한다. 예) [BOOK, FOOD, ETC]

 

타임리프

<!-- radio button -->
<div>
    <div>상품 종류</div>
    <div th:each="type : ${itemTypes}" class="form-check form-check-inline">
        <input type="radio" th:field="${item.itemType}" th:value="${type.name()}" class="form-check-input" disabled>
        <label th:for="${#ids.prev('itemType')}" th:text="${type.description}" class="form-check-label">BOOK</label>
    </div>
</div>
  • th:each를 사용한 반복: itemTypes 컬렉션에 대한 반복을 통해 각 상품 종류에 대한 라디오 버튼을 동적으로 생성한다. type 변수는 현재 반복 중인 상품 종류를 나타낸다.
  • th:field의 사용: th:field="${item.itemType}"는 폼의 백엔드 모델에 있는 itemType 필드와 라디오 버튼 그룹을 바인딩한다. 이를 통해 선택된 라디오 버튼의 값이 해당 모델 필드에 자동으로 반영된다.
  • th:value를 통한 값 지정: 각 라디오 버튼에 th:value="${type.name()}"를 사용하여 값을 지정한다. 이 값은 라디오 버튼이 선택될 때 모델의 itemType 필드에 저장될 값이다.
  • th:text와 th:label을 사용한 레이블 설정: th:text를 사용하여 라디오 버튼 옆에 표시될 상품 종류의 설명을 설정한다. th:label은 라디오 버튼과 연결된 레이블을 지정하는데, th:for="${#ids.prev('itemType')}"는 생성된 라디오 버튼의 id를 레이블의 for 속성과 연결하여, 레이블 클릭 시 해당 라디오 버튼이 선택되도록 한다.
  • disabled 속성의 사용: 라디오 버튼에 disabled 속성이 추가되어 있다. 이는 사용자가 해당 라디오 버튼을 선택하거나 변경할 수 없음을 의미한다. 특정 조건하에만 라디오 버튼을 활성화하려면, 서버 측 또는 클라이언트 측 스크립트를 사용하여 이 속성을 동적으로 제어할 수 있다.

 

실행 결과, 폼 전송

itemType=FOOD //음식 선택, 선택하지 않으면 아무 값도 넘어가지 않는다. 

 

로그 추가

log.info("item.itemType={}", item.getItemType());

 

실행 로그

  • item.itemType=FOOD: 값이 있을 때
  • item.itemType=null: 값이 없을 때

체크 박스는 수정시 체크를 해제하면 아무 값도 넘어가지 않기 때문에, 별도의 히든 필드로 이런 문제를 해결했다.

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

 

셀렉트 박스

컨트롤러

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

 

타임리프

<!-- SELECT -->
<div>
    <div>배송 방식</div>
    <select th:field="${item.deliveryCode}" class="form-select" disabled>
        <option value="">==배송 방식 선택==</option>
        <option th:each="deliveryCode : ${deliveryCodes}" th:value="${deliveryCode.code}" th:text="${deliveryCode.displayName}">FAST</option>
    </select>
</div>
  • th:field를 사용한 바인딩: th:field="${item.deliveryCode}"는 <select> 요소를 폼의 백엔드 모델에 있는 deliveryCode 필드와 바인딩한다. 이를 통해 사용자가 선택한 옵션의 값이 해당 모델 필드에 자동으로 반영된다.
  • 첫 번째 option 요소: 첫 번째 option 요소는 사용자에게 기본 안내 메시지를 제공한다. value=""는 실제 선택 가능한 옵션이 아님을 나타낸다.
  • th:each를 사용한 옵션의 동적 생성: th:each="deliveryCode : ${deliveryCodes}"를 사용하여 deliveryCodes 컬렉션의 각 항목에 대해 <option> 요소를 동적으로 생성한다. deliveryCode 변수는 현재 반복 중인 배송 코드 객체를 나타낸다.
  • th:value와 th:text를 통한 옵션 설정: th:value="${deliveryCode.code}"를 사용하여 각 <option>의 값을 설정한다. 이 값은 사용자가 해당 옵션을 선택했을 때 deliveryCode 필드에 저장될 식별 값이다. th:text="${deliveryCode.displayName}"를 사용하여 드롭다운 메뉴에 표시될 배송 방식의 이름을 설정한다.
  • disabled 속성: <select> 요소에 disabled 속성이 추가되어 있다는 것은 사용자가 드롭다운 메뉴에서 선택을 변경할 수 없음을 의미한다. 특정 조건에서만 이 드롭다운을 활성화하려면, 서버 측 또는 클라이언트 측 스크립트를 사용하여 이 속성을 동적으로 제어할 수 있다.

 

 

댓글