쌩로그

스프링 MVC 2편 - Ch02. 타임리프 - 스프링 통합과 폼 본문

Spring/Spring & Spring Boot

스프링 MVC 2편 - Ch02. 타임리프 - 스프링 통합과 폼

.쌩수. 2024. 5. 6. 17:09
반응형

목록

  1. 포스팅 개요
  2. 본론
      2-1. 타임리프 스프링 통합
      2-2. 입력 폼 처리
      2-3. 요구사항 추가
      2-4. 체크박스 - 단일1
      2-5. 체크박스 - 단일2
      2-6. 체크박스 - 멀티
      2-7. 라디오 버튼
      2-8. 셀렉트 박스
  3. 요약

1. 포스팅 개요

인프런에서 영한님의 스프링 MVC 2편 Section 02 타임리프 - 스프링 통합과 폼을 학습하며 정리한 포스팅이다.

참고로 스프링 1편의 마지막 섹션의 상품 관리 프로젝트를 그대로 사용한다고 하긴 하시는데, 추가 된 게 있기 때문에 프로젝트 소스코드를 임포트하는 것이 더 좋을 것 같다.

2-1. 타임리프 스프링 통합

타임리프는 크게 2가지 메뉴얼을 제공한다.
기본 메뉴얼(스프링 없이 사용할 때)(https://www.thymeleaf.org/doc/tutorials/3.0/usingthymeleaf.html)
스프링 통합 메뉴얼(스프링과 같이 사용할 때)

타임리프는 스프링 없이도 동작하지만, 스프링과 통합을 위한 다양한 기능을 편리하게 제공한다.
그리고 이런 부분은 스프링으로 백엔드를 개발하는 개발자 입장에서 타임리프를 선택하는 하나의 이유가 된다.

만약 스프링과 관련된 기능을 통합하고 그런게 불편하면 허들이 될 것이다.

스프링 통합으로 추가되는 기능들은 다음과 같다.

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

설정 방법

타임리프 템플릿 엔진을 스프링 빈에 등록하고, 타임리프용 뷰 리졸버를 스프링 빈으로 등록하는 방법(매뉴얼)

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

build.gradle

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

타임리프 관련 설정을 변경하고 싶으면 다음을 참고해서 application.properties(혹은 yml) 에 추가하면 된다.

스프링 부트가 제공하는 타임리프 설정, thymeleaf 검색 필요

여기(https://docs.spring.io/spring-boot/docs/current/reference/html/application-properties.html#appendix.application-properties.templating)서 참고할 수 있다.

2-2. 입력 폼

지금부터 타임리프가 제공하는 입력 폼 기능을 적용해서 기존 프로젝트의 폼 코드를 타임리프가 지원하는 기능을 사용하여 효율적으로 개선해본다.

  • th:object : 커맨드 객체를 지정한다.
  • *{...} : 선택 변수 식이라고 한다. th:object 에서 선택한 객체에 접근한다.
  • th:field
    • HTML 태그의 id , name , value 속성을 자동으로 처리해준다.

등록 폼

itemservice/web/form/FormController에서

@GetMapping("/add")  
public String addForm() {  
    return "form/addForm";  
}

위의 코드를 다음과 같이 수정한다.

@GetMapping("/add")  
public String addForm(Model model) {  
    model.addAttribute("itme", new Item());  
    return "form/addForm";  
}

그럼 resources/templates/form/addForm.html로 빈 item 객체가 넘어간다.

그리고 addFrom에서 다음 부분을 수정한다.

<form action="item.html" th:action method="post">
// 수정 ↓
<form action="item.html" th:action th:object="${item}" method="post">
<label for="itemName">상품명</label>  
<input type="text" id="itemName" name="itemName" class="form-control" placeholder="이름을 입력하세요">
// 수정 ↓
<label for="itemName">상품명</label>  
<input type="text" id="itemName" name="itemName" th:field="${item.itemName}" class="form-control" placeholder="이름을 입력하세요">
// 혹은 
<label for="itemName">상품명</label>  
<input type="text" id="itemName" name="itemName" th:field="*{itemName}" class="form-control" placeholder="이름을 입력하세요">

위에서 말한 것처럼 th:field를 사욯하면 id, name이 자동으로 만들어진다.
따라서 id와 name을 지워도된다. 단, id를 지우면 label4에서 인식하지 못하므로, id는 지우지 않고 놔둔다.

<label for="itemName">상품명</label>  
<input type="text" id="itemName" th:field="*{itemName}" class="form-control" placeholder="이름을 입력하세요">

그리고 *{...}th:object에 소속된 것임을 의미한다.

또 다음 내용도 수정해준다.

<div>  
    <label for="price">가격</label>  
    <input type="text" id="price" name="price" class="form-control" placeholder="가격을 입력하세요">  
</div>  
<div>  
    <label for="quantity">수량</label>  
    <input type="text" id="quantity" name="quantity" th:field="*{quantity}" 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>

서버를 실행하여 결과를 보자.

위의 코드에서 id 속성도 지우고 확인해보면 다음과 같다.

이처럼 id도 자동으로 들어가는 것을 확인할 수 있다.

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

수정 폼

FormItemController에서 수정폼은 다음과 같이 유지한다.

@GetMapping("/{itemId}/edit")  
public String editForm(@PathVariable Long itemId, Model model) {  
    Item item = itemRepository.findById(itemId);  
    model.addAttribute("item", item);  
    return "form/editForm";  
}

기존의 editForm은 다음과 같다.

<form action="item.html" th:action method="post">  
    <div>        
        <label for="id">상품 ID</label>  
        <input type="text" id="id" name="id" class="form-control" value="1" th:value="${item.id}" readonly>  
    </div>    
    <div>        
        <label for="itemName">상품명</label>  
        <input type="text" id="itemName" name="itemName" class="form-control" value="상품A" th:value="${item.itemName}">  
    </div>    
    <div>        
        <label for="price">가격</label>  
        <input type="text" id="price" name="price" class="form-control" value="10000" th:value="${item.price}">  
    </div>    
    <div>        
        <label for="quantity">수량</label>  
        <input type="text" id="quantity" name="quantity" class="form-control" value="10" th:value="${item.quantity}">  
    </div>

...
</form>

기존의 값을 보여야하기 때문에, th:value="${item.id}"와 같이 사용하여 값을 나타내준다.

위의 코드를 다음과 같이 수정할 수 있다.

<form action="item.html" th:action th:object="${item}" method="post">  
    <div>        
        <label for="id">상품 ID</label>  
        <input type="text" id="id" class="form-control" th:field="*{id}" readonly>     </div>    
    <div>        
        <label for="itemName">상품명</label>  
        <input type="text" id="itemName" class="form-control" th:field="*{itemName}">  
    </div>    
    <div>        
        <label for="price">가격</label>  
        <input type="text" id="price" class="form-control" th:field="*{price}">  
    </div>    
    <div>        
        <label for="quantity">수량</label>  
        <input type="text" id="quantity" class="form-control" th:field="*{quantity}">  
    </div>

...
</form>

등록 폼에서는 큰 차이를 못 느꼈지만, 수정 폼에서는 더욱더 간결해진 것을 확인할 수 있다.

수정 폼의 경우 id , name , value 를 모두 신경써야 했는데, 많은 부분이 th:field 덕분에 자동으로 처리되는 것을 확인할 수 있다.

정리
th:object , th:field 덕분에 폼을 개발할 때 약간의 편리함을 얻었다.
쉽고 단순해서 크게 어려움이 없었을 것이다.
사실 이것의 진짜 위력은 뒤에 설명할 검증(Validation)에서 나타난다.
이후 검증 부분에서 폼 처리와 관련된 부분을 더 깊이있게 알아본다.

2-3. 요구사항 추가

타임리프를 사용해서 폼에서 체크박스, 라디오 버튼, 셀렉트 박스를 편리하게 사용하는 방법을 학습한다.

기존 상품 서비스에 다음 요구사항이 추가되었다고 가정한다.

  • 판매 여부
    • 판매 오픈 여부
    • 체크 박스로 선택할 수 있다.
  • 등록 지역
    • 서울, 부산, 제주
    • 체크 박스로 다중 선택할 수 있다.
  • 상품 종류
    • 도서, 식품, 기타
    • 라디오 버튼으로 하나만 선택할 수 있다.
  • 배송 방식
    • 빠른 배송
    • 일반 배송
    • 느린 배송
    • 셀렉트 박스로 하나만 선택할 수 있다.

예시 이미지는 다음과 같다.

먼저 상품 종류를 분류할 enum을 만든다.

hello.itemservice.domain.item.ItemType.enum 이다.

package hello.itemservice.domain.item;  

public enum ItemType {  

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

    private final String description;  

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

다음은 배송과 관련된 Delivery.class이다.

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;  

}

codeFAST 같은 시스템에서 전달하는 값이고,
displayName빠른 배송 같은 고객에게 보여주는 값이다.

그리고 Item 객체에서 이 값들을 사용할 수 있도록 하자.

hello.itemservice.domain.item.class에서 다음과 같은 필드를 추가하자.


@Data
public class Item {

    ...

    private Boolean open;         // 판매 여부  
    private List<String> regions; // 등록 지역  
    private ItemType itemType;    // 상품 종류  
    private String deliveryCode;  // 배송 방식

    ...
}

이제 각각의 상황에 어떻게 폼의 데이터를 받을 수 있는지 알아보자.

2-4. 체크박스 - 단일1

단순 HTML 체크 박스를 살펴보자.

참고로 쉬워보이지만 함정이 있다고 하신다.

addForm.html에 추가코드를 작성해준다.

<!DOCTYPE HTML>  
<html xmlns:th="http://www.thymeleaf.org">  
<head>  
    <meta charset="utf-8">  
    <link th:href="@{/css/bootstrap.min.css}"  
          href="../css/bootstrap.min.css" rel="stylesheet">  
    <style>        .container {  
            max-width: 560px;  
        }  
    </style>  
</head>  
<body>  

<div class="container">  

    <div class="py-5 text-center">  
        <h2>상품 등록 폼</h2>  
    </div>  
    <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>  
        <hr class="my-4">  
        <!-- 추가 부분-->
        <!-- 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>
        <!-- 추가 부분--> 

        <div class="row">  
            <div class="col">  
                <button class="w-100 btn btn-primary btn-lg" type="submit">상품 등록</button>  
            </div>            
            <div class="col">  
                <button class="w-100 btn btn-secondary btn-lg"  
                        onclick="location.href='items.html'"  
                        th:onclick="|location.href='@{/form/items}'|"  
                        type="button">취소</button>  
            </div>
        </div>  
    </form>  
</div> <!-- /container -->  
</body>  
</html>

참고로 여기서 추가된 부부은 순수 HTML이다.
순수 HTML로 짜는 체크박스가 골치아픈 게 있는데 그걸 보기 위해서 순수 HTML로 일단 작성한다.

위에서 추가된 부분이 판매 여부에 대한 부분이다.
name이 open인데, 이 값은 방금 Item 클래스에서 추가했던 판매 여부를 나타내는 Boolean 타입인 open 에 들어갈 것이다.

서버를 실행하여 확인해보자.

이처럼 판매 여부에 대해 확인할 수 있다.

FormItemController에서 @Slf4j를 선언하여, 판매 여부에 대한 값이 잘 나오는지 로그를 찍어보자.

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

상품등록을 다음과 같이 해보자.

위처럼 임의로 값을 넣어서 등록해보자.
그럼 다음과 같은 결과가 나온다.

현재 뷰에선 판매여부에 대한 내용은 보이지 않는다.
로그는 다음과 같이 나온다.

참고로 체크 박스를 선택하지 않으면 null이 들어간다.

true로 나온다.
그런데 이를 개발자 도구로 확인해보면, 판매 여부에 대한 값이 on으로 들어가는 것을 확인할 수 있다.

체크 박스를 체크하면 HTML Form에서 open=on 이라는 값이 넘어간다. 스프링은 on 이라는 문자를 true 타입으 로 변환해준다. (스프링 타입 컨버터가 이 기능을 수행하는데, 뒤에서 설명한다.)

참고로 체크 박스를 선택하지 않을 때 HTML에서 체크 박스를 선택하지 않고 폼을 전송하면 open 이라는 필드 자체가 서버로 전송되지 않는다.(아래 그림 참고)

잠시 생각할 것(개인)

  • 분명 서버에서는 if문 처리로 true, false 로 로직을 타게 할건데, null이 들어오는 게.. 흠.. 조금 생각할만 한 요소다. true가 아니면, false나 null이냐 로 분기처리를 해야할 것 같다.

참고로 만약 HTTP 요청 메세지를 서버에서 보고 싶으면 다음 설정을 추가하면 된다.

application.properties

logging.level.org.apache.coyote.http11=debug # 3.2 이전
logging.level.org.apache.coyote.http11=trace # 3.2 이후

trace로 결과를 보자.

아래 줄을 기준으로 header와 body가 나눠서 출력된다.
현재 판매 여부를 체크한 상태인데,
체크하지 않으면 다음과 같이 open이 들어가지 않는다.

이처럼 HTML checkbox는 선택이 안되면 클라이언트에서 서버로 값 자체를 보내지 않는다.
등록에는 문제가 되지 않을 수 있지만, 수정의 경우에는 상황에 따라서 이 방식이 문제가 될 수도 있다.
사용자가 의도적으로 체크되어 있던 값을 체크를 해제해도 저장시 아무 값도 넘어가지 않기 때문에, 서버 구현에 따라서 값이 오지 않은 것으로 판단해서 값을 변경하지 않을 수도 있다.

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

체크 해제를 인식하기 위한 히든 필드는 다음과 같다.

<input type="hidden" name="_open" value="on"/>

기존 코드에 히든 필드를 추가하자.
방금 add.form에 추가했던 부분에 위의 태그를 추가하면 된다.

<!-- single checkbox -->  
<div>판매 여부</div>  
<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>
</div>

서버를 재실행하여 확인해보자.

판매 여부를 체크하지 않았을 때 다음과 같이 값이 넘어간다.

로그는 다음과 같이 나온다.

값이 안 들어가는 경우 _open은 on이 들어가지만, 로그에서는 false로 찍힌다.

체크 박스 체크
open=on&_open=on
체크 박스를 체크하면 스프링 MVC가 open 에 값이 있는 것을 확인하고 사용한다.
이때 _open 은 무시한다.
그래서 true로 인식한다.

체크 박스 미체크
_open=on
체크 박스를 체크하지 않으면 스프링 MVC가 _open 만 있는 것을 확인하고, open 의 값이 체크되지 않았다고 인식한다.
이 경우 서버에서 Boolean 타입을 찍어보면 결과가 null 이 아니라 false 인 것을 확인할 수 있다.

근데 이 경우 체크 박스를 만들 때마다 히든 필드를 계속 만들어줘야한다....
이런 귀차니즘을 타임리프가 해결해준다.

2-5. 체크박스 - 단일2

타임리프

개발할 때 마다 이렇게 히든 필드를 추가하는 것은 상당히 번거롭다.
타임리프가 제공하는 폼 기능을 사용하면 이런 부분을 자동으로 처리할 수 있다.

타임리프의 체크 박스 코드를 추가하자.

addForm.html에 다음과 같이 추가하자.
참고로 방금 추가했던 히든 필드 태그는 지우자.

<div>판매 여부</div>  
<div>  
    <div class="form-check">  
        <input type="checkbox" id="open" name="open" th:field="${item.open}" class="form-check-input">  
        <label for="open" class="form-check-label">판매 오픈</label>  
    </div>
</div>

th:field로 값을 주면 된다.
혹은 th:field에 다음과 같이 값을 줘도 된다.

th:field="*{open}" 

왜냐하면 form태크에 th:object로 item이 선언되어 있기 때문이다.

<form action="item.html" th:action th:object="${item}" method="post">

서버를 재실행해서 상품 등록 페이지의 소스를 보자.

wow...
hidden 필드는 코드에 없음에도 불구하고, 추가된 것을 확인할 수 있다.
타임리프가 자동으로 생성해준 것이다.

체크를 하지 않았을 때와 체크한 것의 로그는 다음과 같다.

2024-05-06 13:10:21.456  INFO 15364 --- [nio-8080-exec-1] h.i.web.form.FormItemController          : item.open = false
2024-05-06 13:10:29.034  INFO 15364 --- [nio-8080-exec-4] h.i.web.form.FormItemController          : item.open = true

다음은 상품 상세에 대해 보자.
상품 상세에는 현재 판매여부에 대한 내용이 없다.

item.html에서 다음과 같이 추가해준다.

...


<div>  
    <label for="quantity">수량</label>  
    <input type="text" id="quantity" name="quantity" class="form-control" value="10" th:value="${item.quantity}" readonly>  
</div>  

<hr class="my-4">  

<!-- 추가된 부분-->
<!-- single checkbox -->  
<div>판매 여부</div>  
<div>  
    <div class="form-check">  
        <input type="checkbox" id="open" th:field="${item.open}" class="formcheck-input" disabled>  
        <label for="open" class="form-check-label">판매 오픈</label>  
    </div>
</div>  

<!-- 추가된 부분-->

<div class="row">  
    <div class="col">  
        <button class="w-100 btn btn-primary btn-lg"  
                onclick="location.href='editForm.html'"  
                th:onclick="|location.href='@{/form/items/{itemId}/edit(itemId=${item.id})}'|"  
                type="button">상품 수정</button>  
    </div>    <div class="col">  
        <button class="w-100 btn btn-secondary btn-lg"  
                onclick="location.href='items.html'"  
                th:onclick="|location.href='@{/form/items}'|"  
                type="button">목록으로</button>  
    </div>
</div>
...

참고로 상세에서는 th:object를 쓰지 않기 때문에, th:field*{open}이 아닌, ${item.open}을 사용해야 한다.

참고로 상세에서는 체크박스의 값이 변하지 않도록 disable해줘야 한다.

그리고 아래 그림과 같이 체크 되어있는 상세의 페이지 소스를 보자.

소스는 다음과 같다.

checked가 표시되어있다.
그런데 원래 개발자는 html 코드에 check여부에 따라 checked가 들어가거나, 들어가지 않게 하거나 해줘야하는데, 타임리프가 자동으로 checked 속성까지 넣어준다. wow..

체크를 안 하면 페이지 소스는 다음과 같다.

checked가 없다.

다음은 상품 수정에도 적용하자.

수정은 등록에 있는 것을 그대로 넣어주면 된다.

그런데, 수정을 할 때 체크가 되어있으면 체크된 상태로 나오는데, 체크를 풀고 저장을 하면 체크 박스의 값이 변하지 않는다.

이럴 때는 화면에서부터 데이터가 넘어오는 것까지 쭉 따라가봐야 한다.
이유는 update 로직에서 open을 수정해주는 로직이 없다.

public void update(Long itemId, Item updateParam) {  
    Item findItem = findById(itemId);  
    findItem.setItemName(updateParam.getItemName());  
    findItem.setPrice(updateParam.getPrice());  
    findItem.setQuantity(updateParam.getQuantity());  
}

해당 코드를 다음처럼 변경해주자.

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());  
}

