
이번 예제에서는 타임리프(Thymeleaf)에 대해서 다뤄보겠습니다.
Ex09Thymeleaf 프로젝트를 만들어보겠습니다. Group은 com.study, Artifact와 Name은 Ex09Thymeleaf이고, Package name은 com.study.springboot로 지정합니다. 디펜던시(Dependencies)는 Spring Web, Dev Tools, Lombok와 추가적으로 Thymeleaf를 추가한 후 zip을 다운로드 합니다. 나머지 과정은 전과 동일합니다.

resources폴더의 application.properties에 다음 내용을 추가하고 저장합니다. 예제 URL : https://bit.ly/3TJlIHX
server.port=8090
spring.thymeleaf.cache=false
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html
spring.thymeleaf.cache 속성은 Thymeleaf를 수정하고 브라우저를 새로고침하면 바로 반영이 되지 않지 않기 때문에, 캐시(cache)속성을 false로 해 주면 html파일 수정시 서버 재시작 없이 새로고침만으로 수정된 내용을 반영되게 해주어 편합니다.
spring.thymeleaf.prefix 속성은 타임리프 파일의 위치를 의미하는데, 지정하지 않으면 static폴더를 기본적으로 가르킵니다. classpath:는 src/main/resources 폴더를 의미합니다. spring.thymeleaf.suffix은 타임리프 파일의 확장자 이름입니다.
build.gradle 파일을 보면 thymeleaf 디펜던시(라이브러리)인 spring-boot-starter-thymeleaf가 들어가 있는 것을 확인할 수 있습니다. 예제 URL : https://bit.ly/3yYk4dB
생략...
dependencies {
//<추가된 부분
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
//추가된 부분>
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
생략...
인텔리제이 프로젝트뷰의 외부 라이브러리 폴더를 열어 스크롤해보면, 2.7.4 버전의 타임리프가 설치된 것을 알 수 있습니다.

com.study.springboot폴더에 MainController 클래스를 생성합니다. 예제 URL : https://bit.ly/3VNpWzW
package com.study.springboot;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class MainController {
@GetMapping("/")
public String main() {
return "redirect:index1";
}
@GetMapping("/index1")
public String index1(Model model) {
model.addAttribute("name", "홍길동");
model.addAttribute("name_html", "<h2 style='color:red;'>홍길동</h2>");
model.addAttribute("value", "서버값");
model.addAttribute("var", "변수값");
return "index1"; //index1.html로 응답함.
}
}
아래 코드를 보면 루트 경로 요청에 대해서 main 메서드가 바로 응답하지 않고, return "redirect:index1" 코드를 통해 index1 경로로 redirect한 것을 알 수 있습니다.
@GetMapping("/")
public String main() {
return "redirect:index1";
}
페이지 이동 명령어에는 redirect와 forward가 있습니다. redirect는 내부적으로 가지고 있던 데이타를 새로운 경로에서는 사용할 수 없습니다. 반면 forward는 내부적으로 가지고 있는 데이타를 가지고 새로운 경로에서 계속 사용할 수 있습니다. 향후에 예제에서 다뤄보겠습니다.
리다이렉트(Redirect)란 웹브라우저에게 다시 지정된 경로로 요청하라고 응답합니다. 그러면 웹브라우저는 서버에서 응답한 경로로 재요청합니다.
포워드(Forward)란 서버 자체적으로 새로운 경로로 이동합니다.
아래 코드를 보면 index1 메서드의 매개변수로 Model 클래스객체가 setter 주입을 통해 주입된 것을 알 수 있습니다. Model 클래스는 다음에 자세히 다루겠지만, 일단은 데이터를 전달하는 일종의 컨테이너 클래스로 생각하면 되겠습니다.
@GetMapping("/index1")
public String index1(Model model) {
생략...
return "index1"; //index1.html로 응답함.
}
정적 웹에서는 html 확장자까지 함께 주었던 것을 기억하실 겁니다.
return "index.html"; //index.html로 응답함.
하지만 동적 웹 템플릿에서는 html파일명이 기본이므로, 확장자없이 그냥 파일이름만 반환합니다.
return "index1"; //index1.html로 응답함.
index이 아니라 index1이라고 한 것은 index1,index2,index3 이렇게 여러 파일을 만드려고 한 것이니 헷갈리지 마세요.
아래 코드를 보면 Model객체에 addAttribut 메서드에 키(key)와 값(value)을 주고 데이타를 저장합니다. 그리고 타임리프를 지원하는 html파일에서 이 데이타를 바로 사용할 수 있습니다.
model.addAttribute("name", "홍길동");
templates 폴더에서 마우스 우클릭하여 index1.html을 만듭니다. 여기서는 타임리프 변수값을 출력해 보겠습니다. 예제 URL : https://bit.ly/3shjdRr
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>index1.html</title>
</head>
<body>
<h2>타임리프 변수값 출력</h2>
<p th:text="${ name }">innerText 출력</p>
<p th:utext="${ name_html }">innerHtml 출력</p>
<p th:text="'Welcome, ' + ${ name }">문자열 연결</p>
<p th:text="|Welcome, ${ name }|">치환</p>
value속성에 값 넣기<br>
<input type="text" th:value="${ value }" /><br>
<p th:with="temp=${ var }" th:text="${ temp }">temp 변수에 넣어서 출력</p>
</body>
</html>
index1.html을 설명하기 전에 일단 한번 실행해 보겠습니다. 프로젝트 실행 후 아래 URL로 요청해 봅니다.
localhost:8090
그러면 웹브라우저의 주소줄이 바뀌면서 index1.html이 출력될 것입니다.

자바 소스는 devtools 기능을 활성화 하면, 수정후 저장할 때 자동으로 서버 Restart를 해줍니다. 하지만 index1.html을 수정후 저장해도 자동으로 Restart나 Refresh가 되지 않습니다. 아쉽게도 인텔리제이 커뮤니티 버젼에서는 정적 리소스(static폴더 밑의 파일들)의 수정을 자동으로 감지하여, Refresh(서버 시작없이 내용 갱신) 또는 Restart(서버 재시작) 해주는 기능이 없습니다. 서버 재시작 버튼을 클릭해야 합니다. 유료버전인 얼티밋 버젼에서는 Refresh가 가능한 옵션이 있습니다.

html의 두번째 줄을 보면 타임리프 태그와 속성을 사용하기 위해 네임스페이스를 정의한 것을 알 수 있습니다. 이것을 정의하지 않으면, 제대로 타임리프가 동작하지 않습니다.
<html xmlns:th="http://www.thymeleaf.org">
자바 코드에는 Model 객체에 "홍길동"이라는 값을 name키에 넣어두었습니다. 그리고 html코드를 보면 p태그의 th:text속성에서 Model 객체에 들어가 name 값을 출력하도록 연결하였습니다. 그러면 타임리프 엔진이 ${ } 안에 기술한 name값을 읽어 p태그의 innerText 속성으로 바꿔줍니다.
자바 코드
model.addAttribute("name", "홍길동");
html 코드
<p th:text="${ name }">innerText 출력</p>
웹브라우저 출력
<p>홍길동</p>
"name값을 innerText표현으로 출력"이라는 문구는 자동으로 사라진다는 점이 처음에는 좀 생소할 것입니다. 타임리프가 적용되면 innerText 내용은 사라집니다.
그럼 다음 코드를 보겠습니다.
자바 코드
model.addAttribute("name_html", "<h2 style='color:red;'>홍길동</h2>");
html 코드
<p th:utext="${ name_html }">innerHtml 출력</p>
웹브라우저 출력
<p><h2 style='color:red;'>홍길동</h2></p>
웹 브라우저에 html 태그 스타일을 적용하려면, th:text 대신 th:utext 속성을 사용합니다.
타임리프를 통해 변환된 값과 기존 문자열을 합치고 싶다면 2가지 방법중에 선택하면 됩니다. 문자열 연결과 치환 입니다.
자바 코드
model.addAttribute("name", "홍길동");
html 코드
<p th:text="'Welcome, ' + ${ name }">문자열 연결</p>
<p th:text="|Welcome, ${ name }|">치환</p>
웹브라우저 출력
<span>Welcome, 홍길동</span>
<span>Welcome, 홍길동</span>
html 입력(Input) 태그의 값(value) 속성에 값을 넣으려면, th:value 속성을 이용합니다.
자바 코드
model.addAttribute("value", "서버값");
html 코드
value속성에 값 넣기<br>
<input type="text" th:value="${ value }" /><br>
웹브라우저 출력
value속성에 값 넣기<br>
<input type="text" value="서버값" /><br>
html 안에서 타임리프 변수로 재사용할 때는 th:with 속성을 이용하여 타임리프 변수를 만들 수 있습니다. th:with 속성을 통해 temp라는 변수를 만들고 th:text 속성을 이용하여 한 줄 안에서 출력할 수 있습니다.
자바 코드
model.addAttribute("var", "변수값");
html 코드
<p th:with="temp=${ var }" th:text="${ temp }">temp 변수에 넣어서 출력</p>
웹브라우저 출력
<p>변수값</p>
MainController 클래스의 다음 내용을 추가합니다. 예제 URL : https://bit.ly/3VNpWzW
생략...
@Controller
public class MainController {
생략...
@GetMapping("/index1")
public String index1(Model model) {
생략...
}
//<추가된 부분
@GetMapping("/index2")
public String index2(Model model) {
model.addAttribute("address", "한양");
model.addAttribute("address_null", null);
model.addAttribute("address_empty", "");
return "index2"; //index2.html로 응답함.
}
//추가된 부분>
}
model에 키 address에는 "한양"이라는 값이 정상적으로 들어 있습니다. 하지만 키 address_null에는 null 값이 들어 있습니다. address_empty에는 "" 빈 문자열이 들어 있습니다. 3가지 형태의 값을 타임리프에서 구분해 보도록 하겠습니다.
templates폴더에 index2.html을 만듭니다. 예제 URL : https://bit.ly/3TmzfVQ
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>index2.html</title>
</head>
<body>
<h2>index2.html입니다.</h2>
<th:block th:if="${address_null == null}">
<p>address_null은 null입니다.</p>
</th:block>
<th:block th:unless="${address_null == null}">
<p>address_null은 null이 아닙니다.</p>
</th:block>
</body>
</html>
실행해서 localhost:8090/index2로 호출해 보면, 웹브라우저에 아래와 같이 보일겁니다.

th:block 태그는 타임리프 자체 태그이며, 웹브라우저에 내려가는 html이 만들어지면 사라집니다. th:if 속성은 타임리프의 조건적 수행을 도와주는 속성입니다. address_null은 null 값이므로 <p >address_null은 null입니다.</p>만 웹브라우저에 출력되고, <p>address_null은 null이 아닙니다.</p>은 출력되지 않습니다.
좀더 코드를 추가해 보겠습니다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
생략...
<body>
생략...
<!-- <추가된 부분 -->
<p th:text="${ address_null } ?: 'address_null은 null입니다.'">삼항연산자</p>
<p th:text="${#strings.defaultString(address_null, 'address_null은 null입니다.')}">defaultString</p>
<p th:if="${#strings.isEmpty(address_empty)}" th:text="|address_empty은 emtpy입니다.|"></p>
<p th:if="${not #strings.isEmpty(address_empty)}" th:text="|address_empty은 emtpy가 아닙니다.|"></p>
<!-- 추가된 부분> -->
</body>
</html>
실행해 보겠습니다.

자바 코드
model.addAttribute("address", "한양");
model.addAttribute("address_null", null);
model.addAttribute("address_empty", "");
html 코드
<p th:text="${ address_null } ?: 'address_null은 null입니다.'">삼항연산자</p>
웹브라우저 출력
<p>address_null은 null입니다.</p>
삼항연산자 ?:을 통해서도 null 체크를 할 수 있습니다. address_null의 값이 null일때는 ?: 뒤에 있는 값이 innerText값으로 들어갑니다.
자바 코드
model.addAttribute("address", "한양");
model.addAttribute("address_null", null);
model.addAttribute("address_empty", "");
html 코드
<p th:text="${#strings.defaultString(address_null, 'address_null은 null입니다.')}">defaultString</p>
웹브라우저 출력
<p>address_null은 null입니다.</p>
#strings.defaultString 메서드를 이용하면, null 일때의 기본값을 설정할 수 있습니다.
자바 코드
model.addAttribute("address", "한양");
model.addAttribute("address_null", null);
model.addAttribute("address_empty", "");
html 코드
<p th:if="${#strings.isEmpty(address_empty)}" th:text="|address_empty은 emtpy입니다.|"></p>
<p th:if="${not #strings.isEmpty(address_empty)}" th:text="|address_empty은 emtpy가 아닙니다.|"></p>
웹브라우저 출력
<p>address_empty은 emtpy입니다.</p>
#strings.isEmpty 메서드를 이용하면, null이거나 empty(문자열의 길이가 0)인 상태를 true/false로 확인할 수 있습니다. not 연산자를 이용하여, true/false 값을 반전할 수도 있습니다. 이 경우 "|address_empty은 emtpy가 아닙니다.|" 문자열은 출력되지 않습니다. 세로바 | 문자가 있는 이유는 th:text에 변수가 없고 오직 문자열만 있기 때문에 세로바 | 문자를 쓰지 않으면 오류가 생기기 때문입니다.
타임리프는 여러가지 표현 형식을 지원합니다. 대표적으로 날짜와 시간, 숫자에 대한 표현 형식에 대해서 알아보겠습니다.
MainController 클래스에 아래 코드를 추가합니다. 예제 URL : https://bit.ly/3VNpWzW
생략...
@Controller
public class MainController {
생략...
@GetMapping("/index2")
public String index2(Model model) {
생략...
}
@GetMapping("/index3")
public String index3(Model model) {
model.addAttribute( "standardDate", new Date() );
model.addAttribute( "localDate", LocalDate.now() );
model.addAttribute( "localDateTime", LocalDateTime.now() );
return "index3"; //index3.html로 응답함.
}
}
index3.html을 생성하고 아래와 같이 추가합니다. 예제 URL : https://bit.ly/3CUUpDA
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>index2.html</title>
</head>
<body>
<h2>타임리프 표현 형식</h2>
<p th:text="${#dates.format(standardDate, 'yyyy/MM/dd HH:mm:ss')}"></p>
<p th:text="${#temporals.format(localDate, 'yyyy/MM/dd HH:mm:ss')}"></p>
<p th:text="${#temporals.format(localDateTime, 'yyyy/MM/dd HH:mm:ss')}"></p>
</body>
</html>
localhost:8090/index3으로 요청하면 아래와 같이 출력될 것입니다.

Date 클래스는 JDK 1.0부터 지원하던 날짜 관련 클래스입니다. JDK 1.8부터 새롭게 지원하는 LocalDate, LocalDateTime클래스를 사용하는 것을 권장합니다. 왜냐면 Date 클래스는 멀티 스레드 환경에서 불변성(싱글톤)을 지원하지 않기 때문입니다. 다양한 기능이 LocalDate와 LocalDateTime에 들어 있으므로, Date 클래스와 Calendar 클래스의 조합을 쓰지 않아도 됩니다.
자바 코드
model.addAttribute( "standardDate", new Date() );
model.addAttribute( "localDate", LocalDate.now() );
model.addAttribute( "localDateTime", LocalDateTime.now() );
html 코드
<p th:text="${#dates.format(standardDate, 'yyyy/MM/dd HH:mm:ss')}"></p>
<p th:text="${#temporals.format(localDate, 'yyyy/MM/dd HH:mm:ss')}"></p>
<p th:text="${#temporals.format(localDateTime, 'yyyy/MM/dd HH:mm:ss')}"></p>
웹브라우저 출력
<p>2022/10/21 16:53:37</p>
<p>2022/10/21 00:00:00</p>
<p>2022/10/21 16:53:37</p>
정수 값 표현에는 #numbers.formatInteger를, 부동소수점 표현에는 #numbers.formatDecimal을 사용합니다. 세자리 수마다 쉼표를 찍어 읽기 편하게 출력할 수 있습니다. 123,456.79 값은 소숫점 2째 자리에서 반올림된 것입니다.
자바 코드
model.addAttribute( "number1", 12345678 );
model.addAttribute( "number2", 123456.789 );
html 코드
<p th:text="${#numbers.formatInteger(number1, 3, 'COMMA')}"></p>
<p th:text="|${#numbers.formatInteger(number1, 3, 'COMMA')}원|"></p>
<p th:text="${#numbers.formatDecimal(number2, 3, 'COMMA', 2, 'POINT')}"></p>
웹브라우저 출력
<p>12,345,678</p>
<p>12,345,678원</p>
<p>123,456.79</p>

타임리프에서 조건적인 출력은 th:if와 th:switch 속성을 이용하여 가능합니다. 먼저 th:if를 이용하여 조건적인 출력을 해보겠습니다.
MainController 클래스에 아래 코드를 추가합니다. 예제 URL : https://bit.ly/3VNpWzW
생략...
@Controller
public class MainController {
생략...
@GetMapping("/index3")
public String index3(Model model) {
생략...
}
@GetMapping("/index4")
public String index4(Model model) {
model.addAttribute("role", "admin");
return "index4"; //index4.html로 응답함.
}
}
index4.html을 생성하고 아래와 같이 추가합니다. 예제 URL : https://bit.ly/3D0XuCd
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>index4.html</title>
</head>
<body>
<h2>타임리프 조건적인 출력</h2>
<!--if( 조건절 )-->
<p th:if="${ role }=='admin'" th:text="'사용자는 관리자입니다.'"><p>
<!--if( ! 조건절 )-->
<p th:unless="${ role }=='admin'" th:text="'사용자는 관리자가 아닙니다.'"></p>
<!--if( 조건절 )-->
<th:block th:if="${ role }=='admin'">
사용자는 관리자입니다.
</th:block>
<!--if( ! 조건절 )-->
<th:block th:unless="${ role }=='admin'">
사용자는 관리자가 아닙니다.
</th:block>
<!-- * : java switch의 default역할 -->
<div th:switch="${ role }">
<p th:case="'admin'">사용자는 관리자입니다.
<p th:case="'guest'">사용자는 손님입니다.
<p th:case="*">사용자는 그외 권한입니다.
</div>
<br><br>
</body>
</html>
localhost:8090/index4으로 요청하면 아래와 같이 출력될 것입니다.

자바 코드
model.addAttribute("role", "admin");
html 코드
<!--if( 조건절 )-->
<p th:if="${ role }=='admin'" th:text="'사용자는 관리자입니다.'"><p>
<!--if( ! 조건절 )-->
<p th:unless="${ role }=='admin'" th:text="'사용자는 관리자가 아닙니다.'"></p>
웹브라우저 출력
<!--if( 조건절 )-->
<p>사용자는 관리자입니다.<p>
<!--if( ! 조건절 )-->
th:if 속성이 p태그에 들어가서 문자열을 비교합니다. 그리고 비교연산자 ==의 결과가 true값이면, th:text값이 출력되고, false이면 th:text값이 출력되지 않습니다.
th:unless 속성은 th:if="! 조건절"과 같은 연산으로 조건절의 논리반전입니다. 즉 조건절이 최종결과가 true가 되어 th:text값이 출력됩니다.
자바 코드
model.addAttribute("role", "admin");
html 코드
<!--if( 조건절 )-->
<th:block th:if="${ role }=='admin'">
사용자는 관리자입니다.
</th:block>
<!--if( ! 조건절 )-->
<th:block th:unless="${ role }=='admin'">
사용자는 관리자가 아닙니다.
</th:block>
웹브라우저 출력
<!--if( 조건절 )-->
사용자는 관리자입니다.
<!--if( ! 조건절 )-->
th:block은 타임리프에만 존재하는 임시 태그입니다. 웹브라우저에 응답시 html에는 존재하지 않습니다.
자바 코드
model.addAttribute("role", "admin");
html 코드
<div th:switch="${ role }">
<p th:case="'admin'">사용자는 관리자입니다.
<p th:case="'guest'">사용자는 손님입니다.
<!-- * : java switch의 default역할 -->
<p th:case="*">사용자는 그외 권한입니다.
</div>
웹브라우저 출력
<div>
<p>사용자는 관리자입니다.</p>
</div>
th:switch은 자바의 switch문과 유사합니다. th:case에 들어가는 문자열이 맞을 때 해당하는 p태그가 출력됩니다.
타임리프에서 객체 데이타를 출력해 보겠습니다.
com.study.springboot 폴더에 Member 클래스를 생성합니다. 예제 URL : https://bit.ly/3zbnkCd
package com.study.springboot;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Member {
private String username;
private String password;
}
@Data를 통해 getter, setter, 매개변수가 없는 기본생성자를 자동생성합니다. 하지만 모든 필드를 매개변수로 받는 생성자를 자동생성하기 위해 @AllArgsConstructor를 넣었습니다. 그러자 다시 기본생성자가 생성되지 않아 @NoArgsConstructor를 추가했습니다.
MainController 클래스에 아래 코드를 추가합니다. 예제 URL : https://bit.ly/3VNpWzW
생략...
import java.util.ArrayList;
import java.util.List;
생략...
@Controller
public class MainController {
생략...
@GetMapping("/index4")
public String index4(Model model) {
생략...
}
@GetMapping("index5")
public String index5(Model model) {
Member member = new Member("hong", "1234");
model.addAttribute("member", member);
List<Member> list = new ArrayList<Member>();
list.add( new Member("lee", "2222") );
list.add( new Member("hana", "3333") );
list.add( new Member("tom", "4444") );
model.addAttribute("list", list);
return "index5"; //index5.html로 응답함.
}
}
index5.html을 생성합니다. 예제 URL : https://bit.ly/3N3h0Tc
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>index5.html</title>
</head>
<body>
<h2>클래스 객체와 리스트 출력</h2>
<p th:text="${ member.username }"></p>
<p th:text="${ member.password }"></p>
<table border="1">
<tr th:object="${ member }">
<td><span>1</span></td>
<td><span th:text="*{ username }"></span></td>
<td><span th:text="*{ password }"></span></td>
</tr>
</table>
<br>
<th:block th:if="${ member.username } == 'hong'">
hong입니다.
</th:block>
<th:block th:unless="${ member.username } == 'hong'">
hong이 아닙니다.
</th:block>
<br>
<table border="1">
<tr th:each="member, status:${ list }">
<td><span th:text="${ status.count }"></span></td>
<td><span th:text="${ member.username }"></span></td>
<td><span th:text="${ member.password }"></span></td>
</tr>
</table>
<br>
</body>
</html>
localhost:8090/index5으로 요청하면 아래와 같이 출력될 것입니다.

자바 코드
Member member = new Member("hong", "1234");
model.addAttribute("member", member);
html 코드
<p th:text="${ member.username }"></p>
<p th:text="${ member.password }"></p>
웹브라우저 출력
<p>hong</p>
<p>1234</p>
자바 코드에서 Member 객체를 생성하고 Model에 넣으면, html에서는 키(key)인 member로 바로 읽으면 됩니다. 이때 ${ } 심볼을 사용합니다. 객체의 필드에 접근하려면 member.username, member.password 이렇게 점(.)을 찍고 접근하면 됩니다.
자바 코드
Member member = new Member("hong", "1234");
model.addAttribute("member", member);
html 코드
<table border="1">
<tr th:object="${ member }">
<td><span>1</span></td>
<td><span th:text="*{ username }"></span></td>
<td><span th:text="*{ password }"></span></td>
</tr>
</table>
웹브라우저 출력
<table border="1">
<tr>
<td><span>1</span></td>
<td><span>hong</span></td>
<td><span>1234</span></td>
</tr>
</table>
td 태그의 상위 태그인 tr태그에서 th:object 속성으로 객체 이름을 지정하면, td 태그에서는 *{ } 심볼을 이용하여, 객체이름을 생략할 수 있습니다.
자바 코드
Member member = new Member("hong", "1234");
model.addAttribute("member", member);
html 코드
<th:block th:if="${ member.username } == 'hong'">
hong입니다.
</th:block>
<th:block th:unless="${ member.username } == 'hong'">
hong이 아닙니다.
</th:block>
웹브라우저 출력
hong입니다.
th:if 속성을 이용하여 조건적인 출력도 가능합니다.
자바 코드
List<Member> list = new ArrayList<Member>();
list.add( new Member("lee", "2222") );
list.add( new Member("hana", "3333") );
list.add( new Member("tom", "4444") );
model.addAttribute("list", list);
html 코드
<table border="1">
<tr th:each="member, status:${ list }">
<td><span th:text="${ status.count }"></span></td>
<td><span th:text="${ member.username }"></span></td>
<td><span th:text="${ member.password }"></span></td>
</tr>
</table>
웹브라우저 출력
<table border="1">
<tr>
<td><span>1</span></td>
<td><span>lee</span></td>
<td><span>2222</span></td>
</tr>
<tr>
<td><span>2</span></td>
<td><span>hana</span></td>
<td><span>3333</span></td>
</tr>
<tr>
<td><span>3</span></td>
<td><span>tom</span></td>
<td><span>4444</span></td>
</tr>
</table>
객체 리스트를 생성하고 Model에 넣어두면, th:each 속성을 이용하여, 반복적인 출력이 가능합니다. status는 th:each 안에서의 반복속성을 값을 가지는 상태변수입니다.
status.index : 현재 반복 인덱스 (0부터 시작합니다.)
status.count : 현재 반복 인덱스 (1부터 시작합니다.)
status.size : 총 요소 수
status.current : 현재 요소
status.even : 현재 반복이 짝수인지 여부 (boolean값)
status.odd : 현재 반복이 홀수인지 여부 (boolean값)
status.first : 현재 반복이 첫번째인지 여부 (boolean값)
status.last : 현재 반복이 마지막인지 여부 (boolean값)
th:href는 a 태그에서 사용되면, 바로 서버의 URL을 요청하는 용도로 사용됩니다.
MainController 클래스에 아래 코드를 추가합니다. 예제 URL : https://bit.ly/3VNpWzW
생략...
@Controller
public class MainController {
생략...
@GetMapping("/index5")
public String index5(Model model) {
생략...
}
@GetMapping("/index6")
public String index6() {
return "index6"; //index6.html로 응답함.
}
@GetMapping("/index7")
public String index7() {
return "index7"; //index7.html로 응답함.
}
}
index6.html, index7.html을 생성합니다. 예제 URL : https://bit.ly/3N3h0Tc
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>index6.html</title>
</head>
<body>
<a th:href="@{ index7 }">타임리프 링크입니다.</a>
</body>
</html>
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>index7.html</title>
</head>
<body>
"/index7" 링크로 넘어온 index7.html입니다.
</body>
</html>
localhost:8090/index6으로 요청하면 아래와 같이 출력될 것입니다.

링크를 출력하면 th:href="@{ index7 }" 부분을 호출하게 됩니다. 자바 코드의 @GetMapping("/index7")에 맵핑되어, index7.html을 응답하게 됩니다.

웹 사이트에는 공통적으로 들어가는 헤더나 푸터나 사이드메뉴 같은 요소가 있습니다. 이렇게 반복적으로 들어가는 요소는 레이아웃과 프래그먼트를 통해, 반복을 줄여줄 수 있습니다.
아래 그림을 보면, html 페이지 구조에서 매 페이지마다 반복적으로 들어가는 요소는 헤드 head(meta, link 정보 포함)와 body 콘텐츠내에서도 헤더 header(nav 메뉴 등이 들어감)와 푸터 footer(회사소개 및 페이지정보) 요소가 있습니다. content 요소는 매 페이지마다 바뀌는 고유의 정보라고 할 수 있습니다. content 요소만 제외하고 나머지는 매 페이지 마다 동일합니다.

Ex09예제는 충분히 복잡해졌습니다. 이해를 돕기 위해 예제프로젝트를 따로 만들겠습니다.
Ex10ThymeleafLayout 프로젝트를 만듭니다. Group은 com.study, Artifact와 Name은 Ex10ThymeleafLayout이고, Package name은 com.study.springboot로 지정합니다. 디펜던시(Dependencies)는 Spring Web, Dev Tools, Lombok와 추가적으로 Thymeleaf를 추가한 후 zip을 다운로드 합니다. 나머지 과정은 전과 동일합니다.

resources폴더의 application.properties에 다음 내용을 추가하고 저장합니다. 예제 URL : https://bit.ly/3TU0ebu
server.port=8090
spring.thymeleaf.cache=false
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html
build.gradle에 thymeleaf-layout-dialect이라는 라이브러리를 사용해서 thymeleaf 템플릿 엔진에 레이아웃 기능을 확장해 보겠습니다.
build.gralde에 아래 내용을 추가해줍니다. 예제 URL : https://bit.ly/3N0BMmu
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'
생략...
dependencies {
//<추가된 내용
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'
//추가된 내용>
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
생략...
추가후 상단의 코끼리 아이콘을 눌러 라이브러리 디펜던시 새로고침을 해줍니다.

먼저 templates 폴더 밑에 first.html, second.html을 생성합니다. 예제 URL : https://bit.ly/3suqttj
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{ layout }">
<!-- 콘텐츠페이지(first.html)의 head내용이 layout.html의 head에 자동으로 추가된다. -->
<head>
<!-- first.html/head 시작 -->
<!-- first.html/title -->
<title>index.html 타이틀</title>
<!-- first.html/css -->
<link rel="stylesheet" href="/css/first.css">
<!-- first.html/js -->
<script></script>
<!-- first.html/head 끝 -->
</head>
<!-- first.html 고유 content 추가 -->
<div layout:fragment="content">
<!-- first.html 화면 시작 -->
<h2>first.html 화면</h2>
<!-- first.html 화면 끝 -->
</div>
</html>
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{ layout }">
<!-- 콘텐츠페이지(second.html)의 head내용이 layout.html의 head에 자동으로 추가된다. -->
<head>
<!-- second.html/head 시작 -->
<!-- second.html/title -->
<title>second.html 타이틀</title>
<!-- second.html/css -->
<link rel="stylesheet" href="/css/second.css">
<!-- second.html/js -->
<script></script>
<!-- second.html/head 끝 -->
</head>
<!-- second.html 고유 content 추가 -->
<div layout:fragment="content">
<!-- second.html 화면 시작 -->
<h2>second.html 화면</h2>
<!-- second.html 화면 끝 -->
</div>
</html>
그 다음 templates 폴더 밑에 layout.html을 생성합니다. 예제 URL : https://bit.ly/3suqttj
<!DOCTYPE html>
<html lang="ko"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<!-- layout/head 시작 -->
<th:block th:replace="fragments/head :: headFragment"></th:block>
<!-- layout/head 끝 -->
</head>
<body>
<!-- layout/header 시작 -->
<header th:replace="fragments/header :: headerFragment"></header>
<!-- layout/header 끝 -->
<!--layout/ content 시작 -->
<div layout:fragment="content"></div>
<!-- layout/content 끝 -->
<!-- layout/footer 시작 -->
<footer th:replace="fragments/footer :: footerFragment"></footer>
<!-- layout/footer 끝 -->
</body>
</html>
처음에는 좀 복잡해 보입니다. 자세히 함께 살펴보겠습니다.
first.html, second.html 내용을 보면, layout:decorate="~{ layout }" 속성을 통해 같은 폴더의 layout.html을 레이아웃 템플릿으로 사용하겠다고 선언합니다.
그러면 first.html, second.html의 head는 layout.html의 head태그에 자동으로 추가됩니다. 그리고 first.html, second.html의 layout:fragment="content" 속성 안의 내용은 각각 자체적인 화면요소로서 layout.html의 <div layout:fragment="content"></div> 요소로 대체 됩니다.

templates폴더 밑에 fragments 폴더를 만들고, head.html, header.html, footer.html을 생성합니다. 예제 URL : https://bit.ly/3f3t7Ty
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<th:block th:fragment="headFragment">
<!-- head.html 시작 -->
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>head.html 타이틀</title>
<!-- head.html/css-->
<!-- head.html/js -->
<!-- head.html 끝 -->
</th:block>
</html>
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<header th:fragment="headerFragment">
<!-- header.html 시작 -->
<link rel="stylesheet" href="/css/header.css">
<h2 id="header">헤더입니다.</h2>
<!-- header.html 끝 -->
</header>
</html>
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<footer th:fragment="footerFragment">
<!-- footer.html 시작 -->
<link rel="stylesheet" href="/css/footer.css">
<h2 id="footer">푸터입니다.</h2>
<!-- footer.html 끝 -->
</footer>
</html>
fragments/head.html의 headFragment요소는 layout.html의 th:replace 요소와 대체됩니다.
<th:block th:replace="fragments/head :: headFragment"></th:block>
fragments/header.html의 headerFragment요소는 layout.html의 th:replace 요소와 대체됩니다.
<header th:replace="fragments/header :: headerFragment"></header>
fragments/footer.html의 headerFragment요소는 layout.html의 th:replace 요소와 대체됩니다.
<footer th:replace="fragments/footer :: footerFragment"></footer>

전체적인 구성도는 아래와 같습니다.

프로젝트뷰의 폴더 구조는 아래와 같습니다.

static 폴더 밑에 css폴더를 생성하고, first.css, second.css, header.css, footer.css 파일을 만들겠습니다. img폴더와 js폴더는 생성만하고 그냥 비워두겠습니다. 예제 URL : https://bit.ly/3f2MCf6
/* first */
div {
border: 3px solid blue;
}
/* second */
div {
border: 3px solid green;
}
/* header */
* {
margin:0 auto;
padding:0;
}
#header {
color: red;
}
/* footer */
#footer {
color: green;
}

