카테고리 없음

Thymeleaf 가이드 - #5. 표준속성 (2)

shaprimanDev 2025. 8. 25. 11:55
반응형

5.4 반복 처리 (th:each)

기본 반복 처리

<div class="basic-iteration">
    <!-- 리스트 반복 -->
    <div class="user-list">
        <div th:each="user : ${users}" class="user-card">
            <h3 th:text="${user.name}">사용자명</h3>
            <p th:text="${user.email}">이메일</p>
            <span th:text="${user.role}">역할</span>
        </div>
    </div>

    <!-- 배열 반복 -->
    <ul class="tag-list">
        <li th:each="tag : ${post.tags}" th:text="${tag}">태그</li>
    </ul>

    <!-- 맵 반복 -->
    <div class="settings">
        <div th:each="setting : ${userSettings}" class="setting-item">
            <label th:text="${setting.key}">설정명</label>
            <span th:text="${setting.value}">설정값</span>
        </div>
    </div>

    <!-- 문자열 반복 (각 문자) -->
    <div class="char-display">
        <span th:each="char : ${word}" th:text="${char}">문자</span>
    </div>
</div>

상태 변수 활용

<div class="iteration-status">
    <!-- 기본 상태 변수 -->
    <table class="data-table">
        <thead>
            <tr>
                <th>순번</th>
                <th>이름</th>
                <th>이메일</th>
                <th>상태</th>
            </tr>
        </thead>
        <tbody>
            <tr th:each="user, stat : ${users}" 
                th:class="${stat.odd} ? 'odd-row' : 'even-row'">
                <td th:text="${stat.count}">1</td>
                <td th:text="${user.name}">사용자명</td>
                <td th:text="${user.email}">이메일</td>
                <td>
                    <span th:if="${stat.first}" class="badge">신규</span>
                    <span th:if="${stat.last}" class="badge">마지막</span>
                </td>
            </tr>
        </tbody>
    </table>

    <!-- 상세한 상태 정보 활용 -->
    <div class="product-grid">
        <div th:each="product, status : ${products}" 
             class="product-card"
             th:classappend="${status.first} ? 'first-item' : ''"
             th:attr="data-index=${status.index},
                     data-count=${status.count},
                     data-total=${status.size}">

            <span class="item-number" th:text="${status.count} + '/' + ${status.size}">1/10</span>
            <h3 th:text="${product.name}">상품명</h3>

            <!-- 첫 번째와 마지막 아이템 특별 처리 -->
            <div th:if="${status.first}" class="featured-badge">추천</div>
            <div th:if="${status.last}" class="last-chance">마지막 기회!</div>

            <!-- 홀짝 번째 아이템 다른 스타일 -->
            <div th:class="${status.even} ? 'price-highlight' : 'price-normal'">
                <span th:text="${#numbers.formatCurrency(product.price)}">₩10,000</span>
            </div>
        </div>
    </div>

    <!-- 페이지네이션 정보와 함께 -->
    <div class="pagination-info" th:if="${not #lists.isEmpty(items)}">
        <p>
            전체 <span th:text="${totalItems}">100</span>개 중 
            <span th:text="${(currentPage * pageSize) + 1}">1</span>-
            <span th:text="${#numbers.min((currentPage + 1) * pageSize, totalItems)}">20</span>
            항목 표시
        </p>
    </div>
</div>

중첩 반복과 복잡한 구조

