리액트 / 스프링 데이터 JPA 환경에서 커서를 통한 페이지네이션(Pagination) 구현하기

kindof

·

2022. 5. 18. 20:01

1. 페이지네이션(Pagination)

Pagination, also known as paging, is the process of dividing a document into discrete pages, either electronic pages or printed pages.

 

페이지네이션은 서버에서 데이터를 가져올 때, 전체 데이터를 클라이언트에게 보여지는 단위 혹은 지정된 개수만큼 잘라서 가져오는 방법을 말합니다.

 

요즘 대부분의 웹/앱 어플리케이션은 무한스크롤을 기반으로 한 페이지네이션을 많이 쓰고 있는데요. 각종 커뮤니티(에브리타임, 블라인드 등)와 SNS(페이스북, 인스타그램)들이 대표적인 예시입니다.

 

페이지네이션을 사용하는 이유는 명백합니다. 전체 데이터를 한꺼번에 가져오는 오버헤드 대신에 지금 당장 필요한 데이터만을 가져옴으로써 데이터 조회에 따른 부담을 더는 것이죠.

 

이번 포스팅에서는 리액트와 스프링 데이터 JPA 환경에서 무한스크롤 + 커서를 통한 페이지네이션 구현에 대해 글을 쓰려고 합니다. 먼저, 아래에서 오프셋(Offset) 기반 페이지네이션과 커서(Cursor) 기반 페이지네이션의 차이에 대해 간단히 살펴보고 가겠습니다.

 

 

2. 오프셋(Offset) 기반 페이지네이션

오프셋 기반 페이지네이션은 많은 사람들이 사용하고 있는 기본적인 페이징 방법입니다. 

 

전체 데이터를 정렬한 후, OFFSET으로 건너 뛸 정도를 정하고, LIMIT 단위로 조회하고 싶은 사이즈만큼을 떼어오는 방식이죠.  예를 들어, 아래와 같은 쿼리를 실행하게 되면 Product의 40번째 데이터부터 49번째 데이터를 가져올 수 있습니다.

SELECT * FROM PRODUCT ORDER BY id DESC LIMIT 10 OFFSET 40

 

스프링 데이터 JPA 환경에서는 org.springframework.data.domain.Pageable 인터페이스를 통해 아래와 같이 페이징을 구현할 수 있습니다.

public interface ProductRepository extends JpaRepository<Product, Long> {
  List<Product> findByCategory(Category category, Pageable pageable);
}


@Controller
public class ProductController {
  @GetMapping("/products/category/{category}")
  public List<Product> findProductByCategory(@RequestParam String category, Pageable pageable) {
    return productRepository.findByCategory(Category.valueOf(category));
  }    
}

Enum 타입에 대한 조회나 DTO로 반환하는 등의 작업은 생략하겠습니다. 핵심은 Pageable 인터페이스를 통해 클라이언트에서 넘어오는 Offset, Limit값을 컨트롤할 수 있고, 이를 스프링 데이터 JPA에서 기본으로 제공하는 Named Query로 처리할 수 있다는 것입니다(QueryDsl이나 다른 방식으로 구현할 수 있습니다).

 

그런데, 오프셋 기반 페이징에는 문제점이 존재합니다.

 

 

2-1. 데이터 요청 사이 데이터의 변화

많은 어플리케이션에서 흔히 일어나는 문제점입니다. 먼저, 전체 게시물(데이터)의 ID가 1, 2, 3, ..., 100과 같이 존재하고 ID 값이 클수록 최신의 데이터라고 해보겠습니다.

  • 사용자가 게시물을 읽으려고 메인 화면에 진입했습니다.
  • 페이징을 통해 첫번째 게시물부터 20번째 게시물까지 가져왔습니다(Id = 81~100).
  • 이 사이에 다른 유저들이 3개의 게시물을 업로드했습니다. 이 게시물들의 ID = 101, 102, 103입니다.
  • 스크롤을 아래로 내려서 다음 20개의 게시물을 보고 싶습니다.

이 상황에서 Offset은 20이고 Limit 값은 20일텐데요. ID 값을 내림차순으로 정렬해서 20번째부터 20개를 가져오게 되면 Id = 61~80 데이터가 아닌 Id = 64 ~ 83 데이터를 불러오게 되고, 결국 이전에 봤던 게시물이 다시 맨위에 뜨게 됩니다.

 

