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 쿼리: 행마다 추가 조회가 발생하지 않도록 필요한 데이터는 함께 조인/페치하세요.
헤더 정렬 표시: 현재 정렬 컬럼에 시각적 강조(굵게/아이콘 텍스트)를 주면 사용성이 좋아집니다.
다음으로
- Data Pagination — 데이터 계층과의 페이지네이션 통합
- Sorting & Filters — 정렬/필터 파이프라인 설계
- Components: Pagination — 페이지 네비게이션 UI 상세
- Page Template & Slots — 테이블을 콘텐츠 슬롯에 배치
- CRUD 3종 — 리스트/상세/폼을 함께 구성하는 레시피