Conditional Rendering
조건부 렌더링은 권한, 상태, 피처 플래그, 데이터 존재 여부에 따라 UI를 다르게 구성하는 패턴입니다. Kosmos DSL은 서버사이드에서 안전하게 조건을 평가해 일관된 HTML을 생성합니다.
언제 사용하는가
| 상황 | 조건 | 권장 UI | 비고 |
|---|---|---|---|
| 권한별 버튼 노출 | ctx.hasRole("ADMIN") | 삭제/승인 버튼 표시 | 서버에서도 반드시 권한 검증 |
| 데이터 비어 있음 | items.isEmpty() | Empty State 카드 + CTA | 샘플 데이터/필터 초기화 링크 |
| 진행 중 상태 | status == "LOADING" | 스켈레톤/플레이스홀더 | 짧은 시간만 표시 |
| 실험 기능 | feature("newForm") | 플래그가 켜진 사용자에게만 | 로그/모니터링 필수 |
RenderContext로 조건 주입
// 예: RenderContext에 유저/롤/플래그 주입
public record AuthInfo(String userId, Set<String> roles, Set<String> flags) {}
@GetMapping("/docs/kosmos/layout/conditional")
public String page(RenderContext ctx) {
ctx.model("auth", new AuthInfo("u-123", Set.of("USER","ADMIN"), Set.of("newForm")));
ctx.model("status", "READY");
ctx.model("items", List.of()); // 비어있는 목록 예
return renderer.render(...);
}
코드 심화: if/else 헬퍼 컴포넌트
public class IfBlock implements HtmlComponent {
private final BooleanSupplier cond;
private HtmlComponent thenC = El.span().text("");
private HtmlComponent elseC = El.span().text("");
public IfBlock(BooleanSupplier cond) { this.cond = cond; }
public IfBlock then(HtmlComponent c) { this.thenC = c; return this; }
public IfBlock els(HtmlComponent c) { this.elseC = c; return this; }
@Override public String render(RenderContext ctx) {
return (cond.getAsBoolean() ? thenC : elseC).render(ctx);
}
}
예: 권한별 액션 버튼
AuthInfo auth = (AuthInfo) ctx.model("auth");
HtmlComponent actions = new IfBlock(() -> auth.roles().contains("ADMIN"))
.then(El.div().css("d-flex gap-2").children(
El.button().css("btn btn-danger").attr("type","button").text("삭제"),
El.button().css("btn btn-success").attr("type","button").text("승인")
))
.els(El.div().css("text-muted").text("권한이 없어 액션을 사용할 수 없습니다."));
코드 심화: 리스트 + Empty State
public class ItemsSection implements HtmlComponent {
private final List<Item> items;
public ItemsSection(List<Item> items) { this.items = items; }
@Override public String render(RenderContext ctx) {
if (items == null || items.isEmpty()) {
return El.div().css("card border-0 shadow-sm")
.child(El.div().css("card-body text-center").children(
El.h2().css("h5 mb-2").text("표시할 항목이 없습니다."),
El.p().css("text-muted mb-3").text("필터를 초기화하거나 새 항목을 추가해 보세요."),
El.a().css("btn btn-primary").href("/docs/kosmos/recipes/crud-3set").text("CRUD 3종 레시피 보기")
)).render(ctx);
}
var list = El.div().css("list-group");
for (var it : items) {
list.child(El.a().css("list-group-item list-group-item-action").href("#")
.children(El.div().css("d-flex w-100 justify-content-between").children(
El.h3().css("h6 mb-1").text(it.title()),
El.small().css("text-muted").text(it.updatedAt().toString())
), El.p().css("mb-1").text(it.summary())));
}
return list.render(ctx);
}
}
코드 심화: 스켈레톤/로딩
String status = String.valueOf(ctx.model("status"));
// 간단 스켈레톤
HtmlComponent skeleton = El.div().css("row g-3").children(
El.div().css("col-12").child(El.div().css("placeholder-glow").children(
El.span().css("placeholder col-7 me-2"),
El.span().css("placeholder col-4")
)),
El.div().css("col-12").child(El.div().css("placeholder-glow").children(
El.span().css("placeholder col-5 me-2"),
El.span().css("placeholder col-6")
))
);
HtmlComponent content = "LOADING".equals(status)
? skeleton
: new ItemsSection((List<Item>) ctx.model("items"));
피처 플래그로 UI 스위칭
boolean newForm = auth.flags().contains("newForm");
HtmlComponent formArea = new IfBlock(() -> newForm)
.then(El.div().css("card border-0 shadow-sm").child(
El.div().css("card-body").children(
El.h2().css("h5").text("신규 폼 (v2)"),
El.p().css("text-muted").text("실험 기능입니다.")
)
))
.els(El.div().css("card border-0 shadow-sm").child(
El.div().css("card-body").children(
El.h2().css("h5").text("기존 폼 (v1)"),
El.p().css("text-muted").text("안정 채널")
)
));
슬롯과 조건 결합
// Page Template에 조건부로 사이드바 삽입
boolean showSidebar = !((List<Item>) ctx.model("items")).isEmpty();
var tpl = new PageTemplate()
.header(new GlobalHeader())
.breadcrumbs(new Breadcrumbs(List.of(
new String[]{"Docs","/docs/kosmos/overview"},
new String[]{"Conditional Rendering","/docs/kosmos/layout/conditional"}
)))
.sidebar(showSidebar ? new DocsSidebar(sideNavTree) : null)
.content(El.div().children(formArea, El.hr(), content))
.actions(new IfBlock(() -> auth.roles().contains("ADMIN"))
.then(El.div().children(
El.button().css("btn btn-primary").attr("type","button").text("저장"),
El.button().css("btn btn-danger").attr("type","button").text("삭제")
))
.els(El.div().css("text-muted").text("읽기 전용입니다.")));
보안: 렌더링과 별개로 서버 검증
- UI 숨김 ≠ 권한: 버튼이 보이지 않아도 API는 보호되어야 합니다.
- 서버 가드: 컨트롤러/서비스 레벨에서
@PreAuthorize또는 커스텀 가드로 반드시 재검증하세요. - 감사 로그: 권한 거부/허용 이벤트를 로깅해 운영 이슈를 빠르게 추적합니다.
성능 팁
| 주제 | 권장 | 지양 |
|---|---|---|
| 조건 계산 | 불변 데이터는 Controller에서 미리 계산 | 템플릿 내 복잡한 계산 반복 |
| 데이터 조회 | 필요한 최소 데이터만 로드 | 모든 탭/패널 데이터를 일괄 조회 |
| 캐싱 | 권한 목록/플래그는 세션/캐시 | 매 요청마다 동일 쿼리 반복 |
테스트 전략
- 렌더 스냅샷: ADMIN/USER 별 HTML 차이를 스냅샷으로 검증
- 빈 데이터: Empty State가 정확히 노출되는지 확인
- 플래그 토글:
newFormon/off 시 UI 전환 확인
주의사항
과도한 중첩: if 안의 if를 피하고 IfBlock/헬퍼로 분리하세요.
권한만 프론트 검증: UI만 숨기면 취약합니다. 서버 가드를 반드시 사용하세요.
로딩 남용: 스켈레톤은 짧은 시간에만. 길어지면 사용자에게 원인/대안을 제공하세요.
다음으로
- Page Template & Slots — 템플릿과 슬롯 구성 복습
- Modal & Offcanvas — 확인/경고 모달과 조건 렌더
- Alerts — 조건부 성공/오류 메시지 패턴
- RenderContext & Auth — 권한/플래그 전달 심화
- Table Setup — 데이터 유무에 따른 테이블/Empty State