농담곰담곰이의곰담농

Thymeleaf 문법

by 브이담곰

Thymeleaf는 Spring Boot에서 가장 많이 사용되는 템플릿 엔진입니다. HTML과 자연스럽게 통합되어 서버사이드 렌더링을 지원한다.

🚀 기본 설정

HTML 템플릿 기본 구조

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Thymeleaf 예제</title>
</head>
<body>
    <!-- Thymeleaf 문법 사용 -->
</body>
</html>

Controller에서 데이터 전달

@Controller
public class HomeController {

    @GetMapping("/")
    public String home(Model model) {
        model.addAttribute("message", "안녕하세요!");
        model.addAttribute("user", new User("김철수", 25));
        model.addAttribute("products", Arrays.asList("상품1", "상품2", "상품3"));
        return "index";
    }
}

📝 기본 문법

1. 텍스트 출력 (th:text)

<!-- 기본 텍스트 출력 -->
<p th:text="${message}">기본 메시지</p>
<!-- 결과: <p>안녕하세요!</p> -->

<!-- 객체의 속성 접근 -->
<p th:text="${user.name}">사용자 이름</p>
<!-- 결과: <p>김철수</p> -->

<!-- 표현식과 문자열 결합 -->
<p th:text="'안녕하세요, ' + ${user.name} + '님!'">인사말</p>
<!-- 결과: <p>안녕하세요, 김철수님!</p> -->

2. HTML 출력 (th:utext)

<!-- HTML 태그가 포함된 텍스트 -->
<div th:utext="${htmlContent}">기본 내용</div>
<!-- htmlContent = "<strong>굵은 글씨</strong>" -->
<!-- 결과: <div><strong>굵은 글씨</strong></div> -->

<!-- th:text와 비교 -->
<div th:text="${htmlContent}">기본 내용</div>
<!-- 결과: <div>&lt;strong&gt;굵은 글씨&lt;/strong&gt;</div> -->

3. 속성 값 설정 (th:속성명)

<!-- value 속성 설정 -->
<input type="text" th:value="${user.name}" />
<!-- 결과: <input type="text" value="김철수" /> -->

<!-- href 속성 설정 -->
<a th:href="'/user/' + ${user.id}">사용자 상세</a>
<!-- 결과: <a href="/user/123">사용자 상세</a> -->

<!-- class 속성 설정 -->
<div th:class="${user.active} ? 'active' : 'inactive'">상태</div>

<!-- id 속성 설정 -->
<div th:id="'user-' + ${user.id}">사용자 정보</div>
<!-- 결과: <div id="user-123">사용자 정보</div> -->

<!-- 여러 속성 동시 설정 -->
<input th:attr="type='text',value=${user.name},placeholder='이름을 입력하세요'" />

🔗 URL 생성 (@{...})

기본 URL 생성

<!-- 정적 URL -->
<a th:href="@{/home}">홈으로</a>
<!-- 결과: <a href="/home">홈으로</a> -->

<!-- 동적 URL (경로 변수) -->
<a th:href="@{/user/{id}(id=${user.id})}">사용자 상세</a>
<!-- 결과: <a href="/user/123">사용자 상세</a> -->

<!-- 쿼리 파라미터 -->
<a th:href="@{/search(keyword=${keyword},page=${page})}">검색</a>
<!-- 결과: <a href="/search?keyword=spring&page=1">검색</a> -->

<!-- 경로 변수 + 쿼리 파라미터 -->
<a th:href="@{/user/{id}/orders(id=${user.id},status=${orderStatus})}">주문 목록</a>
<!-- 결과: <a href="/user/123/orders?status=PENDING">주문 목록</a> -->

폼 action URL

<!-- 기본 폼 -->
<form th:action="@{/user/save}" method="post">
    <input type="text" name="name" th:value="${user.name}" />
    <input type="submit" value="저장" />
</form>

<!-- 동적 폼 action -->
<form th:action="@{/user/{id}/update(id=${user.id})}" method="post">
    <input type="text" name="name" th:value="${user.name}" />
    <input type="submit" value="수정" />