이제 제대로 동작한다.

영한님도 로직이 제대로 돌지 않을 때는 처음부터 살펴보신다..
연차가 있으셔도 이렇게 하시는구나...란 것을 새삼 깨닫게 된다.

2-6. 체크박스 - 멀티

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

  • 등록 지역
    • 서울, 부산, 제주
    • 체크 박스로 다중 선택할 수 있다.

FormItemController에 다음을 추가하자.

등록폼에 다음과 같이 추가하자.

@GetMapping("/add")  
public String addForm(Model model) {  
    model.addAttribute("item", new Item());  

    // model레 region 값들을 추가한다.
        // HashMap을 사용하면 순서가 보장되지 않는다.
        // LinkedHashMap을 사용하면 순서대로 들어간다.
    Map<String, String> regions = new LinkedHashMap<>();  
    regions.put("SEOUL", "서울");  
    regions.put("BUSAN", "부산");  
    regions.put("JEJU", "제주");  
    model.addAttribute(regions);  

    return "form/addForm";  
}

그런데, 이 코드에서 regions를 추가하는 부분은 등록도 등록이지만,
생각해보면, 수정에도 들어가고, 상세에도 나와댜한다.

즉 똑같은 코드가 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;  
}

이렇게 하면
컨트롤러를 호출할 때 위와 같이 선언한 값들이 항상 model에 자등으로 model.addAttribute 로 들어간다.
물론 이렇게 사용하지 않고, 각각의 컨트롤러 메서드에서 모델에 직접 데이터를 담아서 처리해도 된다.
또한 컨트롤러마다 호출되기 때문에 static 영역에 만들어놓고 동적으로 불러오도록 할 수 있도록 성능최적화는 고민해봐야 한다. 현재는 성능에 그렇게 큰 영향이 있진 않다.