<div class="nested-iteration">
    <!-- 카테고리별 상품 목록 -->
    <div th:each="category : ${categories}" class="category-section">
        <h2 th:text="${category.name}">카테고리명</h2>

        <div class="products-grid">
            <div th:each="product, productStat : ${category.products}" class="product-item">
                <span class="category-product-number" 
                      th:text="${category.name} + '-' + ${productStat.count}">CAT-1</span>
                <h3 th:text="${product.name}">상품명</h3>

                <!-- 상품별 리뷰 목록 -->
                <div th:if="${not #lists.isEmpty(product.reviews)}" class="reviews">
                    <h4>리뷰 (<span th:text="${#lists.size(product.reviews)}">3</span>개)</h4>
                    <div th:each="review, reviewStat : ${product.reviews}" 
                         th:if="${reviewStat.index < 3}" class="review-item">
                        <div class="review-header">
                            <span th:text="${review.author}">작성자</span>
                            <div class="stars">
                                <span th:each="star : ${#numbers.sequence(1, 5)}"
                                      th:class="${star <= review.rating} ? 'star filled' : 'star empty'">★</span>
                            </div>
                        </div>
                        <p th:text="${review.content}">리뷰 내용</p>
                    </div>
                    <a th:if="${#lists.size(product.reviews) > 3}" 
                       th:href="@{/product/{id}/reviews(id=${product.id})}">
                        더 보기 (+<span th:text="${#lists.size(product.reviews) - 3}">2</span>개)
                    </a>
                </div>
            </div>
        </div>
    </div>

    <!-- 월별 통계 데이터 -->
    <div class="monthly-stats">
        <table class="stats-table">
            <thead>
                <tr>
                    <th>월</th>
                    <th th:each="metric : ${metrics}" th:text="${metric.name}">지표</th>
                </tr>
            </thead>
            <tbody>
                <tr th:each="month : ${monthlyData}">
                    <td th:text="${#dates.format(month.date, 'yyyy-MM')}">2024-01</td>
                    <td th:each="metric : ${metrics}" 
                        th:text="${month.values[metric.key]}">100</td>
                </tr>
            </tbody>
        </table>
    </div>

    <!-- 트리 구조 (재귀적 렌더링) -->
    <div class="menu-tree">
        <ul>
            <li th:each="menuItem : ${menuItems}">
                <span th:text="${menuItem.name}">메뉴명</span>
                <!-- 하위 메뉴가 있는 경우 -->
                <ul th:if="${not #lists.isEmpty(menuItem.children)}">
                    <li th:each="childItem : ${menuItem.children}">
                        <span th:text="${childItem.name}">하위메뉴</span>
                        <!-- 3단계 메뉴 -->
                        <ul th:if="${not #lists.isEmpty(childItem.children)}">
                            <li th:each="grandChild : ${childItem.children}">
                                <span th:text="${grandChild.name}">3단계메뉴</span>
                            </li>
                        </ul>
                    </li>
                </ul>
            </li>
        </ul>
    </div>
</div>

조건부 반복과 필터링

<div class="conditional-iteration">
    <!-- 조건에 맞는 항목만 표시 -->
    <div class="active-users">
        <h3>활성 사용자</h3>
        <div th:each="user : ${users}" th:if="${user.isActive()}" class="user-card">
            <span th:text="${user.name}">사용자명</span>
            <span class="status active">활성</span>
        </div>
    </div>

    <!-- 컬렉션 선택을 통한 필터링 -->
    <div class="premium-users">
        <h3>프리미엄 사용자</h3>
        <div th:each="user : ${users.?[isPremium()]}" class="premium-user-card">
            <span th:text="${user.name}">프리미엄 사용자</span>
            <span class="badge gold">PREMIUM</span>
        </div>
    </div>

    <!-- 점수별 필터링 -->
    <div class="high-score-users">
        <h3>고득점 사용자 (80점 이상)</h3>
        <div th:each="user : ${users.?[score >= 80]}" class="high-score-card">
            <span th:text="${user.name}">사용자명</span>
            <span th:text="${user.score}">점수</span>
        </div>
    </div>

    <!-- 복합 조건 필터링 -->
    <div class="qualified-users">
        <h3>자격 요건 충족 사용자</h3>
        <div th:each="user : ${users.?[isActive() and isVerified() and age >= 18]}" 
             class="qualified-user">
            <span th:text="${user.name}">사용자명</span>
            <span class="qualification-badge">✓ 자격충족</span>
        </div>
    </div>

    <!-- 빈 목록 처리 -->
    <div class="user-notifications">
        <div th:if="${#lists.isEmpty(notifications)}">
            <p class="empty-message">새로운 알림이 없습니다.</p>
        </div>
        <div th:unless="${#lists.isEmpty(notifications)}">
            <div th:each="notification : ${notifications}" class="notification-item">
                <div th:class="'notification ' + ${notification.type}">
                    <span th:text="${notification.message}">알림 메시지</span>
                    <small th:text="${#temporals.format(notification.createdAt, 'MM-dd HH:mm')}">01-15 14:30</small>
                </div>
            </div>
        </div>
    </div>
