Table Setup

Kosmos DSL로 반응형 테이블서버 페이징/정렬/필터를 일관된 패턴으로 구성합니다. 본 문서는 컴포넌트 구조, 빈 상태(Empty State), 페이징 푸터, 그리고 정렬/필터 연동까지 아우릅니다.

핵심 구성요소

요소 역할 비고
ItemsTable 테이블 본문(헤더/행/빈 상태/푸터 포함) 표준 컴포넌트로 재사용
CompPagination 페이지네이션 UI components/pagination 문서 참조
PageMeta page/size/total/sort/q 등 메타 서비스 결과와 함께 반환
검색 바 q, status, date 등 필터 제출 GET 파라미터 유지

코드 심화: ItemsTable

반응형 래퍼, 정렬 가능한 헤더, 본문/빈 상태, 페이징 푸터를 한 컴포넌트에 캡슐화합니다.

public class ItemsTable implements HtmlComponent {
  public record Row(Long id, String name, String category, String updatedAt) {}
  public record Model(List<Row> rows, PageMeta meta) {}

  private final Model model;
  private final String basePath;                 // "/items"
  private final Map<String,String> params;      // q, status 등(페이지/사이즈 제외)

  public ItemsTable(Model model, String basePath, Map<String,String> params) {
    this.model = model; this.basePath = basePath; this.params = params;
  }

  private String hrefWith(String key, String val) {
    var sb = new StringBuilder(basePath).append("?page=").append(model.meta().page())
                                        .append("&size=").append(model.meta().size());
    if (key != null && val != null) sb.append("&").append(key).append("=").append(UrlEncoder.encode(val));
    if (model.meta().sort() != null && !"sort".equals(key))
      sb.append("&sort=").append(UrlEncoder.encode(model.meta().sort()));
    if (model.meta().q() != null && !"q".equals(key))
      sb.append("&q=").append(UrlEncoder.encode(model.meta().q()));
    if (params != null) params.forEach((k,v) -> {
      if (!List.of("page","size","sort","q").contains(k) && !k.equals(key))
        sb.append("&").append(k).append("=").append(UrlEncoder.encode(v));
    });
    return sb.toString();
  }

  private String sortHref(String field) {
    // sort 형식: "name,asc" 또는 "name,desc"
    String cur = model.meta().sort();
    String dir = "asc";
    if (cur != null && cur.startsWith(field+",")) {
      dir = cur.endsWith(",asc") ? "desc" : "asc";
    }
    return hrefWith("sort", field + "," + dir);
  }

  @Override public String render(RenderContext ctx) {
    var wrapper = El.div().css("table-responsive"); // 반응형

    var table = El.table().css("table table-hover align-middle mb-0");

    // thead (정렬 헤더)
    var thead = El.thead().css("table-light");
    var trh = El.tr().children(
      El.th().css("text-nowrap").child(El.a().href(sortHref("id")).text("ID")),
      El.th().css("text-nowrap").child(El.a().href(sortHref("name")).text("이름")),
      El.th().css("text-nowrap").child(El.a().href(sortHref("category")).text("분류")),
      El.th().css("text-nowrap").child(El.a().href(sortHref("updatedAt")).text("수정일")),
      El.th().css("text-end text-nowrap").text("동작")
    );
    thead.child(trh);
    table.child(thead);

    // tbody or empty
    var tbody = El.tbody();
    if (model.rows() == null || model.rows().isEmpty()) {
      var empty = El.tr().child(
        El.td().attr("colspan","5").child(
          El.div().css("text-center text-muted py-5").children(
            El.div().css("fw-semibold mb-1").text("표시할 항목이 없습니다."),
            El.div().css("small").text("필터를 초기화하거나 새 항목을 추가해 보세요.")
          )
        )
      );
      tbody.child(empty);
    } else {
      for (var r : model.rows()) {
        tbody.child(El.tr().children(
          El.td().text(String.valueOf(r.id())),
          El.td().text(r.name()),
          El.td().text(r.category()),
          El.td().text(r.updatedAt()),
          El.td().css("text-end").children(
            El.a().css("btn btn-sm btn-outline-primary me-1").href("/items/"+r.id()).text("상세"),
            El.a().css("btn btn-sm btn-outline-secondary").href("/items/"+r.id()+"/edit").text("수정")
          )
        ));
      }
    }
    table.child(tbody);

    wrapper.child(table);
    return wrapper.render(ctx);
  }
}
// GET 파라미터 보존형 검색 바
public class ItemsSearchBar implements HtmlComponent {
  private final PageMeta meta;
  private final String basePath;
  private final String q;

  public ItemsSearchBar(PageMeta meta, String basePath, String q) {
    this.meta = meta; this.basePath = basePath; this.q = q;
  }

