페이징과 정렬 — 대량 데이터를 나눠서 조회하는 전략
데이터가 100만 건이면, 한 번에 전부 조회할 수 없습니다. 어떻게 나눠서 보여줄까요?
대부분의 서비스에서 목록 조회는 페이징 처리가 필수입니다. Spring Data JPA는 Pageable 인터페이스를 통해 페이징과 정렬을 쉽게 처리할 수 있도록 지원합니다. 하지만 대량 데이터에서의 성능 문제를 이해하지 못하면 운영 환경에서 장애가 발생할 수 있습니다.
개념 정의
페이징(Paging) 은 전체 데이터를 일정 크기로 나누어 한 번에 필요한 만큼만 조회하는 기법입니다. Spring Data JPA는 Pageable, Page, Slice 등의 인터페이스를 제공하여 이를 추상화합니다.
기본 사용법
PageRequest 생성
// 0번째 페이지, 10건 조회, age 내림차순 정렬
Pageable pageable = PageRequest.of(0, 10, Sort.by("age").descending());
// 여러 정렬 조건
Pageable pageable = PageRequest.of(0, 10,
Sort.by(Sort.Order.desc("age"), Sort.Order.asc("name")));
Spring Data의 페이지 번호는 0부터 시작합니다.
Repository 메서드
public interface MemberRepository extends JpaRepository<Member, Long> {
// Page 반환 — COUNT 쿼리 자동 실행
Page<Member> findByAge(int age, Pageable pageable);
// Slice 반환 — COUNT 쿼리 없음
Slice<Member> findByTeamName(String teamName, Pageable pageable);
// List 반환 — 페이징만, 전체 수 불필요
List<Member> findByName(String name, Pageable pageable);
}
Page vs Slice
Page
Page<Member> page = memberRepository.findByAge(20, PageRequest.of(0, 10));
List<Member> content = page.getContent(); // 조회된 데이터
int totalPages = page.getTotalPages(); // 전체 페이지 수
long totalElements = page.getTotalElements(); // 전체 데이터 수
boolean hasNext = page.hasNext(); // 다음 페이지 존재 여부
int number = page.getNumber(); // 현재 페이지 번호
Page는 ** 총 개수를 알아야** 하므로 별도의 COUNT 쿼리가 자동으로 실행됩니다.
-- 실제 실행되는 쿼리 2개
SELECT * FROM member WHERE age = 20 ORDER BY id LIMIT 10 OFFSET 0;
SELECT COUNT(*) FROM member WHERE age = 20;
Slice
Slice<Member> slice = memberRepository.findByTeamName("teamA", PageRequest.of(0, 10));
List<Member> content = slice.getContent();
boolean hasNext = slice.hasNext(); // 다음 페이지 존재 여부
// slice.getTotalElements() — 사용 불가!
// slice.getTotalPages() — 사용 불가!
Slice는 요청한 크기 + 1개 를 조회하여 다음 페이지 존재 여부만 확인합니다. COUNT 쿼리가 없으므로 성능상 유리합니다.
-- 실제 실행되는 쿼리 1개 (11건 조회 → 다음 페이지 존재 여부 판단)
SELECT * FROM member WHERE team_name = 'teamA' ORDER BY id LIMIT 11 OFFSET 0;
언제 무엇을 사용할까
| 상황 | 추천 |
|---|---|
| 전통적인 페이지 번호 UI (1, 2, 3...) | Page |
| 모바일 무한 스크롤 | Slice |
| 전체 수가 필요 없는 단순 목록 | List + Pageable |
COUNT 쿼리 최적화
Page를 사용할 때 COUNT 쿼리가 성능 병목이 될 수 있습니다. 특히 조인이 포함된 쿼리에서 COUNT까지 조인하면 불필요한 비용이 발생합니다.
별도 COUNT 쿼리 지정
@Query(value = "SELECT m FROM Member m LEFT JOIN m.team t WHERE m.age > :age",
countQuery = "SELECT COUNT(m) FROM Member m WHERE m.age > :age")
Page<Member> findByAgeWithCount(@Param("age") int age, Pageable pageable);
countQuery를 별도로 지정하면 불필요한 조인 없이 COUNT만 효율적으로 실행할 수 있습니다.
PageableExecutionUtils
// QueryDSL 등에서 커스텀 페이징 시 사용
JPAQuery<Long> countQuery = queryFactory
.select(member.count())
.from(member)
.where(condition);
return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
이 유틸리티는 다음 경우에 **COUNT 쿼리를 실행하지 않습니다 **.
- 첫 페이지이면서 content 크기가 pageSize보다 작을 때 (전체 데이터가 한 페이지 이내)
- 마지막 페이지일 때 (offset + content 크기로 전체 수 계산 가능)
정렬 주의사항
엔티티 필드명 사용
// Controller에서 요청 파라미터로 정렬 받기
// GET /members?page=0&size=10&sort=age,desc&sort=name,asc
@GetMapping("/members")
public Page<MemberDto> list(Pageable pageable) {
return memberRepository.findAll(pageable).map(MemberDto::from);
}
복잡한 정렬은 직접 처리
Spring Data의 Sort는 엔티티 필드명 기반이므로, 함수나 CASE 표현식을 사용한 정렬은 지원하지 않습니다.
// 이런 정렬은 Sort로 불가능 → 직접 쿼리 작성
// ORDER BY CASE WHEN status = 'ACTIVE' THEN 0 ELSE 1 END
@Query("SELECT m FROM Member m ORDER BY " +
"CASE WHEN m.status = 'ACTIVE' THEN 0 ELSE 1 END")
List<Member> findAllOrderByStatus(Pageable pageable);
커서 기반 페이징 (무한 스크롤)
오프셋 기반 페이징은 뒤쪽 페이지로 갈수록 성능이 저하됩니다. OFFSET 100000이면 DB가 100,000건을 읽고 건너뛴 후 결과를 반환하기 때문입니다.
오프셋 기반의 문제
-- 100,001번째부터 10건 → 앞의 100,000건을 모두 스캔
SELECT * FROM member ORDER BY id LIMIT 10 OFFSET 100000;
커서 기반 해결
public List<MemberDto> findMembersAfter(Long lastId, int size) {
return queryFactory
.select(new QMemberDto(member.id, member.name, member.age))
.from(member)
.where(
member.id.gt(lastId) // 마지막 조회 ID 이후부터
)
.orderBy(member.id.asc())
.limit(size + 1) // 다음 페이지 존재 여부 확인용
.fetch();
}
// Controller
@GetMapping("/members")
public CursorResponse<MemberDto> list(
@RequestParam(required = false) Long lastId,
@RequestParam(defaultValue = "20") int size) {
List<MemberDto> result = memberQueryRepository
.findMembersAfter(lastId != null ? lastId : 0L, size);
boolean hasNext = result.size() > size;
if (hasNext) {
result = result.subList(0, size); // 마지막 1건 제거
}
Long nextCursor = hasNext ? result.get(result.size() - 1).getId() : null;
return new CursorResponse<>(result, nextCursor, hasNext);
}
커서 기반의 장단점
| 항목 | 오프셋 기반 | 커서 기반 |
|---|---|---|
| 성능 | 뒤쪽 페이지에서 저하 | 일정한 성능 |
| 페이지 번호 이동 | 가능 | 불가능 |
| 데이터 추가/삭제 시 | 중복/누락 가능 | 안전 |
| 구현 복잡도 | 단순 | 상대적으로 복잡 |
주의할 점
Page 사용 시 COUNT 쿼리가 성능 병목이 될 수 있다
Page는 전체 개수를 알기 위해 별도의 COUNT 쿼리 를 실행합니다. 조인이 포함된 쿼리에서 COUNT까지 조인하면 불필요한 비용이 발생합니다. countQuery를 별도로 지정하거나, 전체 수가 필요 없으면 Slice를 사용해야 합니다.
OFFSET이 큰 페이징은 성능이 급격히 저하된다
OFFSET 100000은 DB가 100,000건을 읽고 건너뛴 후 결과를 반환하기 때문에, 뒤쪽 페이지일수록 성능이 나빠집니다. 대량 데이터의 무한 스크롤에는 **커서 기반 페이징 **(WHERE id > :lastId)이 훨씬 유리합니다.
Spring Data Sort는 함수 기반 정렬을 지원하지 않는다
Sort는 엔티티 필드명 기반이므로 CASE WHEN이나 DB 함수를 사용한 정렬은 표현할 수 없습니다. 이런 경우 @Query로 직접 작성해야 합니다.
정리
| 항목 | 설명 |
|---|---|
| Page | COUNT 쿼리 포함, 전체 페이지 정보 제공 |
| Slice | COUNT 없이 limit+1로 다음 페이지 여부만 확인 |
| COUNT 최적화 | countQuery 분리, PageableExecutionUtils 활용 |
| 커서 기반 | WHERE id > :lastId로 일정한 성능 유지 |
| 페이지 번호 | Spring Data는 0부터 시작 |