Pagination
대용량 목록을 빠르고 접근성 있게 탐색하기 위한 페이지네이션 컴포넌트입니다. Kosmos DSL로 번호형 / 이전·다음 / 컴팩트 / 페이지 크기 선택까지 일관되게 구현합니다.
언제 어떤 페이지네이션을 쓰나
| 상황 | 권장 유형 | 이유/비고 |
|---|---|---|
| 페이지 수가 적음(≤10) | 번호형 | 직접 점프가 쉬움 |
| 모바일/좁은 공간 | 이전·다음(컴팩트) | 간결한 UI |
| 수천 페이지 이상 | 컴팩트 + 점프 입력 | 모든 번호 노출은 비현실적 |
| 검색/필터 연동 | 번호형 + 페이지 크기 선택 | 사용자 제어 강화 |
| 실시간 데이터 | 키셋 페이지네이션(고급) | 오프셋 성능/정합성 이슈 완화 |
페이지네이션 모델
// 페이지 메타 정보 (예시 DTO)
public record PageMeta(
int page, // 1-based
int size, // page size
long totalElements, // 전체 개수
int totalPages, // 전체 페이지 수
boolean hasPrev,
boolean hasNext,
String sort, // ex) "name,asc"
String q // 검색어 등
) {}
컴포넌트: 번호형
Bootstrap의 .pagination 구조를 따릅니다. 현재 페이지는 .active, 비활성은 .disabled를 사용합니다.
public class CompPagination implements HtmlComponent {
private final PageMeta meta;
private final String basePath; // "/items"
private final Map<String, String> params; // q, sort 등 (page,size 제외)
public CompPagination(PageMeta meta, String basePath, Map<String, String> params) {
this.meta = meta; this.basePath = basePath; this.params = params;
}
private String hrefOf(int page, Integer sizeOverride) {
var sb = new StringBuilder(basePath).append("?page=").append(page);
int size = sizeOverride != null ? sizeOverride : meta.size();
sb.append("&size=").append(size);
if (meta.sort() != null && !meta.sort().isBlank()) sb.append("&sort=").append(meta.sort());
if (meta.q() != null && !meta.q().isBlank()) sb.append("&q=").append(UrlEncoder.encode(meta.q()));
if (params != null) params.forEach((k,v) -> {
if (!"page".equals(k) && !"size".equals(k)) sb.append("&").append(k).append("=").append(UrlEncoder.encode(v));
});
return sb.toString();
}
private List<Integer> windowPages() {
int cur = meta.page(), last = meta.totalPages();
int start = Math.max(1, cur - 2), end = Math.min(last, cur + 2);
while ((end - start) < 4 && end < last) end++;
while ((end - start) < 4 && start > 1) start--;
List<Integer> list = new ArrayList<>();
for (int i = start; i <= end; i++) list.add(i);
return list;
}
@Override public String render(RenderContext ctx) {
var ul = El.ul().css("pagination");
// Prev
var prevLi = El.li().css("page-item" + (meta.hasPrev() ? "" : " disabled"));
prevLi.child(
El.a().css("page-link").href(meta.hasPrev() ? hrefOf(meta.page()-1, null) : "#")
.attr("aria-label","이전").child(El.span().attr("aria-hidden","true").text("«"))
);
ul.child(prevLi);
// First + ellipsis
if (!windowPages().contains(1)) {
ul.child(El.li().css("page-item").child(El.a().css("page-link").href(hrefOf(1,null)).text("1")));
ul.child(El.li().css("page-item disabled").child(El.span().css("page-link").attr("aria-hidden","true").text("…")));
}
// Window
for (int p : windowPages()) {
var li = El.li().css("page-item" + (p == meta.page() ? " active" : ""));
li.child(
El.a().css("page-link").href(hrefOf(p, null)).attr("aria-current", (p==meta.page()) ? "page" : null).text(String.valueOf(p))
);
ul.child(li);
}
// Ellipsis + Last
if (!windowPages().contains(meta.totalPages()) && meta.totalPages() > 0) {
ul.child(El.li().css("page-item disabled").child(El.span().css("page-link").attr("aria-hidden","true").text("…")));
ul.child(El.li().css("page-item").child(
El.a().css("page-link").href(hrefOf(meta.totalPages(), null)).text(String.valueOf(meta.totalPages()))
));
}
// Next
var nextLi = El.li().css("page-item" + (meta.hasNext() ? "" : " disabled"));
nextLi.child(
El.a().css("page-link").href(meta.hasNext() ? hrefOf(meta.page()+1, null) : "#")
.attr("aria-label","다음").child(El.span().attr("aria-hidden","true").text("»"))
);
ul.child(nextLi);
return El.nav().attr("aria-label","페이지 탐색").child(ul).render(ctx);
}
}
사용 예시
// Controller (Spring)
@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) {
var result = service.findItems(page, size, q, sort); // List<Item> + PageMeta
var table = new ItemsTable(result.items()); // 목록 렌더 컴포넌트
var pager = new CompPagination(result.meta(), "/items", Map.of("q", q==null?"":q, "sort", sort==null?"":sort));
var layout = El.div().css("container").children(
table,
El.div().css("d-flex justify-content-between align-items-center mt-3").children(
new CompPageSizer(result.meta(), "/items", Map.of("q",q==null?"":q, "sort", sort==null?"":sort)),
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;
private final int[] options = {10,20,50,100};
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");
form.child(El.label().attr("for","pageSize").css("form-label mb-0 small").text("페이지 크기"));
var select = El.select().css("form-select form-select-sm").attr("id","pageSize").attr("name","size")
.attr("onchange","this.form.submit()");
for (int opt : options) {
var optEl = El.option().attr("value", String.valueOf(opt)).text(String.valueOf(opt));
if (opt == meta.size()) optEl.attr("selected","selected");
select.child(optEl);
}
form.child(select);
form.child(El.input().attr("type","hidden").attr("name","page").attr("value","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);
}
}
SEO & 접근성 팁
- rel 링크: 목록 페이지 <head>에
<link rel="prev" href="?page=2">,rel="next"를 제공. - aria-label:
<nav aria-label="페이지 탐색">과aria-current="page"적용. - 키보드: 링크 포커스 순서 확인, ←/→ 키보드 내비게이션(선택)을 고려.
- URL 안정성: 필터/정렬 파라미터를 보존하여 공유 가능한 URL을 유지.
동작 흐름
1
UI (Pager/Sizer)
page/size/q/sort 파라미터 구성
→
2
Controller
파라미터 검증/바인딩
→
3
Service
조회/정렬/필터 로직
→
4
Repository
페이징 쿼리/카운트 최적화
서버/DB 고려사항
| 주제 | 권장 | 지양 |
|---|---|---|
| 카운트 쿼리 | 캐시/지연계산(필요 시) 고려 | 매 요청마다 전체 COUNT 풀스캔 |
| 인덱스 | 정렬/필터 컬럼에 적절한 인덱스 | ORDER BY 임의 컬럼 남용 |
| 오프셋 성능 | 대량 데이터는 키셋 페이지네이션 | OFFSET 100k+ 같은 큰 오프셋 |
| 정합성 | 시간순/ID순 고정 정렬 | 불안정한 정렬(랜덤/동률 다수) |
키셋 페이지네이션 (고급)
// key > lastKey 조건으로 다음 페이지를 가져오는 방식 (정렬 키가 단조 증가 가정)
SELECT * FROM items
WHERE (:lastKey IS NULL OR id > :lastKey)
ORDER BY id ASC
LIMIT :size;
장점: 큰 OFFSET 비용 없음, 실시간 데이터에 유리. 단점: 임의 점프가 어렵고 "total" 노출에 제약.
UX & 에러 처리
- 페이지 범위 초과: 요청한
page가totalPages보다 크면 마지막 페이지로 리다이렉트. - 빈 결과:
totalElements=0이면 "결과 없음" 메시지와 검색 방법 안내. - 정렬 보존: 페이지 이동 시
sort유지. - 필터 보존:
q, status, date...등 파라미터를 항상 링크에 포함.
주의사항
파라미터 인코딩: 검색어/필터 값은 반드시 URL 인코딩하고 서버에서 화이트리스트 검증을 수행하세요.
거대 페이지 수: 수천 페이지를 모두 번호로 노출하지 마세요. 윈도우+ellipsis 또는 이전/다음 조합을 사용하세요.
접근성: 현재 페이지는
aria-current="page", 링크에는 aria-label을 제공하세요.다음으로
- Data Pagination — 데이터 계층과의 통합 패턴
- Sorting & Filters — 정렬/필터와의 조합
- Table Setup — 테이블 구성과 페이지네이션 연계