본문 바로가기

Programming/Database

OFFSET 페이지네이션이 느려지는 이유: MariaDB 커서 방식과 Spring Data JPA 구현

추천캐릭터 2026. 7. 29. 13:00
728x90

OFFSET 페이지네이션이 느려지는 이유: MariaDB 커서 방식과 Spring Data JPA 구현

도입부

게시글이 적을 때는 LIMIT 20 OFFSET 0LIMIT 20 OFFSET 100000의 차이가 잘 보이지 않습니다. 하지만 데이터가 쌓이면 뒤 페이지로 갈수록 느려집니다. DB가 10만 번째 행으로 순간 이동하는 것이 아니라 앞의 행을 찾고 건너뛴 뒤 필요한 20건을 반환하기 때문입니다.

이 문제를 줄이는 대표적인 방법이 커서 기반 페이지네이션입니다. 마지막으로 본 데이터의 위치를 다음 요청에 전달하고 그다음부터 조회합니다. 무한 스크롤이나 피드처럼 “다음 목록”이 중요한 화면에 적합합니다.

OFFSET 페이지네이션이란?

OFFSET 방식은 페이지 번호를 시작 위치로 바꿉니다. 한 페이지가 20건이면 5001페이지의 OFFSET은 100000입니다.

SELECT id, title, created_at
FROM post
WHERE status = 'PUBLISHED'
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 100000;

구현이 쉽고 특정 페이지로 바로 이동할 수 있습니다. 반면 OFFSET이 커질수록 DB가 버려야 하는 행도 많아집니다. 정렬 조건에 맞는 인덱스가 없다면 정렬 비용까지 더해집니다.

데이터가 자주 바뀌는 목록에서는 중복과 누락도 생깁니다. 첫 페이지를 본 직후 새 글이 추가되면 기존 행의 위치가 밀려 다음 페이지에서 같은 항목을 다시 볼 수 있습니다. 중간 행이 삭제되면 아직 보지 못한 항목을 건너뛸 수도 있습니다.

커서 기반 페이지네이션의 원리

커서 방식은 “몇 행을 건너뛸지”가 아니라 “어느 값 다음부터 읽을지”를 전달합니다. 마지막 행의 생성 시각과 ID가 각각 2026-07-29 09:00:00, 1530이라면 다음 쿼리는 그보다 오래된 행만 찾습니다.

SELECT id, title, created_at
FROM post
WHERE status = 'PUBLISHED'
  AND (
      created_at < '2026-07-29 09:00:00'
      OR (created_at = '2026-07-29 09:00:00' AND id < 1530)
  )
ORDER BY created_at DESC, id DESC
LIMIT 21;

20건이 필요한데 21건을 조회하는 이유는 다음 데이터가 있는지 확인하기 위해서입니다. 21건이면 마지막 한 건을 제거하고 hasNext=true로 응답합니다.

created_at만 커서로 사용하면 같은 시각에 생성된 행의 순서를 확정할 수 없습니다. 반드시 고유한 id를 함께 사용해야 하며 정렬 조건과 커서 비교 방향도 일치해야 합니다.

MariaDB 인덱스 설계

필터와 정렬 순서에 맞는 복합 인덱스가 필요합니다.

CREATE INDEX idx_post_status_created_id
    ON post (status, created_at DESC, id DESC);

status를 동등 조건으로 좁힌 뒤 created_at, id 순서로 탐색하는 구조입니다. 실제 사용 여부는 EXPLAIN으로 확인해야 합니다. 복합 인덱스의 원리는 MariaDB 복합 인덱스로 느린 목록 조회 개선하기, 실행 계획 점검은 인덱스를 타지 않는 쿼리, 원인과 점검에서 자세히 볼 수 있습니다.

Spring Data JPA 구현

커서는 마지막 행의 정렬 값을 가집니다.

public record PostCursor(LocalDateTime createdAt, Long id) {
}

Repository는 마지막 행보다 뒤에 있는 데이터만 조회합니다.

public interface PostRepository extends JpaRepository<Post, Long> {

    @Query("""
        select p from Post p
        where p.status = :status
          and (p.createdAt < :createdAt
            or (p.createdAt = :createdAt and p.id < :id))
        order by p.createdAt desc, p.id desc
        """)
    List<Post> findNext(
            PostStatus status,
            LocalDateTime createdAt,
            Long id,
            Pageable pageable
    );
}

Service에서는 PageRequest.of(0, size + 1)로 한 건 더 조회합니다. 결과가 요청 크기보다 크면 마지막 한 건을 제거하고 hasNext=true로 응답합니다. 다음 커서는 반환 목록의 마지막 createdAtid로 만듭니다. 첫 요청은 커서가 없으므로 별도의 findFirst() 쿼리를 두는 방식이 읽기 쉽습니다.

Spring Data의 Slice도 전체 페이지 수 대신 다음 Slice 존재 여부를 표현합니다. 그러나 Slice를 반환한다고 쿼리가 자동으로 커서 방식이 되지는 않습니다. 페이지 번호를 증가시키면 여전히 OFFSET을 사용할 수 있으므로 마지막 키 조건을 SQL이나 JPQL에 직접 넣어야 합니다.

OFFSET과 커서 방식 비교

항목 OFFSET 커서
이동 기준 페이지 번호 마지막 정렬 키
깊은 페이지 느려질 수 있음 인덱스 범위 탐색에 유리
데이터 추가·삭제 중복·누락 가능 이미 본 위치 기준으로 안정적
임의 페이지 이동 가능 어려움
전체 페이지 수 제공하기 쉬움 보통 제공하지 않음
적합한 화면 관리자 목록 피드, 무한 스크롤

관리자 화면에는 OFFSET과 Page, 사용자 피드에는 커서와 hasNext를 사용하는 식으로 나눌 수 있습니다.

자주 하는 실수

  • created_at처럼 중복 가능한 값만 정렬 키로 사용합니다.
  • 정렬은 조회수 기준인데 ID 하나만 커서로 전달합니다.
  • 커서만 도입하고 쿼리에 맞는 복합 인덱스를 만들지 않습니다.
  • 요청 크기를 제한하지 않아 한 번에 지나치게 많은 행을 조회합니다.
  • 요청 크기와 커서 형식을 검증하지 않습니다.

재고처럼 같은 행을 동시에 수정한다면 낙관적 락 vs 비관적 락에서 다룬 동시성 제어가 필요합니다.

핵심 요약

  • OFFSET은 구현과 임의 이동이 쉽지만 깊은 페이지에서 버리는 행이 많아집니다.
  • 커서는 마지막 정렬 키 이후를 조회해 대용량 다음 페이지 탐색에 유리합니다.
  • 중복 가능한 정렬 값에는 ID 같은 고유 키를 함께 사용해야 합니다.
  • Slice와 커서 쿼리는 같은 개념이 아닙니다.
  • 도입 전후에는 EXPLAIN과 실제 데이터 규모의 부하 테스트가 필요합니다.

마무리

페이지 번호가 필요하면 OFFSET을 유지하고, 대용량 피드라면 고유한 정렬 키와 복합 인덱스를 갖춘 커서 방식을 적용합니다. 화면의 이동 방식과 데이터 변경 빈도에 맞춰 선택해야 합니다.

최종 확인: 2026-07-28

참고 자료: MariaDB LIMIT 공식 문서, Spring Data Slice API

728x90