addForm.html 다음과 같이 추가하자.

판매여부 밑에 추가한다.

<!-- 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 숫자를 뒤에 붙여준다.

each로 체크박스가 반복 생성된 결과 - id 뒤에 숫자가 추가

<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 값을 사용할 수 있도록 한다.

그래서 생성 결과를 보면 <label for="id 값">에 지정된 idcheckbox 에서 동적으로 생성된 regions1, regions2, regions3 에 맞추어 순서대로 입력된 것을 확인할 수 있다.

로그가 출력되는 부분을 보자.

FormItemController.addItem()에 다음 코드를 추가하자.

log.info("item.regions={}", item.getRegions());

코드는 다음과 같다.

@PostMapping("/add")  
public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes) {  

    log.info("item.open = {}", item.getOpen());  
    log.info("item.regions={}", item.getRegions());  
    Item savedItem = itemRepository.save(item);  
    redirectAttributes.addAttribute("itemId", savedItem.getId());  
    redirectAttributes.addAttribute("status", true);  
    return "redirect:/form/items/{itemId}";  
}

서버를 실행해서 서울, 부산만 체크해서 보면 다음과 같다.

위의 그림은 개발자 도구로 확인한 것이고,
아래는 로그이다.

2024-05-06 14:05:01.056  INFO 984 --- [nio-8080-exec-7] h.i.web.form.FormItemController          : item.open = false
2024-05-06 14:05:01.058  INFO 984 --- [nio-8080-exec-7] h.i.web.form.FormItemController          : item.regions=[SEOUL, BUSAN]