</div>

반복 처리 성능 최적화

<div class="optimized-iteration">
    <!-- 페이징된 데이터 처리 -->
    <div class="paged-results">
        <div th:each="item : ${pagedItems}" class="result-item">
            <h4 th:text="${item.title}">제목</h4>
            <p th:text="${#strings.abbreviate(item.description, 100)}">설명...</p>
        </div>

        <!-- 페이징 정보 -->
        <div class="pagination-controls">
            <span>페이지 <span th:text="${currentPage + 1}">1</span> / <span th:text="${totalPages}">10</span></span>
            <a th:if="${currentPage > 0}" 
               th:href="@{''(page=${currentPage - 1})}">이전</a>
            <a th:if="${currentPage < totalPages - 1}" 
               th:href="@{''(page=${currentPage + 1})}">다음</a>
        </div>
    </div>

    <!-- 부분 렌더링 (처음 N개만) -->
    <div class="limited-results">
        <h3>인기 상품 (상위 5개)</h3>
        <div th:each="product, stat : ${popularProducts}" 
             th:if="${stat.index < 5}" 
             class="popular-product">
            <span class="rank" th:text="${stat.count}">1</span>
            <span th:text="${product.name}">상품명</span>
            <span th:text="${product.viewCount}">조회수</span>
        </div>
        <a th:href="@{/products/popular}">전체 보기</a>
    </div>

    <!-- 조건부 상세 정보 로드 -->
    <div class="user-list-optimized">
        <div th:each="user, stat : ${users}" class="user-summary">
            <div class="basic-info">
                <span th:text="${user.name}">사용자명</span>
                <span th:text="${user.role}">역할</span>
            </div>

            <!-- 처음 10명만 상세 정보 표시 -->
            <div th:if="${stat.index < 10}" class="detailed-info">
                <p>가입일: <span th:text="${#dates.format(user.joinDate, 'yyyy-MM-dd')}">2024-01-01</span></p>
                <p>마지막 로그인: <span th:text="${#temporals.format(user.lastLogin, 'MM-dd HH:mm')}">01-15 14:30</span></p>
            </div>

            <!-- 나머지는 "더보기" 버튼으로 처리 -->
            <div th:if="${stat.index >= 10}" class="load-more-info">
                <button th:onclick="|loadUserDetails(${user.id})|">상세정보</button>
            </div>
        </div>
    </div>
</div>

5.5 폼 관련 속성 (th:field, th:object, th:action)

th:objectth:field - 폼 바인딩

<div class="form-binding-examples">
    <!-- 기본 폼 바인딩 -->
    <form th:action="@{/user/save}" th:object="${userForm}" method="post">
        <div class="form-group">
            <label for="name">이름</label>
            <input type="text" id="name" th:field="*{name}" class="form-control">
            <span th:if="${#fields.hasErrors('name')}" 
                  th:errors="*{name}" class="error">이름 오류</span>
        </div>

        <div class="form-group">
            <label for="email">이메일</label>
            <input type="email" id="email" th:field="*{email}" class="form-control">
            <span th:if="${#fields.hasErrors('email')}" 
                  th:errors="*{email}" class="error">이메일 오류</span>
        </div>

        <div class="form-group">
            <label for="age">나이</label>
            <input type="number" id="age" th:field="*{age}" min="1" max="120" class="form-control">
            <span th:if="${#fields.hasErrors('age')}" 
                  th:errors="*{age}" class="error">나이 오류</span>
        </div>

        <div class="form-group">
            <label for="bio">자기소개</label>
            <textarea id="bio" th:field="*{bio}" rows="4" class="form-control"></textarea>
            <small class="form-text text-muted">200자 이내로 작성해주세요.</small>
        </div>

        <button type="submit">저장</button>
    </form>