2-2. Order By와 Offset

오프셋 기반 페이징은 기본적으로 Order by를 통해 데이터를 정렬해두고 Offset만큼 데이터를 건너뛴 후, Limit 값만큼 데이터를 읽어옵니다. 결국 Offset = N이라면, O(N) 만큼의 시간이 소요되게 됩니다. 유저가 갑자기 맨 마지막 페이지로 접근하려고 한다면, 전체 데이터를 쑥 훑어야하기 때문에 쿼리의 성능이 좋지 않겠죠.

Offset 성능 이슈

 


 

사실 두 번째 문제는 우리가 구현하는 무한 스크롤 방식에서는 크게 문제가 되지 않으리라 생각합니다. 그럼에도 불구하고, 첫번째 문제는 사용자 UI에 있어서 나름 치명적이라고 생각하기에 오프셋 기반 페이징은 완벽하다고 할 수 없는 것 같습니다.

 

 

3. 커서(Cursor) 기반 페이지네이션

커서 기반 페이징은 Offset만큼 데이터를 건너뛰어서 읽어오는 대신에, 특정(현재) 지점을 기준으로 다음 데이터를 가져옵니다. 다시 말해, 아래와 같이 WHERE 절을 통해 '40'이라는 특정 지점을 커서로 포인팅하고 거기서부터 10개의 데이터를 가져오는 것이죠.

SELECT * FROM PRODUCT WHERE id < 40 ORDER BY id DESC LIMIT 10

이처럼 오프셋을 사용하지 않는다면, 단순히 Where 절에 대한 인덱싱(Indexing)을 통해 원하는 지점을 빠르게 찾아 데이터를 가져올 수 있는 것이죠. 그리고 대부분 엔티티에서 Id는 기본 인덱스로 지정되기 때문에 위와 같은 쿼리는 빠른 성능을 보장하게 됩니다.

 

한편, 커서 기반 페이징을 위한 정렬 기준 컬럼에는 적어도 하나의 고유값(Uniqueness)이 존재해야 합니다. 저같은 경우는 대부분 엔티티의 ID를 해당값으로 설정하는데요. 만약 이런 고유한 값을 가지는 컬럼없이 중복된 값을 가질 수 있는 컬럼을 정렬의 기준으로 세워버리게 되면, 오프셋 기반 페이징과 마찬가지로 데이터의 누락이 생길 수 있습니다.

 

아래 예시를 보겠습니다.

SELECT * 
    FROM PRODUCT
    ORDER BY price DESC
    LIMIT 10

위 쿼리를 실행하게 되면 맨 처음에 가격이 비싼 상위 10개 데이터를 가져오게 됩니다. 그리고 이 때 마지막 데이터의 가격이 10,000원이었다고 하겠습니다.

 

그러면 그 다음 10개의 데이터를 가져올 때는 아래와 같이 쿼리를 작성할 수 있습니다.

SELECT * 
    FROM PRODUCT
    WHERE price < 10000       // 커서 기준
    ORDER BY price DESC
    LIMIT 10

하지만 위 쿼리는 맨 처음 10개 데이터에 들지 못한 10,000원짜리 아이템들을 날려버리는 문제를 갖게 됩니다.

 

그래서 위에서 말씀드린 것처럼, ID와 같이 고유값을 가지는 컬럼을 정렬 기준에 포함시킴으로써 데이터의 누락을 방지해야 하는데요. 위 쿼리를 아래와 같이 바꾸겠습니다.

SELECT * 
    FROM PRODUCT
    WHERE 
    	(price < 10000 OR (price = 10000 AND id < 마지막 데이터 ID)) 
    ORDER BY price DESC, id ASC
    LIMIT 10

그러면 다음 데이터를 가져올 때 처음에 미처 가지오지 못한 10,000짜리 제품들을 가져올 수 있게 되죠.

 

4. 구현

이제 스프링 데이터 JPA와 리액트 환경에서 무한스크롤 + 커서 기반 페이징을 간단하게 구현해보겠습니다.

 

[ProductController.java]

@RestController
@RequiredArgsConstructor
@RequestMapping("/products")
@ControllerAdvice
public class ProductController {
    private final ProductService productService;
    private static final int PAGE_DEFAULT_SIZE = 10;