지역을 선택하지 않으면 다음과 같이 나온다.

로그는 다음과 같다.

2024-05-06 14:07:05.430  INFO 984 --- [nio-8080-exec-8] h.i.web.form.FormItemController          : item.regions=[]

참고로 개발자 도구를 보면 알겠지만, 히든 필드가 생기는 것을 알 수 있다.

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

이제 item.html(상새)를 보자.

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>

참고로 영한님은 오류가 났는데, from태그에 th:object가 없이 th:field*{regions}와 같은 선택 변수식을 사용해서 그렇다. 따라서 *{regions}가 아니라, ${item.regions}를 주면 된다.

정상적인 결과는 다음과 같다.

참고로 input 태그에 disabled를 넣어줘야한다.

타임리프의 체크 확인

멀티 체크 박스에서 등록 지역을 선택해서 저장하면, 조회시에 checked 속성이 추가된 것을 확인할 수 있다.
타임리프는 th:field 에 지정한 값과 th:value 의 값을 비교해서 체크를 자동으로 처리해준다.

수정은 등록에 넣은 html 코드를 똑같이 넣으면 된다.

2-7. 라디오 버튼

라디오 버튼은 여러 선택지 중에 하나를 선택할 때 사용할 수 있다.
이번 시간에는 라디오 버튼을 자바 ENUM을 활용해서 개발해본다.

  • 상품 종류
    • 도서, 식품, 기타
    • 라디오 버튼으로 하나만 선택할 수 있다.

