포스트

2022-10-11-TIL

2022-10-11-TIL

Today I Learned

Pagination with Cursor

오프셋 페이지네이션은 LIMIT 20 OFFSET 100000처럼 건너뛸 행 수를 지정한다. 문제는 DB가 건너뛸 행을 실제로 읽은 뒤 버린다는 점이다. 뒤 페이지로 갈수록 읽고 버리는 행이 늘어서 느려진다. 또 페이지를 넘기는 사이 앞쪽에 새 행이 추가되거나 삭제되면 같은 행이 두 번 보이거나 빠진다.

커서(키셋) 페이지네이션은 “마지막으로 본 행의 정렬 키 다음부터”를 조건으로 건다.

1
2
3
4
5
6
7
8
9
10
-- 첫 페이지
SELECT id, title, created_at FROM post
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- 다음 페이지: 직전 페이지 마지막 행의 (created_at, id)를 커서로 사용
SELECT id, title, created_at FROM post
WHERE (created_at, id) < (:lastCreatedAt, :lastId)
ORDER BY created_at DESC, id DESC
LIMIT 20;

정렬 키에 인덱스가 있으면 커서 위치로 바로 찾아가서 20건만 읽으므로, 몇 번째 페이지든 비용이 거의 같다. 정렬 키가 유일하지 않으면(같은 created_at이 여러 개) 경계에서 행이 빠지거나 중복되므로 id 같은 유일한 컬럼을 보조 정렬 키로 함께 써야 한다. 같은 조건을 created_at < ? OR (created_at = ? AND id < ?)로 풀어 쓸 수도 있으며, 어느 형태가 인덱스를 타는지는 EXPLAIN으로 확인한다.

대가도 있다. “17페이지로 바로 이동”이 불가능하고 이전·다음 이동만 자연스럽다. 그래서 무한 스크롤이나 피드에 잘 맞고, 페이지 번호가 필요한 관리자 화면에는 오프셋 방식이 여전히 쓰인다. 클라이언트에는 커서 값을 Base64 같은 불투명한 문자열로 감싸 내려 주면 서버가 커서 구성을 바꿔도 API가 유지된다.

  • https://dev.to/appwrite/this-is-why-you-should-use-cursor-pagination-4nh5
  • https://bojithapiyathilake.medium.com/pagination-offset-vs-cursor-in-mysql-92cbf1a02cfa
  • https://velog.io/@minsangk/%EC%BB%A4%EC%84%9C-%EA%B8%B0%EB%B0%98-%ED%8E%98%EC%9D%B4%EC%A7%80%EB%84%A4%EC%9D%B4%EC%85%98-Cursor-based-Pagination-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0
  • https://www.eversql.com/faster-pagination-in-mysql-why-order-by-with-limit-and-offset-is-slow/
  • https://davidasync.medium.com/why-pagination-is-slow-1734f8709492
  • https://medium.com/swlh/how-to-implement-cursor-pagination-like-a-pro-513140b65f32

Pagination Best Practices

검색엔진 관점의 페이지네이션 가이드도 함께 봤다. Google 검색 문서의 권장은 다음과 같다(Google Search Central: Pagination).

  • 각 페이지에서 다음 페이지로 <a href> 링크를 걸어 크롤러가 순서대로 따라갈 수 있게 하고, 모든 페이지에서 첫 페이지로도 링크한다.
  • 페이지마다 ?page=n처럼 고유한 URL을 준다.
  • 모든 페이지의 canonical을 첫 페이지로 지정하지 말고, 각 페이지가 자기 자신을 canonical로 갖게 한다.
  • Google은 더 이상 rel="next", rel="prev"를 사용하지 않는다. 다른 검색엔진은 쓸 수 있다. Ahrefs 글이 이 변화를 다룬다.
  • “더 보기” 버튼이나 무한 스크롤은 사용자 동작이 있어야 콘텐츠가 로드되므로, 크롤러가 찾을 수 있게 별도 링크나 사이트맵을 제공한다.

정렬·필터 조합마다 URL이 생겨 비슷한 목록이 대량으로 색인되는 것을 막으려면 해당 URL을 noindex로 두거나 robots.txt로 막는다. 커서 방식 목록을 공개 페이지에 쓴다면 크롤러용으로 따라갈 수 있는 링크를 따로 마련해야 한다는 뜻이기도 하다.

  • https://www.seoquake.com/blog/paginated-pages/
  • https://www.seroundtable.com/google-search-tests-pagination-28056.html
  • https://ahrefs.com/blog/rel-prev-next-pagination/
  • https://developers.google.com/search/docs/specialty/ecommerce/pagination-and-incremental-page-loading#sequentially
  • https://isquared.wordpress.com/2013/01/22/designing-search-part-6-interacting-with-results/

선분이력

선분이력은 데이터의 변경 이력을 시작 시점과 종료 시점을 가진 구간(선분)으로 저장하는 모델링 기법이다. 예를 들어 상품 가격 이력을 (상품ID, 시작일시, 종료일시, 가격)으로 두고, 현재 유효한 행의 종료일시에는 9999-12-31 같은 최대값을 넣는다.

1
2
3
4
-- 특정 시점의 가격
SELECT price FROM product_price_history
WHERE product_id = :id
  AND :at BETWEEN start_at AND end_at;

값이 바뀔 때마다 변경 시점만 기록하는 점이력 방식은 “특정 시점의 값”을 구하려면 그 시점 이전의 가장 최근 행을 찾는 서브쿼리가 필요하다. 선분이력은 BETWEEN 한 번으로 찾을 수 있어 조회가 단순하다. 대신 값이 바뀔 때 기존 행의 종료일시를 닫고 새 행을 넣는 두 단계 쓰기를 한 트랜잭션으로 묶어야 하고, 구간이 겹치거나 비지 않도록 관리해야 한다. 경계값을 포함할지(end_at을 다음 시작 직전으로 둘지, 다음 시작과 같게 둘지) 규칙을 정해 두지 않으면 경계 시점에 두 행이 조회될 수 있다.

  • https://moons08.github.io/programming/history_management/
  • https://daphne-dev.github.io/2020/11/17/database-005/
참고한 자료외부 출처 1

외부 출처

이 글은 저작권자의 CC BY 4.0 라이선스를 따릅니다.

댓글

아직 댓글이 없습니다