</div>

다양한 입력 유형 처리

<div class="input-types">
    <form th:action="@{/profile/update}" th:object="${profileForm}" method="post">

        <!-- 체크박스 -->
        <div class="form-group">
            <div class="checkbox">
                <label>
                    <input type="checkbox" th:field="*{newsletter}">
                    뉴스레터 수신 동의
                </label>
            </div>

            <!-- 다중 체크박스 -->
            <fieldset>
                <legend>관심 분야</legend>
                <div th:each="interest : ${availableInterests}">
                    <label>
                        <input type="checkbox" th:field="*{interests}" th:value="${interest.code}">
                        <span th:text="${interest.name}">관심분야</span>
                    </label>
                </div>
            </fieldset>
        </div>

        <!-- 라디오 버튼 -->
        <div class="form-group">
            <fieldset>
                <legend>성별</legend>
                <label>
                    <input type="radio" th:field="*{gender}" value="M">
                    남성
                </label>
                <label>
                    <input type="radio" th:field="*{gender}" value="F">
                    여성
                </label>
                <label>
                    <input type="radio" th:field="*{gender}" value="O">
                    기타
                </label>
            </fieldset>
        </div>

        <!-- 선택 박스 -->
        <div class="form-group">
            <label for="country">국가</label>
            <select id="country" th:field="*{country}" class="form-control">
                <option value="">선택해주세요</option>
                <option th:each="country : ${countries}" 
                        th:value="${country.code}" 
                        th:text="${country.name}">국가명</option>
            </select>
        </div>

        <!-- 다중 선택 -->
        <div class="form-group">
            <label for="skills">보유 기술</label>
            <select id="skills" th:field="*{skills}" multiple class="form-control">
                <option th:each="skill : ${availableSkills}" 
                        th:value="${skill.id}" 
                        th:text="${skill.name}">기술명</option>
            </select>
        </div>

        <!-- 숨겨진 필드 -->
        <input type="hidden" th:field="*{id}">
        <input type="hidden" th:field="*{version}">

        <!-- 파일 업로드 -->
        <div class="form-group">
            <label for="avatar">프로필 사진</label>
            <input type="file" id="avatar" name="avatarFile" accept="image/*" class="form-control">
        </div>

        <button type="submit">업데이트</button>
    </form>
</div>

동적 폼과 배열 처리

<div class="dynamic-forms">
    <!-- 배열 필드 처리 -->
    <form th:action="@{/order/save}" th:object="${orderForm}" method="post">
        <h3>주문 정보</h3>

        <!-- 주문 항목 목록 -->
        <div class="order-items">
            <div th:each="item, stat : *{items}" class="order-item">
                <h4>항목 <span th:text="${stat.count}">1</span></h4>

                <!-- 배열 인덱스 사용 -->
                <input type="hidden" th:field="*{items[__${stat.index}__].id}">

                <div class="form-row">
                    <div class="form-group">
                        <label th:for="'product_' + ${stat.index}">상품</label>
                        <select th:id="'product_' + ${stat.index}" 
                                th:field="*{items[__${stat.index}__].productId}" 
                                class="form-control">
                            <option value="">선택하세요</option>
                            <option th:each="product : ${products}" 
                                    th:value="${product.id}" 
                                    th:text="${product.name}">상품명</option>
                        </select>
                    </div>

                    <div class="form-group">
                        <label th:for="'quantity_' + ${stat.index}">수량</label>
                        <input type="number" th:id="'quantity_' + ${stat.index}"
                               th:field="*{items[__${stat.index}__].quantity}" 
                               min="1" class="form-control">
                    </div>

                    <div class="form-group">
                        <label th:for="'price_' + ${stat.index}">가격</label>
                        <input type="number" th:id="'price_' + ${stat.index}"
                               th:field="*{items[__${stat.index}__].price}" 
                               step="0.01" class="form-control">
                    </div>
                </div>

                <button type="button" th:onclick="|removeItem(${stat.index})|">
                    항목 삭제
                </button>
            </div>
        </div>

        <button type="button" onclick="addOrderItem()">항목 추가</button>
        <button type="submit">주문 완료</button>
    </form>
