많은 데이터를 부분적으로 나누어 여러 페이지에 나누어 표시해 사용자에게 보여주는 것을 pagination이라고 합니다. 이번 시간에는 SpringBoot에서 pagingnation을 사용하는 법을 알아보겠습니다.
Offset Pagination은 Offset부터 offset+limit만큼 데이터를 불러오는 방식입니다.
SELECT * FROM posts
ORDER BY id
LIMIT 10 OFFSET 1000;
Offset+Limit만큼 데이터를 읽어오고 Offset만큼의 데이터는 버립니다.
하지만 이때 두가지 문제점이 생깁니다.
1 성능이 저하됩니다.
Offset을 사용하면 앞쪽 데이터를 무조건 읽고 버리는 방식이므로,
데이터가 많아질수록 조회 성능이 급격히 저하됩니다.
예를 들어
포스트가 1000000개 있고 Offset이 999990이고 limit가 10이라면
999990개의 데이터를 읽어야만 Pagination을 수행할 수 있습니다.
2 데이터가 변경될 경우 잘못된 데이터를 가져올 수 있습니다.
OFFSET을 사용하면 특정 위치에서 데이터를 잘라서 가져오기 때문에 중간에 새로운 데이터가 삽입되거나 삭제되면, 데이터가 밀리거나 당겨져 잘못된 데이터를 가져올 수 있습니다.
이러한 두가지 문제점을 해결하기 위해 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();
}
}
테스트 결과
