본문으로 건너뛰기

안동민 개발노트

본문 시작

Thymeleaf 조건·반복·조각

게시판 목록에서 반복·조건·조각 파라미터를 사용하고 빈 목록과 중복 DOM을 검증합니다.

목록 템플릿은 단순히 행을 반복하는 파일이 아닙니다.

빈 상태, 권한별 행동, 페이지네이션, 공통 헤더까지 한 화면에서 만납니다.

조건을 뷰에 둘 수 있는 기준은 “이미 결정된 표시 상태를 선택하는가”입니다.

권한이나 업무 규칙 자체를 템플릿 표현식으로 계산하지 않습니다.

HTML 다이어그램: /docs/spring/ch7/ch7-2/1.html

목록 전용 모델

컨트롤러는 엔티티 컬렉션을 넘기는 대신 표시 행과 페이지 메타데이터를 만듭니다.

src/main/java/board/web/PostListController.java
package board.web;

import java.util.List;

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 final PostQuery query;

    public PostListController(PostQuery query) {
        this.query = query;
    }

    @GetMapping("/posts")
    String list(
            @RequestParam(defaultValue = "0") int page,
            Model model
    ) {
        var result = query.page(page, 20);
        List<PostRow> rows = result.items().stream()
                .map(post -> new PostRow(
                        post.id(),
                        post.title(),
                        post.content().length(),
                        post.content().length() >= 60))
                .toList();
        model.addAttribute("page", new PostPage(
                rows,
                result.pageNumber(),
                result.hasPrevious(),
                result.hasNext()));
        return "posts/list";
    }

    record PostRow(
            long id,
            String title,
            int characterCount,
            boolean longPost
    ) {
    }

    record PostPage(
            List<PostRow> rows,
            int number,
            boolean hasPrevious,
            boolean hasNext
    ) {
    }
}

longPost는 뷰가 글자 수 기준을 반복 구현하지 않게 합니다.

나중에 기준이 바뀌어도 템플릿은 불리언 표시만 유지합니다.


th:each 반복 상태

src/main/resources/templates/posts/list.html
<tbody>
  <tr th:each="row, state : ${page.rows}"
      th:classappend="${row.longPost} ? 'long-post'">
    <td th:text="${state.count}">1</td>
    <td>
      <a th:href="@{/posts/{id}(id=${row.id})}"
         th:text="${row.title}">Spring</a>
    </td>
    <td th:text="|${row.characterCount}자|">45자</td>
  </tr>
</tbody>

state.index는 0부터, state.count는 1부터 시작합니다.

CSS 줄무늬를 만들려고 인덱스의 홀짝 판정을 템플릿에 넣기보다 :nth-child를 사용합니다.

반복 상태는 순번처럼 실제 콘텐츠에 필요한 경우만 씁니다.

빈 컬렉션은 th:each가 아무 행도 만들지 않습니다.

사용자가 빈 화면을 오류로 오해하지 않도록 별도의 빈 상태를 둡니다.

<section th:if="${#lists.isEmpty(page.rows)}">
  <h2>아직 기록이 없습니다</h2>
  <a th:href="@{/posts/new}">첫 게시글 기록하기</a>
</section>
HTML 다이어그램: /docs/spring/ch7/ch7-2/2.html

표시 조건과 권한 판단

th:if="${canEdit}"는 이미 계산된 불리언으로 버튼 표시를 선택합니다.

${post.ownerId == #authentication.name}처럼 동일성과 소유권을 템플릿에서 비교하면 API와 화면의 권한 규칙이 달라질 수 있습니다.

서버 엔드포인트는 표시 여부와 무관하게 권한을 다시 검사해야 합니다.

th:unless를 같은 요소 근처에 중복 사용하면 조건이 변할 때 둘 다 보이거나 둘 다 사라질 수 있습니다.

하나의 열거형 상태를 th:switch로 표현하면 허용 상태가 드러납니다.

알 수 없는 상태에는 대체를 보여 주되 로그로 스키마 불일치를 기록합니다.

실패 사례는 목록이 비었는데 페이지네이션 “다음” 링크가 나타나는 것입니다.

rows.isEmpty()hasNext를 템플릿에서 조합하지 말고 쿼리 결과가 hasNext를 정확히 계산하게 합니다.

표시 오류는 DB 행 수와 페이지 크기 경계에서 재현됩니다.


템플릿 조각 파라미터

헤더를 복사하면 접근성 레이블과 탐색 변경이 모든 페이지에 번집니다.

조각은 필요한 값만 파라미터로 받습니다.

src/main/resources/templates/fragments/navigation.html
<nav th:fragment="navigation(activeSection, memberName)"
     aria-label="주요 메뉴">
  <a th:href="@{/posts}"
     th:attr="aria-current=${activeSection == 'posts'} ? 'page'"
     th:text="|${memberName}의 기록|">게시글</a>
  <a th:href="@{/reports}"
     th:attr="aria-current=${activeSection == 'reports'} ? 'page'">
    리포트
  </a>
</nav>
fragment 호출
<header th:replace="~{fragments/navigation :: navigation(
        activeSection='posts',
        memberName=${memberName})}"></header>

