Thymeleaf 조건·반복·조각
게시글 목록의 반복·표시 조건·조각·페이지 셸을 실제 Thymeleaf 최종 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 · 검증: 화면 계약 | 모델 조립 뒤 실제 출력까지 확인합니다. |
명시적 화면 계약을 조립한 뒤 최종 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 · 검증: 화면 계약 | 모델 조립 뒤 실제 출력까지 확인합니다. |
도식은 조회 스냅샷이 화면 모델로 바뀐 뒤 목록 페이지와 탐색 조각에 명시적으로 전달되고, Thymeleaf가 하나의 최종 DOM을 만드는 경계를 보여 줍니다. 조각 소스 각각이 유효해 보여도 ID, 랜드마크, 현재 메뉴는 조립 결과에서 다시 검증해야 합니다.
충돌 없는 실행 단위
이 문서는 ch7-1의 루트 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는 목록의 원소 추가·삭제를 막고 원본 목록의 후속 변경과 분리하는 계약입니다. 요소 객체를 깊게 복제하지는 않습니다. 이 예제의 요소는 불변 record와 불변 String으로 구성돼 있어 그 전제에 맞습니다. mutable 요소를 넣는 다른 설계라면 요소의 내부 변경은 별도로 막아야 합니다. 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>예시
원 fixture의 첫 본문 본문 😀는 코드 포인트 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>, 본문 바로가기, 탐색, 바닥글은 페이지 셸의 책임입니다. 조각 몇 개로 충분하면 레이아웃 방언을 먼저 추가하지 않습니다. 중첩 레이아웃, 슬롯, 페이지별 자산 스택이 실제로 필요할 때 의존성 비용을 비교합니다.
| 후보 | 공통화 판단 | 이유 |
|---|---|---|
| 주요 탐색 | 조각 | 모든 페이지가 같은 접근성 구조와 현재 위치 규칙 공유 |
| 게시글 행 | 두 사용처가 같을 때만 조각 | 한 화면뿐이면 인라인이 더 직접적 |
| 등록·수정 필드 | 레이블·오류 계약이 같을 때 조각 | 허용 필드 차이는 호출부에 드러내야 함 |
| 전체 페이지 | 요구 전에는 직접 셸 | 메타데이터·자산 차이를 숨기지 않음 |
| 로딩 표시 | 서버 목록 조각에서 제외 | 비동기 전환을 수행하는 클라이언트가 소유 |
이름이 같은 “카드”를 바로 합치면 조건 파라미터만 늘어날 수 있습니다. 같은 이유로 함께 변경되는 구조인지 확인한 뒤 공통화합니다.
실제 조립 결과를 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, 주요 랜드마크, 본문 바로가기를 실제 응답에서 고정합니다.
fixture의 page(...)는 전달받은 pageSize=20을 검사하고 준비된 스냅샷을 반환합니다. 요청 번호에 맞게 DB를 조회하는 구현이나 음수 페이지의 오류 응답, 수정 API의 권한 검사를 이 테스트가 실행하는 것은 아닙니다. 정상 DOM 검사가 통과해도 그 조회·보안 계약은 각 계층의 별도 검증으로 남습니다.
연습 문제
게시글 목록과 주간 리포트가 함께 쓰는 페이지네이션 조각을 만드세요. 현재 페이지, 이전·다음 가능 여부와 URL을 입력으로 받고 첫 페이지·마지막 페이지·결과 0건을 렌더링합니다.
해설 보기
조각이 기반 경로 문자열을 이어 붙이기보다 서버가 만든 previousUrl과 nextUrl을 받으면 경로 규칙을 몰라도 됩니다. 이동할 수 없을 때는 href 없는 링크를 남기지 말고 일반 텍스트를 렌더링합니다. 최종 DOM에서 링크 텍스트, href, aria-current, 포커스 가능한 비활성 링크, 중복 ID를 검사하세요.
독립 확인
새 테스트에서 /composition/posts?page=2를 요청하고 조회 fixture가 다음 스냅샷을 반환한다고 가정합니다. ID 21의 본문은 😀 60개, ID 22의 본문은 가 59개이며 두 제목은 필수 값을 충족합니다. 두 행의 canEdit는 모두 false입니다. pageNumber=2, hasPrevious=true, hasNext=false, 컨텍스트 경로는 빈 문자열이며 나머지 원 컨트롤러·템플릿은 유지합니다.
표의 순번, 각 글자 수와 long-post, 편집 링크 수, 표시 페이지와 이동 링크를 정하세요. 이후 조회 결과만 빈 목록·pageNumber=0·이전/다음 모두 false로 바꾸면 무엇이 사라지고 무엇이 남나요? 빈 목록이면서 hasNext=true인 스냅샷은 같은 빈 화면으로 렌더링되나요?
반복·표시·페이지 조건을 각각 통과시키는 풀이
순번은 1·2입니다. 첫 본문은 UTF-16으로 120단위여도 코드 포인트로는 60개라 long-post=true, 둘째는 59개라 false입니다. 편집 링크는 0개입니다. ID·반복 위치·문자열 저장 단위로 표시 조건을 추측하면 원 Java의 계산과 어긋납니다.
현재 번호 2는 0부터 세는 번호이므로 3페이지를 표시합니다. 이전 링크만 /composition/posts?page=1로 생기고 다음 링크는 없습니다. pageSize는 원 컨트롤러의 20이며, 두 행이 있다고 다음 페이지가 자동으로 생기지는 않습니다. 여기서는 조회 fixture가 반환한 메타데이터가 조건을 결정합니다.
유효한 빈 목록이면 post-list와 페이지네이션은 없고 empty-state 하나가 생깁니다. 공통 header·주요 탐색·main·footer는 유지되며 posts의 aria-current=page도 하나입니다. 빈 목록+hasNext=true는 PostListPage 생성자의 검사에서 거부되므로 이 정상 빈 상태의 DOM까지 도달하지 않습니다. 실제 오류 응답의 상태·화면은 별도 예외 처리 정책이 없으면 여기서 정답으로 고정할 수 없습니다.
원 테스트의 render()는 page 파라미터 없는 첫 페이지 요청을 고정하므로 새 테스트는 위 page=2 요청과 fixture 메타데이터를 명시해야 합니다. 예상은 소스 조건에서 도출한 것이며 최종 DOM 테스트로 행·속성·링크와 구조를 각각 확인하세요.
다음 문서에서는 HTML 조립을 넘어 폼 문자열이 Java 객체로 바인딩되고, 변환·검증·도메인 명령의 오류가 어떻게 분리되는지 다룹니다.
적용: 목록 복사·페이지 순번·반복 ID의 경계
조건 1. mutable 목록에 유효한 PostRow 하나를 넣고 PostListPage를 만든 뒤 원본 목록을 비웁니다. 이후 page.rows().clear()를 호출하면 어떻게 되나요? 별도 목록에 null 원소를 넣어 생성하면 정상 빈 화면이 되나요?
풀이. 화면 모델의 행 하나는 유지됩니다. 그 모델의 rows는 수정 불가능하므로 clear()는 UnsupportedOperationException을 발생시킵니다. null 원소를 포함한 입력은 List.copyOf에서 NullPointerException으로 거부돼 렌더링까지 도달하지 않습니다. 두 목록의 참조가 다르다는 검사보다 외부 변경 후 내용 유지와 모델의 수정 거부라는 계약을 확인합니다.
조건 2. 0부터 세는 페이지 번호 2, 페이지 크기 20이고 앞의 두 페이지에는 정확히 20행씩 있습니다. 현재 행은 ID 91·92 두 개입니다. 기존 state.index, state.count, 그리고 앞선 행 수를 포함한 위치는 각각 무엇인가요? 두 행의 버튼을 모두 id="edit-button"으로 만들면 안전한가요?
풀이. index는 0·1, count는 1·2입니다. 앞선 40행을 포함한 위치를 별도로 요구한다면 2 × 20 + count로 41·42입니다. 이는 주어진 페이지 조건에서의 위치이며 ID 91·92나 항상 유지되는 게시글 순위와 다릅니다. 고정 ID를 반복하면 같은 최종 DOM에 중복 ID 두 개가 생깁니다. 유일한 게시글 ID를 포함한 edit-91·edit-92 등으로 구분하고, 그 ID를 참조하는 레이블·제어 관계도 함께 맞춰야 합니다.
조건 3. 원 fixture가 유효한 0번 페이지 스냅샷을 계속 반환하도록 둔 채 /composition/posts?page=-1을 요청합니다. 성공한 화면만 관측해 실제 조회 계층도 음수 번호를 올바르게 거절한다고 결론 내릴 수 있나요?
풀이. 없습니다. 원 컨트롤러는 요청의 -1을 조회 포트에 전달하지만 이 fixture는 그 번호를 검증하지 않습니다. 반환된 스냅샷 번호 0이 뷰 모델에 들어가므로 정상 화면이 만들어질 수 있습니다. 실제 제품의 입력 검증·조회 포트 계약과 오류 응답을 별도 조건으로 검증해야 하며, 이 fixture의 정상 응답은 그 증거가 아닙니다.