본문으로 건너뛰기

안동민 개발노트

본문 시작

Thymeleaf 조건·반복·조각

게시글 목록의 반복·표시 조건·조각·페이지 셸을 실제 Thymeleaf 최종 DOM으로 조립하고 검증합니다.

목록 템플릿은 행만 반복하는 파일이 아닙니다. 빈 상태, 권한별 행동, 페이지네이션, 공통 탐색과 랜드마크가 한 응답에서 조립됩니다.

조건을 템플릿에 둘 수 있는 기준은 “서버가 이미 결정한 표시 상태를 선택하는가”입니다. 소유권과 업무 규칙을 표현식으로 다시 계산하지 않습니다.

명시적 화면 계약을 조립한 뒤 최종 DOM을 다시 검증한다

ARCHITECTURE · VIEW ASSEMBLY · FINAL DOM

명시적 화면 계약을 조립한 뒤 최종 DOM을 다시 검증한다

조회 스냅샷과 불변 화면 모델이 반복·권한·페이지 상태를 확정하고, 목록 페이지와 탐색 조각은 명시적 입력만 받아 Thymeleaf에서 하나의 응답으로 조립된다.

명시적 화면 계약을 조립한 뒤 최종 DOM을 다시 검증한다 데이터 계약 영역의 PageSnapshot이 MVC 어댑터의 컨트롤러와 불변 PostListPage로 전달된다. 화면 조립 영역에서는 목록 페이지와 탐색 조각이 Thymeleaf에 들어가 하나의 최종 DOM을 만든다. 마지막으로 반복 행, 빈 상태, 현재 메뉴, 유일한 ID와 랜드마크를 검증한다. DATA CONTRACT MVC ADAPTER VIEW ASSEMBLY PAGE ADAPT MODEL RENDER POST LIST QUERY PageSnapshot List.copyOf · page meta CONTROLLER 표시 판단을 Java에서 확정 codePointCount · canEdit · longPost IMMUTABLE VIEW MODEL PostListPage rows · hasPrevious · hasNext PAGE TEMPLATE 반복 · 빈 상태 · 페이지네이션 composition/posts/list.html PARAMETERIZED FRAGMENT 주요 탐색 activeSection · memberName THYMELEAF 페이지 셸 + 조각 조립 하나의 서버 응답 FINAL DOM CONTRACT 행 · 빈 상태 · 현재 메뉴 · ID · landmark
  1. DATA CONTRACT → MVC ADAPTER

    PageSnapshot → controller → PostListPage

    두 경계에서 목록을 방어적으로 복사하고, Java가 codePointCount·canEdit· longPost·페이지 메타데이터를 확정합니다.

  2. VIEW INPUTS

    목록 페이지와 파라미터 탐색 조각

    페이지는 반복·빈 상태·페이지네이션을, 조각은 activeSectionmemberName만 소유합니다.

  3. THYMELEAF ASSEMBLY

    페이지 셸 + 조각 → 하나의 서버 응답

    숨은 서비스 호출 없이 호출부에 드러난 입력으로 조립합니다. 조각의 소스 유효성만으로 최종 DOM을 보장하지 않습니다.

  4. FINAL DOM CONTRACT

    행·빈 상태·현재 메뉴·ID·landmark 검증

    실제 Boot 응답을 Jsoup으로 읽어 상호 배타적인 상태, 정확히 하나의 현재 메뉴, 유일한 ID와 주요 랜드마크를 확인합니다.

템플릿의 조건은 이미 결정된 표시 상태만 선택한다. 권한과 페이지 경계는 Java 계약이 소유하고, 조립 뒤 생기는 중복 ID와 잘못된 현재 위치는 실제 최종 DOM 테스트가 잡는다.

도식은 조회 스냅샷이 화면 모델로 바뀐 뒤 목록 페이지와 탐색 조각에 명시적으로 전달되고, Thymeleaf가 하나의 최종 DOM을 만드는 경계를 보여 줍니다. 조각 소스 각각이 유효해 보여도 ID, 랜드마크, 현재 메뉴는 조립 결과에서 다시 검증해야 합니다.


충돌 없는 실행 단위

이 문서는 ch7-1의 루트 Gradle 계약을 그대로 사용합니다. 패키지, 요청 경로, 템플릿 경로를 composition으로 분리해 다른 예제와 한 프로젝트에서 함께 컴파일할 수 있게 합니다.

src/main/java/board/composition/CompositionApplication.java
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);
    }
}

조회 결과를 불변 목록 모델로 바꾼다

컨트롤러는 엔티티 컬렉션 대신 조회 포트의 스냅샷을 받습니다. 스냅샷은 컬렉션을 방어적으로 복사하므로 저장소가 원래 리스트를 바꿔도 렌더링 중인 페이지가 달라지지 않습니다.

src/main/java/board/composition/PostListQuery.java
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");
            }
        }
    }
}

