본문 바로가기
Spring MVC

[MVC] 타임리프 - 스프링 통합과 폼(2)

by 개발 Blog 2025. 6. 24.

공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.

 

체크 박스 - 단일1

HTML에서 체크박스를 사용할 때, 체크 여부에 따라 서버로 전달되는 값의 유무와 스프링 MVC의 처리 방식을 이해해야 한다.

 

단순 HTML 체크박스 예제

resources/templates/form/addForm.html

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

 

서버 로그 확인

FormItemController

@PostMapping("/add")
public String addItem(Item item, RedirectAttributes redirectAttributes) {
    log.info("item.open={}", item.getOpen());
    ...
}

클래스에 @Slf4j 어노테이션을 붙여야 로그 출력 가능하다.

 

실행 로그 예시

item.open=true  // 체크박스 선택 시
item.open=null  // 체크박스 미선택 시

체크박스를 선택하면 open=on 형태로 값이 넘어간다. 스프링은 내부 타입 컨버터를 통해 문자열 "on"을 true로 자동 변환한다.

 

체크박스 미선택 시의 문제

HTML 폼의 특성상 체크박스를 선택하지 않으면 아예 해당 필드가 서버로 전송되지 않는다.

예시 HTTP 요청 바디:

itemName=itemA&price=10000&quantity=10

→ open 필드 자체가 존재하지 않는다 → 서버에서는 null 처리됨

이는 특히 수정 화면에서 문제가 될 수 있다. 기존에 체크되어 있던 값을 사용자가 해제했을 경우, 값이 전송되지 않기 때문에 서버는 이를 감지하지 못하고 기존 값을 유지할 수 있다.

 

해결 방법 - 히든 필드 추가

스프링 MVC는 이 문제를 해결하기 위해 히든 필드 트릭을 사용한다.

<input type="hidden" name="_open" value="on"/>
  • name="_open": 원래 필드 이름 앞에 언더스코어(_)를 붙인다
  • 항상 전송되므로 체크 해제 여부를 판단하는 기준이 된다

체크박스 + 히든 필드 통합 코드

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

 

처리 결과 요약

상황 전송 데이터 서버 처리 결과
체크 O open=on&_open=on open=true
체크 X _open=on open=false

Spring Boot 3.2 이상 - HTTP 메시지 로깅 설정

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

debug 대신 trace 사용해야 실제 HTTP 메시지 바디를 확인할 수 있다.

 

정리

  • 체크박스를 선택하지 않으면 값 자체가 전송되지 않음
  • 이를 해결하기 위해 _필드명 형식의 히든 필드를 함께 전송
  • 스프링 MVC는 이 히든 필드를 기반으로 체크 해제를 false로 판단

체크 박스 - 단일2

개발할 때 마다 hidden 필드를 수동으로 추가하는 것은 상당히 번거롭다.
이번에는 타임리프의 폼 기능을 사용해 이 과정을 자동화하는 방법을 정리한다.

 

타임리프 체크박스 코드

<!-- 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>
  • th:field="*{open}" : th:object에 지정된 객체에서 open 필드를 자동으로 바인딩한다.

렌더링 결과 (HTML)

<input type="checkbox" id="open" class="form-check-input" name="open" value="true">
<input type="hidden" name="_open" value="on"/>
  • 타임리프는 hidden 필드도 자동으로 생성해주기 때문에 체크 해제 시에도 값을 서버로 전송할 수 있다.
  • 더 이상 수동으로 히든 필드를 작성할 필요가 없다.

실행 로그 확인

item.open=true  // 체크 시
item.open=false // 미체크 시

 

상품 상세 화면에 적용

item.html

<!-- single checkbox -->
<div>판매 여부</div>
<div>
    <div class="form-check">
        <input type="checkbox" id="open" th:field="${item.open}" class="form-check-input" disabled>
        <label for="open" class="form-check-label">판매 오픈</label>
    </div>
</div>
  • th:object를 사용하지 않으므로 th:field="${item.open}"으로 작성
  • disabled 속성으로 상세화면에서는 선택 불가하게 설정

렌더링 결과 예시

<input type="checkbox" id="open" class="form-check-input" name="open" value="true" checked="checked" disabled>
  • item.open == true인 경우 자동으로 checked="checked" 속성이 생성된다.

상품 수정 화면에도 적용

