Thymeleaf
는View Template Engine
으로 JSP, Freemarked와 같이 서버에서 클라이언트에게 응답할 브라우저 화면을 만들어주는 역할을 한다.
Thymeleaf는 웹뿐만 아니라 다른 환경을 위한 최신의 서버-사이드 자바 Template Engine이며, HTML, CSS, XM, JS 및 Text까지 수용한다.
Thymeleaf의 주 목표는 유지관리가 쉬운 템플릿 생성 방법을 제공하는 것이며, 실제로 템플릿에 영향을 주지 않는 (HTML의 구조를 깨지 않는, 기존 HTML 코드를 변경하지 않고 덧붙이는 코드)방식을 사용한다. 즉, Natural Templates 개념을 기반으로 한다. 이를 통해 디자인 팀과 개발 팀간 갈등, 격차 해소가 기대된다.)
타임리프의 주 목표는 템플릿을 만들 때 유지관리가 쉽도록 하는 것이다. 이를 위해 디자인 프로토타입으로 사용되는 템플릿에 영향을 미치지 않는 방식인
Natural Templates
을 기반으로 한다. Natural Templates은기존 HTML 코드와 구조를 변경하지 않고 덧붙이는 방식이다.
위와같은 Thymeleaf 장점 때문에 Spring에서도 Spring Boot와 Thymeleaf를 함께 사용하는 것을 권장하고 있다. Spring Boot에서는 JSP 사용 시 호환 및 환경설정에 어려움이 많기 때문이다.
반대로 Thymeleaf는 간편하게 Dependency 추가 작업으로 사용할 수 있다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
기본적으로 Thymeleaf의 핵심 라이브러리는 Standard Dialect라는 dialect를 제공하며 대부분 사용자에게 만족된다.
공식 Thymeleaf-spring 3 및 Thymeleaf-spring 4 통합 패키지는 모두 "SpringStandard Dialect"라는 dialect를 정의한다. 이 dialect는 Standard Dialect와 거의 동일하나, Spring Framework의 일부 기능을 더 잘 사용하기 위해 약간의 조정이 있다. 예를 들어 OGNL 대신 Spring Expression Language 또는 Spring EL을 사용한다.
Standard Dialect의 대부분 프로세서는 'Attribute Processor'이다. 이를 통해 브라우저는 단순히 추가 속성을 무시하기 때문에 처리되기 전에도 HTML 템플릿 파일을 올바르게 표시할 수 있다.
예를 들어, Tag Library를 사용하는 JSP에는 다음과 같이 브라우저에서 직접 표시할 수 없는 코드 조각이 포함될 수 있다.
# JSP
<input type="text" name="userName" value="${user.name}">
# Thymeleaf Standard Dialect
<input type="text" name="userName" value="Gorany" th:value="${user.name}">
이렇게 하면 디자이너와 개발자가 동일한 템플릿 파일에서 작업하고 정적인 HTML을 동적인 HTML로 변환하는데 필요한 노력을 줄일 수 있다. 이를 수행하는 기능이 "Natural Templating"이다.
public class GTVGApplication {
...
private final TemplateEngine templateEngine;
...
public GTVGApplication(final ServletContext servletContext) {
super();
ServletContextTemplateResolver templateResolver =
new ServletContextTemplateResolver(servletContext);
// HTML is the default mode, but we set it anyway for better understanding of code
templateResolver.setTemplateMode(TemplateMode.HTML);
// This will convert "home" to "/WEB-INF/templates/home.html"
templateResolver.setPrefix("/WEB-INF/templates/");
templateResolver.setSuffix(".html");
// Template cache TTL=1h. If not set, entries would be cached until expelled
templateResolver.setCacheTTLMs(Long.valueOf(3600000L));
// Cache is set to true by default. Set to false if you want templates to
// be automatically updated when modified.
templateResolver.setCacheable(true);
this.templateEngine = new TemplateEngine();
this.templateEngine.setTemplateResolver(templateResolver);
...
}
}
<html xmlns:th="http://www.thymeleaf.org">
message.properties 외부파일을 이용하여 텍스트를 나타낸다. Map 형태로 Key, Value 형태로 구성된다.(application.yml에서 해당 message.properties 설정)
ex) src/main/resources/messages/message.properties가 있다고 가정.
#message.properties setting
spring:
messages:
basename: messages/message
encoding=UTF-8
#message.properties
title=안녕하세요
greeting=감사합니다.
<p th:utext="#{greeting}">Welcome to our grocery store! </p>
=> 감사합니다 text로 변환
// OGNL
ctx.getVariable("today"); => <span th:text="${greeting}">
((User) ctx.getVariable("session").get("user")).getName();
=>
<p th:utext="#{home.welcome(${session.user.name})}">
Context 변수에서 OGNL 표현식을 평가할 때 일부 객체는 더 높은 유연성/정확성을 위해 표현식에 사용할 수 있다.
#ctx: 컨텍스트 객체
#vars: 컨텍스트 변수
#locale: 컨텍스트 로케일
#request: (웹 컨텍스트에서만) HttpServletRequest 객체
#response: (웹 컨텍스트에서만) HttpServletResponse 객체
#session: (웹 컨텍스트에서만) HttpSession 객체
#servletContext: (웹 컨텍스트에서만) ServletContext 객체
날짜 바꾸기 예시
- Date: #calendars 사용
<td th:text="*{#calendars.foramt(regDate, 'yyyy-MM-dd')}"> 2019-08-18</td> <td th:text="${#calendars.format(notice.regDate, 'yyyy년MM월dd일')}"> <td th:text="${#calendars.format(today, 'yyyy/MM/dd')}"> 2019-08-18</td>
- LocalDateTime / LocalDate: #temporals 사용
<span th:text="*{#temporals.format(updatedAt, 'yyyy/MM/dd')}"></span>
선택한 개체란 th:object 속성을 사용하는 표현식의 결과이다.
<form th:object="${member}" method="post" action="/signup">
<label>Nickname</label>
<input type="text" th:value="*{nickname}" name="nickname">
<label>Password</label>
<input type="text" th:value="*{password} name="password">
</form>
이는 다음과 같다.
==>
<form method="post" action="/signup">
<label>Nickname</label>
<input type="text" th:value="${member.nickname}" name="nickname">
<label>Password</label>
<input type="text" th:value="${member.password} name="password">
</form>
혼용도 가능하다.
==>
<form th:object="${member}" method="post" action="/signup">
<label>Nickname</label>
<input type="text" th:value="*{nickname}" name="nickname">
<label>Password</label>
<input type="text" th:value="${member.password} name="password">
</form>
<a href="www.naver.com" th:href="@{www.naver.com">Naver</a>
<!-- www.naver.com -->
<a th:href="/board/list(field=${field}, query=${query})}">Search</a>
<!-- /board/list?field=title&query=abc -->
<a href="details.html" th:href="@{/order/{orderId}/details(orderId=${o.id})}">view</a>
<!-- /order/3/details?orderId=3 -->
, <, >=, <=, ==, !=
<div th:if="${prodStat.count} > 1">
<span th:text="'Execution mode is ' + ( (${execMode} == 'dev')? 'Development' : 'Production')">
<tr th:class="${row.even}? 'even' : 'odd'">
...
</tr>
<tr th:class="${row.even}? (${row.first}? 'first' : 'even') : 'odd'">
...
</tr>
<tr th:class="${row.even}? 'alt'">
...
</tr>
<div th:object="${session.user}">
<p>Age: <span th:text="*{age}?: '(no age specified)'">27</span></p>
</div>
<!-- 위의 표현식과 아래 표현식과 동일 -->
<p>Age: <span th:text="*{age != null}? *{age} : '(no age specified)'">27</span></p>
<p>Name: <span th:text="*{firstName}?: (*{admin}? 'Admin' : #{default.username})">Sebastian</span></p>
JSTL의 <c:set /> 같이 변수를 만들어 사용하고 싶을 땐 다음과 같이 사용한다. 단, 해당 태그가 끝나기 전 범위 내에서만 사용이 가능하다.
<th:block th:with="temp = ${data}, example = ${foo}">
<span th:text="${temp}">text</span>
</th:block>
<div th:text="${example}"></div> (사용 불가)
<!-- model.addAttribute("data", "Hello World <b>How are you?</b>"); -->
<div th:text=${data}></div>
<!-- Hello World~ <b>How are you?</b> -->
<div th:utext="${data}"></div>
<!-- Hello World~ How are you? (How are you 부분 bold 처리) -->
<!-- model.addAttribute("noticeList", list); -->
<tr th:each="notice : ${noticeList}" th:object="${notice}">
<td th:text="*{id}">B</td>
<td class="title indent text-align-left"><a th:text="*{title}" th:href="@{detail(id=*{id}))}}" href="detail.html>예제 코드</a></td>
<td th:text="*{writerId}">newlec</td>
<td th:text="*{regDate}">2022-08-06</td>
<td><input type="checkbox" th:name="open"></td>
<td><input type="checkbox" th:name="del"></td>
</tr>
<body>
<div th:insert="footer :: copy"></div>
<div th:replace="footer :: copy"></div>
<div th:include="footer :: copy"></div>
</body>
<!-- ---------------------------------------------- -->
<body>
// insert
<div>
<footer>
© 2022
</footer>
</div>
// replace
<footer>
© 2022
</footer>
// include
<div>
© 2022
</div>
</body>
ex)
<!-- header.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<header id="header" th:fragment="hd">
<div class="content-container">
<!-- ---------------------------<header>--------------------------------------- -->
<h1 id="logo">
<a href="/index.html">
<img src="/images/logo.png" alt="OOO 온라인" />
</a>
</h1>
<section>
<h1 class="hidden">헤더</h1>
<nav id="main-menu">
<h1>메인메뉴</h1>
<ul>
<li><a href="/guide">학습가이드</a></li>
<li><a href="/course">강좌선택</a></li>
<li><a href="/answeris/index">AnswerIs</a></li>
</ul>
</nav>
<div class="sub-menu">
<section id="search-form">
<h1>강좌검색 폼</h1>
<form action="/course">
<fieldset>
<legend>과정검색필드</legend>
<label>과정검색</label>
<input type="text" name="q" value="" />
<input type="submit" value="검색" />
</fieldset>
</form>
</section>
<nav id="acount-menu">
<h1 class="hidden">회원메뉴</h1>
<ul>
<li><a href="/index.html">HOME</a></li>
<li><a href="/member/login.html">로그인</a></li>
<li><a href="/member/agree.html">회원가입</a></li>
</ul>
</nav>
<nav id="member-menu" class="linear-layout">
<h1 class="hidden">고객메뉴</h1>
<ul class="linear-layout">
<li><a href="/member/home"><img src="/images/txt-mypage.png" alt="마이페이지" /></a></li>
<li><a href="/notice/list.html"><img src="/images/txt-customer.png" alt="고객센터" /></a></li>
</ul>
</nav>
</div>
</section>
</div>
</header>
</html>
~{...}
- 단편 조각의 경로(Path)를 의미한다. Fragment Expression으로 파일의 경로를 의미하고자 할 때 사용한다.
- ~{}표시는 생략 가능하다.
- @{...}랑은 조금 다르다. @{...}은 URL을 의미한다
<!-- list.html -->
<body>
<th:block th:replace="include/header"></th:block> <!-- 루트를 의미할 때 '/'를 사용하지 않아도 된다. -->
</body>
위의 예시처럼 파일 자체(header.html)를 끼워넣는 것은 HTML 구조가 깨지기도 한다.
이를 극복하기위해 Fragment를 이용한다.
위의 내용을 토대로 하면, 다음과 같이 수정할 수 있다.
<!-- list.html -->
<body>
<th:block th:replace="include/header :: #header}"></th:block> <!-- CSS 선택자 사용 -->
</body>
먼저 방법은 인자(parameter)를 이용하는 것이다.
<!-- layout.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org>
<body th:fragment="body(main)">
<!-- header -->
<th:block th:replace="include/header::header"></th:block>
<!-- visual -->
<div id="visual">
<div class="content-container"></div>
</div>
<!-- body -->
<div id="body">
<div class="content-container clearfix">
<!--aside-->
<aside class="aside">
<h1>ADMIN PAGE</h1>
<nav class="menu text-menu first margin-top">
<h1>마이페이지</h1>
<ul>
<li><a href="/admin/index.html">관리자홈</a></li>
<li><a href="/teacher/index.html">선생님페이지</a></li>
<li><a href="/student/index.html">수강생페이지</a></li>
</ul>
</nav>
<nav class="menu text-menu">
<h1>알림관리</h1>
<ul>
<li><a href="/admin/board/notice/list.html">공지사항</a></li>
</ul>
</nav>
</aside>
<!--main-->
<th:block th:replace="${main}"></th:block>
</div>
</div>
<!--footer-->
<th:block th:replace="~{include/footer :: footer}"></th:block>
</body>
</html>
태그를 보면 th:fragment="body(main)"과 같은 형태를 띄고, 밑에 main 주석 부분에
`th:block th:replace="${main}">` 과 같이 "변수"를 받아 대체하는 형태를 띈다.
이를 사용하려면 main을 작성한 곳에서는 아래와 같이 코드를 작성한다.
<!-- main이 있는 파일 -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
...
</head>
<!-- admin/include/layout 파일의 th:fragment="body"로 대체를 하라 -->
<!-- 그런데 대체 할 때 인자를 넘겨야 한다. 이 파일의 th:fragment="main"을 넘긴다 -->
<!-- layout 파일에서 th:fragment="body(var)로 선언하고, 하부에서 th:replace="${var}"를 쓰면 현재 파일의 main이 끼워지게 된다. -->
<th:block th:replace="admin/include/layout :: body(~{this::main})}">
<main class="main" th:fragment="main">
먼저 thymeleaf 레이아웃 라이브러리를 추가한다.
<dependency>
<groupId>nz.net.ultraq.thymeleaf</groupId>
<artifactId>thymeleaf-layout-dialect</artifactId>
</dependency>
그 다음 레이아웃으로 쓸 페이지(파일)에 xml namespace를 추가한다.
<!-- layout.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<!-- main -->
<th:block layout:fragment="main"></th:block>
이후 main을 갖고있는 파일에서 다음과 같이 설정한다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorator="~{/admin/include/layout}">
<!-- layout:decorator는 현재 내 파일에 레이아웃에 끼워넣을 것이 있으니 그 대상을 지정한다.
<main class="main" layout:fragment="main">
...
</main>
#
- layout에서 "main"이라는 이름으로 쓰일 조각이라고 선언
#
끼워넣을 layout 파일이 해당 경로에 있다고 알려줌
끼워넣으려는 파일에 기입해두면 저절로 포함된다.
// ThymeleafConfig.java -- application.yml에 설정값을 세팅할 수 있도록 + decoupled 설정
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.ConstructorBinding;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.thymeleaf.spring5.templateresolver.SpringResourceTemplateResolver;
@Configuration
public class ThymeleafConfig{
@Bean
public SpringResourceTemplateResolver thymeleafTemplateResolver(
SpringResourceTemplateResolver defaultTemplateResolver,
Thymeleaf3Properties thymeleaf3Properties){
defaultTemplateResolver.setUseDecoupledLogic(thymeleaf3Properties.isDecoupledLogic());
return defaultTemplateResolver;
}
}
@RequiredArgsConstructor
@Getter
@ConstructorBinding // application.yml에서 자동완성으로 사용할 수 있도록
@ConfigurationProperties("spring.thymeleaf3") // application.yml에서 spring: thymeleaf3 : 에 정의된 값을 가져옴
public static class Thymeleaf3Properties{
/**
Use Thymeleaf 3 Decoupled Logic
**/
private final boolean decoupledLogic;
}
// build.gradle
<dependency>
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
</dependency>
// application.yml
spring:
thymeleaf3:
decoupled-logic: true
// index.html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="">
<meta name="author" content="shbae">
<title>게시판 페이지</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0-beta1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-0evHe/X+R7YkIZDRvuzKMRqM+OrBnVFBL6DOitfPri4tjfHxaWutUpFmBp4vmVor" crossorigin="anonymous">
<link href="/css/search-bar.css" rel="stylesheet">
<link href="/css/articles/table-header.css" rel="stylesheet">
</head>
<body>
<header id="header">
헤더 삽입부
<hr>
</header>
<main class="container">
<div class="row">
<div class="card card-margin search-form">
<div class="card-body p-0">
<form id="search-form">
<div class="row">
<div class="col-12">
<div class="row no-gutters">
<div class="col-lg-3 col-md-3 col-sm-12 p-0">
<label for="search-type" hidden>검색 유형</label>
<select class="form-control" id="search-type" name="searchType">
<option>제목</option>
<option>본문</option>
<option>id</option>
<option>닉네임</option>
<option>해시태그</option>
</select>
</div>
<div class="col-lg-8 col-md-6 col-sm-12 p-0">
<label for="search-value" hidden>검색어</label>
<input type="text" placeholder="검색어..." class="form-control" id="search-value" name="searchValue">
</div>
<div class="col-lg-1 col-md-3 col-sm-12 p-0">
<button type="submit" class="btn btn-base">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-search">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</button>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
<div class="row">
<table class="table" id="article-table">
<thead>
<tr>
<th class="title col-6"><a>제목</a></th>
<th class="hashtag col-2"><a>해시태그</a></th>
<th class="user-id"><a>작성자</a></th>
<th class="created-at"><a>작성일</a></th>
</tr>
</thead>
<tbody>
<tr>
<td class="title"><a>첫글</a></td>
<td class="hashtag">#java</td>
<td class="user-id">Uno</td>
<td class="created-at"><time>2022-01-01</time></td>
</tr>
<tr>
<td>두번째글</td>
<td>#spring</td>
<td>Uno</td>
<td><time>2022-01-02</time></td>
</tr>
<tr>
<td>세번째글</td>
<td>#java</td>
<td>Uno</td>
<td><time>2022-01-03</time></td>
</tr>
</tbody>
</table>
</div>
<div class="row">
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a class="btn btn-primary me-md-2" role="button" id="write-article">글쓰기</a>
</div>
</div>
<div class="row">
<nav id="pagination" aria-label="Page navigation">
<ul class="pagination justify-content-center">
<li class="page-item"><a class="page-link" href="#">Previous</a></li>
<li class="page-item"><a class="page-link" href="#">1</a></li>
<li class="page-item"><a class="page-link" href="#">Next</a></li>
</ul>
</nav>
</div>
</main>
<footer id="footer">
<hr>
푸터 삽입부
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0-beta1/dist/js/bootstrap.bundle.min.js" integrity="sha384-pprn3073KE6tl6bjs2QrFaJGz5/SUsLqktiwsUTF55Jfv3qYSDhgCecCxMW52nD2" crossorigin="anonymous"></script>
</body>
</html>
// index.th.xml
<?xml version="1.0"?>
<thlogic>
<attr sel="#header" th:replace="header :: header" />
<attr sel="#footer" th:replace="footer :: footer" />
<attr sel="main" th:object="${articles}">
<attr sel="#search-form" th:action="@{/articles}" th:method="get" />
<attr sel="#search-type" th:remove="all-but-first">
<attr sel="option[0]"
th:each="searchType : ${searchTypes}"
th:value="${searchType.name}"
th:text="${searchType.description}"
th:selected="${param.searchType != null && (param.searchType.toString == searchType.name)}"
/>
</attr>
<attr sel="#search-value" th:value="${param.searchValue}" />
<attr sel="#article-table">
<attr sel="thead/tr">
<attr sel="th.title/a" th:text="'제목'" th:href="@{/articles(
page=${articles.number},
sort='title' + (*{sort.getOrderFor('title')} != null ? (*{sort.getOrderFor('title').direction.name} != 'DESC' ? ',desc' : '') : ''),
searchType=${param.searchType},
searchValue=${param.searchValue}
)}"/>
<attr sel="th.hashtag/a" th:text="'해시태그'" th:href="@{/articles(
page=${articles.number},
sort='hashtag' + (*{sort.getOrderFor('hashtag')} != null ? (*{sort.getOrderFor('hashtag').direction.name} != 'DESC' ? ',desc' : '') : ''),
searchType=${param.searchType},
searchValue=${param.searchValue}
)}"/>
<attr sel="th.user-id/a" th:text="'작성자'" th:href="@{/articles(
page=${articles.number},
sort='userAccount.userId' + (*{sort.getOrderFor('userAccount.userId')} != null ? (*{sort.getOrderFor('userAccount.userId').direction.name} != 'DESC' ? ',desc' : '') : ''),
searchType=${param.searchType},
searchValue=${param.searchValue}
)}"/>
<attr sel="th.created-at/a" th:text="'작성일'" th:href="@{/articles(
page=${articles.number},
sort='createdAt' + (*{sort.getOrderFor('createdAt')} != null ? (*{sort.getOrderFor('createdAt').direction.name} != 'DESC' ? ',desc' : '') : ''),
searchType=${param.searchType},
searchValue=${param.searchValue}
)}"/>
</attr>
<attr sel="tbody" th:remove="all-but-first">
<attr sel="tr[0]" th:each="article : ${articles}">
<attr sel="td.title/a" th:text="${article.title}" th:href="@{'/articles/' + ${article.id}}" />
<attr sel="td.hashtag" th:text="${article.hashtag}" />
<attr sel="td.user-id" th:text="${article.nickname}" />
<attr sel="td.created-at/time" th:datetime="${article.createdAt}" th:text="${#temporals.format(article.createdAt, 'yyyy-MM-dd')}" />
</attr>
</attr>
</attr>
<attr sel="#write-article" sec:authorize="isAuthenticated()" th:href="@{/articles/form}" />
<attr sel="#pagination">
<attr sel="li[0]/a"
th:text="'previous'"
th:href="@{/articles(page=${articles.number - 1}, searchType=${param.searchType}, searchValue=${param.searchValue})}"
th:class="'page-link' + (${articles.number} <= 0 ? ' disabled' : '')"
/>
<attr sel="li[1]" th:class="page-item" th:each="pageNumber : ${paginationBarNumbers}">
<attr sel="a"
th:text="${pageNumber + 1}"
th:href="@{/articles(page=${pageNumber}, searchType=${param.searchType}, searchValue=${param.searchValue})}"
th:class="'page-link' + (${pageNumber} == ${articles.number} ? ' disabled' : '')"
/>
</attr>
<attr sel="li[2]/a"
th:text="'next'"
th:href="@{/articles(page=${articles.number + 1}, searchType=${param.searchType}, searchValue=${param.searchValue})}"
th:class="'page-link' + (${articles.number} >= ${articles.totalPages - 1} ? ' disabled' : '')"
/>
</attr>
</attr>
</thlogic>