반응형
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:object
와 th: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의 핵심 속성들인 텍스트 출력, 속성 설정, 조건부 렌더링, 반복 처리, 그리고 폼 처리에 대해 상세하고 실용적인 예제들을 통해 알아보았습니다.
각 섹션에서 다룬 내용:
- 텍스트 출력:
th:text
와th:utext
의 차이점과 보안 고려사항 - 속성 설정: 동적 속성 설정과 조건부 속성 처리
- 조건부 렌더링: 복잡한 조건 로직과 다중 분기 처리
- 반복 처리: 상태 변수 활용과 중첩 반복, 성능 최적화
- 폼 처리: 다양한 입력 유형과 검증, 동적 폼 구성
이러한 표준 속성들을 잘 활용하면 대부분의 웹 애플리케이션 요구사항을 충족할 수 있습니다.
반응형