카테고리 없음

Thymeleaf 가이드 - #6장. 템플릿 레이아웃과 프래그먼트

shaprimanDev 2025. 8. 26. 10:43
반응형

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.htmlheader 프래그먼트
  • 경로는 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:insertth: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)
반응형