Spring/MVC

[Spring/MVC] 09. 타임리프 - 스프링 통합과 폼

lumana 2024. 12. 28. 18:05

 

09. 타임리프 - 스프링 통합과 폼

#Spring/MVC

정리


타임리프 스프링 통합

타임리프는 스프링을 위해 기능을 제공한다고 말해도 과할 정도가 아닌 수준의 스프링 통합을 위한 다양한 기능을 제공한다.


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

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

입력 폼 처리

타임리프가 제공하는 입력 폼 기능을 적용하면 HTML를 굉장히 간결하고 효율적으로 작성할 수 있다.


<input type="text" id="itemName" name="itemName" value="itemA">
원래 HTML 코드에서는 이런식으로 id 속성과 name 속성이 중복되서 나오는 경우가 많다. 타임리프는 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="formcontrol" 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="formcontrol" placeholder="수량을 입력하세요">
    </div>
</form>

  • th:object: 커맨드 객체를 지정한다.
    • 선택 변수 식 (*{...})을 적용할 수 있다.
    • *{...}: 선택 변수 식이라고 한다. th:object에서 선택한 객체에 접근한다.
      • *{itemName}는 선택 변수 식을 사용했는데, ${item.itemName}과 같다. 앞서 th:objectitem을 선택했기 때문에 선택 변수 식을 적용할 수 있다.
  • th:fieldid, name, value 속성을 모두 자동으로 만들어준다.
    • id: th:field에서 지정한 변수 이름과 같다. id="itemName"
    • name: th:field에서 지정한 변수 이름과 같다. name="itemName"
    • value: th:field에서 지정한 변수의 값을 사용한다. value=""

id 속성 또한 제거해도 th:field가 자동으로 만들어준다. 그리고, 렌더링을 통해 만들어주기 때문에 오타에 대해 오류를 반환해준다 개발자가 오류를 발견하는데 도움을 준다는 이점도 있다.


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


예시로 다음 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; // 배송 방식
}

체크박스 - 단일

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

단순히 이런식으로 HTML 체크박스를 추가한 뒤에 전송을 했을 때

  • 체크 박스를 체크하면 HTML Form에서 open=on이라는 값이 넘어간다. 스프링은 on이라는 문자를 true 타입으로 변환해준다.
  • HTML에서 체크 박스를 선택하지 않고 폼을 전송하면 open이라는 필드 자체가 서버로 전송되지 않는다.

이로 인해 큰 문제가 발생할 수 있다. unopen을 하기 위해 체크를 해제하고 서버에 올려보내면, open 필드 자체가 서버로 전송되지 않아 비즈니스 로직을 처리하는 부분에서 원하는 의도대로 동작하지 않을 수 있다.


이 문제를 해결하기 위해서 스프링 MVC는 약간의 트릭을 사용하는데, 히든 필드를 하나 만들어서, _open처럼 기존 체크 박스 이름 앞에 언더스코어(_)를 붙여서 전송하면 체크를 해제했다고 인식할 수 있다.
히든 필드는 항상 전송되기 때문에, 체크를 해제한 경우 여기에서 open은 전송되지 않고, _open만 전송되는데, 이 경우 스프링 MVC는 체크를 해제했다고 판단한다.


<input type="checkbox" id="open" name="open" class="form-check-input">
<input type="hidden" name="_open" value="on"/> <!-- 히든 필드 추가 -->

체크 박스를 미체크하는 경우 스프링 MVC가 _open만 있는 것을 확인하고, open의 값이 체크되지 않았다고 인식한다. open 필드의 값은 false가 된다. 체크 박스를 체크하면 _open 은 무시한다.


하지만 매 번 HTML에다가 히든 필드를 추가하는 일은 번거롭다. 타임리프는 이러한 부분을 자동으로 만들어주는 기능을 제공해준다.


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

타임리프로 렌더링 한 결과로 <input type="hidden" name="_open" value="on"/> 가 자동으로 생성된다.


주의
선택 변수 식 *{...}를 사용할 때는 반드시 상위 태그에 th:object를 사용한 상태여야 한다. 그렇지 않은 경우에는 ${item.xxx} 프로퍼티 접근법을 사용하자.


체크박스에 출력하기

<input type="checkbox" id="open" th:field="${item.open}" class="formcheck-input" disabled>

disabled를 사용해서 상품 조회에서는 체크 박스가 선택되지 않도록 한다.


실제로는 아래와 같이 HTML이 생성된다.

<input type="checkbox" id="open" class="form-check-input" disabled name="open" value="true" checked="checked">

타임리프의 th:field를 사용하면, 값이 true인 경우 체크를 자동으로 처리해준다. (checked="checked”)


체크박스 - 멀티

체크 박스를 멀티로 사용해서, 하나 이상을 체크할 수 있도록 해보자.


@ModelAttribute의 특별한 사용법

@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()에서 반환한 값이 자동으로 모델에 담기게 된다.


실무에서는 만약 이 부분이 동적으로 생성되는 부분이 아니라면, static 영역에 빼서 성능 최적화를 해줘야 한다. (모든 endpoint에서 regions()가 호출되기 때문)


등록 Form

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


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

동적으로 생성되는 id 값을 정적으로 지정하는 것은 불가능하다. 따라서 타임리프는 ids.prev(...) , ids.next(...) 을 제공해서 동적으로 생성되는 id 값을 사용할 수 있도록 한다.


<label for="id 값">에 지정된 id가 checkbox에서 동적으로 생성된 regions1, regions2, regions3에 맞추어 순서대로 입력된다.


라디오 버튼

라디오 버튼은 여러 선택지 중에 하나를 선택할 때 사용할 수 있다. 자바 ENUM을 활용해서 타입을 다루자.


@ModelAttribute("itemTypes")
public ItemType[] itemTypes() {
    return ItemType.values();
}

모든 폼에서 ItemType 정보가 필요하므로 해당 ENUM의 모든 정보를 배열로 반환하여 내려준다.


입력 폼

<!-- radio button -->
<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;
}

입력 폼

<!-- SELECT -->
<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>
<hr class="my-4">

랜더링 결과

<!-- SELECT -->
<div>
    <div>배송 방식</div>
    <select class="form-select" id="deliveryCode" name="deliveryCode">
        <option value="">==배송 방식 선택==</option>
        <option value="FAST">빠른 배송</option>
        <option value="NORMAL">일반 배송</option>
        <option value="SLOW">느린 배송</option>
    </select>
</div>
<hr class="my-4">

조회에서는 disabled를 사용해서 셀렉트 박스가 선택되지 않도록 하자.


Ref) 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술