FormItemController에 다음을 추가하자.

@ModelAttribute("itemTypes")  
public ItemType[] itemTypes() {  
    return ItemType.values();   // 결과는 ItemType의 배열을 반환한다.
}

itemTypes 를 등록 폼, 조회, 수정 폼에서 모두 사용하므로 @ModelAttribute 의 특별한 사용법을 적용하자.
ItemType.values() 를 사용하면 해당 ENUM의 모든 정보를 배열로 반환한다.

  • 예) [BOOK, FOOD, ETC]

상품 등록 폼에 기능을 추가해보자
addForm.html에 다음 코드들을 추가하자.

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

th:each로 돌고 있는 type은 방금 model로 넣은 itemType의 요소들을 가리킨다.

서버를 실행하면 오류가 발생하늗네, enum에서 getter가 빠졌기 때문이다.
다음과 같이 수정해주면 된다.

package hello.itemservice.domain.item;  

import lombok.Getter;  

public enum ItemType {  

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

    private final String description;  

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

    public String getDescription() {  
        return description;  
    }  
}

결과는 다음과 같다.

페이지 소스는 다음과 같다.

로그를 확인해보자.

@PostMapping("/add")  
public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes) {  

    log.info("item.open = {}", item.getOpen());  
    log.info("item.regions={}", item.getRegions());  
    log.info("item.itemType = {}", item.getItemType());  // 라이도박스 확인 로

    Item savedItem = itemRepository.save(item);  
    redirectAttributes.addAttribute("itemId", savedItem.getId());  
    redirectAttributes.addAttribute("status", true);  
    return "redirect:/form/items/{itemId}";  
}

