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 시스템 구현")
));
다음으로
- Conditional Rendering — 권한/상태 기반 슬롯 제어
- Buttons — 액션 슬롯과 최적 조합
- Modal & Offcanvas — 모달 슬롯 패턴
- Table Setup — 콘텐츠 슬롯에 테이블 배치