화면 모델도 다시 복사합니다. 두 계층의 복사는 각 경계가 자기 입력의 불변성을 독립적으로 보장한다는 뜻입니다.

src/main/java/board/composition/PostListPage.java
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");
        }
    }
}
src/main/java/board/composition/PostListController.java
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";
    }
}

longPostcanEdit는 Java가 결정합니다. 템플릿은 기준 길이나 소유자 ID를 몰라도 됩니다. 링크를 숨기는 것은 권한 검사가 아니므로 수정 엔드포인트는 같은 권한을 다시 검사합니다. 여기서도 String.length()이 아니라 유니코드 코드 포인트 수를 사용하며, 제품이 사용자 인식 문자소를 요구하면 계산 정책을 교체합니다.


반복·빈 상태·표시 조건을 한 템플릿에 둔다

state.index는 0부터, state.count는 1부터 시작합니다. 실제 콘텐츠인 순번에는 반복 상태를 사용하되, 시각적 줄무늬는 CSS :nth-child가 맡습니다.

빈 컬렉션에서 th:each는 아무 행도 만들지 않습니다. 빈 화면이 로딩 중이거나 오류처럼 보이지 않도록 별도 빈 상태를 렌더링합니다. 로딩 상태는 서버 렌더링 목록의 상태가 아니라 비동기 전환을 도입한 클라이언트 계층이 소유합니다.

src/main/resources/templates/composition/fragments/navigation.html
<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'를 검사하면 현재 페이지가 두 개가 됩니다. 각 항목은 자기 섹션을 비교하고, 거짓일 때는 빈 문자열이 아니라 속성 자체를 제거하도록 완전한 삼항식을 사용합니다.

src/main/resources/templates/composition/posts/list.html
<!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>

hasNext는 현재 행 수를 보고 템플릿이 추측하지 않습니다. 조회 계층이 한 건을 더 조회하는 등 저장소 계약에 맞는 방법으로 확정합니다. PostListPage는 빈 결과와 hasNext=true의 모순도 생성 시점에 거부합니다.


조각과 레이아웃의 소유권

조각은 입력을 서명처럼 드러냅니다. 탐색 조각이 서비스 빈이나 요청 파라미터를 읽지 않기 때문에 호출부만 보면 현재 섹션과 회원 이름을 알 수 있습니다. 파라미터가 계속 늘어나면 공통 조각이 서로 다른 화면 책임을 흡수하고 있는지 재검토합니다.

공통 <head>, 본문 바로가기, 탐색, 바닥글은 페이지 셸의 책임입니다. 조각 몇 개로 충분하면 레이아웃 방언을 먼저 추가하지 않습니다. 중첩 레이아웃, 슬롯, 페이지별 자산 스택이 실제로 필요할 때 의존성 비용을 비교합니다.

후보공통화 판단이유
주요 탐색조각모든 페이지가 같은 접근성 구조와 현재 위치 규칙 공유
게시글 행두 사용처가 같을 때만 조각한 화면뿐이면 인라인이 더 직접적
등록·수정 필드레이블·오류 계약이 같을 때 조각허용 필드 차이는 호출부에 드러내야 함
전체 페이지요구 전에는 직접 셸메타데이터·자산 차이를 숨기지 않음
로딩 표시서버 목록 조각에서 제외비동기 전환을 수행하는 클라이언트가 소유

이름이 같은 “카드”를 바로 합치면 조건 파라미터만 늘어날 수 있습니다. 같은 이유로 함께 변경되는 구조인지 확인한 뒤 공통화합니다.


실제 조립 결과를 Jsoup으로 검증한다

리터럴 HTML을 파싱하는 테스트는 Thymeleaf 반복, 조건, 조각 해석을 실행하지 않습니다. 다음 테스트는 실제 Boot MVC 요청으로 템플릿을 렌더링하고 최종 DOM을 검사합니다.

src/test/java/board/composition/PostListRenderingTest.java
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, 주요 랜드마크, 본문 바로가기를 실제 응답에서 고정합니다.


연습 문제

게시글 목록과 주간 리포트가 함께 쓰는 페이지네이션 조각을 만드세요. 현재 페이지, 이전·다음 가능 여부와 URL을 입력으로 받고 첫 페이지·마지막 페이지·결과 0건을 렌더링합니다.

해설 보기

조각이 기반 경로 문자열을 이어 붙이기보다 서버가 만든 previousUrlnextUrl을 받으면 경로 규칙을 몰라도 됩니다. 이동할 수 없을 때는 href 없는 링크를 남기지 말고 일반 텍스트를 렌더링합니다. 최종 DOM에서 링크 텍스트, href, aria-current, 포커스 가능한 비활성 링크, 중복 ID를 검사하세요.

다음 문서에서는 HTML 조립을 넘어 폼 문자열이 Java 객체로 바인딩되고, 변환·검증·도메인 명령의 오류가 어떻게 분리되는지 다룹니다.