스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 : Thymeleaf 스프링 통합(Form)

jkky98·2024년 7월 29일
0

Spring

목록 보기
15/77

th:object , th:field

<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>
	@GetMapping("/add")
    public String addForm(Model model) {
        model.addAttribute("item", new Item());
        return "form/addForm";
    }

컨트롤러의 메서드에서 model을 주입받아 빈 Item 객체를 추가한 후, 이를 템플릿으로 전달하면 타임리프 템플릿 엔진은 해당 객체를 바탕으로 화면을 렌더링하게 된다.

이 방식은 주로 form의 초기 값이나 빈 입력 필드를 제공하기 위해 사용된다. GET 요청에 대해 템플릿을 반환할 때 컨트롤러에서 미리 빈 객체를 모델에 담아 보내면, 타임리프는 이를 바탕으로 form 태그 내의 필드를 초기화할 수 있다.

이전과 다르게 모델에 빈 객체를 넣는 방식은 타입 안정성과 함께 폼 데이터를 처리할 준비를 해 두기 위해 사용된다. 빈 객체가 존재해야 th:object와 th:field를 사용가능하기 때문이다.

object와 field는 html이 인지하지 못하는 태그 속성으로 타임리프가 제공하는 특수한 형식이다. 위의 html태그 뭉치에서 가장 최상위인 form태그에 th:object="${item}"라고 붙여주면서 이 form태그 이하에서 컨트롤러에서 전달한 빈 객체 item을 잡아둘 수 있다.

그리고 th:field="*{item의 필드 이름}"으로 볼 수 있듯 ${item.itemName}이 아닌 *{...}를 사용할 수 있다. *은 위에서 th:object로 잡아둔 item이다.

좋다 두 타임리프 표현식이 다 이해가 되었는데, 이 두 표현이 왜 필요한걸까?

th:field를 활용할 경우 id, name, value태그를 자동 생성해준다. (등록 폼이기에 value는 당연히 비어있다. 이용자가 입력할 경우 value에 값을 부여한다.)
th:field는 단순히 nameid 속성을 자동으로 생성해주는 것에 그치지 않는다. 이 기능은 검증 처리, 체크박스 및 라디오 버튼 활용 등에서 스프링과의 강력한 연동을 제공하며, 개발자에게 큰 편리성을 가져다준다. 특히 스프링 폼과 바인딩될 때 th:field는 객체의 필드와 연결되어 폼 데이터를 처리하거나 검증 오류 메시지를 효율적으로 처리하는 데 중요한 역할을 한다.

체크 박스

<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:field의 효용성이 나타난다. HTML에서 체크 박스의 체크 여부는 open 필드에 의해 좌우된다. 체크를 했을 경우 open=on으로 쿼리 파라미터에 담기지만 체크를 하지 않았을 경우 어떤 값도 넘어가지 않는다. 즉 open=false와 같은 표현이 존재하지 않기에 서버에서 이를 판단하기가 쉽지 않다.

즉 th:field를 사용하지 않았을 시에 여전히 Item객체의 open 필드에 접근하는 것은 문제가 아니지만 쿼리파라미터에 open자체가 존재하지 않으므로 객체의 open 필드에는 false가 아닌 null이 들어온다.

이런 문제를 해결하기 위해 스프링MVC는 약간의 트릭을 사용한다. 스프링MVC는 히든 필드인 name이 _open인 input태그인 <input type="hidden" name="_open" value="on"/>를 요구한다.

<div>판매 여부</div>
        <div>
            <div class="form-check">
                <input type="checkbox" id="open" th:field="*{open}" class="form-check-input">
              	<input type="hidden" name="_open" value="on"/>
                <label for="open" class="form-check-label">판매 오픈</label>
            </div>
        </div>

즉 위와 같이 구성한다면 check시에는 true가 들어오고 uncheck시에는 false가 들어오도록 해석해서 Item객체에 요청을 반영한다.

원리는 다음과 같다.

체크박스가 check시에는 open=on_open=on이 들어올 것이고 체크박스가 uncheck시에는 _open=on만 넘어오게 된다. 이렇게 히든 필드를 만들어 놓으면 언체크를 구분할 수 있기때문에 false값 반영이 가능해진다.

