스프링 공부 (인프런 김영한 선생님)/스프링 MVC 2편

[스프링 웹 MVC 2편] 003. 스프링 통합과 폼

ProgYun. 2023. 8. 11. 16:40

타임리프와 스프링 통합에 관해 공부해보자

관련 통합 덕분에 우리는 다음과 같은 기능을 추가적으로 사용할 수 있다.

  • SpringEL 문법의 통합
  • ${@myBean.doSomething()} 처럼 스프링 빈을 직접 호출할 수 있음
  • 편리한 폼 관리를 위한 추가 속성
    • th:object
    • th:field, th:errors, th:errrorclass
  • 폼 컴포넌트 기능
    • checkbox, radio button, List 등을 편리하게 지원
  • 스프링 메세지, 국제화
    • 메세지를 통해 html 변경 없이 출력하고자 하는 메세지 변경 가능
    • 국제화를 통해 HTTP Header의 Accept-Language에 따라 다른 언어로 html 렌더링 가능
  • 스프링 검증, 오류 처리 통합 (Validated, WebBinder, BindingResult)
  • 스프링의 변환 서비스 통합(Conversion Service?) - 복습 도중인데 이게 왜 기억이 안나죠
입력 폼 처리

기존의 상품관리 예제를 토대로 Thymeleaf의 기능을 사용해 개선해보자

모델로 빈 껍데기 객체를 넘겨주면 다룰 폼이 Item과 관련된 폼임을 알 수 있다

@GetMapping  // 컨트롤러 코드에 해당
public String items(Model model) {  
    List<Item> items = itemRepository.findAll();  
    model.addAttribute("items", items);  
    return "form/items";  
}

타임리프를 통한 HTML 파일 수정은 다음 코드와 같다

<form action="item.html" th:action method="post" th:object="${item}">  
    <div>        <label for="itemName">상품명</label>  
        <input type="text" id="itemName"  
               th:field="*{itemName}" class="form-control" placeholder="이름을 입력하세요">  
     <!-- th:field = id와 name이 자동 생성됨 * th object를 잡았기 때문에 item.itemname으로 인식됨.-->  
     <!-- 그러면 id="itemName" name="itemName" 지워도 됨, id는 일단 남겨둠 (IDE 인식문제) -->  
    </div>
</form>

th:object
Form에서 사용할 객체를 지정하게 되는데 이 때 밑의 input type의 필드로 선택 변수식을 적용할 수 있다.

th:field
앞서 th:object를 이용해서 item 객체를 선택하였기 때문에 선택 변수식 *{itemName}*을 사용할 수 있다
th:fieldid, name, value 속성을 모두 자동으로 만들어준다.
id : th:field에서 지정한 변수의 이름과 같다.
name : th:field에서 지정한 변수의 이름과 같다.
value : th:field에서 지정한 변수의 값을 사용한다.

  • 개발자 입장에서 매우 편리!

Note That) 선택 변수 식 *{itemName} -> ${item.itemName} 해도 됨.

결국 이 item을 뷰를 렌더링 할때 쓰는데 addForm에서 modelAttribute로 넘어감
맞춰 놓으면 이름 실수로 틀렸을 때 오류가 난다 (값이 서버로 안넘어오는것보다 오류가 낫다)

th:field는 id, name, value 속성을 모두 자동으로 만들어준다.

수정 폼 처리
<form action="item.html" th:action th:object="${item}" 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> -->  
     <input type="text" id="id" class="form-control" value="1" th:field="*{id}" readonly>  
    </div>    <div>        <label for="itemName">상품명</label>  
        <input type="text" id="itemName" class="form-control" value="상품A" th:field="*{itemName}">  
    </div>    <div>        <label for="price">가격</label>  
        <input type="text" id="price" class="form-control" value="10000" th:field="*{price}">  
    </div>    <div>        <label for="quantity">수량</label>  
        <input type="text" id="quantity" class="form-control" value="10" th:field="*{quantity}">  
    </div>  
    <hr class="my-4">  
  <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>  <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">  
   <label th:for="${#ids.prev('regions')}"  
          th:text="${region.value}" class="form-check-label">서울</label>  
  </div> </div>  <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> <!-- SELECT -->  
 <div>  
  <div>배송 방식</div>  
  <select th:field="${item.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">  

    <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='item.html'"  
                    th:onclick="|location.href='@{/form/items/{itemId}(itemId=${item.id})}'|"  
                    type="button">취소</button>  
        </div>    </div>  
</form>

수정 폼의 경우 id, name, value를 모두 신경 써주어야 했다. (수동 작성)
하지만 이제는 타임리프에 의해 modelAttribute에서 넘어온 객체 값으로 렌더링된다.


타임리프를 이용한 체크박스, 라디오버튼, 셀렉트박스를 활용하는 방법
<div>판매 여부</div>  
<div>  
 <div class="form-check">  
  <input type="checkbox" id="open" name="open" class="form-check-input">  
  <input type="hidden" id="_open" value="on"/>
  <label for="open" class="form-check-label">판매 오픈</label>  
 </div></div>

이 때 체크박스를 선택하면 HTML Form에서 POST 전송할 때 open=on이라는 값이 넘어간다.
스프링은 on이라는 문자를 true타입으로 변환해준다.

