백엔드 개발을 하다 보면 데이터를 저장하는 것보다 조회 API를 어떻게 설계할지 더 고민하게 되는 순간이 옵니다.
처음에는 저도 목록 조회 API를 만들 때 그냥 데이터를 전부 내려주면 되는 줄 알았습니다.
그런데 실제로 데이터를 다루다 보면, 조회 대상이 많아질수록 “어떻게 끊어서 보여줄지”가 훨씬 중요하다는 걸 느끼게 됩니다.
예를 들어 리뷰가 200개, 게시글이 1,000개, 미션이 수만 개라면
그걸 한 번에 전부 조회해서 내려주는 방식은 성능상으로도, 사용자 경험 측면에서도 비효율적입니다.
그래서 이번 글에서는 워크북 내용을 바탕으로
페이징이 왜 필요한지,
Spring Data JPA에서 Pageable, Page, Slice는 어떤 역할을 하는지,
그리고 오프셋 기반 페이지네이션과 커서 기반 페이지네이션은 어떻게 다르고 언제 사용하는지를 정리해보려고 합니다.
이번 글에서 다룰 내용은 다음과 같습니다.
- 페이징이란 무엇인가
- 조회 API에서 페이징이 왜 중요한가
- Pageable은 무엇인가
- Page와 Slice의 차이
- 오프셋 기반 페이지네이션
- 커서 기반 페이지네이션
- 페이지네이션 응답 DTO는 어떻게 설계할까
조회 API는 왜 더 까다로울까?
처음에는 POST 요청처럼 데이터를 저장하는 API가 더 어려워 보일 수 있습니다.
하지만 실제 서비스에서는 조회(Read) 요청의 비중이 훨씬 더 높은 경우가 많습니다.
사용자는 게시글을 읽고, 리뷰를 보고, 목록을 탐색하고, 검색 결과를 확인합니다.
즉, 서비스는 데이터를 한 번 저장하는 것보다 계속 조회해서 보여주는 일을 더 많이 하게 됩니다.
문제는 조회 대상이 많아질수록 단순히 select *처럼 전부 가져오는 방식으로는 감당이 어려워진다는 점입니다.
예를 들어 리뷰가 200개 있는데 이를 한 번에 전부 내려준다면
- DB는 많은 양의 데이터를 한 번에 조회해야 하고
- 서버는 그 데이터를 응답 형태로 가공해야 하고
- 클라이언트는 그 많은 데이터를 한 번에 렌더링해야 합니다
결국 응답 속도가 느려지고, 불필요한 데이터까지 계속 주고받게 됩니다.
그래서 조회 API를 만들 때는 항상 아래 두 가지를 함께 고민하게 됩니다.
- 많은 데이터를 어떤 기준으로 가져올 것인가
- 그 데이터를 어떻게 나눠서 보여줄 것인가
이때 등장하는 개념이 바로 페이징(Paging) 입니다.
페이징이란?
페이징은 많은 데이터를 한 번에 전부 가져오지 않고, 일정 개수씩 끊어서 조회하는 방식입니다.
예를 들어 게시글이 1,000개 있다고 해도 사용자는 첫 화면에서 그 1,000개를 모두 보지 않습니다.
보통은
- 1페이지에 10개씩 보여주거나
- 스크롤을 내릴 때마다 다음 목록을 추가로 불러오거나
- 최신순으로 일부만 먼저 조회
하는 식으로 데이터를 보게 됩니다.
즉, 페이징은 단순히 화면을 나누는 기능이 아니라
조회 성능을 관리하고 사용자 경험을 개선하기 위한 방식이라고 볼 수 있습니다.
JPA에서 Pageable은 무엇일까?
Spring Data JPA에서는 페이지네이션을 위한 기능을 기본적으로 제공합니다.
이때 핵심이 되는 것이 바로 Pageable 입니다.
Pageable은 쉽게 말하면 페이지네이션에 필요한 정보들을 담고 있는 객체입니다.
예를 들면 이런 정보들이 들어갑니다.
- 현재 페이지 번호
- 한 번에 가져올 데이터 수
- 정렬 기준
SQL로 페이징을 배웠다면 이런 쿼리를 떠올릴 수 있습니다.
-- 오프셋 기반 페이지네이션
select * from mission
order by mission_id desc
limit 10 offset 0;
여기서
- limit 은 가져올 데이터 수
- offset 은 현재 페이지 위치
- order by 는 정렬 기준
이 됩니다.
JPA에서는 이런 정보를 Pageable에 담아서 Repository에 전달합니다.
그리고 실제로 많이 사용하는 구현체가 PageRequest 입니다.
즉, PageRequest는 Pageable을 구현한 객체이고,
우리는 보통 이를 통해 “몇 페이지를 어떤 정렬 기준으로 몇 개 가져올지”를 지정하게 됩니다.
오프셋 기반 페이지네이션이란?
오프셋 기반 페이지네이션은 가장 익숙한 방식입니다.
보통 “1페이지, 2페이지, 3페이지”처럼 페이지 번호를 기준으로 데이터를 조회하는 방식입니다.
예를 들어 10개씩 보여주는 게시글 목록이 있다면
- 1페이지는 0~9번째 데이터
- 2페이지는 10~19번째 데이터
- 3페이지는 20~29번째 데이터
를 가져오는 식입니다.
SQL로 보면 보통 이런 형태가 됩니다.
select * from mission
order by mission_id desc
limit 10 offset 0;
Spring에서는 보통 Query Parameter로 이런 값을 받습니다.
- pageNumber
- pageSize
- sort
그리고 이를 바탕으로 PageRequest를 만들어 Repository에 전달합니다.
오프셋 기반 페이지네이션의 장점은 직관적이라는 점입니다.
사용자도 “지금 3페이지를 보고 있다”는 개념을 이해하기 쉽고, 프론트엔드에서도 페이지 이동 UI를 만들기 편합니다.
하지만 단점도 있습니다.
페이지가 뒤로 갈수록 앞부분 데이터를 계속 건너뛰어야 하기 때문에 데이터가 많아질수록 성능이 떨어질 수 있습니다.
즉, 오프셋 기반 페이지네이션은 구현과 사용은 편하지만 대용량 데이터에서는 비효율적일 수 있습니다.
Page란?
Spring Data JPA에서 오프셋 기반 페이지네이션을 할 때 자주 사용하는 반환 타입이 Page 입니다.
Page는 단순히 데이터 목록만 담고 있는 것이 아니라 페이지네이션에 필요한 여러 정보들을 함께 담고 있습니다.
예를 들면
- 현재 페이지 데이터 목록
- 현재 페이지 번호
- 전체 데이터 수
- 전체 페이지 수
- 첫 페이지인지 여부
- 마지막 페이지인지 여부
같은 정보들을 포함할 수 있습니다.
즉, Page는 프론트엔드가 페이지 UI를 만들기에 편리한 정보를 많이 제공합니다.
다만 여기서 중요한 점이 있습니다.
Page는 전체 데이터 수를 알아야 하기 때문에 보통 count 쿼리가 함께 나갑니다.
예를 들어 “전체 미션이 몇 개인가?”를 알아야 총 페이지 수를 계산할 수 있으니,
목록 조회 쿼리 외에 총 개수를 세는 쿼리가 추가로 실행되는 것입니다.
그래서 Page는 정보가 풍부한 대신, 전체 개수를 매번 세야 한다는 비용이 있습니다.
Slice란?
Slice는 Page와 비슷해 보이지만 조금 더 가벼운 개념입니다.
Slice도 목록 데이터와 정렬 정보, 현재 페이지 정보 등을 담을 수 있지만,
Page와 달리 전체 데이터 수를 포함하지 않습니다.
즉,
- 지금 가져온 데이터가 무엇인지
- 다음 데이터가 더 있는지
정도에 더 집중한 구조입니다.
Slice의 핵심은 “다음 페이지가 있는지만 알면 된다”는 상황에 잘 맞는다는 점입니다.
보통 무한 스크롤 UI에서는 전체 페이지 수가 꼭 필요하지 않은 경우가 많습니다.
사용자는 “총 몇 페이지인지”보다 “다음 데이터가 있으면 더 불러오기”만 하면 되기 때문입니다.
이럴 때 Slice를 사용하면 전체 개수를 세는 count 쿼리를 줄일 수 있어 Page보다 가볍게 사용할 수 있습니다.
Page와 Slice는 어떻게 다를까?
둘 다 페이지네이션을 위한 객체이지만, 차이는 꽤 분명 합니다.
Page
- 전체 데이터 수를 알 수 있음
- 총 페이지 수를 계산할 수 있음
- 일반적인 페이지 번호 기반 UI에 적합
- count 쿼리가 추가될 수 있음
Slice
- 전체 데이터 수는 알 수 없음
- 다음 페이지가 있는지만 확인
- 무한 스크롤이나 커서 기반 페이지네이션에 잘 맞음
- count 쿼리 부담이 적음
즉, 전체 페이지 수까지 필요한 경우에는 Page,
“다음 데이터가 있는지” 정도만 알면 되는 경우에는 Slice가 더 적합하다고 볼 수 있습니다.
그럼 응답을 그대로 내려주면 될까?
처음 Page 객체를 그대로 응답으로 내려보면 생각보다 프론트엔드 입장에서 필요하지 않은 정보까지 많이 포함되는 경우가 있습니다.
예를 들어 내부적으로만 의미 있는 값이나, 클라이언트가 실제로 쓰지 않는 필드까지 함께 보일 수 있습니다.
그래서 실무에서는 보통 Page나 Slice를 그대로 내려주기보다
별도의 페이지네이션 응답 DTO를 만들어 필요한 정보만 정제해서 응답하는 경우가 많습니다.
예를 들어 오프셋 기반 페이지네이션이라면 이런 정보들을 담을 수 있습니다.
- 목록 데이터
- 현재 페이지 번호
- page size
- total elements
- total pages
- 마지막 페이지 여부
즉, 페이지네이션도 결국 API 설계의 일부이기 때문에
“JPA가 주는 값을 그대로 노출할지”,
“클라이언트에게 필요한 형태로 가공할지”를 고민하는 것이 중요합니다.
커서 기반 페이지네이션이란?
커서 기반 페이지네이션은 페이지 번호 대신 특정 기준값(cursor) 을 이용해 다음 데이터를 조회하는 방식입니다.
예를 들어 최신순으로 정렬된 데이터가 있을 때
마지막으로 본 데이터의 ID를 기준으로 다음 데이터를 가져오는 식입니다.
SQL로 보면 이런 느낌입니다.
select *
from mission
where store_id = ?
and id < ?
order by id desc
limit 3;
즉,
- 이전 요청의 마지막 데이터 ID를 커서로 기억해두고
- 다음 요청에서는 그 ID보다 작은 데이터만 조회하는 방식
입니다.
예를 들어 첫 요청에서는 최신 데이터 3개를 가져오고, 마지막 ID가 383이었다면
다음 요청에서는 id < 383 조건으로 그 다음 3개를 가져오는 식입니다.
이 방식은 오프셋처럼 앞의 데이터를 계속 건너뛰지 않기 때문에 데이터가 많아질수록 더 효율적으로 동작할 수 있습니다.
커서 기반 페이지네이션은 왜 사용할까?
커서 기반 페이지네이션은 특히 무한 스크롤에 잘 어울립니다.
사용자는 “2페이지로 이동”하는 것보다
그냥 아래로 계속 내리면서 다음 데이터를 불러오는 경험을 더 자주 하게 됩니다.
이때 중요한 것은 “지금 몇 페이지인지”보다
“다음 데이터를 어디서부터 가져올지”입니다.
커서 기반 페이지네이션의 장점은 다음과 같습니다.
- 데이터가 많아져도 비교적 안정적인 성능
- 무한 스크롤 구조에 잘 맞음
- 중간 데이터가 삽입되더라도 오프셋보다 흐름이 자연스러운 경우가 많음
다만 단점도 있습니다.
- 구현이 오프셋 방식보다 복잡함
- 페이지 번호 기반 UI에는 잘 맞지 않음
- 정렬 기준과 커서 구조를 함께 설계해야 함
즉, 커서 기반 페이지네이션은 “더 고급스럽고 무조건 좋은 방식”이라기보다
대량 데이터와 무한 스크롤에 더 적합한 방식이라고 이해하는 것이 좋습니다.
Slice만 있으면 커서 기반 페이지네이션이 완성될까?
여기서 헷갈릴 수 있는 부분이 있습니다.
Slice가 있다고 해서 커서 기반 페이지네이션이 자동으로 완성되는 것은 아닙니다.
Slice는 어디까지나
- 지금 가져온 데이터
- 다음 데이터가 있는지 여부
같은 정보를 제공하는 역할에 가깝습니다.
실제 커서 기반 페이지네이션을 구현하려면 개발자가 직접
- 커서 구조를 어떻게 만들지 정하고
- 어떤 필드를 기준으로 정렬할지 결정하고
- where 절에 어떤 조건을 넣을지 설계해야 합니다
예를 들어 ID 기준으로 내림차순 정렬한다면
커서는 id 값을 사용하면 됩니다.
하지만 별점, 생성일시처럼 중복될 수 있는 값을 기준으로 정렬한다면
단순히 하나의 값만으로는 정확한 커서를 만들기 어렵습니다.
이럴 때는 별점 + reviewId처럼 여러 값을 조합해서 커서를 설계해야 합니다.
즉, Slice는 커서 기반 페이지네이션에 잘 어울리는 도구이지만,
커서 자체를 설계하는 책임까지 대신해주지는 않습니다.
오프셋 기반과 커서 기반은 어떻게 다를까?
둘의 차이를 정리하면 이렇게 볼 수 있습니다.
오프셋 기반 페이지네이션
- 페이지 번호를 기준으로 조회
- 구현이 직관적이고 익숙함
- 일반적인 게시판 UI에 적합
- 뒤로 갈수록 성능이 떨어질 수 있음
커서 기반 페이지네이션
- 마지막 조회 기준값(cursor)으로 다음 데이터 조회
- 무한 스크롤에 적합
- 대량 데이터에서 더 효율적일 수 있음
- 구현과 응답 설계가 조금 더 복잡함
즉, 둘 중 하나가 무조건 정답이라기보다
서비스의 사용 방식에 따라 선택하는 것이 중요하다고 느꼈습니다.
페이지 번호가 중요한 서비스라면 오프셋 기반이 더 자연스럽고,
계속 내려보는 피드형 서비스라면 커서 기반이 더 잘 맞을 수 있습니다.
페이지네이션 응답 DTO는 어떻게 설계할까?
페이지네이션을 구현할 때는 데이터 목록만 내려주는 것보다
클라이언트가 다음 동작을 할 수 있도록 필요한 메타데이터를 함께 주는 것이 중요합니다.
예를 들어 오프셋 기반 페이지네이션이라면 보통 이런 구조를 생각할 수 있습니다.
{
"content": [
{
"missionId": 1,
"point": 500,
"conditional": "음료 포함 1만원 이상 주문"
}
],
"page": 0,
"size": 10,
"totalElements": 37,
"totalPages": 4,
"isLast": false
}
반면 커서 기반 페이지네이션이라면
전체 개수보다 다음 커서와 다음 데이터 존재 여부가 더 중요합니다.
예를 들면 이런 느낌입니다.
{
"content": [
{
"missionId": 383,
"point": 500,
"conditional": "음료 포함 1만원 이상 주문"
}
],
"listSize": 10,
"hasNext": true,
"nextCursor": "ID:383"
}
즉, 페이지네이션 응답도 단순히 데이터를 감싸는 것이 아니라
프론트엔드가 다음 요청을 어떻게 보내야 하는지까지 고려해서 설계해야 한다는 점이 중요합니다.
정리해보면
이번 내용을 정리하면서 느낀 것은, 조회 API는 단순히 데이터를 가져오는 작업이 아니라
많은 데이터를 어떻게 효율적으로, 그리고 사용하기 좋은 형태로 보여줄지 설계하는 작업이라는 점이었습니다.
Spring Data JPA에서는 Pageable, Page, Slice 같은 도구를 제공하기 때문에
SQL을 직접 다루지 않더라도 페이지네이션을 꽤 편하게 구현할 수 있습니다.
다만 도구를 쓰는 것과 설계를 잘하는 것은 조금 다른 문제라고 느꼈습니다.
- 전체 페이지 수가 필요한가?
- 다음 데이터가 있는지만 알면 되는가?
- 페이지 번호 기반 UI인가?
- 무한 스크롤 구조인가?
- 응답은 어떤 형태로 내려줘야 프론트가 쓰기 편한가?
이런 질문들을 함께 고민해야
비로소 서비스에 맞는 페이지네이션을 설계할 수 있다고 생각했습니다.
마무리
처음에는 저도 페이징을 단순히 “데이터를 조금씩 끊어오는 기능” 정도로만 생각했습니다.
그런데 정리해보니 페이징은 단순한 구현 포인트가 아니라 조회 API 설계의 핵심 요소에 가깝다고 느꼈습니다.
특히 Page와 Slice의 차이,
오프셋 기반 페이지네이션과 커서 기반 페이지네이션의 차이,
그리고 필요한 정보만 담아서 응답 DTO를 설계하는 과정까지 보면서
“조회 API는 생각보다 더 많은 선택지를 포함하고 있구나”를 다시 느끼게 됐습니다.
앞으로 조회 API를 만들 때는 그냥 목록을 내려주는 데서 끝나지 않고,
이 API는 어떤 페이징 방식이 더 적합할까?
프론트엔드가 실제로 필요한 정보는 무엇일까?
를 함께 고민하면서 설계해야겠다고 느꼈습니다