Page Template & Slots

페이지 전반의 공통 레이아웃을 템플릿슬롯으로 정의해, 개별 화면은 핵심 컨텐츠에만 집중하게 만듭니다. 헤더/사이드바/본문/액션바/모달/스크립트 삽입 지점을 표준화하여 일관성·재사용성·확장성을 동시에 확보합니다.

핵심 개념

  • PageTemplate: 페이지 골격(헤더/네비/사이드/본문/푸터)을 캡슐화한 상위 컴포넌트
  • Slots: 템플릿이 노출하는 삽입 지점. header, breadcrumbs, sidebar, content, actions, modals, scripts
  • Composition: 각 화면은 슬롯에 필요한 컴포넌트만 주입해 구성 (조건부·권한 기반 렌더 쉽다)

슬롯 매핑 표

슬롯 용도 예시 비고
header 상단 영역 로고, 글로벌 메뉴, 사용자 메뉴 대부분 고정
breadcrumbs 경로 표시 홈 / 문서 / 상세 SEO/접근성에 유용
sidebar 보조 네비 문서 트리, 필터 좌측/우측 모두 가능
content 본문 카드·테이블·폼 페이지 고유
actions 고정 액션 저장/삭제/도움말 상단/하단 스티키
modals 오버레이 확인/폼/미리보기 페이지 전용 모달
scripts 하단 스크립트 페이지 전용 JS 지연 로딩, 충돌 최소화

코드 심화: PageTemplate 기본형

public class PageTemplate implements HtmlComponent {
  private HtmlComponent header;
  private HtmlComponent breadcrumbs;
  private HtmlComponent sidebar;
  private HtmlComponent content;
  private HtmlComponent actions;
  private HtmlComponent modals;
  private HtmlComponent scripts;

  // Fluent setters
  public PageTemplate header(HtmlComponent c)       { this.header = c; return this; }
  public PageTemplate breadcrumbs(HtmlComponent c)  { this.breadcrumbs = c; return this; }
  public PageTemplate sidebar(HtmlComponent c)      { this.sidebar = c; return this; }
  public PageTemplate content(HtmlComponent c)      { this.content = c; return this; }
  public PageTemplate actions(HtmlComponent c)      { this.actions = c; return this; }
  public PageTemplate modals(HtmlComponent c)       { this.modals = c; return this; }
  public PageTemplate scripts(HtmlComponent c)      { this.scripts = c; return this; }

  @Override public String render(RenderContext ctx) {
    var root = El.div().css("container-fluid");

    // 1) 헤더
    if (header != null) {
      root.child(El.header().css("py-2 border-bottom bg-white").child(
        El.div().css("container d-flex align-items-center justify-content-between").child(header)
      ));
    }

    // 2) 본문: 사이드바 + 컨텐츠
    var main = El.main().css("container my-4");
    if (breadcrumbs != null) main.child(El.nav().css("mb-3").child(breadcrumbs));

    var row = El.div().css("row");
    if (sidebar != null) {
      row.child(El.aside().css("col-12 col-lg-3 mb-4 mb-lg-0").child(sidebar));
      row.child(El.section().css("col-12 col-lg-9").child(content != null ? content : El.div()));
    } else {
      row.child(El.section().css("col-12").child(content != null ? content : El.div()));
    }
    main.child(row);

    // 3) 액션바
    if (actions != null) {
      main.child(El.div().css("position-sticky bottom-0 py-3 bg-body border-top mt-4").child(
        El.div().css("d-flex gap-2 justify-content-end").child(actions)
      ));
    }

    root.child(main);

    // 4) 모달/스크립트
    if (modals != null)  root.child(El.div().css("modal-portal").child(modals));
    if (scripts != null) root.child(El.div().css("page-scripts").child(scripts));

    return root.render(ctx);
  }
}

슬롯 구현 예

// Header 컴포넌트 (간단 예)
public class GlobalHeader implements HtmlComponent {
  @Override public String render(RenderContext ctx) {
    return El.div().css("d-flex w-100 align-items-center")
      .children(
        El.a().css("navbar-brand fw-bold text-decoration-none").href("/docs/kosmos/overview").text("Kosmos"),
        El.div().css("ms-auto d-flex gap-2").children(
          El.a().css("btn btn-sm btn-outline-secondary").href("/docs/kosmos/reference").text("Reference"),
          El.a().css("btn btn-sm btn-primary").href("/docs/kosmos/changelog").text("Changelog")
        )
      ).render(ctx);
  }
}

// Breadcrumbs (문서 경로 예)
public class Breadcrumbs implements HtmlComponent {
  private final List<String[]> items; // {title, href}
  public Breadcrumbs(List<String[]> items) { this.items = items; }
  @Override public String render(RenderContext ctx) {
    var ol = El.ol().css("breadcrumb mb-0");
    for (int i=0;i<items.size();i++) {
      var it = items.get(i);
      var li = El.li().css("breadcrumb-item" + (i==items.size()-1 ? " active" : ""));
      if (i<items.size()-1) li.child(El.a().href(it[1]).text(it[0])); else li.text(it[0]);
      ol.child(li);
    }
    return ol.render(ctx);
  }
}

// Docs 사이드바 (트리 일부 예)
public class DocsSidebar implements HtmlComponent {
  private final List<DocsNodeTreeDto> nodes;
  public DocsSidebar(List<DocsNodeTreeDto> nodes) { this.nodes = nodes; }
  @Override public String render(RenderContext ctx) {
    var nav = El.nav().attr("aria-label","Docs sidebar").css("list-group list-group-flush");
    for (var n : nodes) {
      if (Boolean.FALSE.equals(n.getVisible())) continue;
      nav.child(El.a().css("list-group-item list-group-item-action").href("/docs/" + n.getPath()).text(n.getTitle()));
    }
    return nav.render(ctx);
  }
}