도서를 선택한 결과다.

2024-05-06 16:28:51.837  INFO 27960 --- [nio-8080-exec-1] h.i.web.form.FormItemController          : item.itemType = BOOK

참고로 enum은 배열이 아니다.
만약에 체크를 안하면 어떻게 될까?

라디오 박스는 체크를 안 하면 null이다.

2024-05-06 16:29:51.040  INFO 27960 --- [nio-8080-exec-7] h.i.web.form.FormItemController          : item.itemType = null

라디오 박스는 체크를 하지 않아도 된다.
참고로 라디오 박스는 hidden 필드도 만들지 않는다.
참고로 라디오 박스는 한 가지만 선택할 수 있는데, 한 번 선택하면 수정할 때도 무조건 선택지를 비울 수 없다. 왜 그런지는 수정 폼에서 알아본다.

다음은 상세다.
상세에 다음과 같이 코드를 추가한다.

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

참고로 조심할 부분은 input 태그에서 th:field의 값을 ${item.itemType}으로 줘야한다.
또한 input 태그 끝에 disabled도 넣어줘야 한다.

저장하면 다음과 같이 나온다.

선택을 해서 저장을 하면 위의 그림처럼 나온다.
그리고 소스를 보면 음식이 체크되어있는 것을 볼 수 있다.

