본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
7장 : View·폼·메시지·검증

Thymeleaf 조건·반복·조각

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

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

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

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

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


목록 전용 모델

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

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>

표시 조건과 권한 판단

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>

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

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

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


레이아웃 소유권

공통 <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

공통화 범위

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

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

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

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


연습 문제

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

현재 페이지, 이전·다음 가능 여부, 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 폼 바인딩이 객체와 필드 오류를 어떻게 연결하는지 다룹니다.