</div>

고급 폼 검증과 오류 처리

<div class="advanced-validation">
    <form th:action="@{/registration}" th:object="${registrationForm}" method="post" novalidate>

        <!-- 전역 오류 메시지 -->
        <div th:if="${#fields.hasGlobalErrors()}" class="global-errors">
            <div class="alert alert-danger">
                <ul>
                    <li th:each="error : ${#fields.globalErrors()}" th:text="${error}">전역 오류</li>
                </ul>
            </div>
        </div>

        <!-- 사용자명 검증 -->
        <div class="form-group" th:classappend="${#fields.hasErrors('username')} ? 'has-error' : ''">
            <label for="username">사용자명 *</label>
            <input type="text" id="username" th:field="*{username}" 
                   class="form-control"
                   th:classappend="${#fields.hasErrors('username')} ? 'is-invalid' : ''">

            <!-- 필드별 오류 메시지 -->
            <div th:if="${#fields.hasErrors('username')}" class="invalid-feedback">
                <div th:each="error : ${#fields.errors('username')}" th:text="${error}">
                    사용자명 오류
                </div>
            </div>

            <!-- 도움말 텍스트 -->
            <small class="form-text text-muted">
                3-20자의 영문, 숫자, 밑줄만 사용 가능
            </small>
        </div>

        <!-- 비밀번호 검증 -->
        <div class="form-group" th:classappend="${#fields.hasErrors('password')} ? 'has-error' : ''">
            <label for="password">비밀번호 *</label>
            <input type="password" id="password" th:field="*{password}" 
                   class="form-control"
                   th:classappend="${#fields.hasErrors('password')} ? 'is-invalid' : ''">

            <div th:if="${#fields.hasErrors('password')}" class="invalid-feedback">
                <div th:each="error : ${#fields.errors('password')}" th:text="${error}">
                    비밀번호 오류
                </div>
            </div>

            <!-- 실시간 비밀번호 강도 표시 -->
            <div class="password-strength">
                <div class="strength-meter">
                    <div class="strength-bar" id="strengthBar"></div>
                </div>
                <small id="strengthText">비밀번호를 입력하세요</small>
            </div>
        </div>

        <!-- 비밀번호 확인 -->
        <div class="form-group" th:classappend="${#fields.hasErrors('confirmPassword')} ? 'has-error' : ''">
            <label for="confirmPassword">비밀번호 확인 *</label>
            <input type="password" id="confirmPassword" th:field="*{confirmPassword}" 
                   class="form-control"
                   th:classappend="${#fields.hasErrors('confirmPassword')} ? 'is-invalid' : ''">

            <div th:if="${#fields.hasErrors('confirmPassword')}" class="invalid-feedback">
                <div th:each="error : ${#fields.errors('confirmPassword')}" th:text="${error}">
                    비밀번호 확인 오류
                </div>
            </div>
        </div>

        <!-- 이메일 검증 -->
        <div class="form-group" th:classappend="${#fields.hasErrors('email')} ? 'has-error' : ''">
            <label for="email">이메일 *</label>
            <div class="input-group">
                <input type="email" id="email" th:field="*{email}" 
                       class="form-control"
                       th:classappend="${#fields.hasErrors('email')} ? 'is-invalid' : ''">
                <div class="input-group-append">
                    <button type="button" class="btn btn-outline-secondary" onclick="checkEmailDuplicate()">
                        중복확인
                    </button>
                </div>
            </div>

            <div th:if="${#fields.hasErrors('email')}" class="invalid-feedback">
                <div th:each="error : ${#fields.errors('email')}" th:text="${error}">
                    이메일 오류
                </div>
            </div>
        </div>

        <!-- 약관 동의 -->
        <div class="form-group" th:classappend="${#fields.hasErrors('agreeToTerms')} ? 'has-error' : ''">
            <div class="form-check">
                <input type="checkbox" id="agreeToTerms" th:field="*{agreeToTerms}" 
                       class="form-check-input"
                       th:classappend="${#fields.hasErrors('agreeToTerms')} ? 'is-invalid' : ''">
                <label for="agreeToTerms" class="form-check-label">
                    <a href="/terms" target="_blank">이용약관</a>에 동의합니다 *
                </label>

                <div th:if="${#fields.hasErrors('agreeToTerms')}" class="invalid-feedback">
                    <div th:each="error : ${#fields.errors('agreeToTerms')}" th:text="${error}">
                        약관 동의 오류
                    </div>
                </div>
            </div>
        </div>

        <!-- CSRF 토큰 -->
        <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">

        <!-- 제출 버튼 -->
        <div class="form-group">
            <button type="submit" class="btn btn-primary btn-block">
                회원가입
            </button>
        </div>
    </form>
