웹 개발

Thymeleaf

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> → &lt;b&gt;). 태그를 그대로 렌더링하려면 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

${user.username}, ${user['username']}, ${user.getUsername()}

List

${users[0].username}, ${users[0]['username']}, ${users[0].getUsername()}

Map

${userMap['userA'].username}, ${userMap['userA']['username']}

<!-- 지역 변수 th:with -->
<div th:with="first=${users[0]}">
    <p>처음 사람의 이름은 <span th:text="${first.username}"></span></p>
</div>

4. 기본 객체와 편의 객체

기본 객체

설명

${#request}

HttpServletRequest

${#response}

HttpServletResponse

${#session}

HttpSession

${#servletContext}

ServletContext

${#locale}

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. 유틸리티 객체와 날짜

객체

기능

#message

메시지, 국제화 처리

#uris

URI 이스케이프

#dates

java.util.Date 서식

#temporals

자바8 날짜(LocalDateTime 등) 서식

#numbers

숫자 서식

#strings

문자 편의 기능

#lists, #sets, #maps

컬렉션 편의 기능

<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

@{/hello}

쿼리 파라미터

@{/hello(param1=${param1}, param2=${param2})}

경로 변수

@{/hello/{param1}/{param2}(param1=${param1}, param2=${param2})}

경로 변수 + 쿼리

@{/hello/{param1}(param1=${param1}, param2=${param2})}

<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>   <!-- 리터럴 대체 -->

연산

비교 연산

텍스트

> < >= <=

gt lt ge le

== !=

eq neq(ne)

<!-- 조건식 (삼항) -->
<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>

상태 변수

의미

index

0부터 시작하는 인덱스

count

1부터 시작하는 번호

size

전체 크기

even / odd

짝수 / 홀수 여부

first / last

처음 / 마지막 여부

current

현재 객체


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를 재사용해 중복을 줄인다.

마지막 수정 2026년 10월 22일