인프런/스프링 MVC 2편

4)타임리프 적용,체크박스,멀티체크박스

backend dev 2023. 2. 2.

타임리프

타임리프는 크게 2가지 메뉴얼을 제공한다.

 

기본 메뉴얼

https://www.thymeleaf.org/doc/tutorials/3.0/usingthymeleaf.html

 

타임리프 + 스프링  (스프링 통합 메뉴얼)

https://www.thymeleaf.org/doc/tutorials/3.0/thymeleafspring.html

 

 

타임리프는 스프링 없이도 동작하지만, 스프링과 통합을 위한 다양한 기능을 편리하게 제공한다.

그리고
이런 부분은 스프링으로 백엔드를 개발하는 개발자 입장에서 타임리프를 선택하는 하나의 이유가 된다.

 

 

설정방법

https://www.thymeleaf.org/doc/tutorials/3.0/thymeleafspring.html#the-springstandard-dialect

https://www.thymeleaf.org/doc/tutorials/3.0/thymeleafspring.html#views-and-view-resolvers

타임리프 템플릿 엔진을 스프링 빈에 등록하고, 타임리프용 뷰 리졸버를 스프링 빈으로 등록하는 방법을 원래는 알아야하는데 스프링 부트는 이런 부분을 모두 자동화 해준다.

 build.gradle 에 다음 한줄을 넣어주면 Gradle은 타임리프와 관련된 라이브러리를 다운로드 받고, 

스프링 부트는 앞서 설명한 타임리프와 관련된 설정용 스프링 빈을 자동으로 등록해준다.

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

 

 

스프링 관련설정

https://docs.spring.io/spring-boot/docs/current/reference/html/application-properties.html#common-application-properties-templating

 

스프링 부트의 문서에 나와있다.

 

 

 


입력 폼 개선 

지금부터 타임리프가 제공하는 입력 폼 기능을 적용해서

기존 프로젝트의 폼 코드를 타임리프가 지원하는 기능을 사용해서 효율적으로 개선해보자.

 

컨트롤러

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

해당 템플릿뷰로 이동하는 메소드로가서 Model에다가 비어있는 Item객체를 넣어준다 

@Data
public class Item {

    private Long id;
    private String itemName;
    private Integer price;
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

Item클래스는 다음과 같다.

 

현재 form 코드

<form action="item.html" th:action th:object="${item}" method="post">
    <div>
        <label for="itemName">상품명</label>
        <input type="text" id="itemName" name="itemName" class="form-control" placeholder="이름을 입력하세요">
    </div>
    <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" class="form-control" placeholder="수량을 입력하세요">
    </div>

