[UMC] Page VS Slice
조회 기능을 만들 때 가장 먼저 떠올려야 하는 것이 있다. 바로 페이징이다. 글이 몇 개 안 될 때는 상관없지만, 서비스가 조금만 커져도 한 번에 수천, 수만 개의 데이터를 통째로 내려주는 순간, 유저는 로딩 화면만 멍하니 바라보게 된다. 쿼리도 느려지고, 네트워크 트래픽도 무거워지고, 브라우저 렌더링까지 버거워진다.
전통적인 SQL에서는 LIMIT, OFFSET 같은 키워드를 활용해서 직접 페이징 쿼리를 작성한다.
예를 들어 “10개씩 끊어서 2페이지를 보여줘” 같은 요구사항이 들어오면 다음과 같이 쿼리를 작성할 수 있다.
SELECT *
FROM posts
ORDER BY created_at DESC
LIMIT 10 OFFSET 10;
그런데 Spring Data JPA를 쓰기 시작하면 이야기가 조금 달라진다.
애플리케이션 코드에서 SQL을 직접 다루지 않고, Repository 인터페이스와 메서드 이름, Pageable 같은 추상화를 통해 페이징을 처리하게 된다. 이때 등장하는 것이 바로 Page와 Slice다.
둘 다 “페이징된 결과 리스트”를 담고 있는데, 도대체 뭐가 다를까?
언제는 Page를 쓰고, 언제는 Slice를 쓰라고 할까?
성능이나 쿼리 측면에서 실제로 어떤 차이가 있을까?
이번 글에서는 Spring Data JPA에서 제공하는 Page와 Slice가 어떻게 다른 타입인지, 그리고 언제 어떤 걸 선택하는 것이 맞는지를 정리해 보겠다.
Page와 Slice
Spring Data 공통 모듈에서 Page와 Slice는 둘 다 org.springframework.data.domain 패키지에 있다.
Slice
다음/이전 슬라이스가 있는지 여부를 알려주는 데이터 조각. 이전/다음 슬라이스를 요청할 수 있는 Pageable도 제공한다.
즉, “이번에 받은 조각이 마지막인지, 아직 뒤에 더 있는지만 알려주는 뷰” 라고 보면 된다.
Page
리스트의 하위 리스트(sublist)이며, 이 조각이 전체 리스트 중 어디에 위치하는지에 대한 정보를 함께 제공한다.
그리고 선언을 보면 Page는 Slice를 상속한다.
public interface Page<T> extends Slice<T> {
int getTotalPages();
long getTotalElements();
<U> Page<U> map(Function<? super T, ? extends U> converter);
}
정리하면,
- Slice = “다음 페이지가 있는지 여부까지 아는 데이터 조각”
- Page = Slice + “전체 개수/전체 페이지 수까지 아는 데이터 조각”
Page와 Slice로 같은 API를 만들어보면?
기존 pagination 코드를 Page/Slice로 각각 바꿨다고 가정해보자.
public interface PostRepository extends JpaRepository<Post, Long> {
// Page 기반
Page<Post> findByCategory(String category, Pageable pageable);
// Slice 기반
Slice<Post> findSliceByCategory(String category, Pageable pageable);
}
둘 다 Pageable을 파라미터로 받는 건 동일하고, 리턴 타입만 다르다. 쿼리 메서드에 Pageable을 넘기면, Spring Data JPA가 자동으로 페이징 쿼리를 만들어준다.
Page 버전 API
@GetMapping("/posts/page")
public Page<Post> getPostsPage(
@RequestParam String category,
Pageable pageable
) {
return postRepository.findByCategory(category, pageable);
}
이 API를 page=0&size=5로 호출했다고 치면, 응답 구조는 대략 이런 느낌이다.
{
"content": [
{ "id": 101, "title": "글1", ... },
{ "id": 100, "title": "글2", ... },
{ "id": 99, "title": "글3", ... },
{ "id": 98, "title": "글4", ... },
{ "id": 97, "title": "글5", ... }
],
"number": 0,
"size": 5,
"numberOfElements": 5,
"first": true,
"last": false,
"empty": false,
"totalElements": 243,
"totalPages": 49,
"sort": { ... },
"pageable": { ... }
}
여기서 핵심은 아래 두 개의 값이다.
- totalElements : 전체 글 개수
- totalPages : 전체 페이지 수
이 값들을 채우기 위해 Spring Data JPA는 추가로 COUNT(*) 쿼리를 한 번 더 날린다.
일반적으로 이런 두 개의 쿼리가 수행된다.
-- 1) 실제 데이터 조회
SELECT *
FROM post
WHERE category = ?
ORDER BY created_at DESC
LIMIT 5 OFFSET 0;
-- 2) 전체 개수 계산용 count 쿼리
SELECT COUNT(*)
FROM post
WHERE category = ?;
즉, Page = “데이터 쿼리 1번 + 카운트 쿼리 1번” 이 기본 패턴이다.
Slice 버전 API
이번에는 Slice를 반환하는 API를 한 개 더 만들었다고 해보자.
@GetMapping("/posts/slice")
public Slice<Post> getPostsSlice(
@RequestParam String category,
Pageable pageable
) {
return postRepository.findSliceByCategory(category, pageable);
}
page=0&size=5로 호출했을 때, 응답 JSON은 이렇게 생겼을 것이다.
{
"content": [
{ "id": 101, "title": "글1", ... },
{ "id": 100, "title": "글2", ... },
{ "id": 99, "title": "글3", ... },
{ "id": 98, "title": "글4", ... },
{ "id": 97, "title": "글5", ... }
],
"number": 0,
"size": 5,
"numberOfElements": 5,
"first": true,
"last": false,
"empty": false,
"sort": { ... },
"pageable": { ... }
// totalElements, totalPages 없음
}
totalElements, totalPages 필드가 없다.
대신 코드 레벨에서는 이런 메서드를 쓴다.
Slice<Post> slice = postRepository.findSliceByCategory(category, pageable);
slice.hasNext(); // 다음 페이지가 있는지?
slice.hasPrevious(); // 이전 페이지가 있는지?
Slice를 사용할 때 Spring Data JPA는 현재 페이지 사이즈보다 1개 더 많이 가져오는 방식으로 hasNext() 여부를 판단한다.
즉, size = 5로 요청하면 내부 쿼리는 대략 이런 식으로 나간다.
SELECT *
FROM post
WHERE category = ?
ORDER BY created_at DESC
LIMIT 6 OFFSET 0; -- size + 1
실제 응답에는 5개만 담고, “6번째가 있었냐?”를 기준으로 hasNext()를 true/false로 설정한다.
Slice는 전체 개수를 알 필요가 없기 때문에, 일반적으로 추가 count 쿼리를 날리지 않는다. 이게 Page와의 가장 큰 차이다!
Page vs Slice: 장단점 비교
이제 각각의 장단점을 정리해보자.
Page의 장단점
장점
- 전체 개수/전체 페이지 수 제공
- getTotalElements(), getTotalPages()를 바로 쓸 수 있다.
- “총 243개의 글 중 3/49페이지” 같은 정보를 UI에 정확하게 보여줄 수 있다.
- 페이지 번호 기반 네비게이션(1, 2, 3, …) 구현이 쉽다.
- 관리자·통계 화면에 최적
- 관리용 대시보드, 백오피스 리스트, 검색 결과 등 “전체가 몇 건인지”가 비즈니스적으로 중요한 화면에 잘 맞는다.
단점
- 항상 count 쿼리가 추가로 나간다
- 데이터가 많고, 조인 구조가 복잡하면 COUNT(*) 한 번이 꽤 비싸질 수 있다.
- 특히 커스텀 JPQL + 복잡한 join fetch가 섞이면, 성능 최적화용으로 countQuery를 따로 분리해줘야 한다.
- 굳이 필요 없는 정보까지 끌어올 수 있다
- 무한 스크롤, 피드 등에서는 “전체가 몇 건인지”보다는 “뒤에 더 있냐 없냐”가 중요한데, 이때도 어쩔 수 없이 count 쿼리가 돈다.
Slice의 장단점
장점
- 가볍다
- 전체 개수 계산을 하지 않고, “다음 페이지가 있는지만” 알면 되기 때문에 쿼리 비용이 줄어든다. 데이터가 많은 테이블에서, 특히 where 조건이 복잡한 경우 더 체감된다.
- 무한 스크롤/더 보기 UI에 딱 맞는다
- hasNext()만 있으면 된다.
- 프론트에서 “더 보기” 버튼을 숨기거나, 무한 스크롤 요청을 멈출 기준만 있으면 되는 경우에 최적.
단점
- 전체 개수/페이지 수를 모른다
- “총 n건” 헤더를 찍어야 하거나, “마지막 페이지 번호”를 알고 싶다면 직접 별도의 쿼리를 작성해야 한다.
- 페이지 점프 UI에 잘 안 맞는다
- "10페이지로 바로 이동” 같은 기능은 전체 페이지 수를 모르면 애매하다.
- 계속 앞에서부터 슬라이스를 타고 내려가야 하므로 효율이 떨어질 수 있다.
언제 Page, 언제 Slice를 써야 할까?
결국 선택 기준은 비즈니스가 정말로 전체 개수를 필요로 하느냐 이다.
Page를 쓰는 게 맞는 상황
- 관리자/백오피스 리스트
- 전체 사용자 수, 전체 주문 수, 전체 게시글 수를 상단에 노출해야 할 때
- “총 524건 중 3페이지” 같은 문구가 UI 요구사항에 명시된 경우
- 검색 결과 페이지네이션
- 검색 결과 상단에 “총 n건의 결과”를 보여줘야 할 때
- 페이지 번호를 클릭해서 왔다 갔다 하는 전통적인 페이지네이션 UI (1, 2, 3 …)
- 보고서·통계 화면
- 전체 규모를 아는 것 자체가 의미 있는 화면
이럴 땐 Page를 쓰고, count 쿼리 비용을 감수하는 대신 정확한 통계를 제공하는 쪽으로 가는 게 맞다.
Slice가 더 어울리는 상황
- 무한 스크롤 피드
- SNS 타임라인, 알림 목록, 활동 로그, 채팅 메시지 히스토리 등 “전체가 몇 건인지”보다는 스크롤이 더 내려갈 수 있느냐가 핵심이다.
- 모바일 앱에서 “더 보기” 버튼
- “더 보기”를 누를 때마다 다음 chunk만 가져오면 되는 구조
- 전체 개수를 알아도 UX에 별 도움이 안 되는 경우
- 성능이 민감한 대용량 리스트
- 특정 로그 테이블, 이벤트 이력처럼 레코드가 폭발적으로 쌓이는 테이블
- 필터 조건이 복잡한 쿼리에 count까지 더해지면 DB에 부담이 큰 경우