[JPA] Pagination 구현해보기

dnjstjt12·2025년 3월 5일

Pagination

많은 데이터를 부분적으로 나누어 여러 페이지에 나누어 표시해 사용자에게 보여주는 것을 pagination이라고 합니다. 이번 시간에는 SpringBoot에서 pagingnation을 사용하는 법을 알아보겠습니다.

Offset Pagination

Offset Pagination은 Offset부터 offset+limit만큼 데이터를 불러오는 방식입니다.

SELECT * FROM posts
ORDER BY id
LIMIT 10 OFFSET 1000;

Offset+Limit만큼 데이터를 읽어오고 Offset만큼의 데이터는 버립니다.
하지만 이때 두가지 문제점이 생깁니다.

Offset Pagination의 문제점

1 성능이 저하됩니다.
Offset을 사용하면 앞쪽 데이터를 무조건 읽고 버리는 방식이므로,
데이터가 많아질수록 조회 성능이 급격히 저하됩니다.

예를 들어
포스트가 1000000개 있고 Offset이 999990이고 limit가 10이라면
999990개의 데이터를 읽어야만 Pagination을 수행할 수 있습니다.

2 데이터가 변경될 경우 잘못된 데이터를 가져올 수 있습니다.

OFFSET을 사용하면 특정 위치에서 데이터를 잘라서 가져오기 때문에 중간에 새로운 데이터가 삽입되거나 삭제되면, 데이터가 밀리거나 당겨져 잘못된 데이터를 가져올 수 있습니다.

이러한 두가지 문제점을 해결하기 위해 No-offset pagination을 사용할 수 있습니다.

No-Offset Pagination

No-offset pagingation은 Offset을 사용하지 않고, 이전 페이지의 마지막 ID를 기준으로 다음 데이터를 조회합니다. 즉, Offset을 사용하지 않고, WHERE 조건을 활용하여 페이징을 구현하는 방식입니다.

이 방식은 id(또는 정렬 기준이 되는 컬럼) 기반 페이징을 활용하며, 이전 페이지의 마지막 id 값을 활용하여 다음 페이지의 데이터를 가져오는 방식입니다.

SELECT * FROM users
WHERE id > last_id  -- 이전 페이지에서 마지막으로 가져온 ID
ORDER BY id
LIMIT 10;

Offset을 사용하면 N개의 데이터를 먼저 읽고 버려야 하지만,
No Offset 방식은 이전 페이지의 마지막 ID 이후 데이터만 가져오므로 성능이 좋아집니다.

기존 Offset 방식은 중간에 데이터가 추가/삭제되면 결과가 밀려 중복되거나 누락될 가능성이 있습니다. 그러나 No Offset 방식은 WHERE id > last_id를 사용하므로 정확한 다음 페이지 데이터를 가져옵니다.

구현하기

다음은 JPA와 Querydsl을 통해 no-offset pagination을 구현한 코드입니다.


@Repository
@RequiredArgsConstructor
public class PostRepositoryImpl implements PostRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public Slice<Post> searchPostPages(Long lastPostId, Pageable pageable) {
        BooleanExpression whereCondition = (lastPostId != null) ? post.id.lt(lastPostId) : null;

        List<Post> results = queryFactory
                .select(post)
                .from(post)
                .where(whereCondition)
                .orderBy(post.id.desc())
                .limit(pageable.getPageSize() + 1)
                .fetch();

        boolean hasNext = false;
        if (results.size() > pageable.getPageSize()) {
            results.remove(pageable.getPageSize());
            hasNext = true;
        }

        return new SliceImpl<>(results, pageable, hasNext);
    }
}

Pageable은 Spring Data JPA에서 page를 쉽게 구현하게 해주는 인터페이스입니다.
사용법은 다음과 같습니다.
Pageable pageable = PageRequest.of(0, 10); // 0번째 페이지, 10개씩 가져오기

Spring Data JPA에서 페이지를 처리하고 데이터를 담는 방법은 Slice와 Page두가지가 있습니다.

Slice< T > vs Page< T > 차이점
둘 다 Pageable을 이용하여 페이징 처리를 하지만, 데이터의 개수와 다음 페이지 여부를 다루는 방식에서 차이가 있습니다.

  • Page< T >는 현재 페이지의 데이터 + 전체 데이터 개수(total count)를 함께 반환합니다.

  • Slice< T >는 전체 데이터 개수(total count)를 조회하지 않고, 현재 페이지 데이터만 가져오면서 다음 페이지 존재 여부를 확인합니다.

Page< T >가 적합한 경우
페이지네이션 UI에서 전체 페이지 수 또는 총 데이터 개수를 보여줘야 할 때
예) 검색 결과 페이지(1, 2, 3... 버튼이 있는 경우)
Slice< T >가 적합한 경우
무한 스크롤 방식의 API (다음 페이지가 있는지 여부만 중요할 때)
예) SNS 피드, 뉴스 피드, 게시판 목록 (스크롤 다운 시 자동 로딩)

@DataJpaTest
@Transactional
public class PostRepositoryTest extends JpaConfig {

    @Autowired
    PostRepository postRepository;
    @Autowired
    UserRepository userRepository;

    @BeforeEach
    void setUp() {
        List<Post> posts = new ArrayList<>();

        for (int i = 1; i <= 60; i++) {
        	posts.add(PostFixture.createPost(String.valueOf(i), users.get(i-1)));
        }
        postRepository.saveAll(posts);
    }

    @Test
    @DisplayName("게시글을 목록을 정상적으로 조회할 수 있다.")
    public void testSearchPostPages() {
        Long lastPostId = 60L;
        Pageable pageable = PageRequest.of(60, 10);
        Slice<Post> result = postRepository.searchPostPages(lastPostId, pageable);

        assertThat(result).isNotNull();
        assertThat(result.getContent()).hasSize(10);
        assertThat(result.hasNext()).isTrue();

    }
}

테스트 결과

profile
안녕하세요!

0개의 댓글