    @GetMapping("")
    public ResponseEntity<Message> productList(Long cursor) {
        List<ProductDTO.DetailResponseDTO> productDetailResponseDtoList = productService.findProductsByPage(cursor, PageRequest.of(0, PAGE_DEFAULT_SIZE));
        return new ResponseEntity<>(Message.builder()
                .status(StatusEnum.OK)
                .message("게시물 조회 결과입니다.")
                .data(productDetailResponseDtoList)
                .build(), HttpStatus.OK);
    }
    
    ...
}

productList()는 cursor를 파라미터로 받은 뒤 Service에 로직을 위임하여 데이터를 10개씩 조회한 뒤 결과를 리턴하는 역할을 합니다. 위에서 사용하는 DetailResponseDTO는 Product 필드들을 담아서 전달해주는 클래스이며, 사람마다 다르게 설계하기 때문에 따로 코드를 쓰진 않겠습니다.

 

[ProductService.java]

@Service
@RequiredArgsConstructor
public class ProductService {
    private final ProductRepository productRepository;
    
	...

    // 모든 게시물 조회 - 페이징 처리
    @Transactional(readOnly = true)
    public List<ProductDTO.DetailResponseDTO> findProductsByPage(Long cursor, Pageable pageable){
        return getProductList(cursor, pageable)
                .stream().map(ProductDTO.DetailResponseDTO::new).collect(Collectors.toList());
    }
    
    ...
    // 페이징 처리를 위한 메서드
    private List<Product> getProductList(Long id, Pageable page) {
        return id.equals(0L)
                ? productRepository.findByOrderByIdDescWithList(page)
                : productRepository.findByIdLessThanOrderByIdDesc(id, page);
    }
}

Service에서 findProductsByPage() 함수는 컨트롤러에서 cursor값과 Pageable 인터페이스를 통해 Limit 값을 받아옵니다. 

 

그리고 getProductList() 메서드를 호출하게 되는데, 이 메서드는 커서가 맨 처음 생성될 때(Id = 0L)와 그 이후 커서가 갱신될 때를 다르게 처리해주는 역할을 합니다.

 

커서가 맨 처음 생성될 때는 그냥 정렬된 데이터를 Pageable page에서 요구한만큼 잘라오면 되고, 커서가 생성된 후에는 해당 커서 지점을 Where절에 포함시켜 잘라와야 하기 때문이죠.

 

[ProductRepository.java]

public interface ProductRepository extends JpaRepository<Product, Long> {
    List<Product> findByMemberId(Long memberId);

    List<Product> findByProductCategory(ProductCategory category);

    @Query("select p from Product p order by p.id desc")
    List<Product> findByOrderByIdDescWithList(Pageable pageable);

    List<Product> findByIdLessThanOrderByIdDesc(Long id, Pageable pageable);
}

레포지토리는 스프링 데이터 JPA를 기반으로 작성합니다. 위와 같이 By~, LessThan~, findBy[필드명]과 같이 메서드명을 작성해주면, 스프링 데이터 JPA는 이를 자동으로 파싱하여 해당하는 쿼리를 작성해주게 됩니다.

 

한편, findByOrderByIdDescWithList() 메서드는 정적인 Named Query로 작성해주었는데 이런 부분은 아마 사용자 정의 레포지토리로 따로 빼거나 하는 방식이 더 나을 것 같긴합니다. 여기서는 설명을 위해 디테일은 생략하겠습니다.

 

 


이제 리액트 코드를 보겠습니다. 제가 프로젝트 때문에 리액트도 공부하고 있지만, 초보자라서 코드는 조금 어설플 수 있습니다...🧙

 

[ProductMain.js]

const ProductMain = () => {
  const [cursor, setCursor] = useState(0);
  const [loading, setLoading] = useState(true);
  const [products, setProducts] = useState([]);

  useEffect(() => {
    fetchGet(`http://localhost:8080/products?cursor=${cursor}`)
      .then((res) => res.json())
      .then((res) => {
        setProducts((prev) => [...prev, ...res.data]);
        setLoading(false);
      });
  }, [cursor]);

  return loading ? null : (
    <>
      <Header />
      <MainContainer>
        <MainHeader/>
        <ItemContainer>
          <ProductList products={products} />
          <FetchMore items={products} setCursor={setCursor} />
        </ItemContainer>
      </MainContainer>
    </>
  );
};
export default ProductMain;