-> 개발할때는 주로 true, false식의 boolean 형태를 이용하는데, HTML과는 맞지 않는 부분을 맞춰준다.

그런데 HTML에서 체크박스를 선택하지 않고 폼을 전송하면 open 이라는 필드 자체가 서버로 전송이 안됨

-> 문제가 생기는 이유
사실 제품을 추가한다고 가정하면 field 전송이 없으면 그냥 체크가 안된것으로 간주하면 되는데
문제는 체크가 되어있던 상품을 체크해제하고 그 상태를 저장한다고 했을때 체크가 안되어있다면
null로는 그 상품의 체크박스를 해제한 것인지 알기가 애매함
(서버 구현에 따라 값 변경이 일어나지 않을 수 있음.)

-> 스프링의 해결책 (트릭)
히든필드를 하나 만들어서 _open 처럼 기존 체크박스 앞에 언더스코어를 붙여 전송하면 체크를 해제했다고 간주한다. 따라서 체크를 해제한 경우 open은 전송되지 않고 _open만 전송된다.

정리.
open, _open 모두 전송되는 경우 - 체크한 것으로 인식
_open 만 전송되는 경우 - 체크 해제된 것으로 인식


체크박스 - 단일 2

개발할때마다 히든 필드를 추가하기 번거롭지만 타임리프가 제공하는 폼 기능을 활용하면 자동으로 처리됨.

<div>  
    <label for="itemName">상품명</label>  
    <input type="text" id="itemName"  
           th:field="*{itemName}" class="form-control" placeholder="이름을 입력하세요">  
 <!-- th:field = id와 name이 자동 생성됨 * th object를 잡았기 때문에 item.itemname으로 인식됨.-->  
     <!-- 그러면 id="itemName" name="itemName" 지워도 됨, id는 일단 남겨둠 (IDE 인식문제) -->  <label for="open" class="form-check-label"> 판매 오픈 </label>
</div>

해당 코드의 렌더링 결과는 아래와 같다.

<!-- single checkbox -->
<div>판매 여부</div>
<div>
<div class="form-check">
<input type="checkbox" id="open" class="form-check-input" name="open"
value="true">
<input type="hidden" name="_open" value="on"/>
<label for="open" class="form-check-label">판매 오픈</label>
</div>
</div>

체크박스 - 멀티
@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를 이용해 메서드를 추가했다.
이런식의 코드 구사는 해당 Map객체가 모든 컨트롤러 호출시 필요한 경우에 사용한다.

여기서 해당 Map객체는 지역을 나타내는데, 지역선택은 제품 추가/수정에서 모두 필요하다.

이렇게하면 해당 컨트롤러를 요청할때 regions에서 반환한 값이 자동으로 model에 담기게 된다.

<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">  
  <label th:for="${#ids.prev('regions')}"  
         th:text="${region.value}" class="form-check-label">서울</label>  
 </div> <!-- th:for="${#ids.prev('regions')}" 멀티 체크박스는 같은 이름의 여러 체크박스를 만들 수 있다.  
 그런데 문제는 이렇게 반복해서 HTML 태그를 생성할 때, 생성된 HTML 태그 속성에서 name 은 같아도 되지만, id 는 모두 달라야 한다. 따라서  
 타임리프는 체크박스를 each 루프 안에서 반복해서 만들 때 임의로 1 , 2 , 3 숫자를 뒤에 붙여준다. -->  
</div>

멀티 체크박스의 html thymeleaf 기술방법은 위와 같다.
멀티 체크박스는 같은 이름의 여러 체크박스를 만들 수 있는데
HTML 태그를 반복해서 생성할 때 name은 태그에서 같아도 되는데 id까지 같아버리면 안된다
따라서 타임리프는 체크박스를 each 루프 안에서 반복해서 만들 때 임의로 숫자를 뒤에 붙여준다.

HTML의 id가 타임리프에 의해 동적으로 생성되는 경우,
label의 대상이 되는 id 값을 임의로 지정하면 안된다.
타임리프는 ids.prev(), ids.next()의 제공을 통해 동적으로 생성되는 id값을 사용할 수 있도록 한다.


라디오 버튼

라디오 버튼은 여러 선택지 중에 하나를 선택할 때 사용할 수 있다.

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

ModelAttribute 또한 모든 컨트롤러에서 이용될 것이기 때문에 따로 추가했다.
ItemType.values()를 사용하면 모든 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>

실행 로그

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

체크박스의 경우 체크해제시 아무 값이 안넘어가서 히든필드가 필요했는데
라디오버튼은 이미 선택이 되어있다면 수정시에도 반드시 하나를 선택하도록 되어있어서
체크 박스와 달리 별도의 히든 필드를 사용해야할 이유가 없다.

<div th:each="type : ${T(hello.itemservice.domain.item.ItemType).values}"

스프링 EL문법으로 ENUM을 직접 사용할 수도 있다. ENUM에 values()를 호출하면 해당 enum의 모든 정보가 배열로 반환된다.

그런데 이렇게 사용하는 경우 패키지 변경에서 자바 컴파일러가 thymeleaf의 변경까지 오류로 못잡아내기 때문에 권장하지 않는다.


셀렉트 박스
@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;  
}
<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>

다음과 같이 사용한다.