Thymeleaf 조건·반복·조각
게시글 목록의 반복·표시 조건·조각·페이지 셸을 실제 Thymeleaf 최종 DOM으로 조립하고 검증합니다.
목록 템플릿은 행만 반복하는 파일이 아닙니다. 빈 상태와 권한에 따른 행동, 페이지네이션, 공통 탐색과 랜드마크(landmark)가 하나의 응답에서 조립됩니다. 조건을 템플릿에 두어도 되는지는 서버가 이미 정한 표시 상태를 고르기만 하는지로 판단합니다. 소유 여부나 업무 규칙을 표현식으로 다시 계산하지 않습니다.
명시적 화면 계약을 조립한 뒤 최종 DOM을 다시 검증한다
snapshot·계산된 page model·명시 fragment/shell·최종 DOM으로 출력 산출물이 바뀐다.
ARCHITECTURE · VIEW ASSEMBLY · FINAL DOM
명시적 화면 계약을 조립한 뒤 최종 DOM을 다시 검증한다
조회 스냅샷과 불변 화면 모델이 반복·권한·페이지 상태를 확정하고, 목록 페이지와 탐색 조각은 명시적 입력만 받아 Thymeleaf에서 하나의 응답으로 조립된다.
목록 페이지는 탐색 조각에 현재 섹션과 회원 이름을 명시합니다. 화살표는 조회 모델에서 조립 결과로 이어지는 관계이며 수정 업무 권한을 템플릿에서 판정하는 흐름이 아닙니다.
DATA → VIEW MODEL
표시 판단의 주인을 남긴다
query가 canEdit·이전/다음 page meta를 결정합니다. controller는 code point 수와 60 이상 longPost를 계산합니다. 두 생성자는 목록을 복사하고 PostListPage는 빈 rows + hasNext의 모순을 거부합니다.
EXPLICIT FRAGMENT INPUTS
셸이 탐색 조각을 명시적으로 부른다
목록 템플릿의 navigation('posts', memberName)은 현재 섹션과 이름을 넘깁니다. 조각은 각 메뉴를 자기 섹션과 비교하고 다른 메뉴의 aria-current 속성은 제거합니다. 숨은 서비스·요청 입력은 없습니다.
NONEMPTY FIXTURE
두 행·한 편집 링크·다음 페이지
th:each의 count로 1·2를 표시하고 둘째 행만 long-post입니다. canEdit=true인 첫 행만 편집 링크를 가집니다. 다음 링크는 /composition/posts?page=1입니다. 링크 숨김은 수정 엔드포인트의 권한 검사를 대신하지 않습니다.
EMPTY FIXTURE
빈 목록과 표·페이지네이션은 배타적
rows가 비면 post-list와 pagination은 없고 empty-state가 하나입니다. loading은 비동기 전환을 도입한 클라이언트의 상태입니다.
FINAL DOM CONTRACT
조립 결과에서 ID·현재 메뉴·랜드마크를
fixture의 현재 메뉴는 posts 하나, reports는 aria-current가 없습니다. 중복·빈 ID는 없고 main·header·footer·본문 바로가기가 각 하나입니다. 리터럴 HTML이나 조각 원본만 검사하면 실제 조립 경계를 건너뜁니다.
템플릿의 조건은 이미 결정된 표시 상태만 선택한다. 권한과 페이지 경계는 Java 계약이 소유하고, 조립 뒤 생기는 중복 ID와 잘못된 현재 위치는 실제 최종 DOM 테스트가 잡는다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 조회 PageSnapshot은 items를 복사합니다. controller는 codepoint length와 60 이상인 longPost를 계산하고 조회가 판정한 canEdit/page 메타를 넘깁니다. PostListPage는 rows를 다시 복사합니다. 목록 페이지는 page와 memberName을 받고 navigation은 정적인 posts 섹션과 memberName을 명시적으로 받습니다. Thymeleaf가 셸과 조각을 조립한 실제 응답 DOM에서 행/빈 상태/현재 메뉴/ID/랜드마크를 검증합니다.
- 원 데스크톱의 여섯 노드와 다섯 관계를 세로로 재배치한다. 복사한 조회 목록과 페이지 메타에서 컨트롤러가 코드 포인트·longPost를 계산하고 복사한 화면 모델을 만든다. 목록 페이지와 명시적 탐색 입력을 Thymeleaf가 조립하며 최종 응답 DOM으로 반복·현재 메뉴·ID·랜드마크를 검사한다. 수정 링크 숨김은 서버 권한 검사가 아니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 2 | 조회 PageSnapshot→controller→복사한 PostListPage→목록 페이지와 명시적 navigation 입력→Thymeleaf→실제 최종 DOM입니다. query가 canEdit와 페이지 메타를 판정하고 controller가 codepoint/longPost를 계산합니다. 두 행/한 편집/next page 1과 빈 상태의 배타성, posts 현재 메뉴 하나, 유일 ID와 랜드마크를 실제 Boot MVC 응답 DOM에서 검증합니다. 편집 링크 숨김은 권한 검사를 대신하지 않습니다. |
| 5 | 명시적 목록 모델에서 최종 DOM까지 |
| 6 | 조회 PageSnapshot은 items를 복사합니다. controller는 codepoint length와 60 이상인 longPost를 계산하고 조회가 판정한 canEdit/page 메타를 넘깁니다. PostListPage는 rows를 다시 복사합니다. 목록 페이지는 page와 memberName을 받고 navigation은 정적인 posts 섹션과 memberName을 명시적으로 받습니다. Thymeleaf가 셸과 조각을 조립한 실제 응답 DOM에서 행/빈 상태/현재 메뉴/ID/랜드마크를 검증합니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| snapshot과 계산 복사된 items에서 codepoint length·60 이상 longPost를 계산합니다. | 입력: PageSnapshot · 계산: longPost · 권한: 조회 canEdit | 조회 결과가 명시적인 화면 값이 됩니다. |
| page·fragment 계약 rows를 복사하고 page·memberName·posts 메뉴를 명시적으로 전달합니다. | 산출물: PostListPage · fragment: 명시 인자 | 셸과 조각이 같은 화면 계약을 받습니다. |
| 최종 DOM 검증 실제 응답 DOM의 행·빈 상태·메뉴·ID·랜드마크를 검증합니다. | 근거: 응답 DOM · 검증: 화면 계약 | 모델 조립 뒤 실제 출력까지 확인합니다. |
명시적 화면 계약을 조립한 뒤 최종 DOM을 다시 검증한다
snapshot·계산된 page model·명시 fragment/shell·최종 DOM으로 출력 산출물이 바뀐다.
ARCHITECTURE · VIEW ASSEMBLY · FINAL DOM
명시적 화면 계약을 조립한 뒤 최종 DOM을 다시 검증한다
조회 스냅샷과 불변 화면 모델이 반복·권한·페이지 상태를 확정하고, 목록 페이지와 탐색 조각은 명시적 입력만 받아 Thymeleaf에서 하나의 응답으로 조립된다.
목록 페이지는 탐색 조각에 현재 섹션과 회원 이름을 명시합니다. 화살표는 조회 모델에서 조립 결과로 이어지는 관계이며 수정 업무 권한을 템플릿에서 판정하는 흐름이 아닙니다.
DATA → VIEW MODEL
표시 판단의 주인을 남긴다
query가 canEdit·이전/다음 page meta를 결정합니다. controller는 code point 수와 60 이상 longPost를 계산합니다. 두 생성자는 목록을 복사하고 PostListPage는 빈 rows + hasNext의 모순을 거부합니다.
EXPLICIT FRAGMENT INPUTS
셸이 탐색 조각을 명시적으로 부른다
목록 템플릿의 navigation('posts', memberName)은 현재 섹션과 이름을 넘깁니다. 조각은 각 메뉴를 자기 섹션과 비교하고 다른 메뉴의 aria-current 속성은 제거합니다. 숨은 서비스·요청 입력은 없습니다.
NONEMPTY FIXTURE
두 행·한 편집 링크·다음 페이지
th:each의 count로 1·2를 표시하고 둘째 행만 long-post입니다. canEdit=true인 첫 행만 편집 링크를 가집니다. 다음 링크는 /composition/posts?page=1입니다. 링크 숨김은 수정 엔드포인트의 권한 검사를 대신하지 않습니다.
EMPTY FIXTURE
빈 목록과 표·페이지네이션은 배타적
rows가 비면 post-list와 pagination은 없고 empty-state가 하나입니다. loading은 비동기 전환을 도입한 클라이언트의 상태입니다.
FINAL DOM CONTRACT
조립 결과에서 ID·현재 메뉴·랜드마크를
fixture의 현재 메뉴는 posts 하나, reports는 aria-current가 없습니다. 중복·빈 ID는 없고 main·header·footer·본문 바로가기가 각 하나입니다. 리터럴 HTML이나 조각 원본만 검사하면 실제 조립 경계를 건너뜁니다.
템플릿의 조건은 이미 결정된 표시 상태만 선택한다. 권한과 페이지 경계는 Java 계약이 소유하고, 조립 뒤 생기는 중복 ID와 잘못된 현재 위치는 실제 최종 DOM 테스트가 잡는다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 조회 PageSnapshot은 items를 복사합니다. controller는 codepoint length와 60 이상인 longPost를 계산하고 조회가 판정한 canEdit/page 메타를 넘깁니다. PostListPage는 rows를 다시 복사합니다. 목록 페이지는 page와 memberName을 받고 navigation은 정적인 posts 섹션과 memberName을 명시적으로 받습니다. Thymeleaf가 셸과 조각을 조립한 실제 응답 DOM에서 행/빈 상태/현재 메뉴/ID/랜드마크를 검증합니다.
- 원 데스크톱의 여섯 노드와 다섯 관계를 세로로 재배치한다. 복사한 조회 목록과 페이지 메타에서 컨트롤러가 코드 포인트·longPost를 계산하고 복사한 화면 모델을 만든다. 목록 페이지와 명시적 탐색 입력을 Thymeleaf가 조립하며 최종 응답 DOM으로 반복·현재 메뉴·ID·랜드마크를 검사한다. 수정 링크 숨김은 서버 권한 검사가 아니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 2 | 조회 PageSnapshot→controller→복사한 PostListPage→목록 페이지와 명시적 navigation 입력→Thymeleaf→실제 최종 DOM입니다. query가 canEdit와 페이지 메타를 판정하고 controller가 codepoint/longPost를 계산합니다. 두 행/한 편집/next page 1과 빈 상태의 배타성, posts 현재 메뉴 하나, 유일 ID와 랜드마크를 실제 Boot MVC 응답 DOM에서 검증합니다. 편집 링크 숨김은 권한 검사를 대신하지 않습니다. |
| 5 | 명시적 목록 모델에서 최종 DOM까지 |
| 6 | 조회 PageSnapshot은 items를 복사합니다. controller는 codepoint length와 60 이상인 longPost를 계산하고 조회가 판정한 canEdit/page 메타를 넘깁니다. PostListPage는 rows를 다시 복사합니다. 목록 페이지는 page와 memberName을 받고 navigation은 정적인 posts 섹션과 memberName을 명시적으로 받습니다. Thymeleaf가 셸과 조각을 조립한 실제 응답 DOM에서 행/빈 상태/현재 메뉴/ID/랜드마크를 검증합니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| snapshot과 계산 복사된 items에서 codepoint length·60 이상 longPost를 계산합니다. | 입력: PageSnapshot · 계산: longPost · 권한: 조회 canEdit | 조회 결과가 명시적인 화면 값이 됩니다. |
| page·fragment 계약 rows를 복사하고 page·memberName·posts 메뉴를 명시적으로 전달합니다. | 산출물: PostListPage · fragment: 명시 인자 | 셸과 조각이 같은 화면 계약을 받습니다. |
| 최종 DOM 검증 실제 응답 DOM의 행·빈 상태·메뉴·ID·랜드마크를 검증합니다. | 근거: 응답 DOM · 검증: 화면 계약 | 모델 조립 뒤 실제 출력까지 확인합니다. |
그림은 조회 스냅샷이 컨트롤러를 거쳐 행을 복사한 PostListPage가 되고, 목록 페이지가 탐색 조각에 현재 섹션 posts와 회원 이름을 명시적으로 넘긴 뒤, Thymeleaf가 셸과 조각을 하나의 응답으로 조립하는 과정을 보여 줍니다. 조회가 canEdit와 이전·다음 페이지 정보를 정하고, 컨트롤러는 코드 포인트 수와 60 이상인지를 나타내는 longPost를 계산합니다. 조각 하나하나가 올바르게 보여도 ID와 랜드마크, 현재 메뉴는 조립된 결과에서 다시 검증해야 하며, 편집 링크를 숨기는 것은 권한 검사를 대신하지 않습니다.
충돌 없는 실행 단위
이 글은 Thymeleaf 표현식의 루트 Gradle 빌드를 그대로 씁니다. 패키지와 요청 경로, 템플릿 경로를 composition으로 나누어, 다른 예제와 한 프로젝트에서 함께 컴파일할 수 있게 합니다.
package board.composition;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class CompositionApplication {
public static void main(String[] args) {
SpringApplication.run(CompositionApplication.class, args);
}
}조회 결과를 불변 목록 모델로 바꿉니다
컨트롤러는 엔티티 컬렉션 대신 조회 포트의 스냅샷을 받습니다. 스냅샷은 컬렉션을 방어적으로 복사하므로, 저장소가 원래 리스트를 바꿔도 렌더링 중인 페이지는 달라지지 않습니다.
package board.composition;
import java.util.List;
import java.util.Objects;
public interface PostListQuery {
PageSnapshot page(int pageNumber, int pageSize);
record PostSnapshot(
long id,
String title,
String content,
boolean canEdit
) {
public PostSnapshot {
if (id <= 0) {
throw new IllegalArgumentException("id must be positive");
}
Objects.requireNonNull(title, "title");
Objects.requireNonNull(content, "content");
}
}
record PageSnapshot(
List<PostSnapshot> items,
int pageNumber,
boolean hasPrevious,
boolean hasNext
) {
public PageSnapshot {
items = List.copyOf(items);
if (pageNumber < 0) {
throw new IllegalArgumentException(
"pageNumber must not be negative");
}
}
}
}화면 모델도 다시 복사합니다. 두 계층에서 복사하는 것은 각자가 자기 입력의 불변성을 따로 보장한다는 뜻입니다.
여기서 List.copyOf는 목록에 원소를 더하거나 빼지 못하게 하고, 원본 목록의 이후 변경과 떼어 놓습니다. 원소 객체를 깊게 복제하지는 않으며, 이 예제의 원소는 불변 레코드와 불변 String으로 되어 있어 그 전제에 맞습니다. 변경 가능한 원소를 넣는 설계라면 원소 내부의 변경은 따로 막아야 합니다. null 목록과 원소도 거부하며, 이미 알맞은 불변 목록이면 같은 인스턴스를 다시 쓸 수 있으므로 두 복사본이 서로 다른 객체인지를 테스트 조건으로 삼지 않습니다.
package board.composition;
import java.util.List;
import java.util.Objects;
public record PostListPage(
List<PostRow> rows,
int number,
boolean hasPrevious,
boolean hasNext
) {
public PostListPage {
rows = List.copyOf(rows);
if (number < 0) {
throw new IllegalArgumentException(
"page number must not be negative");
}
if (rows.isEmpty() && hasNext) {
throw new IllegalArgumentException(
"an empty page cannot have a next page");
}
}
public record PostRow(
long id,
String title,
int characterCount,
boolean longPost,
boolean canEdit
) {
public PostRow {
if (id <= 0 || characterCount < 0) {
throw new IllegalArgumentException("invalid post row");
}
Objects.requireNonNull(title, "title");
}
}
}package board.composition;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public final class PostListController {
private static final int PAGE_SIZE = 20;
private static final int LONG_POST_THRESHOLD = 60;
private final PostListQuery query;
public PostListController(PostListQuery query) {
this.query = query;
}
@GetMapping("/composition/posts")
String list(
@RequestParam(name = "page", defaultValue = "0") int page,
Model model
) {
var result = query.page(page, PAGE_SIZE);
var rows = result.items().stream()
.map(post -> {
var content = post.content();
var characterCount = content.codePointCount(
0, content.length());
return new PostListPage.PostRow(
post.id(),
post.title(),
characterCount,
characterCount >= LONG_POST_THRESHOLD,
post.canEdit());
})
.toList();
model.addAttribute("memberName", "동민");
model.addAttribute("page", new PostListPage(
rows,
result.pageNumber(),
result.hasPrevious(),
result.hasNext()));
return "composition/posts/list";
}
}longPost와 canEdit는 Java가 정하므로, 템플릿은 기준 길이나 소유자 ID를 몰라도 됩니다. 링크를 숨기는 것은 권한 검사가 아니므로 수정 엔드포인트는 같은 권한을 다시 검사합니다. 여기서도 String.length()가 아니라 유니코드 코드 포인트 수를 쓰며, 제품이 사용자가 보는 글자 단위를 요구하면 계산 정책을 바꿉니다.
반복과 빈 상태, 표시 조건을 한 템플릿에 둡니다
state.index는 0부터, state.count는 1부터 셉니다. 실제 내용인 순번에는 반복 상태를 쓰고, 시각적인 줄무늬는 CSS의 :nth-child에 맡깁니다.
빈 컬렉션에서 th:each는 행을 하나도 만들지 않습니다. 빈 화면이 로딩 중이거나 오류처럼 보이지 않도록 별도의 빈 상태를 렌더링합니다. 로딩 상태는 서버가 렌더링하는 목록의 상태가 아니라, 비동기 전환을 도입한 클라이언트 쪽에서 다룹니다.
<nav xmlns:th="http://www.thymeleaf.org"
th:fragment="navigation(activeSection, memberName)"
aria-label="주요 메뉴">
<a th:href="@{/composition/posts}"
th:attr="aria-current=${activeSection == 'posts' ? 'page' : null}"
th:text="|${memberName}의 기록|">게시글</a>
<a th:href="@{/composition/reports}"
th:attr="aria-current=${activeSection == 'reports' ? 'page' : null}">
리포트
</a>
</nav>두 링크가 모두 activeSection == 'posts'를 검사하면 현재 페이지가 두 개가 됩니다. 항목마다 자기 섹션과 비교하고, 거짓일 때는 빈 문자열이 아니라 속성 자체를 없애도록 완전한 삼항식을 씁니다.
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>게시글 목록</title>
</head>
<body>
<a class="skip-link" href="#main">본문 바로가기</a>
<header id="page-header"
th:insert="~{composition/fragments/navigation
:: navigation('posts', ${memberName})}">
주요 메뉴
</header>
<main id="main">
<h1>게시글 목록</h1>
<section id="post-list"
aria-labelledby="post-list-heading"
th:unless="${#lists.isEmpty(page.rows)}">
<h2 id="post-list-heading">기록</h2>
<table>
<caption>현재 페이지 게시글</caption>
<thead>
<tr>
<th scope="col">순번</th>
<th scope="col">제목</th>
<th scope="col">길이</th>
<th scope="col">행동</th>
</tr>
</thead>
<tbody>
<tr th:each="row, state : ${page.rows}"
th:classappend="${row.longPost ? 'long-post' : null}">
<td th:text="${state.count}">1</td>
<td>
<a class="post-link"
th:href="@{/composition/posts/{id}(id=${row.id})}"
th:text="${row.title}">Spring</a>
</td>
<td th:text="|${row.characterCount}자|">45자</td>
<td>
<a class="edit-link"
th:if="${row.canEdit}"
th:href="@{/composition/posts/{id}/edit(id=${row.id})}">
수정
</a>
</td>
</tr>
</tbody>
</table>
</section>
<section id="empty-state"
aria-labelledby="empty-state-heading"
th:if="${#lists.isEmpty(page.rows)}">
<h2 id="empty-state-heading">아직 기록이 없습니다</h2>
<a th:href="@{/composition/posts/new}">첫 게시글 기록하기</a>
</section>
<nav class="pagination"
aria-label="목록 페이지"
th:if="${page.hasPrevious or page.hasNext}">
<a rel="prev"
th:if="${page.hasPrevious}"
th:href="@{/composition/posts(page=${page.number - 1})}">
이전
</a>
<span th:text="|${page.number + 1}페이지|">1페이지</span>
<a rel="next"
th:if="${page.hasNext}"
th:href="@{/composition/posts(page=${page.number + 1})}">
다음
</a>
</nav>
</main>
<footer id="page-footer">게시판 예제</footer>
</body>
</html>예를 들어 예제의 첫 본문 본문 😀는 코드 포인트가 4개이므로 60 미만이고, 둘째 본문인 가 60개는 경곗값에 닿아 long-post가 됩니다. 순번 1과 2는 ID 11과 12에서 나온 것이 아니라 state.count에서 나옵니다. 첫 행에만 편집 링크가 보이는 것도 행의 위치 때문이 아니라 조회 결과의 canEdit=true 때문입니다.
탐색 조각의 th:insert는 header#page-header를 그대로 두고 그 안에 nav를 넣습니다. 같은 호출을 th:replace로 바꾸면 호스트인 header가 조각의 nav로 바뀌어, 랜드마크 검사의 header#page-header가 사라집니다. 둘 다 메뉴 글자는 보이지만 DOM 구조는 다릅니다. Thymeleaf의 조각 삽입과 교체 설명과 최종 DOM을 함께 읽습니다.
hasNext는 템플릿이 현재 행 수를 보고 추측하지 않습니다. 조회 계층이 한 건을 더 조회하는 식으로, 저장소에 맞는 방법으로 정합니다. PostListPage는 결과가 비었는데 hasNext=true인 모순도 생성할 때 거부합니다.
조각과 레이아웃이 맡는 일
조각은 필요한 입력을 호출하는 쪽에 드러냅니다. 탐색 조각이 서비스 빈이나 요청 파라미터를 읽지 않으므로, 호출하는 곳만 보면 현재 섹션과 회원 이름을 알 수 있습니다. 파라미터가 계속 늘어난다면, 공통 조각이 서로 다른 화면의 일을 떠안고 있지 않은지 다시 봅니다.
공통 <head>와 본문 바로 가기, 탐색, 바닥글은 페이지 셸이 맡습니다. 조각 몇 개로 충분하다면 레이아웃 방언(layout dialect)부터 추가하지 않습니다. 중첩 레이아웃이나 슬롯, 페이지별 자산 묶음이 실제로 필요해질 때 의존성의 비용을 비교합니다.
| 후보 | 공통화 판단 | 이유 |
|---|---|---|
| 주요 탐색 | 조각 | 모든 페이지가 같은 접근성 구조와 현재 위치 규칙을 함께 씀 |
| 게시글 행 | 두 곳에서 똑같이 쓸 때만 조각 | 한 화면에서만 쓰면 인라인이 더 직접적임 |
| 등록·수정 필드 | 레이블과 오류 표시가 같을 때 조각 | 허용 필드의 차이는 호출하는 쪽에 드러내야 함 |
| 전체 페이지 | 필요해지기 전에는 셸을 직접 작성 | 메타데이터와 자산의 차이를 숨기지 않음 |
| 로딩 표시 | 서버 목록 조각에서 제외 | 비동기 전환을 하는 클라이언트가 다룸 |
이름이 같은 카드라고 바로 합치면 조건 파라미터만 늘어날 수 있습니다. 같은 이유로 함께 바뀌는 구조인지 확인한 뒤에 공통화합니다.
실제 조립 결과를 Jsoup으로 확인합니다
리터럴 HTML을 파싱하는 테스트는 Thymeleaf의 반복과 조건, 조각 해석을 실행하지 않습니다. 다음 테스트는 실제 Boot MVC 요청으로 템플릿을 렌더링한 뒤 최종 DOM을 검사합니다.
package board.composition;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Bean;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest(classes = {
CompositionApplication.class,
PostListRenderingTest.FixtureConfiguration.class
})
@AutoConfigureMockMvc
class PostListRenderingTest {
@Autowired
MockMvc mvc;
@Autowired
MutablePostListQuery query;
@BeforeEach
void resetPage() {
query.respondWith(new PostListQuery.PageSnapshot(
List.of(
new PostListQuery.PostSnapshot(
11, "짧은 글", "본문 😀", true),
new PostListQuery.PostSnapshot(
12, "긴 글", "가".repeat(60), false)),
0,
false,
true));
}
@Test
void 반복_상태와_서버가_결정한_표시값을_렌더링한다()
throws Exception {
var document = render();
var rows = document.select("#post-list tbody tr");
assertThat(rows).hasSize(2);
assertThat(rows.get(0).selectFirst("td").text()).isEqualTo("1");
assertThat(rows.get(1).selectFirst("td").text()).isEqualTo("2");
assertThat(rows.get(1).hasClass("long-post")).isTrue();
assertThat(document.select(".edit-link")).hasSize(1);
assertThat(document.select("a[rel=next]").attr("href"))
.isEqualTo("/composition/posts?page=1");
}
@Test
void 빈_결과는_table과_pagination_대신_빈_상태를_보인다()
throws Exception {
query.respondWith(new PostListQuery.PageSnapshot(
List.of(), 0, false, false));
var document = render();
assertThat(document.select("#post-list")).isEmpty();
assertThat(document.select("#empty-state")).hasSize(1);
assertThat(document.select(".pagination")).isEmpty();
}
@Test
void 화면_model은_rows를_복사하고_빈_next_모순을_거부한다() {
var mutableRows = new ArrayList<PostListPage.PostRow>();
mutableRows.add(new PostListPage.PostRow(
21, "복사할 행", 5, false, false));
var page = new PostListPage(
mutableRows, 0, false, false);
mutableRows.clear();
assertThat(page.rows()).hasSize(1);
assertThatThrownBy(() -> new PostListPage(
List.of(), 0, false, true))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("empty page");
}
@Test
void 현재_메뉴는_정확히_하나다() throws Exception {
var document = render();
var current = document.select(
"nav[aria-label='주요 메뉴'] [aria-current=page]");
assertThat(current).hasSize(1);
assertThat(current.attr("href"))
.isEqualTo("/composition/posts");
assertThat(document.select("a[href='/composition/reports']"
+ "[aria-current]"))
.isEmpty();
}
@Test
void 조립된_DOM은_유일한_id와_landmark를_가진다()
throws Exception {
var document = render();
var observed = new HashSet<String>();
var duplicateIds = document.select("[id]").stream()
.map(element -> element.id())
.filter(id -> id.isBlank() || !observed.add(id))
.toList();
assertThat(duplicateIds).isEmpty();
assertThat(document.select("main#main")).hasSize(1);
assertThat(document.select("header#page-header")).hasSize(1);
assertThat(document.select("footer#page-footer")).hasSize(1);
assertThat(document.select("a.skip-link[href='#main']"))
.hasSize(1);
}
private Document render() throws Exception {
var result = mvc.perform(get("/composition/posts"))
.andExpect(status().isOk())
.andExpect(view().name("composition/posts/list"))
.andReturn();
return Jsoup.parse(result.getResponse()
.getContentAsString(StandardCharsets.UTF_8));
}
static final class MutablePostListQuery implements PostListQuery {
private PageSnapshot response;
void respondWith(PageSnapshot response) {
this.response = response;
}
@Override
public PageSnapshot page(int pageNumber, int pageSize) {
assertThat(pageSize).isEqualTo(20);
return response;
}
}
@TestConfiguration(proxyBeanMethods = false)
static class FixtureConfiguration {
@Bean
MutablePostListQuery postListQuery() {
return new MutablePostListQuery();
}
}
}이 테스트는 반복 결과와 빈 상태뿐 아니라, 조각의 현재 위치와 편집 링크의 표시 조건, 중복되거나 빈 ID, 주요 랜드마크, 본문 바로 가기를 실제 응답에서 고정합니다.
예제의 page(...)는 전달받은 pageSize=20을 검사하고 준비된 스냅샷을 돌려줄 뿐입니다. 요청 번호에 맞춰 DB를 조회하는 구현이나 음수 페이지의 오류 응답, 수정 API의 권한 검사를 이 테스트가 실행하지는 않습니다. 정상 DOM 검사가 통과해도 그 조회와 보안은 각 계층에서 따로 검증해야 합니다.
정리
- 템플릿의 조건은 서버가 이미 정한 표시 상태를 고르기만 하며,
canEdit와longPost,hasNext는 Java가 정합니다. - 조회 스냅샷과 화면 모델은 각자
List.copyOf로 목록을 복사하지만, 원소를 깊게 복제하지는 않습니다. state.count는 1부터 세는 순번이고, 빈 목록에서는th:each가 아무것도 만들지 않으므로 별도의 빈 상태를 렌더링합니다.- 조각은 필요한 입력을 호출하는 쪽에 드러내며,
th:insert와th:replace는 호스트 요소가 남는지가 다릅니다. - 중복 ID와 잘못된 현재 메뉴는 조립된 뒤에 생기므로 실제 렌더링된 최종 DOM에서 검사합니다.
연습 문제
1. 함께 쓰는 페이지네이션 조각
게시글 목록과 주간 리포트가 함께 쓰는 페이지네이션 조각을 만들려고 합니다. 현재 페이지와 이전·다음 이동 가능 여부, URL을 입력으로 받고, 첫 페이지와 마지막 페이지, 결과 0건을 렌더링해야 합니다. 조각의 입력과 이동할 수 없을 때의 표시, 테스트할 항목을 정하세요.
해설 보기
조각이 기준 경로 문자열을 이어 붙이기보다 서버가 만든 previousUrl과 nextUrl을 받으면, 경로 규칙을 몰라도 됩니다. 이동할 수 없을 때는 href 없는 링크를 남기지 말고 일반 텍스트를 렌더링합니다. 최종 DOM에서 링크 텍스트와 href, aria-current, 포커스를 받는 비활성 링크가 없는지, 중복 ID가 없는지를 검사합니다.
2. 다른 페이지의 결과 예측하기
새 테스트에서 /composition/posts?page=2를 요청하고, 조회 예제가 다음 스냅샷을 돌려준다고 가정합니다. ID 21의 본문은 😀 60개, ID 22의 본문은 가 59개이며 두 제목은 필수 조건을 만족합니다. 두 행의 canEdit는 모두 거짓이고, pageNumber=2, hasPrevious=true, hasNext=false, 컨텍스트 경로는 빈 문자열입니다. 표의 순번과 각 글자 수, long-post, 편집 링크 수, 표시되는 페이지와 이동 링크를 정하세요. 이어서 조회 결과만 빈 목록과 pageNumber=0, 이전·다음 모두 거짓으로 바꾸면 무엇이 사라지고 무엇이 남나요? 빈 목록이면서 hasNext=true인 스냅샷도 같은 빈 화면으로 렌더링되나요?
해설 보기
순번은 1과 2입니다. 첫 본문은 UTF-16으로 120단위지만 코드 포인트로는 60개라 long-post가 참이고, 둘째는 59개라 거짓입니다. 편집 링크는 0개입니다. ID나 반복 위치, 문자열의 저장 단위로 표시 조건을 추측하면 Java의 계산과 어긋납니다.
현재 번호 2는 0부터 세는 번호이므로 3페이지를 표시합니다. 이전 링크만 /composition/posts?page=1로 생기고 다음 링크는 없습니다. pageSize는 컨트롤러의 20이며, 두 행이 있다고 다음 페이지가 저절로 생기지는 않습니다. 여기서는 조회 예제가 돌려준 메타데이터가 조건을 정합니다.
올바른 빈 목록이면 post-list와 페이지네이션은 없고 empty-state 하나가 생깁니다. 공통 header와 주요 탐색, main, footer는 그대로이며 posts의 aria-current=page도 하나입니다. 빈 목록에 hasNext=true인 스냅샷은 PostListPage 생성자의 검사에서 거부되므로 이 정상 빈 상태의 DOM까지 가지 않습니다. 실제 오류 응답의 상태와 화면은 별도의 예외 처리 정책이 없으면 여기서 정할 수 없습니다. 기존 테스트의 render()는 page 파라미터 없는 첫 페이지 요청을 고정하므로, 새 테스트는 page=2 요청과 예제의 메타데이터를 명시해야 합니다.
3. 목록 복사와 순번, 반복되는 ID
① 변경 가능한 목록에 올바른 PostRow 하나를 넣어 PostListPage를 만든 뒤 원본 목록을 비웁니다. 그다음 page.rows().clear()를 호출하면 어떻게 되나요? 별도의 목록에 null 원소를 넣어 만들면 정상 빈 화면이 되나요? ② 0부터 세는 페이지 번호 2, 페이지 크기 20이고 앞의 두 페이지에는 정확히 20행씩 있습니다. 현재 행은 ID 91과 92 두 개입니다. state.index와 state.count, 앞선 행 수를 포함한 위치는 각각 무엇인가요? 두 행의 버튼을 모두 id="edit-button"으로 만들어도 되나요? ③ 예제가 올바른 0번 페이지 스냅샷을 계속 돌려주게 둔 채 /composition/posts?page=-1을 요청합니다. 성공한 화면만 보고 실제 조회 계층도 음수 번호를 제대로 거절한다고 결론 내릴 수 있나요?
해설 보기
① 화면 모델의 행 하나는 그대로 남습니다. 그 모델의 rows는 수정할 수 없으므로 clear()는 UnsupportedOperationException을 냅니다. null 원소가 든 입력은 List.copyOf에서 NullPointerException으로 거부되어 렌더링까지 가지 않습니다. 두 목록의 참조가 다른지보다, 외부 변경 뒤에도 내용이 유지되는지와 모델이 수정을 거부하는지를 확인합니다.
② index는 0과 1, count는 1과 2입니다. 앞선 40행을 포함한 위치가 따로 필요하다면 2 × 20 + count로 41과 42입니다. 이것은 주어진 페이지 조건에서의 위치일 뿐, ID 91·92나 언제나 유지되는 게시글 순위와 다릅니다. 고정 ID를 반복하면 같은 최종 DOM에 중복 ID가 두 개 생기므로, 게시글 ID를 넣은 edit-91, edit-92처럼 구분하고 그 ID를 참조하는 레이블과 제어 관계도 함께 맞춰야 합니다.
③ 그럴 수 없습니다. 컨트롤러는 요청의 -1을 조회 포트에 넘기지만 이 예제는 그 번호를 검증하지 않고, 돌려준 스냅샷의 번호 0이 뷰 모델에 들어가므로 정상 화면이 만들어질 수 있습니다. 실제 제품의 입력 검증과 조회 포트, 오류 응답은 따로 검증해야 하며, 이 예제의 정상 응답으로는 알 수 없습니다.
다음 글 Spring 폼 바인딩에서는 HTML 조립을 넘어, 폼의 문자열이 Java 객체로 바인딩되고 변환과 검증, 도메인 명령의 오류가 어떻게 나뉘는지 다룹니다.