</form>

🔄 조건문 (th:if, th:unless, th:switch)

if/unless 조건문

<!-- th:if - 조건이 true일 때 렌더링 -->
<div th:if="${user.age >= 18}">
    성인 사용자입니다.
</div>

<!-- th:unless - 조건이 false일 때 렌더링 -->
<div th:unless="${user.age >= 18}">
    미성년 사용자입니다.
</div>

<!-- 복합 조건 -->
<div th:if="${user != null and user.active}">
    활성 사용자입니다.
</div>

<!-- 문자열 비교 -->
<div th:if="${user.role == 'ADMIN'}">
    관리자 메뉴
</div>

<!-- null 체크 -->
<div th:if="${user?.email != null}">
    이메일: <span th:text="${user.email}"></span>
</div>

switch/case 조건문

<div th:switch="${user.role}">
    <p th:case="'ADMIN'">관리자입니다.</p>
    <p th:case="'USER'">일반 사용자입니다.</p>
    <p th:case="'GUEST'">게스트입니다.</p>
    <p th:case="*">알 수 없는 역할입니다.</p>
</div>

🔁 반복문 (th:each)

기본 반복문

<!-- 리스트 반복 -->
<ul>
    <li th:each="product : ${products}" th:text="${product}">상품명</li>
</ul>
<!-- 결과:
<ul>
    <li>상품1</li>
    <li>상품2</li>
    <li>상품3</li>
</ul>
-->

<!-- 객체 리스트 반복 -->
<table>
    <tr th:each="user : ${users}">
        <td th:text="${user.id}">ID</td>
        <td th:text="${user.name}">이름</td>
        <td th:text="${user.email}">이메일</td>
    </tr>
</table>

반복 상태 변수 (Status Variable)

<table>
    <tr th:each="user, status : ${users}">
        <td th:text="${status.index}">인덱스</td>      <!-- 0부터 시작 -->
        <td th:text="${status.count}">순번</td>        <!-- 1부터 시작 -->
        <td th:text="${user.name}">이름</td>
        <td th:text="${status.odd} ? '홀수' : '짝수'">순서</td>
        <td th:if="${status.first}">첫 번째</td>
        <td th:if="${status.last}">마지막</td>
    </tr>
</table>

Map 반복

<!-- Map 순회 -->
<div th:each="entry : ${userMap}">
    <p>키: <span th:text="${entry.key}"></span></p>
    <p>값: <span th:text="${entry.value}"></span></p>
</div>

📋 폼 처리

기본 폼 바인딩

<!-- 객체 바인딩 -->
<form th:action="@{/user/save}" th:object="${user}" method="post">
    <input type="text" th:field="*{name}" placeholder="이름" />
    <input type="email" th:field="*{email}" placeholder="이메일" />
    <input type="number" th:field="*{age}" placeholder="나이" />
    <select th:field="*{role}">
        <option value="USER">사용자</option>
        <option value="ADMIN">관리자</option>
    </select>
    <input type="submit" value="저장" />
</form>

체크박스와 라디오 버튼

<!-- 체크박스 -->
<input type="checkbox" th:field="*{active}" />
<label th:for="${#ids.prev('active')}">활성 상태</label>

<!-- 여러 체크박스 -->
<div th:each="hobby : ${hobbies}">
    <input type="checkbox" th:field="*{selectedHobbies}" th:value="${hobby}" />
    <label th:for="${#ids.prev('selectedHobbies')}" th:text="${hobby}">취미</label>
</div>

<!-- 라디오 버튼 -->
<div th:each="gender : ${genders}">
    <input type="radio" th:field="*{gender}" th:value="${gender}" />
    <label th:for="${#ids.prev('gender')}" th:text="${gender}">성별</label>
</div>

선택 옵션 (Select)

<!-- 기본 셀렉트 -->
<select th:field="*{categoryId}">
    <option value="">카테고리 선택</option>
    <option th:each="category : ${categories}" 
            th:value="${category.id}" 
            th:text="${category.name}">카테고리</option>
