thymeleaf - 소개 및 정리

박민수·2023년 11월 15일
post-thumbnail

타임리프(thymeleaf)란?

타임리프는 백엔드 서버에서 HTML을 동적으로 렌더링 하는 용도로 사용된다. 타임리프로 작성한 파일은 HTML을 유지하기 때문에 웹 브라우저에서 파일을 직접 열어도 내용을 확인할 수 있고, 서버를 통해 뷰 템플릿을 거치면 동적으로 변경된 결과를 확인할 수 있다.
이렇게 순수 HTML을 그대로 유지하면서 뷰 템플릿도 사용할 수 있는 타임리프의 특징을 네츄럴 템플릿(natural templates)이라 한다.

사용법

타임리프를 사용하려면 namespace를 선언 하면 된다.

<html lang="en" xmlns:th="http://www.thymeleaf.org">

실행 구조

  • 컨트롤러에서 리턴 값으로 문자를 반환하면 뷰 리졸버(viewResolver)가 화면을 찾아서 처리한다.
    • 스프링 부트 템플릿엔진 기본 viewName 매핑
    • resources:templates/ + {viewName} + .html

참고: spring-boot-devtools 라이브러리를 추가하면, html 파일을 컴파일만 해주면 서버 재시작 없이 View 파일 변경이 가능하다.

문법

기본 표현식

간단한 표현

  • 변수 표현식: ${...}
  • 선택 변수 표현식: *{...}
  • 메시지 표현식: #{...}
  • 링크 URL 표현식: @{...}
  • 조각 표현식: ~{...}

리터럴

  • 텍스트: 'one text', 'Another one!',…
  • 숫자: 0, 34, 3.0, 12.3,…
  • 불린: true, false
  • 널: null
  • 리터럴 토큰: one, sometext, main,…

문자 연산

  • 문자 합치기: +
  • 리터럴 대체: |The name is ${name}|

산술 연산

  • Binary operators: +, -, *, /, %
  • Minus sign (unary operator): -

불린 연산

  • Binary operators: and, or
  • Boolean negation (unary operator): !, not

비교와 동등

  • 비교: >, <, >=, <= (gt, lt, ge, le)
  • 동등 연산: ==, != (eq, ne)

조건 연산

  • If-then: (if) ? (then)
  • If-then-else: (if) ? (then) : (else)
  • Default: (value) ?: (defaultvalue)

특별한 토큰

  • No-Operation: _

텍스트 출력 - text, utext

타임리프는 기본적으로 HTML 태그의 속성에 기능을 정의해서 동작한다.

  • th:text='${}' : HTML의 컨텐츠 영역에 데이터를 출력할 때 사용된다.
<span th:text="${data}">
  • [[${data}]] : HTML 태그의 속성이 아니라 HTML 컨텐츠 영역안에서 직접 데이터를 출력하고자 할 때 사용된다.
<span>[[${data}]]</span>
  • th:utext="${}" : th:text는 출력할 문자열에 태그가 포함되는 경우, 태그를 반영하지 않고 문자 그대로 출력한다. 태그를 반영하려면 th:text 대신 th:utext를 사용하면 된다.
<span th:utext="'Hello <b>Spring!</b>'"</span>
  • [(${data})] : [[${data}]]는 출력한 문자열에 태그가 포함되는 경우, 태그를 반영하지 않고 문자 그대로 출력한다. 태그를 반영하려면 th:text 대신 [(${data})] 를 사용하면 된다.
[('Hello <b>Spring!</b>')]

변수 - SpringEL

Object

  • user.username : user의 username을 프로퍼티 접근 -> user.getUsername()
  • user.['username'] : user의 username을 프로퍼티 접근 -> user.getUsername()
  • user.getUsername() : user의 getUsername()을 직접 호출

List

  • users[0].username : List에서 첫 번째 회원을 찾고 username 프로퍼티 접근 -> - list.get(0).getUsername()
  • users[0].['username'] : List에서 첫 번째 회원을 찾고 username 프로퍼티 접근 -> list.get(0).getUsername()
  • users[0].getUsername() : List에서 첫 번째 회원을 찾고 메서드 직접 호출

Map

  • userMap['userA'].username : Map에서 userA를 찾고, username 프로퍼티 접근 -> map.get("userA").getUsername()
  • userMap['userA']['username'] : Map에서 userA를 찾고, username 프로퍼티 접근 -> map.get("userA").getUsername()
  • userMap['userA'].getUsername() : Map에서 userA를 찾고 메서드 직접 호출