이제 만들어진 레이아웃을 테스트할 때가 되었습니다.
com.study.springboot폴더 밑에 MainController 클래스를 만들겠습니다. 예제 URL : https://bit.ly/3Du4vgn
package com.study.spring;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class MainController {
@GetMapping("/")
public String main() {
return "first"; //first.html로 응답함.
}
@GetMapping("/second")
public String second() {
return "second"; //second.html로 응답함.
}
}
localhost:8090으로 요청하면, 아래와 같이 화면이 출력됩니다.

localhost:8090/second으로 요청하면, 아래와 같이 화면이 출력됩니다.

웹브라우저에서 우클릭하여 페이지 소스 보기를 하면, 웹브라우저에 출력된 html파일 내용을 볼수 있습니다. localhost:8090/second 요청에서 보면, 아래와 같이 html 내용이 보일 것 입니다. 요약된 내용은 아래와 같습니다.
<!DOCTYPE html>
<html lang="ko">
<head>
<!-- layout/head 시작 -->
생략...
<!-- layout/head 끝 -->
<!-- second.html/head 시작 -->
생략...
<!-- second.html/head 끝 -->
</head>
<body>
<!-- layout/header 시작 -->
생략...
<!-- layout/header 끝 -->
<!--layout/ content 시작 -->
생략...
<!-- layout/content 끝 -->
<!-- layout/footer 시작 -->
생략
<!-- layout/footer 끝 -->
</body>
</html>
first.html을 요청하거나 second.html을 요청하여도 layout.html 템플릿을 유지하면서, first.html과 seconde.html의 head와 content 요소를 가져다 사용하는 것을 알 수 있을 것입니다.
처음에는 복잡해 보이지만, 계속 파악해보면 연관관계를 알 수 있을 것입니다.