</select>

<!-- 선택된 값 표시 -->
<select th:field="*{role}">
    <option th:each="role : ${roles}" 
            th:value="${role}" 
            th:text="${role}"
            th:selected="${role == user.role}">역할</option>
</select>

🎨 CSS 클래스와 스타일

동적 클래스 적용

<!-- 조건부 클래스 -->
<div th:class="${user.active} ? 'active' : 'inactive'">상태</div>

<!-- 기존 클래스에 추가 -->
<div class="base-class" th:classappend="${user.premium} ? 'premium' : ''">
    사용자 정보
</div>

<!-- 클래스 제거 -->
<div class="default error" th:classremove="${user.valid} ? 'error' : ''">
    입력 필드
</div>

동적 스타일 적용

<!-- 인라인 스타일 -->
<div th:style="'background-color: ' + ${user.favoriteColor}">배경색</div>

<!-- 조건부 스타일 -->
<div th:style="${user.online} ? 'color: green;' : 'color: gray;'">
    온라인 상태
</div>

🔧 유틸리티 객체

날짜 처리 (#dates)

<!-- 현재 날짜 -->
<p th:text="${#dates.createNow()}">현재 시간</p>

<!-- 날짜 포맷팅 -->
<p th:text="${#dates.format(user.createdAt, 'yyyy-MM-dd HH:mm:ss')}">
    생성일시
</p>

<!-- 날짜 계산 -->
<p th:text="${#dates.daysBetween(startDate, endDate)}">일수 차이</p>

문자열 처리 (#strings)

<!-- 문자열 길이 -->
<p th:text="${#strings.length(user.name)}">이름 길이</p>

<!-- 문자열 자르기 -->
<p th:text="${#strings.substring(user.description, 0, 50)}">설명</p>

<!-- 대소문자 변환 -->
<p th:text="${#strings.toUpperCase(user.name)}">대문자 이름</p>

<!-- 문자열 체크 -->
<div th:if="${#strings.isEmpty(user.email)}">이메일이 없습니다.</div>
<div th:if="${#strings.contains(user.name, '김')}">김씨입니다.</div>

숫자 처리 (#numbers)

<!-- 숫자 포맷팅 -->
<p th:text="${#numbers.formatDecimal(product.price, 0, 'COMMA', 0, 'POINT')}">
    가격: 1,000
</p>

<!-- 퍼센트 -->
<p th:text="${#numbers.formatPercent(rate, 1, 2)}">비율</p>

컬렉션 처리 (#lists, #sets, #maps)

<!-- 리스트 크기 -->
<p th:text="'총 ' + ${#lists.size(products)} + '개의 상품'">상품 개수</p>

<!-- 리스트 비어있는지 확인 -->
<div th:if="${#lists.isEmpty(products)}">상품이 없습니다.</div>

<!-- 리스트 포함 여부 -->
<div th:if="${#lists.contains(favoriteProducts, product.id)}">
    즐겨찾기 상품입니다.
</div>

🌐 국제화 (i18n)

메시지 사용

<!-- 기본 메시지 -->
<p th:text="#{welcome.message}">환영 메시지</p>

<!-- 파라미터가 있는 메시지 -->
<p th:text="#{welcome.user(${user.name})}">사용자 환영 메시지</p>

<!-- 조건부 메시지 -->
<p th:text="#{${user.gender == 'M'} ? 'male.message' : 'female.message'}">
    성별별 메시지
</p>

properties 파일 예시

# messages.properties
welcome.message=환영합니다!
welcome.user=환영합니다, {0}님!
male.message=남성 사용자입니다.
female.message=여성 사용자입니다.

🧩 프래그먼트 (Fragment)

프래그먼트 정의

<!-- header.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:fragment="head">
    <meta charset="UTF-8">
    <title>공통 헤더</title>
    <link rel="stylesheet" href="/css/common.css">
</head>
<body>
    <header th:fragment="header">
        <nav>
            <a href="/">홈</a>
            <a href="/products">상품</a>
            <a href="/about">소개</a>
        </nav>
    </header>
</body>
</html>

프래그먼트 사용

<!-- main.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{header :: head}"></head>
<body>
    <!-- 헤더 포함 -->
    <div th:replace="~{header :: header}"></div>

    <!-- 메인 컨텐츠 -->
    <main>
        <h1>메인 페이지</h1>
        <p>내용...</p>
    </main>

    <!-- 푸터 포함 -->
    <div th:include="~{footer :: footer}"></div>
</body>
</html>

파라미터가 있는 프래그먼트

<!-- components.html -->
<div th:fragment="card(title, content)">
    <div class="card">
        <h3 th:text="${title}">제목</h3>
        <p th:text="${content}">내용</p>
    </div>
</div>

<!-- 사용 -->
<div th:replace="~{components :: card('공지사항', '새로운 기능이 추가되었습니다.')}">
</div>

🔍 표현식 언어

변수 표현식 (${...})

<!-- 기본 변수 -->
<p th:text="${user.name}">이름</p>

<!-- 중첩 객체 -->
<p th:text="${user.address.city}">도시</p>

<!-- 메서드 호출 -->
<p th:text="${user.getFullName()}">전체 이름</p>

<!-- null 안전 연산자 -->
<p th:text="${user?.address?.city}">도시 (null 안전)</p>

선택 변수 표현식 (*{...})

<!-- th:object와 함께 사용 -->
<div th:object="${user}">
    <p th:text="*{name}">이름</p>
    <p th:text="*{email}">이메일</p>
    <p th:text="*{age}">나이</p>
</div>

링크 표현식 (@{...})

<!-- 상대 URL -->
<a th:href="@{/user/list}">사용자 목록</a>

<!-- 절대 URL -->
<a th:href="@{http://www.example.com}">외부 링크</a>

<!-- 컨텍스트 상대 URL -->
<a th:href="@{~/documents/report.pdf}">문서</a>

메시지 표현식 (#{...})

<!-- 기본 메시지 -->
<p th:text="#{home.welcome}">환영 메시지</p>

<!-- 변수가 있는 메시지 -->
<p th:text="#{user.greeting(${user.name})}">인사말</p>

💡 고급 기능

조건부 렌더링

<!-- Elvis 연산자 -->
<p th:text="${user.name} ?: '이름 없음'">기본 이름</p>

<!-- 삼항 연산자 -->
<p th:text="${user.age >= 18} ? '성인' : '미성년자'">연령대</p>

<!-- No-Operation 토큰 -->
<p th:text="${user.description} ?: _">
    기본 설명입니다. (user.description이 null이면 이 텍스트 유지)
</p>

데이터 속성 추가

<!-- 동적 데이터 속성 -->
<div th:data-user-id="${user.id}" 
     th:data-user-role="${user.role}">
    사용자 정보
</div>
<!-- 결과: <div data-user-id="123" data-user-role="ADMIN">사용자 정보</div> -->

인라인 처리

<!-- 텍스트 인라인 -->
<p>안녕하세요, [[${user.name}]]님!</p>

<!-- JavaScript 인라인 -->
<script th:inline="javascript">
    var user = /*[[${user}]]*/ {};
    var userName = /*[[${user.name}]]*/ 'default';
    console.log('사용자:', userName);
</script>

<!-- CSS 인라인 -->
<style th:inline="css">
    .user-color {
        color: /*[[${user.favoriteColor}]]*/ blue;
    }
</style>

🎯 실전 예제

완전한 사용자 목록 페이지

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title th:text="#{page.user.list}">사용자 목록</title>
    <link rel="stylesheet" href="/css/bootstrap.min.css">
</head>
<body>
    <div class="container">
        <h1 th:text="#{page.user.list}">사용자 목록</h1>

        <!-- 검색 폼 -->
        <form th:action="@{/users}" method="get" class="mb-3">
            <div class="row">
                <div class="col-md-4">
                    <input type="text" name="search" 
                           th:value="${param.search}" 
                           placeholder="이름 또는 이메일 검색" 
                           class="form-control">
                </div>
                <div class="col-md-2">
                    <select name="role" class="form-control">
                        <option value="">전체 역할</option>
                        <option th:each="role : ${roles}" 
                                th:value="${role}" 
                                th:text="${role}"
                                th:selected="${param.role == role}">역할</option>
                    </select>
                </div>
                <div class="col-md-2">
                    <button type="submit" class="btn btn-primary">검색</button>
                </div>
            </div>
        </form>

        <!-- 사용자 목록 -->
        <div th:if="${#lists.isEmpty(users)}" class="alert alert-info">
            등록된 사용자가 없습니다.
        </div>

        <table th:unless="${#lists.isEmpty(users)}" class="table table-striped">
            <thead>
                <tr>
                    <th>#</th>
                    <th>이름</th>
                    <th>이메일</th>
                    <th>역할</th>
                    <th>상태</th>
                    <th>가입일</th>
                    <th>액션</th>
                </tr>
            </thead>
            <tbody>
                <tr th:each="user, status : ${users}">
                    <td th:text="${status.count}">순번</td>
                    <td>
                        <a th:href="@{/users/{id}(id=${user.id})}" 
                           th:text="${user.name}">이름</a>
                    </td>
                    <td th:text="${user.email}">이메일</td>
                    <td>
                        <span class="badge" 
                              th:classappend="${user.role == 'ADMIN'} ? 'badge-danger' : 'badge-primary'"
                              th:text="${user.role}">역할</span>
                    </td>
                    <td>
                        <span th:if="${user.active}" class="text-success">활성</span>
                        <span th:unless="${user.active}" class="text-danger">비활성</span>
                    </td>
                    <td th:text="${#dates.format(user.createdAt, 'yyyy-MM-dd')}">가입일</td>
                    <td>
                        <a th:href="@{/users/{id}/edit(id=${user.id})}" 
                           class="btn btn-sm btn-outline-primary">수정</a>
                        <a th:href="@{/users/{id}/delete(id=${user.id})}" 
                           class="btn btn-sm btn-outline-danger"
                           onclick="return confirm('정말 삭제하시겠습니까?')">삭제</a>
                    </td>
                </tr>
            </tbody>
        </table>

        <!-- 페이징 -->
        <nav th:if="${totalPages > 1}">
            <ul class="pagination justify-content-center">
                <li class="page-item" th:classappend="${currentPage == 0} ? 'disabled'">
                    <a class="page-link" 
                       th:href="@{/users(page=0, search=${param.search}, role=${param.role})}">
                        첫 페이지
                    </a>
                </li>
                <li class="page-item" th:classappend="${currentPage == 0} ? 'disabled'">
                    <a class="page-link" 
                       th:href="@{/users(page=${currentPage - 1}, search=${param.search}, role=${param.role})}">
                        이전
                    </a>
                </li>

                <li th:each="pageNum : ${#numbers.sequence(0, totalPages - 1)}"
                    class="page-item" 
                    th:classappend="${pageNum == currentPage} ? 'active'">
                    <a class="page-link" 
                       th:href="@{/users(page=${pageNum}, search=${param.search}, role=${param.role})}"
                       th:text="${pageNum + 1}">페이지</a>
                </li>

                <li class="page-item" th:classappend="${currentPage == totalPages - 1} ? 'disabled'">
                    <a class="page-link" 
                       th:href="@{/users(page=${currentPage + 1}, search=${param.search}, role=${param.role})}">
                        다음
                    </a>
                </li>
                <li class="page-item" th:classappend="${currentPage == totalPages - 1} ? 'disabled'">
                    <a class="page-link" 
                       th:href="@{/users(page=${totalPages - 1}, search=${param.search}, role=${param.role})}">
                        마지막 페이지
                    </a>
                </li>
            </ul>
        </nav>
    </div>
</body>
</html>

이 가이드를 통해 Thymeleaf의 주요 문법들을 실전에서 바로 활용할 수 있습니다! 🚀

블로그의 정보

농담곰담곰이의곰담농

브이담곰

활동하기