2025. 12. 21. 16:16ㆍSpring RoadMap/Spring MVC
백엔드 웹 개발 핵심 기술, 스프링 MVC - 타임리프(Thymeleaf)와 폼 처리
이전 1편 강의를 통해 스프링 MVC의 구조를 파악했다면, 2편에서는 이를 활용한 웹 기술들을 다룬다. 나는 현재 프론트엔드 프레임워크(Vue.js, React)를 주력으로 공부하고 있지만, 스프링이 제공하는 서버 사이드 렌더링(SSR) 방식과 데이터 바인딩 원리를 이해하는 것은 백엔드 로직을 탄탄하게 만드는 데 필수적이다.
이번 글에서는 타임리프의 기본 문법부터, 실무에서 자주 마주치는 폼(Form) 데이터 처리, 체크박스, 라디오 버튼 등의 처리 방식을 정리한다.
또한 국제화에 대해서 간단히 알아본다. 보통 실무에서는 Thymeleaf로 SSR을 작성하는게 많진 않기 때문에 간단히 살펴보고 건너가기 위해 3가지 학습 섹터를 한 글에 정리했다.

1. 타임리프(Thymeleaf) 소개와 기본 기능
타임리프는 스프링 진영에서 밀고 있는 서버 사이드 템플릿 엔진이다. JSP와 달리 HTML의 모양을 유지하면서 속성(Attribute)을 추가해 동적으로 동작하게 만드는 '내추럴 템플릿(Natural Templates)' 특징을 가진다.
1-1. 텍스트 출력 (th:text, th:utext)
가장 기본적인 기능이다. 서버에서 model에 담아준 데이터를 화면에 출력한다.
<span th:text="${data}">기존 데이터</span>
- HTML 엔티티: 기본적으로 < 같은 특수 문자는 <로 이스케이프(Escape) 처리된다.
- Unescaped Text: 만약 HTML 태그를 그대로 적용해서 출력하고 싶다면 th:utext를 사용해야 한다. (하지만 보안상 권장하지 않는다.)
1-2. 변수 표현식 (SpringEL)
타임리프는 스프링 EL(Spring Expression Language)을 사용하여 객체에 접근한다.
<ul>
<li th:text="${user.username}">유저명</li>
<li th:text="${user.age}">나이</li>
<li th:text="${users[0].username}">첫 번째 유저</li>
<li th:text="${userMap['userA'].username}">Map 유저</li>
</ul>
자바 빈 프로퍼티 접근법(getXxx)을 사용하므로 user.username은 실제로는 user.getUsername()이 호출된다.
1-3. URL 링크 (@{...})
<a th:href="@{/hello(param1=${data1}, param2=${data2})}">링크</a>
<a th:href="@{/hello/{p1}/{p2}(p1=${data1}, p2=${data2})}">링크</a>
URL을 생성할 때 복잡한 더하기 연산 없이, 괄호 안에 파라미터를 정의하면 자동으로 쿼리 파라미터나 경로 변수(Path Variable)로 매핑해준다.
1-4. 반복과 조건
<tr th:each="item : ${items}">
<td th:text="${item.name}">상품명</td>
</tr>
<span th:if="${item.price > 10000}">비싸다</span>
<span th:unless="${item.price > 10000}">싸다</span>
th:if는 조건이 false면 태그 자체를 렌더링하지 않는다(DOM에서 사라짐).
2. 타임리프와 스프링 통합 (폼 처리)
여기서부터가 진짜 핵심이다. 단순히 값을 뿌리는 것을 넘어, 사용자의 입력을 스프링 커맨드 객체와 연결하는 과정이다.
2-1. th:object와 th:field
스프링 컨트롤러에서 폼을 보여줄 때, 빈 객체라도 모델에 담아서 넘겨줘야 한다.
@GetMapping("/add")
public String addForm(Model model) {
model.addAttribute("item", new Item()); // 빈 객체 전달
return "form/addForm";
}
HTML에서는 th:object로 해당 객체를 잡고, th:field로 필드를 연결한다.
<form action="item.html" th:object="${item}" method="post">
<div>
<label for="itemName">상품명</label>
<input type="text" th:field="*{itemName}" class="form-control">
</div>
</form>
th:field="*{itemName}"의 마법 타임리프는 이걸 렌더링할 때 다음 3가지 속성을 자동으로 만들어준다.
- id="itemName": <label>과 연결하기 위해 생성.
- name="itemName": 서버 전송 시 HTTP 요청 파라미터 이름.
- value="...": 모델에 있는 값을 꺼내서 미리 넣어줌. (수정 폼에서 매우 강력하다!)
3. 체크박스(Checkbox) 처리의 비밀
체크박스는 웹 개발에서 고질적인 문제를 하나 안고 있다. 체크를 해제하고 전송하면, 필드 자체가 서버로 전송되지 않는다는 점이다.
- 체크 함: open=on 전송 -> 스프링이 true로 변환.
- 체크 안 함: 아예 전송 안 됨 -> 스프링 입장에서는 null이 들어옴.
우리는 체크를 풀었을 때 null이 아니라 false가 들어오길 원한다.
3-1. 히든 필드 트릭 (_open)
스프링 MVC는 약간의 트릭을 사용한다. 기존 체크박스 앞에 _가 붙은 히든 필드를 하나 더 만드는 것이다.
<input type="checkbox" id="open" name="open" class="form-check-input">
<input type="hidden" name="_open" value="on"> ```
이렇게 하면 체크를 해제해도 `_open=on`은 전송되므로, 스프링이 "아, `open`은 없고 `_open`만 있네? 이건 체크 해제(`false`)구나"라고 판단한다.
#### 3-2. 타임리프의 자동 처리
타임리프의 `th:field`를 사용하면 이 히든 필드마저 자동으로 생성해준다.
```html
<input type="checkbox" id="open" th:field="*{open}" class="form-check-input">
4. 멀티 체크박스와 라디오 버튼
여러 개의 옵션 중 선택하는 기능은 어떻게 구현할까? 데이터가 동적으로 변할 수 있으므로, 컨트롤러에서 데이터를 담아 뷰로 넘겨줘야 한다.
4-1. @ModelAttribute의 특별한 사용법
컨트롤러의 어떤 메서드에서든 사용 가능한 참조 데이터(지역 목록, 상품 타입 등)를 만들고 싶다면, 메서드 레벨에 @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;
}
이렇게 하면 해당 컨트롤러가 호출될 때마다 regions라는 이름으로 모델에 데이터가 자동으로 담긴다.
4-2. 타임리프 반복문으로 구현
<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:for="${#ids.prev('regions')}": 반복문 안에서 체크박스를 만들면 id가 regions1, regions2 처럼 동적으로 생성된다. 타임리프는 바로 직전에 생성된 id를 가져오는 기능을 제공하여 <label>과 정확히 연결해준다.
- 체크 상태 유지: th:field는 모델(item.regions)에 들어있는 값과 현재 체크박스의 th:value를 비교해서, 값이 있으면 자동으로 checked="checked" 속성을 추가해준다. (이게 진짜 편리하다!)
4-3. 라디오 버튼 (Radio Button)
라디오 버튼은 여러 개 중 하나만 선택할 수 있다. Enum을 활용하면 더 깔끔하게 처리할 수 있다.
@ModelAttribute("itemTypes")
public ItemType[] itemTypes() {
return ItemType.values(); // ENUM의 모든 값을 배열로 반환
}
<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">도서</label>
</div>
라디오 버튼도 체크박스와 마찬가지로 th:field가 값 비교 및 선택 상태(checked)를 자동으로 처리해준다. 참고로 라디오 버튼은 하나도 선택하지 않으면 null이 전송되므로 히든 필드가 굳이 필요 없다.
5. 메시지, 국제화
웹 애플리케이션을 개발하다 보면 수많은 텍스트(라벨, 버튼 이름, 에러 문구 등)가 들어간다. 만약 이 텍스트들을 HTML이나 자바 코드에 직접 하드코딩해 두었다면? "상품명"을 "상품 이름"으로 바꿔달라는 요청이 왔을 때 수십 개의 파일을 뒤져야 할 것이다.
게다가 한국어 사용자에게는 "안녕", 영어 사용자에게는 "Hello"를 보여줘야 한다면? if (lang == "ko") 같은 코드로 도배를 할 수는 없는 노릇이다.
스프링은 이를 해결하기 위해 메시지(Message) 와 국제화(Internationalization, i18n) 기능을 제공한다.
5 - 1. 스프링 부트 메시지 소스 설정
과거에는 MessageSource 빈을 직접 등록해야 했지만, 스프링 부트는 설정 파일(application.properties)만 있으면 자동으로 등록해준다.
application.properties
spring.messages.basename=messages,errors
- basename: 읽어들일 메시지 파일의 기본 이름을 설정한다.
- messages라고 적으면 messages.properties, messages_en.properties, messages_ko.properties 등을 자동으로 인식한다.
- 콤마(,)로 구분하여 여러 파일을 등록할 수 있다. (기본값은 messages)
5 - 2. 메시지 파일 관리
설정된 이름을 기반으로 /resources 경로에 프로퍼티 파일을 만든다.
resources/messages.properties (기본 - 한국어)
hello=안녕
hello.name=안녕 {0}
label.item=상품
label.item.id=상품 ID
label.item.itemName=상품명
label.item.price=가격
label.item.quantity=수량
resources/messages_en.properties (영어)
hello=Hello
hello.name=Hello {0}
label.item=Item
label.item.id=Item ID
label.item.itemName=Item Name
label.item.price=Price
label.item.quantity=Quantity
- 파라미터 바인딩: {0}, {1} 등을 사용하여 텍스트 중간에 동적으로 값을 넣을 수 있다.
5 - 3. 스프링의 언어 감지 원리 (LocaleResolver)
그렇다면 스프링은 사용자가 한국어 사용자인지 영어 사용자인지 어떻게 알까?
5-3-1. Accept-Language 헤더
웹 브라우저는 서버로 요청을 보낼 때, Accept-Language라는 HTTP 헤더에 자신이 선호하는 언어 정보를 담아 보낸다.
- 예: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
스프링 MVC는 LocaleResolver 라는 인터페이스를 통해 이 헤더 값을 읽어서 언어(Locale)를 결정한다. 기본 구현체는 AcceptHeaderLocaleResolver이다.
5-3-2. 동작 순서
- 클라이언트 요청 (GET /hello, 헤더 Accept-Language: en)
- 스프링(LocaleResolver)이 헤더를 보고 Locale.ENGLISH로 판단.
- MessageSource에게 hello라는 키의 텍스트를 달라고 요청.
- messages_en.properties 파일에서 "Hello"를 찾아서 반환.
- 만약 해당 언어 파일이 없으면 기본 파일(messages.properties)을 사용.
5 - 4. 사용 방법 (타임리프 & 자바)
5-4-1. 타임리프 (뷰 템플릿)
타임리프는 메시지 표현식 #{...}을 제공한다.
<h2 th:text="#{label.item}">상품</h2>
<p th:text="#{hello.name(${item.itemName})}">안녕</p>
5-4-2. 자바 코드 (API 개발 시 중요!)
API 개발자라면 MessageSource를 직접 주입받아 사용할 일이 있다. 예외가 터졌을 때 클라이언트 언어 설정에 맞는 에러 메시지를 JSON으로 내려줘야 하기 때문이다.
@RestController
@RequiredArgsConstructor
public class MessageController {
private final MessageSource messageSource;
@GetMapping("/api/hello")
public String hello(Locale locale) {
// Locale 정보는 컨트롤러 파라미터로 자동 주입받을 수 있다.
return messageSource.getMessage("hello", null, locale);
}
}
6. 정리
이번 섹션에서는 타임리프의 기본 문법과 스프링이 폼 데이터를 처리하는 방식에 대해 알아보았다.
- 타임리프 기본: ${...}로 변수를 출력하고, @{...}로 링크를 걸며, th:each, th:if로 제어 로직을 작성한다.
- 폼 통합: th:object와 **th:field**를 사용하면 id, name, value 속성을 자동으로 처리해주며, 특히 수정 폼에서 기존 값을 채워넣을 때 매우 강력하다.
- 체크박스 트릭: 체크 해제 시 false를 전송하기 위해 스프링은 _open이라는 히든 필드를 사용하며, 타임리프는 이를 자동 생성해준다.
- 동적 폼 생성: @ModelAttribute 메서드를 활용해 컨트롤러에서 공통 데이터를 넘기고, 타임리프 반복문을 통해 체크박스나 라디오 버튼 목록을 동적으로 생성할 수 있다.
- 메시지: 텍스트를 코드에서 분리하여 properties 파일에 모아두고 관리하는 것.
- 국제화: 파일명 뒤에 언어 코드(_en, _ko)를 붙여두면, 요청한 사용자의 언어에 맞춰 자동으로 다른 텍스트를 보여주는 것.
- LocaleResolver: HTTP 요청의 Accept-Language 헤더를 분석하여 사용자의 언어를 판단하는 스프링의 핵심 인터페이스.
비록 프론트엔드 프레임워크를 사용하더라도, 이러한 데이터 바인딩(Binding) 과 HTTP 요청 파라미터 처리 원리는 백엔드 개발자에게 변하지 않는 핵심 지식이다. 다음 섹션에서는 이렇게 넘어온 데이터가 올바른지 검사하는 검증(Validation) 로직에 대해 본격적으로 학습한다.
'Spring RoadMap > Spring MVC' 카테고리의 다른 글
| 스프링 MVC 2편 (5), 검증 2 - Bean Validation (0) | 2025.12.21 |
|---|---|
| 스프링 MVC 2편 (4), 검증 1 - Validation (0) | 2025.12.21 |
| 스프링 MVC 1편 (7 - 마지막), 웹 페이지 만들기 (0) | 2025.12.21 |
| 스프링 MVC 1편 (6), 스프링 MVC 기본 기능 (1) | 2025.12.21 |
| 스프링 MVC 1편 (5), 스프링 MVC 구조 이해 (0) | 2025.12.20 |