컨텐츠/액션/모달/스크립트 슬롯

// Content (본문 카드)
HtmlComponent article = El.div().css("card border-0 shadow-sm")
  .child(El.div().css("card-body").children(
    El.h2().css("h4 mb-3").text("Docs 페이지 템플릿 샘플"),
    El.p().css("text-muted").text("이 영역에 카드/테이블/폼 등 실제 콘텐츠를 배치합니다.")
  ));

// Actions (스티키 액션바)
HtmlComponent actions = El.div().children(
  El.button().css("btn btn-primary").attr("type","button").text("저장"),
  El.a().css("btn btn-secondary").href("/docs/kosmos/overview").text("취소")
);

// Modals (확인 모달 예시)
HtmlComponent modals = El.div().children(
  El.div().css("modal fade").attr("id","confirmModal").attr("tabindex","-1").children(
    El.div().css("modal-dialog").child(
      El.div().css("modal-content").children(
        El.div().css("modal-header").children(
          El.h2().css("modal-title fs-5").text("저장하시겠습니까?"),
          El.button().css("btn-close").attr("type","button").attr("data-bs-dismiss","modal")
        ),
        El.div().css("modal-body").text("저장 후 되돌릴 수 없습니다."),
        El.div().css("modal-footer").children(
          El.button().css("btn btn-secondary").attr("data-bs-dismiss","modal").text("취소"),
          El.button().css("btn btn-danger").attr("type","button").text("확인")
        )
      )
    )
  )
);

// Scripts (페이지 전용 스크립트)
HtmlComponent scripts = El.script().text(
  "document.addEventListener('DOMContentLoaded',function(){ /* 페이지 전용 JS */ });"
);

조립: 템플릿 + 슬롯 구성

// Controller
@GetMapping("/docs/{*path}")
public ResponseEntity<String> docs(@PathVariable String path, RenderContext ctx) {
  // 사이드바 데이터, 브레드크럼 데이터 준비 (생략)
  var template = new PageTemplate()
      .header(new GlobalHeader())
      .breadcrumbs(new Breadcrumbs(List.of(
        new String[]{"Docs","/docs/kosmos/overview"},
        new String[]{"Page Template & Slots","/docs/kosmos/layout/page-template"}
      )))
      .sidebar(new DocsSidebar(sideNavTree))
      .content(article)
      .actions(actions)
      .modals(modals)
      .scripts(scripts);

  return ResponseEntity.ok(template.render(ctx));
}

템플릿 변형

  • Minimal: 사이드바·액션바·모달 없이 content만 주입
  • Dual Sidebar: sidebar를 좌우 이중으로 확장한 변형 템플릿(필터/도움말 동시 배치)
  • Fullscreen: container-fluid + row-cols-*로 가득 차게 구성

분책임 & 접근성

  • 분책임: 템플릿은 “레이아웃만”, 비즈니스/검증/데이터는 화면 컴포넌트가 담당
  • 랜드마크 태그: <header>, <main>, <aside>, <nav> 사용
  • 키보드 순서: 헤더 → 사이드 → 본문 → 액션 순서 일관 유지
  • aria-label: 사이드바/브레드크럼 Nav에 aria-label 제공

성능 & 운영 팁

주제 권장 지양
공통 영역 헤더/푸터 캐싱 고려 매 요청마다 복잡한 조립
리소스 페이지 전용 스크립트는 scripts 슬롯으로 분리 글로벌 번들에 모든 페이지 로직 포함
모달 모달은 modals 슬롯으로 일괄 포탈 본문 중첩에 모달 다수 산재
오류 처리 에러/토스트 슬롯(선택)을 추가해 일관 피드백 임시 div에 흩어진 알림

주의사항

슬롯 과다: 슬롯을 너무 세분화하면 결합도가 올라갑니다. 페이지에 실제로 필요한 지점만 노출하세요.
스타일 중복: 템플릿과 컨텐츠가 동일 여백/그리드를 중복 적용하지 않게 유의하세요.
권한 제어: 액션슬롯 버튼은 UI 숨김만으로 끝내지 말고 서버에서 권한 검증을 함께 수행하세요.

샘플: Docs 시스템 템플릿

// 좌측 Docs 트리 + 우측 문서 카드 + 상단 브레드크럼 + 하단 액션
HtmlComponent docsContent = El.div().css("card border-0 shadow-sm")
  .child(El.div().css("card-body").children(
    El.h2().css("h4 mb-2").text(ctx.model("doc.title")),
    El.p().css("text-muted").text(ctx.model("doc.subtitle")),
    El.div().html(ctx.model("doc.content")) // 저장된 HTML 본문 렌더
  ));

var tpl = new PageTemplate()
  .header(new GlobalHeader())
  .breadcrumbs(new Breadcrumbs(bc))  // 홈/Docs/현재 페이지
  .sidebar(new DocsSidebar(sideNavTree))
  .content(docsContent)
  .actions(El.div().children(
    El.a().css("btn btn-light").href("/docs/kosmos/changelog").text("Changelogs"),
    El.a().css("btn btn-secondary").href("/docs/kosmos/reference").text("Reference"),
    El.a().css("btn btn-primary").href("/docs/kosmos/recipes/docs-system").text("Docs 시스템 구현")
  ));

다음으로