Data Pagination
Kosmos DSL에서 페이지네이션은 서비스의 결과 메타(page/size/total/totalPages/hasPrev/hasNext)와 표준 컴포넌트(UI)를 결합해 구성합니다. 리스트(테이블/카드)에 공통 패턴으로 적용하고 URL 파라미터를 통해 뒤로 가기/공유가 가능한 상태를 유지합니다.
개념 개요
| 요소 | 설명 | 비고 |
|---|---|---|
PageMeta |
현재 페이지, 페이지 크기, 전체 건수/페이지 수, 이전/다음 가능 여부, 정렬/검색값 보존 | 서비스가 생성 |
CompPagination |
이전/다음, 첫/끝, 현재 페이지 표시, 페이지 번호(윈도우) 렌더 | UI 컴포넌트 |
| URL 상태 | ?page=1&size=20&sort=name,asc&q=... |
링크 공유/히스토리 호환 |
| 전략 | Offset 기반(일반) / Keyset 기반(대용량) | 도메인/규모에 따라 선택 |
코드 심화: PageMeta & CompPagination
public record PageMeta(
int page, int size, long total, int totalPages,
boolean hasPrev, boolean hasNext,
String sort, String q
) {}
public class CompPagination implements HtmlComponent {
private final PageMeta meta;
private final String basePath; // "/items"
private final Map<String,String> params; // q, sort 등(페이지/사이즈 제외)
public CompPagination(PageMeta meta, String basePath, Map<String,String> params) {
this.meta = meta; this.basePath = basePath; this.params = params;
}
private String href(int page) {
var sb = new StringBuilder(basePath).append("?page=").append(page).append("&size=").append(meta.size());
if (meta.sort() != null) sb.append("&sort=").append(UrlEncoder.encode(meta.sort()));
if (meta.q() != null) sb.append("&q=").append(UrlEncoder.encode(meta.q()));
if (params != null) params.forEach((k,v) -> {
if (!List.of("page","size","sort","q").contains(k)) {
sb.append("&").append(k).append("=").append(UrlEncoder.encode(v));
}
});
return sb.toString();
}
private int windowStart() {
int w = 5; // 페이지 버튼 창 크기
int start = Math.max(1, meta.page() - w/2);
return Math.min(start, Math.max(1, meta.totalPages() - (w - 1)));
}
@Override public String render(RenderContext ctx) {
if (meta.totalPages() <= 1) return ""; // 한 페이지면 숨김
var nav = El.nav().attr("aria-label","Pagination");
var ul = El.ul().css("pagination mb-0");
// First / Prev
ul.child(El.li().css("page-item" + (meta.hasPrev() ? "" : " disabled"))
.child(El.a().css("page-link").href(meta.hasPrev() ? href(1) : "#")
.attr("aria-label","First").text("«")));
ul.child(El.li().css("page-item" + (meta.hasPrev() ? "" : " disabled"))
.child(El.a().css("page-link").href(meta.hasPrev() ? href(meta.page()-1) : "#")
.attr("aria-label","Previous").text("‹")));
// window pages
int start = windowStart();
int end = Math.min(meta.totalPages(), start + 4);
for (int p = start; p <= end; p++) {
ul.child(El.li().css("page-item" + (p == meta.page() ? " active" : ""))
.child(El.a().css("page-link").href(p == meta.page() ? "#" : href(p)).text(String.valueOf(p))));
}
// Next / Last
ul.child(El.li().css("page-item" + (meta.hasNext() ? "" : " disabled"))
.child(El.a().css("page-link").href(meta.hasNext() ? href(meta.page()+1) : "#")
.attr("aria-label","Next").text("›")));
ul.child(El.li().css("page-item" + (meta.hasNext() ? "" : " disabled"))
.child(El.a().css("page-link").href(meta.hasNext() ? href(meta.totalPages()) : "#")
.attr("aria-label","Last").text("»")));
nav.child(ul);
return nav.render(ctx);
}
}
Controller: 리스트 + 페이징 결합
@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 table = new ItemsTable(new ItemsTable.Model(result.rows(), result.meta()), "/items", params);
HtmlComponent pager = new CompPagination(result.meta(), "/items", params);
var layout = El.div().children(
new ItemsSearchBar(result.meta(), "/items", q),
table,
El.div().css("d-flex justify-content-between align-items-center mt-3").children(
new CompPageSizer(result.meta(), "/items", params), // size 변경 컴포넌트(아래 예시)
pager
)
);
return ResponseEntity.ok(layout.render(ctx));
}
페이지 크기 변경
public class CompPageSizer implements HtmlComponent {
private final PageMeta meta;
private final String basePath;
private final Map<String,String> params;
public CompPageSizer(PageMeta meta, String basePath, Map<String,String> params) {
this.meta = meta; this.basePath = basePath; this.params = params;
}
@Override public String render(RenderContext ctx) {
var form = El.form().attr("method","GET").attr("action", basePath).css("d-inline-flex align-items-center gap-2");
var select = El.select().css("form-select form-select-sm").attr("name","size")
.children(opt(10), opt(20), opt(50), opt(100));
form.child(El.span().css("small text-muted").text("표시 개수"));
form.child(select);
form.child(El.button().css("btn btn-sm btn-outline-secondary").attr("type","submit").text("적용"));
form.child(El.input().attr("type","hidden").attr("name","page").attr("value","1")); // size 변경 시 1페이지로
if (meta.sort() != null) form.child(El.input().attr("type","hidden").attr("name","sort").attr("value", meta.sort()));
if (meta.q() != null) form.child(El.input().attr("type","hidden").attr("name","q").attr("value", meta.q()));
if (params != null) params.forEach((k,v) -> {
if (!List.of("page","size","sort","q").contains(k))
form.child(El.input().attr("type","hidden").attr("name",k).attr("value",v));
});
return form.render(ctx);
}
private HtmlComponent opt(int n) {
var o = El.option().attr("value", String.valueOf(n)).text(n + "개");
if (meta.size() == n) o.attr("selected","selected");
return o;
}
}
Service 전략: Offset vs Keyset
- Offset 기반(일반):
LIMIT size OFFSET (page-1)*size— 소규모/중간 규모 데이터에 적합 - Keyset 기반(대용량): 마지막 키 기준 다음 페이지 조회 —
WHERE id < lastId ORDER BY id DESC LIMIT size - 정렬 필드 인덱스: 정렬 필드에 인덱스가 있어야 성능이 안정적입니다.
// Offset 기반
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); // OFFSET/ LIMIT
long total = repo.count(q);
// PageMeta 구성...
// (Table Setup 문서의 예와 동일)
...
}
// Keyset 기반 (대용량 스크롤)
public KeysetResult searchItemsByKeyset(String lastId, int size, String q) {
int sizeSafe = List.of(10,20,50,100).contains(size) ? size : 20;
List<Item> found = repo.findKeyset(q, lastId, sizeSafe); // WHERE id < lastId ORDER BY id DESC LIMIT size
boolean hasNext = found.size() == sizeSafe;
String nextCursor = hasNext ? String.valueOf(found.get(found.size()-1).getId()) : null;
// Keyset은 보통 cursor 기반 링크를 만듭니다(예: /items?cursor=123&size=20)
return new KeysetResult(found, nextCursor, hasNext);
}
접근성 & UX
- 네비게이션 역할: 페이지네이션 래퍼에
<nav aria-label="Pagination">제공 - 키보드/읽기: 링크 요소 사용, 현재 페이지는 비활성 링크 또는 active 표시
- 상태 보존: 정렬/검색 파라미터를 링크에 포함해 사용자가 돌아왔을 때 맥락을 유지
성능 & 운영 팁
| 주제 | 권장 | 지양 |
|---|---|---|
| COUNT 비용 | 필요 시 캐시/근사치 사용 | 대용량에서 매요청 풀카운트 |
| 정렬 | 정렬 필드 인덱스 준비 | 함수/표현식 기반 정렬 남용 |
| 페이지 범위 | 1~totalPages 범위 검증 | 과도한 페이지 요청을 그대로 수행 |
| 응답 크기 | 필요 필드만 Select | 거대한 행 payload 반환 |
에지 케이스
- 마지막 페이지에서 삭제: 현재 페이지가 빈 상태가 되면 한 페이지 앞 번호로 리다이렉트
- size 변경: 항상
page=1로 초기화 - 허용 외 size/page: 화이트리스트/범위 검증으로 보정
주의사항
URL 파라미터 누락:
sort, q를 보존하지 않으면 사용자가 맥락을 잃습니다.Keyset 오용: 임의 정렬 컬럼과 혼용하면 순서가 깨질 수 있습니다. 단조 증가 키에만 적용하세요.
과도한 버튼: 페이지 번호가 너무 많으면 윈도우(예: 5개)로 제한하세요.
다음으로
- Sorting & Filters — 정렬/필터 파이프라인과 URL 상태
- Components: Pagination — 페이지 네비게이션 UI 상세
- Table Setup — 테이블과 페이징 결합
- CRUD 3종 — 리스트/상세/폼 통합 레시피
- Page Template & Slots — 페이징 푸터 배치