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 & 에러 처리

  • 페이지 범위 초과: 요청한 pagetotalPages보다 크면 마지막 페이지로 리다이렉트.
  • 빈 결과: totalElements=0이면 "결과 없음" 메시지와 검색 방법 안내.
  • 정렬 보존: 페이지 이동 시 sort 유지.
  • 필터 보존: q, status, date... 등 파라미터를 항상 링크에 포함.

주의사항

파라미터 인코딩: 검색어/필터 값은 반드시 URL 인코딩하고 서버에서 화이트리스트 검증을 수행하세요.
거대 페이지 수: 수천 페이지를 모두 번호로 노출하지 마세요. 윈도우+ellipsis 또는 이전/다음 조합을 사용하세요.
접근성: 현재 페이지는 aria-current="page", 링크에는 aria-label을 제공하세요.

다음으로