    <hr class="my-4">

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

th:action에만 타임리프 문법이 들어가고 내부에는 들어간곳이 없다. 

th:object를 이용해서 모델에 넣은 item객체를 받는다.

그러면 th:object로 넘어온 객체를 보고, 

<input type="text" id="itemName" name="itemName" class="form-control" placeholder="이름을 입력하세요">

input안의 id,name 속성이 있고 값은 동일하다. 그걸 th:field를 이용하여 한번에 처리가 가능하다.

<input type="text" th:field="${item.itemName}" class="form-control" placeholder="이름을 입력하세요">

이런식으로 수정 가능하다. 하지만 여기서 더 개선이 가능하다.

(field에 들어가는 값으로 id,name,value 속성을 자동으로 처리해준다, 여기서는 itemName안에 들어가는 값이 될것이다.)

(field가 곧 이 form으로 인해 전송되는 데이터가 매핑될 객체의 필드라고 생각하면 된다.

해당 input태그가 itemName을 매핑해주기 위한것이니까 th:field="{item.itemName}" 으로 한것이다.

field가 있으므로 id,name,value 속성이 자동으로 처리될것인데 id와 name은 th:field에 적은 필드명(itemName)이고

value는 만약에 해당 뷰 템플릿으로 Item객체가 모델에 담겨 들어왔을때 그때의 itemName의 값이 될것이다. -> 상품등록하고 검증오류가 발생해서 다시 상품등록폼을 보여줄때 입력한값은 그대로 냅둬진다!

선택 변수 식을 이용하여 개선해보자.

<input type="text" th:field="*{itemName}" class="form-control" placeholder="이름을 입력하세요">

이렇게 *{}를 이용해서 th:object에서 지정한 커맨드 객체를 이용하는구나를 알려준다

그래서 *{itemName} 만 적어도 커맨드 객체의 itemName이겠구나를 알려주는것이다

(field에 들어가는 값으로 id,name,value 속성을 자동으로 처리해준다,여기서는 itemName안에 들어가는 값이 될것이다.)

 

 

id를 지워버리면

label태그에서 id값을 사용하고있었므로 빨간줄이 생긴다. 하지만 동작하는데 발생하는 오류는없다.

(신경이 쓰인다면 input태그의 id속성은 냅두거나, 신경안쓴다면 지운다)

 

수정한 결과

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

 

결과 페이지의 소스보기

id,name값이 알아서 들어간것을 확인할 수 있다. (지정한 변수의 이름으로!)

value는 비어있음. (지금 커맨드객체의 itemName 변수가 빈값이라서!)

th:object의 영향 범위는 form태그내부이다!

 

 

상품 수정폼 개선

상품 수정 컨트롤러

@GetMapping("/{itemId}/edit")
public String editForm(@PathVariable Long itemId, Model model) {
    Item item = itemRepository.findById(itemId);
    model.addAttribute("item", item);
    return "form/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>

상품 수정폼은 수정을 누른 상품에 대한 정보를 띄워줘야하기 때문에 

th.value 를 사용하고 있다.

 

위의 수정폼 템플릿을 수정해보자.

<form action="item.html" th:action th:object="${item}" method="post">
    <div>
        <label for="id">상품 ID</label>
        <input type="text" id="id" lass="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>

th:object를 통해 커맨드객체를 받아온다. 상품수정 컨트롤러에서 값이있는 item객체를 모델에 담아 보내주므로

 

th:field로 인해 생성되는 value속성에 값이 잘 들어갈것이다!

 

th:field로 인해 원래있던 name, value, th:value 속성 모두 지워도된다.

value는 html파일을 웹브라우저에서 열 필요가 있다면 냅둔다!

 

결과페이지의 소스보기

id는 빨간줄때문에 냅뒀고, name과 value가 자동으로 처리된것을 확인할 수 있따.


요구사항 추가 (체크박스,라디오버튼,셀렉트박스)

이렇게 체크박스, 라디오버튼, 셀렉트박스를 만들어볼것이다.

 

판매여부, 등록지역, 상품 종류 , 배송방식은 Item 객체에 

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

다음과 같이 추가했고,

 

상품종류는 Enum으로 

public enum ItemType {

    BOOK("도서"),FOOD("음식"),ETC("기타");
    private final String description;
    ItemType(String description) {
        this.description = description;
    }
}

배송방식은 Class로 

/**
 * FAST: 빠른 배송
 * NORMAL: 일반 배송
 * SLOW: 느린 배송
 */
@Data
@AllArgsConstructor
public class DeliveryCode {

    private String code;
    private String displayName;

}

 

 

 

체크박스 - (단일 체크박스)

 

순수 html코드로 만들어보기

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

상품 등록 thml의 <form> 태그 내부에 다음과 같은 체크박스를 추가했다.

 

체크박스의 값이 잘들어오는지 확인하기 위해 상품등록 컨트롤러에 open변수값 확인하는 log 추가

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

 

상품등록 페이지 확인

체크박스가 잘보인다.

 

개발자도구를 키고 상품등록을 누르면

체크박스가 체크된 값은 on이라고 넘어간다. (체크박스의 name속성 값이 open이라 open으로 날아간다.)

 

인텔리제이 log에찍힌값은

다음과 같다!

(form태그안의 input태그의 name의 값을 이용해서 쿼리파라미터를 만들어서 데이터를 보내고 addItem이라는 메소드의 @ModelAttribute가 파라미터값을 객체로 변환시켜준다! 그 객체.open의 값을 확인한것이다)

(체크박스의 name속성 값이 open이라 open으로 날아간다.)

 

체크를 하지않으면?

아예 open이라는 필드가 없다.

(체크박스의 name속성 값이 open이라 open으로 날아간다.)

그래서 log를 보면

null이다! (무언가 값이 와야 변환을 시켜줄텐데 값조차 안왔으니까 null)

 

 

http요청메시지를 서버에서 보고싶다면 (http요청메시지 로깅)

 

application.*에 다음을 추가한다.

logging.level.org.apache.coyote.http11=debug

http요청이 올때마다 엄청나게 로그가 보인다.

상품등록(post)를 한 로그를 찾아보니 제일아래에 쿼리파라미터가 어떻게 들어갔는지 확인할 수 있다!

체크박스를 체크했더니 opne=on인것을 확인가능

 

 

체크하지않았을때 필드가 넘어가지않는것에 대한 문제점과 해결방안

값을 체크 해제해도 아무값도 넘어오지않으니까 서버구현을 어떻게 했는지에 따라서 문제가 있을 수 있다

(null로 오면 체크 해제했나보다라고 처리해주면 되긴함, 하지만 깔끔하지못하다.

true또는 false로 왔으면 좋겠다. -> 스프링MVC가 트릭을 제공)

 

히든필드를 이용해서 해결해보자.

 

히든필드

체크 해제를 인식하기 위한 히든 필드

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

체크박스의 name속성에 값이 open이니까 

type="hidden"을 이용해서 히든필드를 만들고 name속성을 체크박스이름의 _를 붙여준 _open으로 만든다.

 

 

히든필드를 추가하고 체크박스를 체크했을때

서버

http요청의 쿼리파라미터에 open필드 자체가 들어왔고, on이니까 -> true  (스프링은 on을 true로 변환시켜준다)

 

히든필드를 추가하고 체크박스를 체크하지않았을때

서버

http요청의 쿼리파라미터에 open필드가 안들어왔고, _open만 들어왔네?

-> 스프링은 open의 값이 체크안됬음을 인식하고 false를 넣는다.

이렇게 체크박스의 한계를 spring이 제공하는 히든필드를 이용해서 해결가능하다.

하지만 체크박스마다 히든필드를 넣기에는 너무 불편하다.

이런 불편함을 타임리프에서 해결해준다.


체크박스 단일체크 -2

개발할 때 마다 이렇게 히든 필드를 추가하는 것은 상당히 번거롭다.

 

타임리프가 제공하는 폼 기능을 사용하면 이런 부분을 자동으로 처리할 수 있다.

 

체크 박스의 기존 코드를 제거하고 타임리프가 제공하는 체크 박스 코드로 변경하자

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

이렇게 수정! (name속성, 히든필드를 지우고, th:field를 추가)

<input type="checkbox" id="open" th:field="*{open}" class="form-check-input">

이렇게 해도된다! 체크박스가 form태그안에 있고 

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

th:object가 item으로 설정되어있기 떄문!

 

상품등록폼 소스보기해보면

th:field로 인해 name,value 속성들   그리고 히든필드를 알아서 넣어줬다!

th:field는 id,name,value 속성을 자동 처리해주고, 체크박스인 경우에는 히든필드까지 자동 처리해준다.

 

테스트!

체크박스를 체크했을경우

http요청의 쿼리파라미터를 보면 open과 _open이 보인다.   히든필드를 지웠는데 th:field가 자동으로 처리해주었다.

 

 

체크박스를 체크하지않았을경우

http요청의 쿼리파라미터를 보면 히든필드인  _open만 들어왔으므로 Spring은 false로 처리해준다!

 

 

 

상품상세 페이지에도 적용해기

상품상세에도 체크박스를 보여주기위해 수정한다.

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

다음과 같이 체크박스를 추가할것인데 , 체크박스가 form태그안에 들어간것이 아니므로 ${}인 변수표현식을 사용해줘야한다. 

 

추가로 상품상세에서는 체크박스가 체크되면 안되므로 disabled라는 속성을 넣어줘야한다!

이렇게 보인다!

체크가 안된 상품의 상품상세 결과 페이지 소스

th:field 덕분에 name,value 속성이 잘 추가되어있다.

 

 

체크가 된 상품의 상세를 본 결과페이지의 소스

체크가 된 상품을 하나 만들고 상품 상세를 본 화면

checked 속성이 생긴것을 확인할 수있다. (모델로 전달된 item객체의 open변수의 값이 true임을 확인하고 

th:field가 checked = "checked"를 만들어준다.)

 


체크박스 - 멀티체크

 

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

 

이런 멀티체크박스를 만들것이다.

상품등록 폼에서 멀티체크박스를 만드려면 위의 서울,부산,제주처럼

각 체크박스마다 데이터값이 필요하다.

 

 

상품등록폼 컨트롤러

@GetMapping("/add")
public String addForm(Model model) {
    model.addAttribute("item", new Item()); //커맨드 객체를 위한 비어있는 Item 객체를 모델에 넣는다.

    Map<String, String> regions = new LinkedHashMap<>();//그냥 HashMap은 순서가 보장 안된다,그러므로 LinkedHashMap을 이용한다.
    regions.put("SEOUL", "서울");
    regions.put("BUSAN", "부산");
    regions.put("JEJU", "제주");
    model.addAttribute("regions",regions); 
    return "form/addForm";
}

상품등록 뷰 템플릿에서 체크박스를 만들때 필요한 데이터를 LinkedHashMap에 담아서 모델에 담고 넘겨준다.

 

그런데 멀티체크 체크박스가 있는 모든 페이지로 가는 컨트롤러에 해당 코드를 추가해줘야하는 불편함이 존재한다.

 

그래서 Spring은 이런 문제를 해결하기 위해 제공하는것이 있다.

@Slf4j
@Controller
@RequestMapping("/form/items")
@RequiredArgsConstructor
public class FormItemController {

    private final ItemRepository itemRepository;

    @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에 

regions이라는 이름과 Map<String,String>의 데이터가 들어간다.

 

즉 

@ModelAttribute (속성이름)

반환값 -> 속성데이터

로 모든 메소드의 Model에 데이터가 들어간다.

이렇게 하면 반복적으로 Model에 Map을 담는 중복적인 코드를 지워도된다.

 

하지만 모든 메소드의 Model마다 데이터를 넣어주는것이므로

성능최적화를 고민해봐야한다.

 

@ModelAttirbute~ 메소드를 static 영역 어딘가에 만들어놓고 불러쓰거나 해야한다( Model에 들어갈 값이 고정이라면)

하지만 그렇게 성능에 영향이있다고하진않는다!

성능 최적화

예를 들어 어떤 클래스안에

public class momo {

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

}

static 메소드로 만들어두고 

@GetMapping
public String items(Model model) {
    List<Item> items = itemRepository.findAll();
    model.addAttribute("items", items);
    Map<String, String> regions = momo.regions();
    model.addAttribute("regions", regions);
    return "form/items";
}

필요한 컨트롤러의 메소드에서 이런식으로 불러와 쓰라는거 같다!

 

 

뷰 템플릿에  멀티체크박스 생성!

 

멀티체크박스 코드

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

 

결과 페이지의 소스보기

 

<div th:each="region : ${regions}" class="form-check form-check-inline">

멀티체크박스는 단일체크박스를 여러개 만드는것과 다를게없으므로 th:each를 이용한다.

 

regions에서 region을 하나씩 가져와서 

<input type="checkbox" th:field="*{regions}" th:value="${region.key}" class="form-check-input">

th:value에 region.key로 하나씩 넣어준다. (SEOUL,BUSAN,JEJU로 설정한 키값!)

그리고 th:field는 커맨드객체인 Item의 regions이다.

private List<String> regions; //등록지역

즉 th:fieled = "${item.regions} "와 같다는뜻!

 

결국 

th:field="*{regions}"

이거 때문에  id, name , value가 자동으로 생기고

 

만약

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

value가 SEOUL인 체크박스를 체크했고 form 데이터가 전송되었다면

name인 regions를 이용해서  쿼리파라미터는 regions=SEOUL  이 되고 

@ModelAttribute Item item이 들어온 쿼리파라미터를 보고 

private List<String> regions; //등록지역

알아서 regions이라는 파라미터명으로 들어온것들은 리스트에 알아서 넣어줄것이다!

 

멀티체크박스를 모두 체크했을떄 쿼리 파라미터 형식

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

 

 

멀티체크박스의 체크확인

 

th:value로 서울,부산,제주 체크박스를 하나씩 만들면서

th:field에 만약 값이 서울,제주가 있다면

서울,제주 체크박스를 만들때는 checked 속성을 추가해서 선택되어있게 만든다.

 

[th:field에 item.regions을 넣게되면 th:each때문에 타임리프가 th:field의 name속성을

region1 , region2 ... 이런식으로 만들어주게 되고

 

또한 field에 들어간 item.regions에는 선택된 지역 리스트가 들어있기에

th:value로 들어오는 값과 비교해서 체크를 자동 처리해준다.

 

멀티체크박스 라벨

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

다시 돌아가서 라벨부분을 보자.

 

th:text는 region.value이므로 서울,부산,제주 처럼 Map의 벨류이다.

 

th:text로 들어간 서울,제주,부산을 확인할 수 있다!

 

그다음 th:for 부분을 보자.

내가 서울을 눌러도 앞에있는 체크박스의 체크가 될것이다! 

즉 라벨은 앞에 있는 체크박스의 id를 알아야한다  th:for="체크박스의 id" 이런식으로 구성이 되어야함.

 

th:field로인해 자동으로 생성되는 체크박스의 id를 받아오려면

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

타임리프에서 지원하는 #ids.prev()를 사용한다

 

() 괄호안에는 체크박스의 th:field에 설정한 변수명을 가져오면 된다.

 

 

 

테스트해보기

서울,제주만 체크하고 item의 resions에 어떤값이 저장되는지 확인해보자.

부산은 선택이 되지않았으므로 히든필드인 _regions만 날아가는것을 볼수있다.

value인 SEOUL,JEJU가 잘 저장되어있다.

지역을 선택하지않는다면?

빈 리스트 그대로이다.

th:field를 통해 히든필드가 자동으로 생성되므로 _regions가 생긴것이다. 타임리프도 히든필드도 사용하지않으면 _regions가 안날아가고 그렇게되면 로그에 찍힌 결과는 null이 될것이다.

 

상품상세 

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

코드는 동일하다!  

th:field로 item객체의 regions를 가져오고, region데이터의 key값이 regions 안에 들어있는지 체크하고 있으면 checked="checked"를 추가해서 체크된 상태로 표시해준다.

 

(regions -> 리스트 , region -> Map  으로 넣은 데이터들을 하나씩 받아 region에다가 저장했다.)

 

댓글