</div>

<script>
// 비밀번호 강도 검사
document.getElementById('password').addEventListener('input', function(e) {
    const password = e.target.value;
    const strengthBar = document.getElementById('strengthBar');
    const strengthText = document.getElementById('strengthText');

    let strength = 0;
    let message = '';

    if (password.length >= 8) strength++;
    if (/[a-z]/.test(password)) strength++;
    if (/[A-Z]/.test(password)) strength++;
    if (/[0-9]/.test(password)) strength++;
    if (/[^A-Za-z0-9]/.test(password)) strength++;

    switch (strength) {
        case 0:
        case 1:
            strengthBar.style.width = '20%';
            strengthBar.style.backgroundColor = '#dc3545';
            message = '매우 약함';
            break;
        case 2:
            strengthBar.style.width = '40%';
            strengthBar.style.backgroundColor = '#fd7e14';
            message = '약함';
            break;
        case 3:
            strengthBar.style.width = '60%';
            strengthBar.style.backgroundColor = '#ffc107';
            message = '보통';
            break;
        case 4:
            strengthBar.style.width = '80%';
            strengthBar.style.backgroundColor = '#20c997';
            message = '강함';
            break;
        case 5:
            strengthBar.style.width = '100%';
            strengthBar.style.backgroundColor = '#28a745';
            message = '매우 강함';
            break;
    }

    strengthText.textContent = message;
});

// 이메일 중복 확인
function checkEmailDuplicate() {
    const email = document.getElementById('email').value;
    if (!email) {
        alert('이메일을 입력해주세요.');
        return;
    }

    // AJAX 요청으로 중복 확인
    fetch(`/api/check-email?email=${encodeURIComponent(email)}`)
        .then(response => response.json())
        .then(data => {
            if (data.duplicate) {
                alert('이미 사용 중인 이메일입니다.');
            } else {
                alert('사용 가능한 이메일입니다.');
            }
        })
        .catch(error => {
            console.error('Error:', error);
            alert('이메일 확인 중 오류가 발생했습니다.');
        });
}
</script>

이제 5장 "표준 속성"을 완성했습니다. 이 장에서는 Thymeleaf의 핵심 속성들인 텍스트 출력, 속성 설정, 조건부 렌더링, 반복 처리, 그리고 폼 처리에 대해 상세하고 실용적인 예제들을 통해 알아보았습니다.

 

각 섹션에서 다룬 내용:

  1. 텍스트 출력: th:textth:utext의 차이점과 보안 고려사항
  2. 속성 설정: 동적 속성 설정과 조건부 속성 처리
  3. 조건부 렌더링: 복잡한 조건 로직과 다중 분기 처리
  4. 반복 처리: 상태 변수 활용과 중첩 반복, 성능 최적화
  5. 폼 처리: 다양한 입력 유형과 검증, 동적 폼 구성

이러한 표준 속성들을 잘 활용하면 대부분의 웹 애플리케이션 요구사항을 충족할 수 있습니다.

반응형