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가 정확히 노출되는지 확인
  • 플래그 토글: newForm on/off 시 UI 전환 확인

주의사항

과도한 중첩: if 안의 if를 피하고 IfBlock/헬퍼로 분리하세요.
권한만 프론트 검증: UI만 숨기면 취약합니다. 서버 가드를 반드시 사용하세요.
로딩 남용: 스켈레톤은 짧은 시간에만. 길어지면 사용자에게 원인/대안을 제공하세요.

다음으로