페이지네이션에 대한 고찰
페이지네이션(Pagination)
우리가 서비스에서 리스트 형태의 데이터를 볼 때마다 자연스럽게 마주치는 기능이 있다. 바로 페이지네이션으로 게시글 목록, 숙소 검색, 상품 검색 등 대부분의 화면에서 "적당한 크기로 끊어서 보여주는 방식"을 본 적이 있을 것이다.
구글의 페이지네이션

위와 같이 구현한 이유는 단순하다. 데이터가 많아질수록, 한 번에 모든 데이터를 가져오는 방식으로는 좋은 성능을 낼 수 없기 때문이다.
왜 페이지네이션이 필요할까?
간단하게 게시글(post) 을 가져오는 쿼리 예시를 생각해보자.
데이터가 적을 땐 별 문제가 없는 쿼리이다. 하지만 몇 십만, 몇 백만 건으로 늘어나는 순간 상황은 달라진다.
서버는 사용자가 안볼 수도 있는 데이터를 모두 읽어온다.
네트워크 I/O를 통해 이러한 대량의 데이터를 주고 받아야 한다.
애플리케이션 서버의 메모리에 대량의 데이터를 적재하게 된다.
마지막으로 브라우저는 대량의 데이터를 한 번에 렌더링하느라 버벅이게 된다.
위와 같은 상황이 발생하기 때문에 데이터를 나눠서 필요한 만큼만 가져오는 페이지네이션 전략을 기본적으로 사용하게 된다.
Offset-based vs Cursor-based
페이지네이션을 구현하는 데엔 크게 오프셋 기반과 커서 기반 두 가지 방식이 있다.
오프셋 기반(Offset-based)
흔하게 사용되는 방식으로 쿼리 문에 LIMIT ? OFFSET ? 방식을 사용하는 것이다. 예를 들어, /posts?page=1&size=10 과 같은 API 요청이 왔을 때 게시글(Posts) 테이블에 대해 내부적으로 offset을 계산하여 10개만큼의 데이터를 가져오게 되는 것이다.
장점: 구현이 쉽고, 페이지 이동이 간단하다. URL 만으로 동일한 결과를 다시 재현하기도 쉽다. 또한, JPA를 사용하는 상황이라면
Pageable,Page객체를 사용해서 쉽게 구현할 수 있다.단점: offset이 커지면 필요하지 않은 데이터(레코드)를 계속 스캔(Disk I/O)해야 하므로 점점 느려진다.
적합한 상황
사용자가 특정 페이지로 이동할 일이 많다.
가격, 평점, 거리 등 정렬 조건과 다양한 필터 조합이 있어야 한다.
결과 개수, 전체 페이지 같은 정보가 필요하다.
페이지 단위 캐싱이 필요하다.
커서 기반(Cursor-based)
No-offset 방식이라고도 불린다.
무한 스크롤(페이스 북, 인스타그램과 같은 피드 형식)에서 자주 사용하는 방식으로 마지막으로 받은 ID 값 이후의 데이터를 요청하는 방식으로 데이터를 가져오게 된다.
장점: 거의 인덱스를 기반으로 스캔을 하기 때문에 스캔 비용이 거의 없어 대량 데이터에서도 빠르다.
단점: 페이지 번호 같은 개념이 없어 사용자 경험 측면에서 "3페이지로 이동"과 같은 행동이 불가능하다. 정렬 기준이 조금만 복잡해져도 커서 정의가 어려워진다.
위의 쿼리와 같이 커서를 Clustered Index인 PK로 사용함으로써 조회 시작 부분을 인덱스로 빠르게 조회를 할 수 있다.
적합한 상황
정렬 기준이 단순하다(대부분 최신순).
무한 스크롤으로 "More(끝없는 피드)" 형태의 콘텐츠 소비를 의도한다.
필터나 정렬이 단순하여 커서 정의가 간단하다.
Offset-based Pagination을 선택한 이유
내가 진행하는 프로젝트에서도 숙소 검색에 대한 결과에 대해 응답을 내려줄 때 페이지네이션이 필요했다. 사용자 경험 측면에서 오프셋 기반의 페이지네이션이 더 적합하다고 판단을 내렸다.
탐색 패턴: 여러가지 숙소에 대해 탐색을 하며, "뒤로 돌아가기"와 같은 행동과 다양한 필터 조합을 통해 여러 페이지를 왔다갔다하는 상황이 많을 것이다.
검색 결과의 범위: "총 5개의 페이지 중 1번 페이지" 와 같은 정보를 통해 좀 더 구체적인 검색이나, 다른 키워드를 기반으로 검색을 유도할 수 있다.
페이지의 끝(footer): 서비스의 전체적인 페이지 footer에 추천 지역, FAQ, 프로모션 등을 보여주는 구조를 지니게 되는데, 무한 스크롤에서는 이러한 구성을 하기 어렵다고 판단했다.
또한, 유사한 서비스를 살펴봤을 때에도 모바일 환경에서도 Offset-based를 사용하는 것으로 보인다.
여기서 잠깐, 오프셋 기반은 항상 느리고 나쁜 방식일까?
흔히 "오프셋 기반은 불필요한 데이터를 스캔하니까 대량의 데이터가 있는 조건에서는 좋지 않다"는 이유로 단점만 부각되지만, 요구사항을 따져보면 오프셋 기반으로 구현을 해야하는 경우가 있을 것이다.
특히, 검색/정렬/필터링이 다양한 도메인에서 커서 기반으로 구현 자체가 불가능하거나, 커서 정의가 어려운 경우가 있다. 이러한 상황에서 우리는 "오프셋을 사용하되 최적화된 방식으로 사용하는 방법"을 탐구해볼 필요가 있다.
내가 생각하기엔 오프셋 기반도 충분히 빠르게 만들 수 있다고 본다. 오프셋의 성능을 결정짓는 핵심은 "얼마나 빠르게 정렬 기준을 만족하는 행(row)에 접근할 수 있는가" 이다.
이 부분을 이해하기 위해 MySQL 정렬 방식과 인덱스 전략을 참고해보자.
MySQL 정렬 방식
스트리밍 정렬 방식
데이터가 이미 정렬되어 있다고 판단되면, 읽는 즉시 클라이언트로 결과를 그대로 전달한다.
별도의 정렬이 없으며, LIMIT/OFFSET 효과를 높일 수 있다.
Offset 기반에서도 이 방식이면 성능 문제를 충분히 개선할 수 있다고 보인다.
버퍼링 정렬 방식
반대로, MySQL이 데이터를 "정렬되지 않음"으로 판단한 경우
WHERE 조건을 만족하는 모든 ROW를 읽음
Sort Buffer에서 정렬 (메모리와 디스크 사용)
LIMIT/OFFSET 적용
즉, LIMIT 20 OFFSET 10000이어도 10020건을 모두 정렬한 뒤에 20건만 반환한다.
위의 특징을 통해 "스트리밍 정렬 방식"을 통해 오프셋 기반 페이지네이션의 성능을 높일 수 있다는 것을 파악할 수 있다.
MySQL 정렬 전략
Using Index
정렬 기준이 인덱스와 완전히 일치할 때 사용되어 별도의 정렬 작업이 필요없다.
가장 빠른 방식으로 위의 스트리밍 정렬 방식으로 동작한다.
Using Filesort
정렬 기준이 인덱스와 맞지 않는 경우에 사용되어 별도의 정렬 작업이 필요하다.
즉, 버퍼링 정렬 방식을 사용하여 Sort Buffer에서 정렬되어 성능 저하가 일어난다.
Using Temporary
JOIN이나 서브쿼리 등이 포함된 복잡한 쿼리에서 주로 보이며, 정렬 대상 데이터가 많아 임시 테이블을 생성하는 방식이다.
세 가지 정렬 방식 중 가장 비싼 방식
Offset-based 페이지네이션의 핵심은 결국 Using Index를 만들 수 있느냐에 달려있다.
페이지네이션 실험을 해보자
직접 게시글(posts) 테이블을 만들고 실행 계획을 통해 비교해보자. 테이블을 생성하고 10만건의 더미 데이터를 생성해서 테스트를 진행했다.
인덱스 없는 상태에서 실행 계획
위와 같은 쿼리에서 OFFSET이 사실상 의미가 없어지는 경우이다. 흔히 말하는 "Offset 기반은 느리다"의 전형적인 케이스를 보여준다.
정렬 최적화를 위해 복합 인덱스 적용
"Using filesort" 정렬으로 인해 버퍼링 정렬 방식을 사용하는 쿼리의 속도를 개선하기 위해서 WHERE 절에서 사용하는 조건문에 대해 복합 인덱스를 걸어보자.
Full Scan에서 Index Lookup으로 변경됨
Using filesort; 삭제
위에서 실행 계획을 봤을 때 48.8 -> 14.4 ms로 변경되는 것을 확인할 수 있었다.
커버링 인덱스 구성
MySQL 인덱스의 특징으로 인해 인덱스를 탔더라도, Index Lookup을 통해 실제 컬럼에 대한 정보를 가져오기 위해 두 번 읽는 상황이 발생할 수 밖에 없다. 이러한 구조에서 커버링 인덱스까지 도입한다면 어떤 성능을 보일지 궁금하여 테스트를 진행해봤다.
위와 같이 인덱스를 행성해줌으로써 인덱스의 리프 노드는 다음과 같은 구성이 될 것이다.
(status, createad_at, title id(PK))
즉, SELECT 문에 필요한 컬럼에 대한 정보들이 모두 들어있다.
Covering Index lookup: 테이블 페이지 접근 없이 인덱스 리프만으로 결과 생성
double read가 발생하지 않음
정렬도 인덱스 순서
LIMIT/OFFSET가 의도대로 빠르게 동작함.
최종적으로 원했던 "스트리밍 방식" 달성
오프셋 기반 페이지네이션은 분명 단점이 있지만, 그 단점의 근본 원인을 알고 나면 해결책도 명확해진다.
정렬 방식이 스트리밍 방식으로 동작하도록 인덱스를 설계하고, 커버링 인덱스까지 설정해줄 경우 OFFSET이 커져도 충분히 대비할 수 있을 것으로 보인다.
결국 중요한 건 "어떤 방식이 더 좋다"가 아니라 도메인에 적합한 패턴과 사용자 경험 측면에서 어떤 방향으로 가는 것이 더 중요한지와 쿼리 패턴을 구조적으로 보았을 때 적합한 방식을 채택할 수 있는 능력이라고 판단된다.
Last updated
Was this helpful?