그럼 우리는 매번 저러한 히든 필드를 템플릿에 구성해야할까?

매우 번거롭다. 스프링은 이 또한 th:field를 통해 자동화한다. 체크박스 타입의 인풋 필드에 th:field에 객체의 필드를 제공할 경우 타임리프는 히든필드를 자동생성해준다.

어쩌면 스프링에 굉장히 종속적일지도 모르지만, 여전히 네추럴 템플릿의 특징을 가져 타임리프 없이 랜더링 되는 것에는 문제가 없다.(서버와의 통신에는 문제가 있겠지만)

체크박스-multi

우리의 요구사항에 등록지역을 추가되었다. 등록지역은 3개(서울, 부산, 제주)로 체크 박스로 다중 선택이 가능한 경우이다.

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

컨트롤러 클래스에 위와 같이 @ModelAttribute 애노테이션을 붙이고 인자에 모델안에 데이터를 넣는 과정을 작성한다. 이 컨트롤러 안에서 쓰이는 메서드에서는 Model을 사용할 때 자동적으로 Map 타입의 데이터인 region이 추가되어 적용된다. (초기화 기능)

이렇게 쓰는 이유는 많은 컨트롤러에서 이를 쓸 것이기 때문이다.

addForm 기준으로 두 가지 객체가 모델에 담겨 템플릿으로 전송된다.(item, region)

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

등록 지역(서울,부산,제주)의 정보가 담긴 LinkedHashMap을 사용하기 위해(모델에 담은 그 regions) th:each로 반복을 해준다. th:fields="*{regions}"는 Item객체의 필드에 해당한다(List 타입) th:fields의 목적은 폼 제출시 객체의 필드 값이 수정되어야 한다. th:value의 ${region.key}는 모델에 담긴 regions이다.

타임리프는 이렇게 반복으로 돌아갈 때 th:fields로 하여금 만들어지는 태그 id(id는 고유)를자동으로 구분할 수 있게 한다.

위와 같이 name은 동일하지만 id는 1,2,3으로 구분되는 모습을 볼 수 있다. input태그 옆에 존재하는 label태그의 경우 for속성이 id와 대응되어야 한다. 이또한 타임리프는 내장객체인 ids를 제공하며${#ids.prev('regions')}로 자동으로 대응되도록 할 수 있다.

  • #ids.prev() : 바로 직전에 생성한 id를 가져온다.

라디오 버튼

라디오 버튼은 여러 선택지 중 하나를 선택할 때 사용한다. 이전과 다르게 Map객체를 사용하는 것이 아니라 ENUM을 활용하여 상품 종류 선택지를 제공해보자.

public enum ItemType {

    BOOK("도서"), FOOD("음식"), ETC("기타");

    private final String description;

    ItemType(String description) {
        this.description = description;
    }

    public String getDescription() {
        return description;
    }
}
// Controller
    @ModelAttribute("itemTypes")
    public ItemType[] itemTypes() {
        return ItemType.values();
    }

이전과 같은 방법으로 ModelAttribute 초기화 과정에 "itemTypes"를 추가해준다. Enum 타입을 담는 배열로 리턴할 것이며 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>

체크박스와 달리 radio는 히든필드 생성 로직이 존재하지 않는다. 만약 라디오 버튼을 누르지 않고 제출할 경우 역시 쿼리 파라미터로 어떠한 값도 넘어가지 않으며 저장을 위한 객체의 필드(itemType)에는 null이 들어간다.

type.name()은 Enum의 자체 메서드로 해당 타입에 접근할 경우(ex. ItemType.ETC.name())으로 ETC String을 뽑을 수 있다.

type.description은 직접 필드를 조회하는 것이 아니라 getter를 통해서 가져오게 된다.

셀렉트 박스

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

이전과 크게 다르지않은 로직을 가진다. 이전 내용들을 이해했다면 모두 이해가능한 코드.

요구사항 반영 랜더링 결과

변경된 사항을 addForm뿐만아니라 editForm, item 템플릿에도 약간의 변화와 함께 적용했다.


  • editForm : 거의 동일
  • item : 신규 태그들에 disabled 속성 추가, th:object 사용안하므로 ${item.regions}와 같이 변경
profile
자바집사의 거북이 수련법

0개의 댓글