editForm.html에는 등록에 있는 것을 복붙해주면 된다.

참고로 체크 박스는 수정시 체크를 해제하면 아무 값도 넘어가지 않기 때문에, 별도의 히든 필드로 이런 문제를 해결했다.
라디오 버튼은 이미 선택이 되어 있다면, 수정시에도 항상 하나를 선택하도록 되어 있으므로 체크 박스와 달리 별도의 히든 필드를 사용할 필요가 없다.

타임리프에서 ENUM 직접 사용하기

이렇게 모델에 ENUM을 담아서 전달하는 대신에 타임리프는 자바 객체에 직접 접근할 수 있다.

@ModelAttribute("itemTypes")  
public ItemType[] itemTypes() {  
    return ItemType.values();  
}
  • 타임리프에서 ENUM 직접 접근
    • <div th:each="type : ${T(hello.itemservice.domain.item.ItemType).values()}>

${T(hello.itemservice.domain.item.ItemType).values()}
스프링EL 문법으로 ENUM을 직접 사용 할 수 있다.
ENUM에 values() 를 호출하면 해당 ENUM의 모든 정보가 배열로 반환된다.

그런데 이렇게 사용하면 ENUM의 패키지 위치가 변경되거나 할 때 자바 컴파일러가 타임리프까지 컴파일 오류를 잡을 수 없으므로 추천하지는 않는다.

2-8. 셀렉트 박스

셀렉트 박스는 여러 선택지 중에 하나를 선택할 때 사용할 수 있다.
이번시간에는 셀렉트 박스를 자바 객체를 활용해서 개발해본다.

  • 배송 방식
    • 빠른 배송
    • 일반 배송
    • 느린 배송
    • 셀렉트 박스로 하나만 선택할 수 있다.

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 객체도 계속 생성된다.

이런 부분은 미리 생성해두고 재사용하는 것이 더 효율적이다.
예시라서 단순하게 하기위해 일단은 이렇게 진행한다.

addForm.html에 다음을 추가하자.
(라디오 박스 뒷부분에 추가)

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

결과는 다음과 같다.

페이지 소스는 다음과 같다.

드래그 한 부분이 타임리프에 의해 추가되었다.

수정에는 addForm에 추가한 그대로 넣어주자.

상세(item.html)에는 다음과 같이 추가해준다.

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

${item.deliveryCode} 부분과 disabled를 조심하자.
결과를 보자.
일반 배송 선택 후 저장된 화면이다.

다음은 느린 배송으로 수정 후 저장된 화면이다.

페이지 소스는 다음과 같다.

느린 배송에 selected가 되어있는데,
타임리프에서 select의 th:field와 option 에서의 th:value가 같은 것을 비교하여 selected를 넣어준다.

3. 요약

스프링과 타임리프의 통합된 기능을 통해서 간단히 MVC 미니 프로젝트(?)를 만들어봤다.

  • 타임리프가 굉장히 편리한 기능을 제공한다는 것을 알아보았고, th:field가 깡패인 것을 알 수 있었다.
  • @ModelAttribute 를 통해서 model에 반복적으로 들어가는 부분을 생략할 수 있도록 스프링이 제공하는 기능을 알아보았다.
  • 체크박스가 체크를 안 해주면 null값이 들어오는 것을 타임리프와 스프링이 hidden 속성을 이용하여 false로 인식해주는 기능을 알아보았다.

다음은 메세지 및 국제화에 대해 알아본다.

728x90
Comments