editForm.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>
  • 수정 폼에서도 th:object, th:field를 모두 사용해 자동 바인딩 처리

수정 데이터 반영 안 되는 경우

체크박스를 수정해도 실제 데이터에 반영되지 않는다면, ItemRepository의 update() 로직을 확인해야 한다.

 

ItemRepository - update 메서드 수정

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());
}
  • open 필드를 포함한 모든 속성을 반영해 줘야 수정된 값이 저장된다.

정리

  • th:field="*{open}"을 사용하면 체크박스 관련 속성(name, value, checked, hidden)을 타임리프가 자동 처리해 준다.
  • HTML에서 발생할 수 있는 누락 문제를 자동으로 해결할 수 있어 유지보수가 쉬워진다.
  • 수정/상세 페이지에서도 th:field를 활용하면 값 반영과 체크 여부 표현을 자동으로 처리할 수 있다.

체크 박스 - 멀티

이번에는 체크박스를 여러 개 선택 가능한 형태로 구현해 보자. 예시로 상품 등록 시 등록 지역(서울, 부산, 제주)을 다중 선택할 수 있도록 구성한다.

 

1. 컨트롤러에서 등록 지역 데이터 제공

@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;
}
  • 키는 서버에 저장될 값 (SEOUL)
  • 값은 사용자에게 보여지는 라벨 (서울)

2. 다중 체크박스 - 등록 폼

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:field="*{regions}" : Item 객체의 List<String> regions와 바인딩
  • th:each="region : ${regions}" : 반복해서 체크박스 생성
  • th:for="${#ids.prev('regions')}" : id가 자동 생성되므로 라벨도 동적으로 매칭시킴

3. 타임리프 렌더링 결과 예시

<input type="checkbox" id="regions1" name="regions" value="SEOUL">
<input type="checkbox" id="regions2" name="regions" value="BUSAN">
<input type="checkbox" id="regions3" name="regions" value="JEJU">
  • 각 체크박스의 name="regions"은 동일하지만 id는 다르게 생성됨
  • 히든 필드 _regions는 자동 생성되어 체크 안 했을 때를 대비

4. 서버 로그로 선택값 확인

FormItemController에 로그 추가

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

 

결과 예시

서울, 부산 선택 시

item.regions=[SEOUL, BUSAN]

 

선택 없음

item.regions=[]

※ 타임리프가 생성한 _regions 히든 필드 덕분에 선택하지 않은 경우에도 null이 아닌 빈 리스트로 처리됨

 

5. 상세화면에 적용

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>
  • disabled를 설정하여 읽기 전용 처리
  • th:field="${item.regions}"을 사용한 이유는 th:object를 사용하지 않았기 때문

렌더링 결과

선택된 지역에는 checked="checked"가 자동 추가됨

 

6. 수정 폼에도 적용

editForm.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:field는 리스트 타입 바인딩도 자동으로 처리해 준다.
  • List<String> 타입 필드와 같은 이름을 가진 체크박스를 반복 생성하면 자동으로 바인딩된다.
  • 체크된 값은 checked, 체크 해제 시엔 hidden 필드로 빈 리스트 처리
  • #ids.prev(...)를 통해 id를 라벨과 안전하게 연결할 수 있다.
  • 상세 페이지에서는 disabled, th:field="${item.필드}" 조합 사용

라디오 버튼

라디오 버튼은 여러 항목 중에서 하나만 선택할 수 있는 입력 방식이다.
타임리프에서는 자바의 enum 타입과 결합하여 간단하게 라디오 버튼을 구성할 수 있다.

 

1. 상품 종류 ENUM

public enum ItemType {
    BOOK("도서"),
    FOOD("식품"),
    ETC("기타");

    private final String description;

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

    public String getDescription() {
        return description;
    }
}

 

2. 컨트롤러 - ENUM 값을 모델에 주입

@ModelAttribute("itemTypes")
public ItemType[] itemTypes() {
    return ItemType.values();
}
  • ItemType.values()로 enum 전체 항목을 배열로 반환
  • 등록, 조회, 수정 폼에서 모두 사용할 수 있도록 @ModelAttribute 활용

3. 등록 폼 - 라디오 버튼 추가

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:field="*{itemType}" : Item.itemType 필드와 바인딩
  • th:value="${type.name()}" : enum 값 그대로 전송 (BOOK, FOOD, ETC)
  • th:text="${type.description}" : 사용자에게 보여줄 라벨값
  • #ids.prev('itemType') : 자동 생성된 라디오 버튼의 id 값과 label 연결