지역 변수 선언

th:with 를 사용하면 지역 변수를 선언해서 사용할 수 있다. 지역 변수는 선언한 테그 안에서만 사용할 수 있다.

<div th:with="first=${users[0]}">
    <p>첫번째 회원의 이름은 <span th:text="${first.username}"> 입니다.</span></p>
</div>

기본 객체

타임리프는 다양한 기본 객체들을 제공한다.

  • ${#request}
  • ${#response}
  • ${#session}
  • ${#servletContext}
  • ${#locale}
<ul>
    <li>request = <span th:text="${#request}"></span></li>
    <li>response = <span th:text="${#response}"></span></li>
    <li>session = <span th:text="${#session}"></span></li>
    <li>sevletContext = <span th:text="${#sevletContext}"></span></li>
    <li>locale = <span th:text="${#locale}"></span></li>
</ul>

편의 객체

  • param : HTTP 요청 파라미터 접근
${param.paramData}
  • session : HTTP 세션 접근
${session.sessionData}
  • @ : 스프링 빈 접근
${@helloBean.hello('Spring!)}

유틸리티 객체와 날짜

타임리프는 문자, 숫자, 날짜, URI 등을 편리하게 다루는 다양한 유틸리티 객체들을 제공한다.

  • #message : 메시지, 국제화 처리
  • #uris : URI 이스케이프 지원
  • #dates : java.util.Date 서식 지원
  • #calendars : java.util.Calendar 서식 지원
  • #temporals : 자바8 날짜 서식 지원
<span th:text=${#temporals.format(localDateTime, 'yyyy-MM-dd HH:mm:ss')}"></span>
<li>${#temporals.day(localDateTime)} = <span th:text="${#temporals.day(localDateTime)}"></span></li>
<li>${#temporals.month(localDateTime)} = <span th:text="${#temporals.month(localDateTime)}"></span></li>
<li>${#temporals.monthName(localDateTime)} = <span th:text="${#temporals.monthName(localDateTime)}"></span></li>
<li>${#temporals.monthNameShort(localDateTime)} = <span th:text="${#temporals.monthNameShort(localDateTime)}"></span></li>
<li>${#temporals.dayOfWeek(localDateTime)} = <span th:text="${#temporals.dayOfWeek(localDateTime)}"></span></li>
<li>${#temporals.dayOfWeekName(localDateTime)} = <span th:text="${#temporals.dayOfWeekName(localDateTime)}"></span></li>
<li>${#temporals.dayOfWeekNameShort(localDateTime)} = <span th:text="${#temporals.dayOfWeekNameShort(localDateTime)}"></span></li>
<li>${#temporals.hour(localDateTime)} = <span th:text="${#temporals.hour(localDateTime)}"></span></li>
<li>${#temporals.minute(localDateTime)} = <span th:text="${#temporals.minute(localDateTime)}"></span></li>
<li>${#temporals.second(localDateTime)} = <span th:text="${#temporals.second(localDateTime)}"></span></li>
<li>${#temporals.nanosecond(localDateTime)} = <span th:text="${#temporals.nanosecond(localDateTime)}"></span></li>
  • #numbers : 숫자 서식 지원
  • #strings : 문자 관련 편의 기능
  • #objects : 객체 관련 기능 제공
  • #bools : boolean 관련 기능 제공
  • #arrays : 배열 관련 기능 제공
  • #lists, #sets, #maps : 컬렉션 관련 기능 제공
  • #ids : 아이디 처리 관련 기능 제공
  • th:href="@{}": 괄호안에 이동하고자 하는 링크를 입력하면 된다.
  • th:value="${}":
  • th:with="${}":

URL 링크

타임리프에서 URL을 생성할 때는 @{link} 문법을 사용하면 된다.

<ul>
    // -> /hello
    <li><a th:href="@{/hello}">basic url</a></li>
    
    // -> /hello?param1=data1&param2=data2
    <li><a th:href="@{/hello(param1=${param1}, param2=${param2})}">hello query param</a></li>
    
    // -> /hello/data1/data2
    <li><a th:href="@{/hello/{param1}/{param2}(param1=${param1}, param2=${param2})}">path variable</a></li>
    
    // -> /hello/data1?param2=data2
    <li><a th:href="@{/hello/{param1}(param1=${param1}, param2=${param2})}">path variable + query parameter</a></li>
</ul>

리터럴

소스 코드상에 고정된 값을 리터럴이라고 한다. 타임리프에서 문자 리터럴은 항상 '(작은 따옴표)로 감싸야 한다. 타임리프에는 다음과 같은 리터럴이 있다.

  • 문자 : hello
  • 숫자 : 10
  • 불린 : true, false
  • null : null

단, 문자열이 공백 없이 쭉 이어진다면 하나의 의미있는 토큰으로 인지해서 작은 따옴표를 생략할 수 있다.

<!-- OK -->
<span th:text="HelloSpring">
<!-- ERROR -->
<span th:text="Hello Spring"> 
<!-- OK -->
<span th:text="'Hello Spring'">

타임리프에서 제공하는 리터럴 대체 문법을 사용하면 전체 문자열을 하나의 문자열로 인식하고, 치환해야 할 부분들만 자동으로 치환해준다.

<!-- 리터럴 대체 문법 사용 -->
<span th:text="|hello ${data}|"></span>

<!-- 리터럴 대체 문법 사용 X -->
<span th:text="'hello " + ${data}"></span>

연산

타임리프 연산은 자바와 크게 다르지 않다. HTML 안에서 사용하기 때문에 HTML 엔티티를 사용하는 부분만 주의하자.

비교연산

<ul>
    <li>1 > 2 = <span th:text="1 &gt; 2"></span></li>
    <li>1 gt 2 = <span th:text="1 gt; 2"></span></li>
    <li>1 >= 2 = <span th:text="1 >=; 2"></span></li>
    <li>1 ge 2 = <span th:text="1 ge; 2"></span></li>
    <li>1 == 2 = <span th:text="1 ==; 2"></span></li>
    <li>1 != 2 = <span th:text="1 !=; 2"></span></li>
</ul>

조건식

<span th:text="(4 % 2 == 0)? '짝수' : '홀수'"></span>

Elvis 연산자

<!-- 데이터가 있으면 데이터를 출력하고 없으면 문자열 출력 -->
<span thLtext="${data}?: '데이터 없음'"></span></li>

<!-- 데이터가 없으면 문자열 출력 -->
<span th:text="${nullData}?: '데이터 없음'"></span></li>

No-Operation

<!-- 타임리프 태그가 동작하지 않음. 기본 HTML 출력 -->
<span th:text="${nullData}?: _">데이터 없음</span></li>

속성 값 설정

타임리프는 주로 HTML 태그에 th: 속성을 지정하는 방식으로 동작한다. th:로 속성을 적용하면 기존 속성을 대체한다. 이 때 기존 속성이 존재하지 않다면 속성을 새로 만들어준다.

속성 설정

<!-- name 속성 값이 th:name에 지정한 값으로 대체된다 -->
<!-- 랜더링 전 -->
<input type="text" name="userName" th:name="HongGildong"/>
<!-- 랜더링 후 -->
<input type="text" name="HongGildong"/>

<input type="text"

속성 추가

<!-- th:attrappend 랜더링 전 (띄어쓰기 주의) -->
<input type="text" class="text" th:attrappend="class=' large'"/>
<!-- 랜더링 후 -->
<input type="text" class="text large"/>

<!-- th:attrprepend 랜더링 전 (띄어쓰기 주의) -->
<input type="text" class="text" th:attrprepend="class='large '"/>
<!-- 랜더링 후 -->
<input type="text" class="large text"/>

<!-- th:classappend 랜더링 전 -->
<input type="text" class="text" th:classappend="large"/>
<!-- 랜더링 후 -->
<input type="text" class="text large"/>

checked 처리

HTML에서는 checked 속성 값이 true이던 false이던 상관없이 checked 속성이 있다면 무조건 checked 처리가 되어버린다. 반면 타임리프의 th:checked를 사용하면 값이 false인 경우 checked 속성을 제거해준다.

<!-- 랜더링 전 -->
<input type="checkbox" name="test" th:checked="false"/>
<!-- 랜더링 후 -->
<input type="checkbox" name="test"/>

반복

타임리프에서 반복문을 처리하기 위해 제공되는 문법에는 th:each 가 있다. 아래 예시에서는 users에 담긴 값들을 하나씩 꺼내서 지정한 변수(user)에 담아서 반복 실행한다.

<tr th:each="user : ${users}">
   <td th:text="${user.username}">username</td>
   <td th:text="${user.age}">age</td>
</tr>

추가로 th:each의 2번째 파라미터를 설정해서 반복문의 다양한 상태 정보를 확인 할 수 있다. 2번째 파라미터를 생략하면 "변수명 + Stat" 으로 변수명이 자동으로 만들어진다. (userStat)

<tr th:each="user, status : ${users">
   <!-- 0부터 시작하는 인덱스 -->
   <td th:text="${status.index}"></td>
   <!-- 1부터 시작하는 인덱스 -->
   <td th:text="${status.count}"></td>
   <!-- 전체 사이즈 -->
   <td th:text="${status.size}"></td>
   <!-- 홀수, 짝수 여부 -->
   <td th:text="${status.even}"></td>
   <td th:text="${status.odd}"></td>
   <!-- 처음, 마지막 여부 -->
   <td th:text="${status.first}"></td>
   <td th:text="${status.last}"></td>
   <!-- 현재 객체 -->
   <td th:text="${status.current}"></td>
</tr>

조건부 평가

타임리프에서 조건문을 처리하기 위해 제공되는 문법에는 th:if, th:unless, th:switch 가 있다.

th:if, th:unless

<!-- 지정한 조건에 해당하면 실행 -->
<span th:text"'미성년자'" th:if="${user.age lt 20"></span>
<!-- 지정한 조건에 해당하지 않으면 실행 -->
<span th:text"'미성년자'" th:unless="${user.age ge 20"></span>

th:switch, th:case

Java의 switch문과 동일하다.

<td th:switch="${user.age}">
    <span th:case="10">10살</span>
    <span th:case="20">20살span>
    <!-- *은 만족하는 조건이 없을 때 실행된다 -->
    <span th:case="*">기타</span>
</td>

주석

표준 HTML 주석

자바스크립트의 표준 HTML 주석은 타임리프가 랜더링 하지 않고, 그대로 남겨둔다.

<!--
<span th:text="${data}">html data</span>
-->

타임리프 파서 주석

랜덩링에서 주석 부분을 제거한다.

<!--/*-->
<span th:text="${data}">html data</span>
<!--*/-->

타임리프 프로토타입 주석

HTML 파일을 그대로 열어보면 주석처리가 되지만, 랜더링 과정을 거쳤을 경우에는 정상적으로 출력이 된다.

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

자바스크립트 인라인

타임리프는 자바스크립트에서 타임리프를 편리하게 사용할 수 있는 자바스크립트 인라인 기능을 제공한다. 자바스크립트 인라인 기능은 다음과 같이 적용하면 된다.

<script th:inline="javascript">
</script>

자바스크립트 인라인 기능을 사용하기 전과 후를 비교해보자.

<!-- 자바스크립트 인라인 사용 전 -->
<script>
    var username = [[${user.username}]];
    var age = [[${user.age}]];
    
    //자바스크립트 내추럴 템플릿
    var username2 = /*[[${user.username}]]*/ "test username";
    
    //객체
    var user = [[${user}]];
</script>
<!-- 자바스크립트 인라인 사용 후 -->
<script th:inline="javascript">
    var username = [[${user.username}]];
    var age = [[${user.age}]];
    
    //자바스크립트 내추럴 템플릿
    var username2 = /*[[${user.username}]]*/ "test username";
    
    //객체
    var user = [[${user}]];
</script>
자바스크립트 인라인 사용 전 결과
<script>
    var username = userA;
    var  age = 10;

    //자바스크립트 내추럴 템플릿
    var username2 = /*userA*/ "test username";

    //객체
    var user = BasicController.User(username=userA, age=10);
</script>
자바스크립트 인라인 사용 후 결과
<script>
    var username = "userA";
    var  age = 10;

    //자바스크립트 내추럴 템플릿
    var username2 = "userA";

    //객체
    var user = {"username":"userA","Age":10};
</script>

위 예제를 통해 자바스크립트 인라인을 사용하지 않았을 때 발생하는 3가지 문제점을 확인할 수 있다.

  1. 인라인 사용 전 랜더링 결과를 보면 userA라는 변수 이름이 그대로 남아있다. userA가 변수명으로 사용되어서 자바스크립트 오류가 발생한다. (숫자 age의 경우에는 "(쌍 따옴표)가 필요 없기 때문에 정상적으로 랜더링이 된다) 반면 인라인 사용 후 랜더링 결과를 보면 문자 타입의 경우 "(쌍 따옴표)를 자동으로 붙여주기 때문에 오류가 발생하지 않는다.
  2. 인라인 사용 전 랜더링 결과를 보면 원하는 결과값이 아닌 입력한 문자열 그 자체가 그대로 출력이 되어버렸고, 심지어 일부 내용이 주석처리 되었다. 반면 인라인 사용 후 랜더링 결과를 보면 주석 부분은 제거되고 기대한 "userA"가 정상적으로 출력되었다.
  3. 인라인 사용 전 랜더링 결과를 보면 객체의 toString()이 호출된 값이 출력되었다. 반면 인라인 사용 후 랜더링 결과를 보면 객체를 JSON 값으로 변환하여 출력되었다.

자바스크립트 인라인 each

자바스크립트 인라인은 each를 지원하는데 다음과 같이 사용한다.

템플릿 조각 & 레이아웃

웹 페이지를 개발할 때는 공통 영역이 많이 있다. 예를 들어서 상단 영역이나 하단 영역, 좌측 카테고리 등등 여러 페이지에서 함께 사용되는 영역들이 있다. 이런 부분을 코드를 복사해서 사용한다면 변경시 여러 페이지를 모두 수정해야 하므로 상당히 비효율 적이다. 타임리프는 이러한 문제점을 해결하기 위해 템플릿 조각과 레이아웃 기능을 지원한다.

예를 들어 해당 기능을 통해 head 태그에 공통으로 사용되는 css, javascript 같은 정보들을 한 곳에 모아둘 수 있고, 각 페이지마다 개별로 적용할 css, javascript 정보를 더 추가해서 사용할 수도 있다.

build.gradle 추가

// dependecy 추가
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'

header.html

<!-- header.html -->
<th:block xmlns:th="http://www.thymeleaf.org" th:fragment="header">
    <p>header</p>
</th:block>

footer.html

<!-- footer.html -->
<th:block xmlns:th="http://www.thymeleaf.org" th:fragment="footer">
    <p>footer</p>
</th:block>

defaultLayout.html

<!-- defaultLayout.html -->
<!DOCTYPE html>
<html lagn="ko" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
    <head>
        <meta charset="UTF-8" />
        <title>My Website</title>
        <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous" />
        <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" integrity="sha512-iecdLmaskl7CVkqkXNQ/ZH/XLlvWZOJyj7Yy7tcenmpD1ypASozpmT/E0iPtmFIB46ZmdtAc9eNBvH0H/ZpiBw==" crossorigin="anonymous" referrerpolicy="no-referrer" />
        <!-- 공통 css -->
        <link rel="stylesheet" th:href="@{/css/style.css}" />
        <!-- 개별 css -->
        <th:block layout:fragment="css"></th:block>
    </head>
    <body>
        <!-- header -->
        <th:block th:replace="fragments/header :: header"></th:block>

        <!-- content -->
        <th:block layout:fragment="content"></th:block>

        <!-- footer -->
        <th:block th:replace="fragments/footer :: footer"></th:block>

        <!-- Bootstrap Js -->
        <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous"></script>
        <!-- fontawesome Js -->
        <script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/js/all.min.js" integrity="sha512-fD9DI5bZwQxOi7MhYWnnNPlvXdp/2Pj3XSTRrFs5FQa4mizyGLnJcN6tuvUS6LbmgN1ut+XGSABKvjN0H6Aoow==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
        <!-- 공통 js -->
        <script type="text/javascript" th:src="@{/js/main.js}"></script>
        <!-- 개별 js -->
        <th:block layout:fragment="script"></th:block>
    </body>
</html>

page.html

<!-- page.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layouts/defaultLayout}">
    <!-- 사용자 CSS -->
    <th:block layout:fragment="css"></th:block>

    <!-- Content -->
    <div layout:fragment="content">
        <h1>Content</h1>
    </div>

    <!-- 사용자 스크립트 -->
    <th:block layout:fragment="script"></th:block>
</html>
  • th:fragment : th:fragment가 있는 태그는 다른곳에 포함되는 코드 조각으로 이해하면 된다.
  • template/fragment/footer :: footer: template/fragment/footer.html 템플릿에 있는 th:fragment="footer"라는 부분을 템플릿 조각으로 가져와서 사용한다는 의미이다.

th:insert, th:replace

<!-- div 태그 안에 지정한 조각이 추가된다 -->
<div th:insert="~{template/fragment/footer :: footer}"></div>

<!-- div 태그가 사라지고 지정한 조각으로 대체된다 -->
<div th:insert="~{template/fragment/footer :: footer}"></div>

파라미터 사용

<div th:replace="~{template/fragment/footer :: copyParam ('데이터1', '데이터2')}"></div>
<footer th:fragment="copyParam (param1, param2)">
    <p th:text="${param1}></p>
    <p th:text="${param1}></p>
</footer>

참조
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2

profile
안녕하세요 백엔드 개발자입니다.

0개의 댓글