ProductMain 컴포넌트는 전체 제품을 볼 수 있는 메인 화면에 대한 컴포넌트입니다. 맨 처음에 커서값은 0으로 설정되어 있습니다. 그래서 useEffect() 동작에 따라 서버(컨트롤러)에 GET 요청을 보내게 되고, 위에서 작성한 서버 코드 로직을 따라 데이터를 반환받습니다. 반환받은 데이터는 products 상태에 추가되고, 이 데이터들은 return() 안에 뿌려지게 됩니다.

 

한편, 위 컴포넌트의 return 안에 FetchMore 라는 컴포넌트가 존재합니다. 해당 컴포넌트를 통해 무한 스크롤과 커서값 조정이 이루어지는데요.

 

[FetchMore.js]

import React, { useRef, useEffect } from "react";
import styled, { css } from "styled-components";
export const MoreBox = styled.div`
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  display: block;
  margin-top: 10px;
  text-align: center;
  line-height: 28px;
  border-top: solid 1px #000;
  border-bottom: solid 1px #000;
  background-color: #ff9;
`;

const FetchMore = ({ items, setCursor }) => {
  const fetchMoreTrigger = useRef(null);
  const fetchMoreObserver = new IntersectionObserver(([{ isIntersecting }]) => {
    if (isIntersecting) setCursor((prev) => items.at(-1).id);
  });

  useEffect(() => {
    let observerRefValue = null;
    fetchMoreObserver.observe(fetchMoreTrigger.current);
    observerRefValue = fetchMoreTrigger.current;
    return () => {
      if (observerRefValue) fetchMoreObserver.unobserve(observerRefValue);
    };
  }, [items]);

  return <MoreBox ref={fetchMoreTrigger} />;
};

export default FetchMore;

ProductMain의 products 내용을 props로 받아오고, products의 변경을 감지하여 useEffect() 훅이 동작하게 됩니다. 그래서 products가 변화한다면 fetchMoreObserver가 MoreBox라는 녀석을 관찰하게 됩니다. IntersectionObserver에 대해 궁금하신 분은 아래 링크를 참고해주시면 좋을 것 같습니다.

 

https://developer.mozilla.org/ko/docs/Web/API/Intersection_Observer_API

 

Intersection Observer API - Web API | MDN

Intersection Observer API는 타겟 요소와 상위 요소 또는 최상위 document 의 viewport 사이의 intersection 내의 변화를 비동기적으로 관찰하는 방법입니다.Intersection Observer API는 타겟 요소와 상위 요소 또는

developer.mozilla.org

 

한편, <MoreBox />라는 컴포넌트는 useRef를 통해 관리되는데, 이 녀석은 화면에 뿌려지는 데이터의 맨 마지막에 붙게 되서 이 녀석이 관측되면 커서값이 지금까지 products 데이터의 마지막 ID로 상태 변경이 일어나게 됩니다.

 

약간 설명이 복잡할 수 있는데, 쉽게 말하면 데이터의 맨 마지막 부분을 감지해서 그 부분에 스크롤이 도착하면 맨 마지막 ID를 다시 서버에 커서로 보낸다는 뜻입니다. 그리고 더 이상 가져올 데이터가 없으면 products의 상태 변화가 일어나지 않기 때문에 Observer도 더 이상 동작하지 않는 것이죠. 그러면 커서값이 변하지 않기 때문에 ProductMain에서 useEffect() 훅도 더 이상 동작하지 않습니다!

 

 


 

이렇게 이번 포스팅에서는 오프셋 기반 페이징과 커서 기반 페이징의 컨셉에 대해 정리해보고, 무한 스크롤을 커서 기반 페이징으로 구현해보았습니다.

 

5. Reference

https://velog.io/@minsangk/%EC%BB%A4%EC%84%9C-%EA%B8%B0%EB%B0%98-%ED%8E%98%EC%9D%B4%EC%A7%80%EB%84%A4%EC%9D%B4%EC%85%98-Cursor-based-Pagination-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0

https://jojoldu.tistory.com/528

https://bbbicb.tistory.com/40

https://www.jiniaslog.co.kr/article/view?articleId=202