조각이 서비스 빈을 조회하거나 요청 파라미터를 직접 읽으면 숨은 입력이 생깁니다.

호출부에 파라미터가 보이도록 유지합니다.

파라미터가 열 개를 넘으면 공통 조각이 너무 많은 화면 책임을 흡수한 신호입니다.

HTML 다이어그램: /docs/spring/ch7/ch7-2/3.html

레이아웃 소유권

공통 <head>, 본문 바로가기 링크, 탐색, 바닥글은 레이아웃이 소유하고 페이지는 제목과 메인 콘텐츠를 제공합니다.

단순 조각 조합만으로 충분하면 별도 레이아웃 방언을 추가하지 않습니다.

여러 중첩 레이아웃과 슬롯, 자산 스택이 필요할 때 방언 도입 비용을 비교합니다.

잘못된 레이아웃은 같은 id="main"을 셸과 콘텐츠에 모두 만들어 접근성 탐색이 불안정해집니다.

최종 DOM에서 ID 유일성을 검사해야 합니다.

조각 소스가 문법상 맞아도 조립 후 HTML이 잘못될 수 있습니다.

아래 검사는 완성된 HTML을 DOM으로 읽기 위해 Jsoup을 사용합니다.

Spring Boot 의존성 목록이 Jsoup 버전을 관리하지 않으므로 재현 가능한 테스트를 위해 현재 버전을 함께 명시합니다.

build.gradle - DOM 검사 의존성
testImplementation 'org.jsoup:jsoup:1.22.2'
src/test/java/board/web/PostListRenderingTest.java
package board.web;

import static org.assertj.core.api.Assertions.assertThat;

import java.util.HashSet;

import org.junit.jupiter.api.Test;
import org.jsoup.Jsoup;

class PostListRenderingTest {
    @Test
    void 조립된_DOM의_id는_중복되지_않는다() {
        var html = """
                <html><body>
                  <nav id="primary-nav">기록</nav>
                  <main id="main"><h1>게시글 목록</h1></main>
                </body></html>
                """;
        var document = Jsoup.parse(html);
        var observed = new HashSet<String>();

        var duplicateIds = document.select("[id]").stream()
                .map(element -> element.id())
                .filter(id -> !observed.add(id))
                .toList();

        assertThat(duplicateIds).isEmpty();
        assertThat(document.select("main#main h1").text())
                .isEqualTo("게시글 목록");
    }
}
실행 결과
PostListRenderingTest
  > 조립된_DOM의_id는_중복되지_않는다() PASSED
rendered id count = 2
duplicate id count = 0

공통화 범위

후보공통화 판단이유
탐색조각전 페이지가 같은 접근성 구조 공유
게시글 행작은 조각 또는 인라인목록 두 곳에서 실제로 같은 마크업일 때
등록·수정 폼필드 조각레이블·오류 연결이 같고 허용 필드는 다름
전체 페이지무조건 레이아웃 금지페이지별 스크립트·메타데이터 차이를 확인

한 번 나온 마크업을 바로 조각으로 빼지 않습니다.

두 화면이 같은 이유로 바뀌는지 확인합니다.

이름만 같은 “카드”를 하나로 합치면 파라미터 분기가 늘어 실제 구조를 읽기 어려워집니다.

HTML 다이어그램: /docs/spring/ch7/ch7-2/4.html

연습 문제

게시글 목록과 주간 리포트에서 함께 쓰는 페이지네이션을 조각으로 만드세요.

현재 페이지, 이전·다음 가능 여부, URL 생성 전략을 입력으로 받고 첫·마지막 페이지에서 비활성화 링크에 포커스할 수 없는지 검증합니다.

해설 보기

조각은 기반 경로를 문자열로 받아 URL을 조립하기보다 서버가 만든 previousUrl, nextUrl을 받으면 경로 규칙을 모릅니다.

링크가 없을 때 <a href>를 남기지 않고 텍스트 요소 또는 aria-disabled="true" 버튼을 사용합니다.

package board.web;

public record PaginationModel(
        int currentPage,
        String previousUrl,
        String nextUrl
) {
    public boolean hasPrevious() {
        return previousUrl != null;
    }

    public boolean hasNext() {
        return nextUrl != null;
    }
}

0 페이지와 마지막 페이지, 결과 0건을 각각 렌더링합니다.

링크 텍스트, href, aria-current, 중복 ID를 최종 DOM에서 검사합니다.

다음 문서에서는 HTML 요소 조립을 넘어 Spring 폼 바인딩이 객체와 필드 오류를 어떻게 연결하는지 다룹니다.