  @Override public String render(RenderContext ctx) {
    var form = El.form().attr("method","GET").attr("action", basePath)
      .css("row g-2 align-items-center mb-3");
    form.child(El.div().css("col-auto").child(
      El.input().css("form-control").attr("type","search").attr("name","q")
        .attr("placeholder","검색어를 입력").attr("value", q == null ? "" : q)
    ));
    form.child(El.div().css("col-auto").child(
      El.select().css("form-select").attr("name","size").children(
        option(10), option(20), option(50)
      )
    ));
    form.child(El.div().css("col-auto").child(
      El.button().css("btn btn-primary").attr("type","submit").text("검색")
    ));
    // 페이지 초기화
    form.child(El.input().attr("type","hidden").attr("name","page").attr("value","1"));
    // sort는 유지
    if (meta.sort() != null) form.child(El.input().attr("type","hidden").attr("name","sort").attr("value", meta.sort()));
    return form.render(ctx);
  }

  private HtmlComponent option(int n) {
    var o = El.option().attr("value", String.valueOf(n)).text(n + "개씩");
    if (meta.size() == n) o.attr("selected","selected");
    return o;
  }
}

Controller / Service 바인딩

@GetMapping("/items")
public ResponseEntity<String> list(@RequestParam(defaultValue="1") int page,
                                    @RequestParam(defaultValue="20") int size,
                                    @RequestParam(required=false) String q,
                                    @RequestParam(required=false) String sort,
                                    RenderContext ctx) {
  // 서비스 호출 (검색/정렬/페이징 결과)
  ItemsResult result = service.searchItems(page, size, q, sort);
  var params = new LinkedHashMap<String,String>();
  if (q != null) params.put("q", q);
  if (sort != null) params.put("sort", sort);

  HtmlComponent searchBar = new ItemsSearchBar(result.meta(), "/items", q);
  HtmlComponent table     = new ItemsTable(new ItemsTable.Model(result.rows(), result.meta()), "/items", params);
  HtmlComponent pager     = new CompPagination(result.meta(), "/items", params);

  var layout = El.div().css("container").children(
    searchBar,
    table,
    El.div().css("d-flex justify-content-between align-items-center mt-3").children(
      new CompPageSizer(result.meta(), "/items", params),
      pager
    )
  );
  return ResponseEntity.ok(layout.render(ctx));
}

Service: 페이징/정렬 처리

public record ItemsResult(List<ItemsTable.Row> rows, PageMeta meta) {}

public ItemsResult searchItems(int page, int size, String q, String sort) {
  int pageSafe = Math.max(page, 1);
  int sizeSafe = List.of(10,20,50,100).contains(size) ? size : 20;
  SortSpec spec = SortSpec.parse(sort, "updatedAt,desc"); // 기본 내림차순

  // 데이터 조회 (예시)
  List<Item> found = repo.find(q, spec, pageSafe, sizeSafe);
  long total = repo.count(q);

  List<ItemsTable.Row> rows = found.stream()
    .map(it -> new ItemsTable.Row(it.getId(), it.getName(), it.getCategory(), it.getUpdatedAt().toString()))
    .toList();

  int totalPages = (int) Math.ceil((double) total / sizeSafe);
  PageMeta meta = new PageMeta(pageSafe, sizeSafe, total, totalPages,
    pageSafe > 1, pageSafe < totalPages, sort, q);

  return new ItemsResult(rows, meta);
}

정렬 쿼리 규칙

파라미터 예시 설명
sort name,asc 필드,asc|desc
page 1 1-based
size 20 허용 값만 화이트리스트(10/20/50/100)
q notebook LIKE/풀텍스트 등 구현체에 맞춰 처리

반응형 & 접근성 팁

  • 반응형: 테이블 상위에 .table-responsive를 사용해 작은 화면에서 가로 스크롤을 허용합니다.
  • 헤더 링크: 정렬 가능한 헤더는 <a>로 제공하고 aria-sort 속성(선택)을 고려하세요.
  • 빈 상태: 데이터가 없을 때 “무엇을 해야 하는지” 명확한 CTA를 제공합니다.

성능 & 운영 팁

주제 권장 지양
카운트 캐시/지연 계산(필요 시) 매 요청 전체 COUNT 풀스캔
정렬 인덱스 정렬 필드 사용 함수 기반 정렬 남용
페이지 이동 URL 파라미터로 상태 보존 세션만 의존해 히스토리 깨짐
대용량 키셋 페이지네이션 고려 큰 OFFSET(예: 100k) 사용

주의사항

파라미터 신뢰 금지: sort/size/page는 화이트리스트와 범위 검사를 적용하세요.
N+1 쿼리: 행마다 추가 조회가 발생하지 않도록 필요한 데이터는 함께 조인/페치하세요.
헤더 정렬 표시: 현재 정렬 컬럼에 시각적 강조(굵게/아이콘 텍스트)를 주면 사용성이 좋아집니다.

다음으로