4. 실행 결과 및 로그 확인

log.info("item.itemType={}", item.getItemType());
  • 선택한 경우: item.itemType=FOOD
  • 선택 안 한 경우: item.itemType=null
 

5. 상품 상세화면에 적용

item.html

<!-- 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>
  • th:object를 사용하지 않으므로 th:field="${item.itemType}"로 바인딩
  • disabled 속성으로 읽기 전용 처리

6. 수정 폼에 적용

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

 

7. 렌더링 된 HTML 예시

<input type="radio" id="itemType1" name="itemType" value="BOOK">
<input type="radio" id="itemType2" name="itemType" value="FOOD" checked="checked">
<input type="radio" id="itemType3" name="itemType" value="ETC">
  • 선택한 값(FOOD)은 checked="checked" 속성이 자동 추가된다.

8. 타임리프에서 ENUM 직접 접근 (비추천 방식)

<div th:each="type : ${T(hello.itemservice.domain.item.ItemType).values()}">
  • T(패키지.클래스명).values()를 통해 enum 값을 직접 접근할 수 있다.
  • 단점: 패키지 위치가 바뀌면 컴파일 에러로 잡히지 않음

따라서 일반적으로는 @ModelAttribute를 통해 enum 값을 모델에 담아 사용하는 방식을 추천한다.

 

정리

  • 라디오 버튼은 enum 타입과의 조합이 간단하고 직관적이다.
  • 타임리프의 th:field, th:value, th:text, #ids.prev() 등을 활용하면 반복 구조와 바인딩을 자동화할 수 있다.
  • 체크박스와 달리 선택하지 않으면 값이 넘어가지 않지만, 하나는 항상 선택되어 있기 때문에 hidden 필드가 필요 없다.
  • 상세 화면에서는 disabled 속성을 통해 읽기 전용으로 구성할 수 있다.

셀렉트 박스

이번에는 타임리프의 select 태그 바인딩 기능을 이용하여 배송 방식을 선택할 수 있는 셀렉트 박스를 구성한다.
자바 객체(DeliveryCode)를 활용하여 사용자에게는 보기 좋은 이름을 보여주고, 시스템에는 코드 값을 전달한다.

 

1. 배송 방식 도메인 클래스

@Data
@AllArgsConstructor
public class DeliveryCode {
    private String code;         // FAST, NORMAL, SLOW
    private String displayName;  // 빠른 배송, 일반 배송, 느린 배송
}

 

2. 컨트롤러 - 배송 방식 목록 제공

@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;
}
  • @ModelAttribute를 사용하면 모든 폼에서 deliveryCodes를 사용할 수 있다.
  • 참고: 매 요청마다 새로 생성되므로 성능상 필요시 캐싱하거나 별도 서비스로 분리하는 것이 좋다.

3. 등록 폼 - 셀렉트 박스 추가

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>
  • th:field="*{deliveryCode}": Item.deliveryCode 필드와 바인딩
  • th:value, th:text: 사용자에게 보여지는 값과 실제 전송 값 분리

4. HTML 렌더링 결과 예시

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

 

5. 상세 페이지 - 읽기 전용 셀렉트 박스

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>
  • th:object를 사용하지 않기 때문에 th:field="${item.deliveryCode}"로 작성
  • disabled로 셀렉트 박스를 읽기 전용 처리

6. 수정 폼 - 바인딩 적용

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

 

7. 선택 상태 유지 확인

렌더링 결과:

<option value="FAST" selected="selected">빠른 배송</option>
  • Item.deliveryCode == "FAST"일 경우 해당 옵션에 selected="selected" 속성이 자동으로 추가됨
  • 타임리프가 th:field 값과 th:value를 비교해 자동 처리

정리

  • 타임리프의 select 바인딩 기능은 자바 객체와 HTML 값을 효과적으로 연결할 수 있다.
  • DeliveryCode 객체는 사용자에게 보여지는 값과 시스템 내부 코드 값을 분리할 수 있는 구조로 설계되었다.
  • th:field, th:value, th:text 조합으로 코드와 표시 이름을 명확히 구분할 수 있다.
  • 선택 상태 유지, 읽기 전용 처리 등도 타임리프가 자동으로 처리해 준다.