반응형
6장. 템플릿 레이아웃과 프래그먼트
6.1 프래그먼트 정의와 사용 (th:fragment
)
프래그먼트는 재사용 가능한 HTML 조각으로, Thymeleaf에서 코드 중복을 줄이고 유지보수성을 높이는 핵심 기능입니다.
프래그먼트란?
프래그먼트는 th:fragment
속성으로 정의되는 HTML의 재사용 가능한 부분입니다. 헤더, 푸터, 네비게이션 등 여러 페이지에서 공통으로 사용되는 부분을 프래그먼트로 만들어 관리할 수 있습니다.
기본 프래그먼트 정의
<!-- templates/fragments/common.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!-- 헤더 프래그먼트 -->
<header th:fragment="header" class="site-header">
<div class="container">
<nav class="navbar">
<div class="navbar-brand">
<a th:href="@{/}">
<img src="/images/logo.png" alt="Logo">
<span th:text="#{site.name}">MyApp</span>
</a>
</div>
<ul class="navbar-nav">
<li><a th:href="@{/}">홈</a></li>
<li><a th:href="@{/products}">상품</a></li>
<li><a th:href="@{/about}">회사소개</a></li>
<li><a th:href="@{/contact}">문의</a></li>
</ul>
<div class="navbar-user">
<div th:if="${currentUser}">
<span th:text="|안녕하세요, ${currentUser.name}님|">사용자</span>
<a th:href="@{/logout}">로그아웃</a>
</div>
<div th:unless="${currentUser}">
<a th:href="@{/login}">로그인</a>
<a th:href="@{/signup}">회원가입</a>
</div>
</div>
</nav>
</div>
</header>
<!-- 푸터 프래그먼트 -->
<footer th:fragment="footer" class="site-footer">
<div class="container">
<div class="footer-content">
<div class="footer-section">
<h3>회사 정보</h3>
<p>우리 회사는 최고의 서비스를 제공합니다.</p>
<p>Email: info@company.com</p>
<p>Tel: 02-1234-5678</p>
</div>
<div class="footer-section">
<h3>빠른 링크</h3>
<ul>
<li><a th:href="@{/privacy}">개인정보처리방침</a></li>
<li><a th:href="@{/terms}">이용약관</a></li>
<li><a th:href="@{/help}">고객지원</a></li>
</ul>
</div>
<div class="footer-section">
<h3>소셜 미디어</h3>
<div class="social-links">
<a href="#" class="social-facebook">Facebook</a>
<a href="#" class="social-instagram">Instagram</a>
<a href="#" class="social-twitter">Twitter</a>
</div>
</div>
</div>
<div class="footer-bottom">
<p th:text="|© ${#dates.year(#dates.createNow())} MyApp. All rights reserved.|">
© 2024 MyApp. All rights reserved.
</p>
</div>
</div>
</footer>
<!-- 사이드바 프래그먼트 -->
<aside th:fragment="sidebar" class="sidebar">
<!-- 최근 게시글 위젯 -->
<div class="widget">
<h3>최근 게시글</h3>
<ul class="recent-posts">
<li th:each="post : ${recentPosts}">
<a th:href="@{/posts/{id}(id=${post.id})}" th:text="${post.title}">게시글 제목</a>
<small th:text="${#temporals.format(post.createdAt, 'MM-dd')}">01-15</small>
</li>
</ul>
</div>
<!-- 카테고리 위젯 -->
<div class="widget">
<h3>카테고리</h3>
<ul class="categories">
<li th:each="category : ${categories}">
<a th:href="@{/category/{id}(id=${category.id})}" th:text="${category.name}">카테고리</a>
<span th:text="|( ${category.postCount} )|"> (5)</span>
</li>
</ul>
</div>
</aside>
</html>
프래그먼트 사용하기
프래그먼트를 사용할 때는 ~{템플릿경로 :: 프래그먼트명}
형식으로 참조합니다.
<!-- templates/home.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>홈 - MyApp</title>
<link href="/css/main.css" rel="stylesheet">
</head>
<body>
<!-- 헤더 프래그먼트 삽입 -->
<div th:replace="~{fragments/common :: header}"></div>
<div class="container">
<div class="row">
<!-- 메인 콘텐츠 -->
<main class="col-md-9">
<h1>MyApp에 오신 것을 환영합니다!</h1>
<p>우리는 최고의 서비스를 제공합니다.</p>
<div class="featured-products">
<h2>추천 상품</h2>
<div class="product-grid">
<div th:each="product : ${featuredProducts}" class="product-card">
<img th:src="@{/images/products/{id}.jpg(id=${product.id})}"
th:alt="${product.name}">
<h3 th:text="${product.name}">상품명</h3>
<p th:text="${#numbers.formatCurrency(product.price)}">가격</p>
</div>
</div>
</div>
</main>
<!-- 사이드바 프래그먼트 삽입 -->
<div class="col-md-3">
<div th:replace="~{fragments/common :: sidebar}"></div>
</div>
</div>
</div>
<!-- 푸터 프래그먼트 삽입 -->
<div th:replace="~{fragments/common :: footer}"></div>
</body>
</html>
프래그먼트 참조 문법:
~{fragments/common :: header}
:fragments/common.html
의header
프래그먼트- 경로는
templates
폴더 기준의 상대 경로 ::
뒤에 프래그먼트 이름 지정
6.2 프래그먼트 삽입 (th:insert
, th:replace
, th:include
)
프래그먼트를 삽입하는 방법에는 여러 가지가 있으며, 각각 다른 결과를 만들어냅니다.
삽입 방식의 차이점
먼저 예시용 프래그먼트를 정의해보겠습니다:
<!-- templates/fragments/sample.html -->
<div th:fragment="myContent" class="my-fragment">
<h2>프래그먼트 제목</h2>
<p>이것은 프래그먼트 내용입니다.</p>
</div>
이제 세 가지 삽입 방식을 비교해보겠습니다:
<!-- templates/test.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>프래그먼트 삽입 테스트</title>
</head>
<body>
<!-- 1. th:insert 사용 -->
<section class="test-section">
<h1>th:insert 결과</h1>
<div class="host-container" th:insert="~{fragments/sample :: myContent}">
<p>기존 내용입니다.</p>
</div>
</section>
<!-- 2. th:replace 사용 -->
<section class="test-section">
<h1>th:replace 결과</h1>
<div class="host-container" th:replace="~{fragments/sample :: myContent}">
<p>이 내용은 완전히 사라집니다.</p>
</div>
</section>
<!-- 3. th:include 사용 (deprecated, 사용 권장하지 않음) -->
<section class="test-section">
<h1>th:include 결과</h1>
<div class="host-container" th:include="~{fragments/sample :: myContent}">
<p>이 내용도 사라집니다.</p>
</div>
</section>
</body>
</html>
렌더링 결과:
<body>
<!-- 1. th:insert 결과 -->
<section class="test-section">
<h1>th:insert 결과</h1>
<div class="host-container">
<p>기존 내용입니다.</p>
<div class="my-fragment">
<h2>프래그먼트 제목</h2>
<p>이것은 프래그먼트 내용입니다.</p>
</div>
</div>
</section>
<!-- 2. th:replace 결과 -->
<section class="test-section">
<h1>th:replace 결과</h1>
<div class="my-fragment">
<h2>프래그먼트 제목</h2>
<p>이것은 프래그먼트 내용입니다.</p>
</div>
</section>
<!-- 3. th:include 결과 -->
<section class="test-section">
<h1>th:include 결과</h1>
<div class="host-container">
<h2>프래그먼트 제목</h2>
<p>이것은 프래그먼트 내용입니다.</p>
</div>
</section>
</body>
언제 어떤 방식을 사용할까?
th:replace
(가장 일반적, 권장)
- 호스트 태그를 프래그먼트로 완전히 교체
- 대부분의 상황에서 가장 직관적이고 깔끔
- 헤더, 푸터, 네비게이션 등에 적합
th:insert
- 기존 태그를 유지하면서 내부에 프래그먼트 추가
- 호스트 태그의 CSS 클래스나 속성을 유지해야 할 때
- 컨테이너 역할을 하는 태그가 중요할 때
th:include
(사용 권장하지 않음)
- Thymeleaf 3.0부터 deprecated
- 프래그먼트의 내용만 가져오고 태그는 제외
th:insert
나th:replace
사용 권장
실제 활용 예제
<!-- templates/fragments/alerts.html -->
<!-- 성공 알림 프래그먼트 -->
<div th:fragment="success(message)" class="alert alert-success" role="alert">
<i class="fas fa-check-circle"></i>
<strong>성공!</strong> <span th:text="${message}">작업이 완료되었습니다.</span>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<!-- 오류 알림 프래그먼트 -->
<div th:fragment="error(message)" class="alert alert-danger" role="alert">
<i class="fas fa-exclamation-triangle"></i>
<strong>오류!</strong> <span th:text="${message}">문제가 발생했습니다.</span>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<!-- 경고 알림 프래그먼트 -->
<div th:fragment="warning(message)" class="alert alert-warning" role="alert">
<i class="fas fa-exclamation-circle"></i>
<strong>주의!</strong> <span th:text="${message}">주의가 필요합니다.</span>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<!-- 알림 프래그먼트 사용 -->
<div class="alerts-container">
<!-- 성공 메시지가 있을 때만 표시 -->
<div th:if="${successMessage}"
th:replace="~{fragments/alerts :: success(${successMessage})}"></div>
<!-- 오류 메시지가 있을 때만 표시 -->
<div th:if="${errorMessage}"
th:replace="~{fragments/alerts :: error(${errorMessage})}"></div>
<!-- 경고 메시지가 있을 때만 표시 -->
<div th:if="${warningMessage}"
th:replace="~{fragments/alerts :: warning(${warningMessage})}"></div>
</div>
6.3 레이아웃 상속과 확장
레이아웃 상속을 통해 공통 구조를 정의하고, 각 페이지에서 필요한 부분만 변경할 수 있습니다.
기본 레이아웃 템플릿
<!-- templates/layouts/base.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- 동적 제목 설정 -->
<title th:text="${pageTitle != null} ? ${pageTitle} + ' - MyApp' : 'MyApp'">MyApp</title>
<!-- 메타 태그 -->
<meta name="description" th:content="${pageDescription ?: 'MyApp 기본 설명'}">
<meta name="keywords" th:content="${pageKeywords ?: '기본, 키워드'}">
<!-- 기본 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<link th:href="@{/css/main.css}" rel="stylesheet">
<!-- 페이지별 추가 CSS (하위 템플릿에서 정의) -->
<th:block th:fragment="extra-css">
<!-- 여기에 페이지별 CSS가 추가됩니다 -->
</th:block>
</head>
<body th:class="${bodyClass ?: ''}">
<!-- 헤더 -->
<div th:replace="~{fragments/common :: header}"></div>
<!-- 브레드크럼 네비게이션 (선택적) -->
<nav th:if="${breadcrumbs}" aria-label="breadcrumb" class="breadcrumb-nav">
<div class="container">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a th:href="@{/}"><i class="fas fa-home"></i> 홈</a>
</li>
<li th:each="crumb, stat : ${breadcrumbs}"
th:class="${stat.last} ? 'breadcrumb-item active' : 'breadcrumb-item'">
<a th:unless="${stat.last}"
th:href="@{${crumb.url}}"
th:text="${crumb.title}">브레드크럼</a>
<span th:if="${stat.last}" th:text="${crumb.title}">현재 페이지</span>
</li>
</ol>
</div>
</nav>
<!-- 메인 콘텐츠 영역 -->
<main class="main-content">
<div class="container">
<!-- 페이지 헤더 -->
<div th:if="${pageTitle}" class="page-header mb-4">
<div class="row align-items-center">
<div class="col">
<h1 th:text="${pageTitle}">페이지 제목</h1>
<p th:if="${pageSubtitle}"
th:text="${pageSubtitle}"
class="text-muted">페이지 부제목</p>
</div>
<div th:if="${pageActions}" class="col-auto">
<div class="page-actions">
<a th:each="action : ${pageActions}"
th:href="@{${action.url}}"
th:class="'btn ' + ${action.btnClass}"
th:text="${action.label}">액션</a>
</div>
</div>
</div>
</div>
<!-- 알림 메시지 -->
<div class="alerts">
<div th:if="${successMessage}"
th:replace="~{fragments/alerts :: success(${successMessage})}"></div>
<div th:if="${errorMessage}"
th:replace="~{fragments/alerts :: error(${errorMessage})}"></div>
<div th:if="${warningMessage}"
th:replace="~{fragments/alerts :: warning(${warningMessage})}"></div>
</div>
<!-- 메인 콘텐츠 블록 (하위 템플릿에서 구현) -->
<div th:fragment="content" class="page-content">
<!-- 기본 콘텐츠 - 하위 템플릿에서 오버라이드됩니다 -->
<div class="alert alert-info">
<p>이 영역은 하위 템플릿에서 정의해야 합니다.</p>
</div>
</div>
</div>
</main>
<!-- 푸터 -->
<div th:replace="~{fragments/common :: footer}"></div>
<!-- 기본 JavaScript -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script th:src="@{/js/main.js}"></script>
<!-- 페이지별 추가 JavaScript (하위 템플릿에서 정의) -->
<th:block th:fragment="extra-js">
<!-- 여기에 페이지별 JavaScript가 추가됩니다 -->
</th:block>
</body>
</html>
레이아웃을 상속하는 페이지 예제
<!-- 추가 CSS -->
<th:block th:fragment="extra-css">
<link th:href="@{/css/products.css}" rel="stylesheet">
<style>
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
}
</style>
</th:block>
<!-- 브레드크럼 설정 -->
<div th:with="breadcrumbs=${
{
{title: '상품', url: '/products'}
}
}"></div>
<!-- 페이지 액션 설정 -->
<div th:with="pageActions=${
{
{label: '상품 추가', url: '/products/new', btnClass: 'btn-primary'}
}
}"></div>
<!-- 메인 콘텐츠 - base.html의 content 블록을 오버라이드 -->
<div th:fragment="content" class="page-content" th:remove="tag">
<!-- 필터 섹션 -->
<div class="filters card mb-4">
<div class="card-body">
<h5 class="card-title">필터</h5>
<form th:action="@{/products}" method="get" class="row g-3">
<div class="col-md-3">
<label for="category" class="form-label">카테고리</label>
<select name="category" id="category" class="form-select">
<option value="">전체 카테고리</option>
<option th:each="cat : ${categories}"
th:value="${cat.id}"
th:text="${cat.name}"
th:selected="${cat.id == selectedCategory}">카테고리</option>
</select>
</div>
<div class="col-md-3">
<label for="minPrice" class="form-label">최소 가격</label>
<input type="number" name="minPrice" id="minPrice"
th:value="${minPrice}" class="form-control"
placeholder="0">
</div>
<div class="col-md-3">
<label for="maxPrice" class="form-label">최대 가격</label>
<input type="number" name="maxPrice" id="maxPrice"
th:value="${maxPrice}" class="form-control"
placeholder="무제한">
</div>
<div class="col-md-3 d-flex align-items-end">
<button type="submit" class="btn btn-primary me-2">필터 적용</button>
<a th:href="@{/products}" class="btn btn-outline-secondary">초기화</a>
</div>
</form>
</div>
</div>
<!-- 상품 목록 -->
<div th:if="${#lists.isEmpty(products)}" class="alert alert-info">
<h4>상품이 없습니다</h4>
<p>조건에 맞는 상품이 없습니다. 필터를 조정해보세요.</p>
</div>
<div th:unless="${#lists.isEmpty(products)}">
<!-- 정렬 옵션 -->
<div class="d-flex justify-content-between align-items-center mb-3">
<p class="text-muted mb-0">
총 <strong th:text="${totalProducts}">0</strong>개의 상품이 있습니다.
</p>
<div class="sort-options">
<select name="sort" class="form-select form-select-sm" style="width: auto;" onchange="location = this.value;">
<option th:value="@{/products(sort='name')}"
th:selected="${sort == 'name'}">이름순</option>
<option th:value="@{/products(sort='price-low')}"
th:selected="${sort == 'price-low'}">가격 낮은순</option>
<option th:value="@{/products(sort='price-high')}"
th:selected="${sort == 'price-high'}">가격 높은순</option>
<option th:value="@{/products(sort='newest')}"
th:selected="${sort == 'newest'}">최신순</option>
</select>
</div>
</div>
<!-- 상품 그리드 -->
<div class="product-grid">
<div th:each="product : ${products}" class="card product-card">
<img th:src="@{/images/products/{id}.jpg(id=${product.id})}"
th:alt="${product.name}" class="card-img-top">
<div class="card-body">
<h5 class="card-title" th:text="${product.name}">상품명</h5>
<p class="card-text" th:text="${#strings.abbreviate(product.description, 80)}">
상품 설명
</p>
<div class="price-section">
<span th:if="${product.originalPrice != product.currentPrice}"
class="text-muted text-decoration-line-through small"
th:text="${#numbers.formatCurrency(product.originalPrice)}">₩129,000</span>
<span class="h5 text-primary"
th:text="${#numbers.formatCurrency(product.currentPrice)}">₩99,000</span>
<span th:if="${product.discountRate > 0}"
class="badge bg-danger ms-2"
th:text="${product.discountRate} + '%'">20%</span>
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end mt-3">
<a th:href="@{/products/{id}(id=${product.id})}"
class="btn btn-outline-primary btn-sm">상세보기</a>
<button class="btn btn-primary btn-sm"
th:onclick="|addToCart(${product.id})|">
<i class="fas fa-cart-plus"></i> 장바구니
</button>
</div>
</div>
</div>
</div>
<!-- 페이지네이션 -->
<nav th:if="${totalPages > 1}" aria-label="상품 페이지네이션" class="mt-4">
<ul class="pagination justify-content-center">
<!-- 이전 페이지 -->
<li th:class="${currentPage == 0} ? 'page-item disabled' : 'page-item'">
<a class="page-link"
th:href="@{/products(page=${currentPage - 1}, category=${selectedCategory}, minPrice=${minPrice}, maxPrice=${maxPrice}, sort=${sort})}"
th:unless="${currentPage == 0}">이전</a>
<span th:if="${currentPage == 0}" class="page-link">이전</span>
</li>
<!-- 페이지 번호들 -->
<li th:each="pageNum : ${#numbers.sequence(0, totalPages - 1)}"
th:class="${pageNum == currentPage} ? 'page-item active' : 'page-item'">
<a class="page-link"
th:href="@{/products(page=${pageNum}, category=${selectedCategory}, minPrice=${minPrice}, maxPrice=${maxPrice}, sort=${sort})}"
th:text="${pageNum + 1}">1</a>
</li>
<!-- 다음 페이지 -->
<li th:class="${currentPage >= totalPages - 1} ? 'page-item disabled' : 'page-item'">
<a class="page-link"
th:href="@{/products(page=${currentPage + 1}, category=${selectedCategory}, minPrice=${minPrice}, maxPrice=${maxPrice}, sort=${sort})}"
th:unless="${currentPage >= totalPages - 1}">다음</a>
<span th:if="${currentPage >= totalPages - 1}" class="page-link">다음</span>
</li>
</ul>
</nav>
</div>
</div>
<!-- 추가 JavaScript -->
<th:block th:fragment="extra-js">
<script>
function addToCart(productId)
반응형