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개)로 제한하세요.

다음으로