Thymeleaf는 스프링이 권장하는 서버 사이드 템플릿 엔진이다. 컨트롤러가 Model에 담아 보낸 데이터를 HTML에 끼워 넣어 완성된 화면을 만든다. 이 장에서는 텍스트 출력부터 반복·조건, 템플릿 조각·레이아웃까지 실습 코드와 함께 다룬다.
1. 타임리프 소개
특징
서버 사이드 HTML 렌더링(SSR): 서버에서 HTML을 동적으로 렌더링한다.
네추럴 템플릿(Natural Template): 순수 HTML을 그대로 유지하면서 뷰 템플릿으로 사용할 수 있다. 파일을 그냥 브라우저로 열어도 깨지지 않는다.
스프링 통합 지원: 스프링과 자연스럽게 통합된다.
타임리프 사용 선언
<html xmlns:th="http://www.thymeleaf.org">2. 텍스트 출력
th:text — 태그 속성으로 출력
@GetMapping("/text-basic")
public String textBasic(Model model) {
model.addAttribute("data", "Hello Spring");
return "basic/text-basic";
}<ul>
<li>th:text 사용 <span th:text="${data}"></span></li>
<li>컨텐츠 안에서 직접 출력하기 = [[${data}]]</li>
</ul>th:text: HTML 태그의 속성으로 사용해 데이터를 출력[[...]]: 콘텐츠 영역 안에 직접 출력
utext — Unescape 출력
th:text는 HTML 특수문자를 이스케이프한다(<b> → <b>). 태그를 그대로 렌더링하려면 th:utext를 쓴다.
model.addAttribute("data", "Hello <b>Spring</b>");<li>th:text = <span th:text="${data}"></span></li> <!-- Hello <b>Spring</b> (글자 그대로) -->
<li>th:utext = <span th:utext="${data}"></span></li> <!-- Hello Spring (b 태그 적용) -->th:utext또는[(...)]: unescape 출력th:inline="none": 타임리프가 해석하지 않도록 함
3. 변수 표현식 ${...}
@GetMapping("/variable")
public String variable(Model model) {
User userA = new User("userA", 10);
User userB = new User("userB", 20);
List<User> list = List.of(userA, userB);
Map<String, User> map = Map.of("userA", userA, "userB", userB);
model.addAttribute("user", userA);
model.addAttribute("users", list);
model.addAttribute("userMap", map);
return "basic/variable";
}대상 | 접근 방법 |
|---|---|
Object |
|
List |
|
Map |
|
<!-- 지역 변수 th:with -->
<div th:with="first=${users[0]}">
<p>처음 사람의 이름은 <span th:text="${first.username}"></span></p>
</div>4. 기본 객체와 편의 객체
기본 객체 | 설명 |
|---|---|
| HttpServletRequest |
| HttpServletResponse |
| HttpSession |
| ServletContext |
| Locale |
편의 객체:
HTTP 요청 파라미터:
${param.paramData}HTTP 세션:
${session.sessionData}스프링 빈:
${@helloBean.hello('Spring!')}
<li>Request Parameter = <span th:text="${param.paramData}"></span></li>
<li>session = <span th:text="${session.sessionData}"></span></li>
<li>spring bean = <span th:text="${@helloBean.hello('Spring!')}"></span></li>5. 유틸리티 객체와 날짜
객체 | 기능 |
|---|---|
| 메시지, 국제화 처리 |
| URI 이스케이프 |
|
|
| 자바8 날짜(LocalDateTime 등) 서식 |
| 숫자 서식 |
| 문자 편의 기능 |
| 컬렉션 편의 기능 |
<li>default = <span th:text="${localDateTime}"></span></li>
<li>포맷 = <span th:text="${#temporals.format(localDateTime, 'yyyy-MM-dd HH:mm:ss')}"></span></li>
<li>년 = <span th:text="${#temporals.year(localDateTime)}"></span></li>
<li>월 이름 = <span th:text="${#temporals.monthName(localDateTime)}"></span></li>
<li>요일 = <span th:text="${#temporals.dayOfWeekName(localDateTime)}"></span></li>6. URL 링크 @{...}
형태 | 예시 |
|---|---|
단순 URL |
|
쿼리 파라미터 |
|
경로 변수 |
|
경로 변수 + 쿼리 |
|
<li><a th:href="@{/hello}">basic url</a></li>
<li><a th:href="@{/hello(param1=${param1}, param2=${param2})}">query param</a></li>
<li><a th:href="@{/hello/{param1}/{param2}(param1=${param1}, param2=${param2})}">path variable</a></li>경로 변수에 다 쓰이지 못한 파라미터는 자동으로 쿼리 파라미터로 붙는다.
7. 리터럴과 연산
리터럴
문자:
'hello'/ 숫자:10/ 불린:true,false/ null:null리터럴 대체:
|hello ${data}|
<li><span th:text="'hello' + ' world!'"></span></li>
<li><span th:text="|hello ${data}|"></span></li> <!-- 리터럴 대체 -->연산
비교 연산 | 텍스트 |
|---|---|
|
|
|
|
<!-- 조건식 (삼항) -->
<span th:text="(10 % 2 == 0)?'짝수':'홀수'"></span>
<!-- Elvis 연산자: 값이 있으면 출력, 없으면 ? 뒤 -->
<span th:text="${data}?: '데이터가 없습니다.'"></span>
<!-- No-Operation: 타임리프가 실행되지 않은 것처럼 동작(기존 HTML 유지) -->
<span th:text="${nullData}?:_">데이터가 없습니다.</span>8. 속성 값 설정
th:속성명: 기존 속성을 대체하고, 없으면 만든다.th:attrappend/th:attrprepend: 속성 값 뒤/앞에 추가th:classappend: class 속성에 자연스럽게 추가th:checked="true"이면 checked 처리,false이면 속성 제거
<input type="text" class="text" th:classappend="large"/>
<input type="checkbox" name="active" th:checked="true"/>9. 반복 th:each
<tr th:each="user : ${users}">
<td th:text="${user.username}">username</td>
<td th:text="${user.age}">0</td>
</tr>반복 상태(userStat)도 함께 받을 수 있다.
<tr th:each="user, userStat : ${users}">
<td th:text="${userStat.count}">1</td>
<td th:text="${user.username}">username</td>
</tr>상태 변수 | 의미 |
|---|---|
| 0부터 시작하는 인덱스 |
| 1부터 시작하는 번호 |
| 전체 크기 |
| 짝수 / 홀수 여부 |
| 처음 / 마지막 여부 |
| 현재 객체 |
10. 조건부 평가
<!-- if / unless -->
<span th:text="'미성년자'" th:if="${user.age lt 20}"></span>
<span th:text="'미성년자'" th:unless="${user.age ge 20}"></span>
<!-- switch (* = default) -->
<td th:switch="${user.age}">
<span th:case="10">10살</span>
<span th:case="20">20살</span>
<span th:case="*">기타</span>
</td>th:if 조건이 거짓이면 해당 태그 자체가 렌더링되지 않는다.
11. 주석과 블록
<!-- 1. 표준 HTML 주석: 타임리프가 건드리지 않고 그대로 둠 -->
<!-- 2. 타임리프 파서 주석: 렌더링 시 제거됨 -->
<!--/* [[${data}]] */-->
<!-- 3. 타임리프 프로토타입 주석: 렌더링한 경우에만 보임 -->
<!--/*/
<span th:text="${data}">html data</span>
/*/--><th:block>은 타임리프 자체 태그로, 반복 등에서 묶음 처리에 유용하다.
<th:block th:each="user : ${users}">
<div>사용자 이름 <span th:text="${user.username}"></span></div>
<div>요약 <span th:text="${user.username} + ' / ' + ${user.age}"></span></div>
</th:block>12. 자바스크립트 인라인
<script th:inline="javascript">를 사용하면 자바스크립트에 안전하게 값을 넣을 수 있다(문자열 자동 따옴표, 객체 자동 JSON 변환).
<script th:inline="javascript">
var username = [[${user.username}]]; // "userA" (따옴표 자동)
var user = [[${user}]]; // 객체 → JSON 자동 변환
var username2 = /*[[${user.username}]]*/ "test"; // 내추럴 템플릿
</script>
<!-- 인라인 each -->
<script th:inline="javascript">
[# th:each="user, stat : ${users}"]
var user[[${stat.count}]] = [[${user}]];
[/]
</script>13. 템플릿 조각 (Fragment)
머리글·바닥글처럼 반복되는 영역을 조각으로 만들어 재사용한다.
<footer th:fragment="copy">푸터 자리입니다.</footer>
<footer th:fragment="copyParam (param1, param2)">
<p th:text="${param1}"></p>
<p th:text="${param2}"></p>
</footer><!-- th:insert : 현재 태그 하위에 삽입 -->
<div th:insert="~{/template/fragment/footer :: copy}"></div>
<!-- th:replace : 현재 태그를 조각으로 대체 -->
<div th:replace="~{/template/fragment/footer :: copy}"></div>
<!-- 파라미터 전달 -->
<div th:replace="~{/template/fragment/footer :: copyParam ('데이터1', '데이터2')}"></div>조각 표현식은 반드시 ~{...}로 감싼다 (Thymeleaf 3.1 변경점)
조각을 가리키는 표현식은 ~{템플릿 :: 조각} 형태로 ~{...}로 감싸는 것이 정식 문법이다. 과거에는 th:replace="footer :: copy"처럼 감싸지 않고도 동작했지만, Thymeleaf 3.1부터 이 "맨(unwrapped) 조각 표현식"은 폐기(deprecated) 되어 콘솔에 경고가 출력되고 이후 버전에서 제거될 예정이다. 스프링 부트 3.x는 Thymeleaf 3.1을 쓰므로 항상 ~{...}로 감싸야 한다.
th:insert/th:replace자체는 폐기된 것이 아니다. 감싸지 않은 문법만 폐기됐다.반면
th:include는 폐기되었으니th:insert(태그 안에 삽입) 또는th:replace(태그를 대체)로 쓴다.
<div th:replace="footer :: copy"></div> <!-- ❌ 폐기: ~{} 누락 → 경고 -->
<div th:replace="~{footer :: copy}"></div> <!-- ✅ 권장 -->
<div th:include="~{footer :: copy}"></div> <!-- ❌ th:include 폐기 -->14. 템플릿 레이아웃
공통 <head>(공통 CSS·JS)를 한곳에서 관리하고, 각 페이지에서 필요한 부분만 전달한다.
<head th:fragment="common_header(title, links)">
<title th:replace="${title}">레이아웃 타이틀</title>
<link rel="stylesheet" th:href="@{/css/awesomeapp.css}">
<th:block th:replace="${links}"/> <!-- 페이지별 추가 링크 -->
</head><!-- 바깥 조각 호출도 ~{...}로 감싼다. 안쪽 ~{::title}·~{::link}는 '이 페이지의 해당 태그'를 인자로 넘기는 조각 표현식 -->
<head th:replace="~{base :: common_header(~{::title}, ~{::link})}">
<title>메인 타이틀</title>
<link rel="stylesheet" th:href="@{/css/bootstrap.min.css}">
</head>~{::태그명}: 현재 페이지의 해당 태그들을 레이아웃에 전달한다.조각을 호출하는
th:replace="~{base :: common_header(...)}"도 바깥을~{...}로 감싼다(위 13절 경고 참고). 감싸지 않은th:replace="base :: ..."는 Thymeleaf 3.1에서 폐기되었다.
정리
출력은
th:text(이스케이프) /th:utext(태그 유지), 변수는${...}로 접근한다.URL은
@{...}, 반복은th:each, 조건은th:if·th:switch로 처리한다.폼 데이터 바인딩(
th:field)과 검증 메시지(th:errors)는 Spring MVC — 검증과 쿠키·세션의 Bean Validation과 함께 쓰인다.조각(fragment) 과 레이아웃으로 머리글·바닥글·공통 head를 재사용해 중복을 줄인다.