필자는 Next.js와 Spring Boot를 이용해 서비스를 개발하고 있다.
이번에 무한 스크롤 기능이 필요했는데, No-Offset 방식의 레퍼런스가 많이 없어 애를 먹었다.
시행착오 끝에 잘 작동하여 이를 정리해보고자 한다.
근데 No-Offset이 뭐지?
No-Offset 페이지네이션은 Offset을 사용하지 않고 페이지네이션을 진행한다는 말이다.
Offset은 게임 개발할 때 많이 쓰이는 단어이기도 하다.
일반적으로 Offset을 사용하는 페이지네이션은 Offset(어디부터) limit(몇개의) 데이터를 불러올지 결정한다.
즉, 네이버 카페 같은 곳에서 볼 수 있는 위와 같은 번호들이 페이지 넘버이고 이 페이지 넘버가 Offset이라고 생각하면 된다.
No-Offset은 이러한 Offset을 사용하지 않고 페이지네이션 하는 방식을 말한다.
그럼 왜 굳이 No-Offset 방식을 사용하나?
귀찮게 왜 No-Offset 방식을 사용하나 싶을 수 있다.
이는 여러 다른 이유도 있을 수 있겠지만 성능차이 때문이다.
기존의 Offset 방식은 불러오려는 페이지까지 모두 탐색하고 필요한 데이터를 불러오기에 페이지 수가 늘어날수록 성능이 저하된다. (Full Scan 방식)
하지만 No-Offset 방식은 마지막 조회 결과의 ID를 활용하기에 이전 페이지 전체를 건너 뛸 수 있다. 따라서 성능이 향상된다.
물론 이는 인덱스를 이용한 쿼리 튜닝이 되어있을 때의 이야기이다. 조회 쿼리의 인덱스 사용이 안되고 있다면 먼저 진행하여야 한다.
이제 대충 개념을 알아보았으니 직접 구현해보겠다.
구현은 백엔드와 프론트 모두 소개할 것이다.
필요한 부분만 발췌해 활용하길 바란다.
Backend (Spring Boot (Java))
먼저 백 부분이다.
Spring Data JPA를 사용하고 있다.
PostsRepository.java
public interface PostsRepository extends JpaRepository<Posts, Long> {
@Query(value = "SELECT p FROM Posts p WHERE p.postId < ?1 ORDER BY p.postId DESC")
Page<Posts> findByPostIdLessThanOrderByPostIdDesc(Long lastPostId, PageRequest pageRequest);
}
Query DSL이나 명명규칙을 활용해도 된다.
필자는 그냥 @Query까지 작성해주었다.
명명규칙은 아래 링크를 참조하면 된다.
Spring Data JPA - Reference Documentation
Example 119. Using @Transactional at query methods @Transactional(readOnly = true) interface UserRepository extends JpaRepository { List findByLastname(String lastname); @Modifying @Transactional @Query("delete from User u where u.active = false") void del
docs.spring.io
PostsService.java
public List<PostsResponseDto> fetchPostPagesBy(Long lastPostId, int size) {
PageRequest pageRequest = PageRequest.of(0, size); // page는 0으로 고정. 한번에 불러올 size를 명시하여 PageRequest 를 만듦
Page<Posts> entityPage = postsRepository.findByPostIdLessThanOrderByPostIdDesc(lastPostId, pageRequest); // 마지막에 불러왔던 postId와 pageRequest를 인자로 전달
List<Posts> entityList = entityPage.getContent();
return entityList.stream()
.map(PostsResponseDto::new)
.collect(Collectors.toList());
}
page를 항상 0으로 고정하는 이유는 No-Offset 방식이기 때문이다.
기존의 Offset 방식이 "2페이지의 데이터 20개 주세요" 와 같은 방식이었다면,
No-Offset 방식은 "id=100보다 작은 데이터 20개 주세요" 와 같은 방식이기에
기존에 0페이지, 1페이지, 2페이지... 로 가져오던 것을 lastId를 기준으로 가져오기에 항상 0번째 페이지를 조회하도록 하는 것이다.
PostsApiController.java
@GetMapping()
public List<PostsResponseDto> getPostsLowerThanId(@RequestParam Long lastPostId, @RequestParam int size) {
return postsService.fetchPostPagesBy(lastPostId, size);
}
이제 위와 같이 controller를 작성하여 API를 완성시킨다.
다음으로 프론트단의 구현을 보겠다.
Frontend (Next.JS + React-Query (Typescript))
프론트에서는 React-Query의 useInfiniteQuery 훅을 사용할 것이다.
우선 게시글을 무한 스크롤로 불러올 함수를 만들어줘야한다.
/** 게시글 무한 스크롤 불러오기 */
export const getPostInfoListInfinitely = async (lastPostId: number, size: number) => {
const res = await db.get<PostInfoType[]>(`/posts?lastPostId=${lastPostId}&size=${size}`)
const postList: PostInfoType[] = res.data
return { postList, nextLastPostId: postList[postList.length - 1]?.postId, isLast: postList.length < size }
}
axios 라이브러리를 사용하였다. db는 axios의 create 함수에 baseURL을 입력해 만들어준 객체다.
return에서는 useInfiniteQuery 훅에서 사용되는 값들을 반환해주었다.
먼저 반환된 값인 postList를 전달하고, 전달한 postList 중 가장 마지막 값의 id를 nextLastPostId 라는 이름으로 전달한다.
마지막으로 isLast는 postList 가 입력한 size 값보다 작은지를 확인해 참이라면 true를 반환하고 아니면 false를 반환한다.
(항상 size 값만큼 불러오게 되어있는데 size 값보다 작게 불러왔다는 건 마지막 값이 포함되어있다는 이야기이므로)
이제 위에서 작성한 함수를 이용해 무한 스크롤을 구현해보겠다.
function InfinitePostListSection() {
const { ref, inView } = useInView()
const { data: postInfoList, fetchNextPage, isFetchingNextPage } = useInfiniteQuery(
['infinitePostList'],
({ pageParam = 999999 }) => getPostInfoListInfinitely(pageParam, 20), // pageParam 초기 값에 최대한 큰 숫자 입력
{
getNextPageParam: (lastPage) =>
!lastPage.isLast ? lastPage.nextLastPostId : undefined
}
)
// 바닥에 닿으면 새로 불러오기
useEffect(() => {
if (inView) fetchNextPage()
}, [inView])
return (
<section>
{postInfoList?.pages.map((page, index) => (
<Fragment key={index}>
{page.postList.map((postInfo) =>
<PostPreview key={postInfo.postId} postInfo={postInfo} />
)}
</Fragment>
))}
{/* 무한 스크롤 옵저버 */}
{isFetchingNextPage ? <LoadingCircular /> : <div ref={ref}></div>}
</section>
)
}
export default InfinitePostListSection
위 예시 코드를 하나씩 살펴보겠다.
우선 react-intersection-observer 라이브러리가 필요하다. npm이나 yarn으로 설치해주자.
react-intersection-observer
Monitor if a component is inside the viewport, using IntersectionObserver API. Latest version: 9.4.3, last published: 24 days ago. Start using react-intersection-observer in your project by running `npm i react-intersection-observer`. There are 809 other p
www.npmjs.com
위 라이브러리는 사용자의 스크롤이 바닥에 닿았는지를 판단하기 위한 용도이다.
리스트 맨 밑에 무한 스크롤 옵저버를 div 태그에 달아 해당 div 태그가 보이면 useEffect 훅에서 fetchNextPage() 함수를 실행하여 다음 페이지를 불러온다.
위 코드에서 useInfiniteQuery 부분을 자세히 살펴보겠다.
const { data: postInfoList, fetchNextPage, isFetchingNextPage } = useInfiniteQuery(
['infinitePostList'],
({ pageParam = 999999 }) => getPostInfoListInfinitely(pageParam, 20), // pageParam 초기 값에 최대한 큰 숫자 입력
{
getNextPageParam: (lastPage) =>
!lastPage.isLast ? lastPage.nextLastPostId : undefined
}
)
앞서 개념 설명했던 것처럼 No-Offset 방식이기 때문에 이전에 불러온 리스트의 마지막 값의 id가 중요하다.
처음 불러올 때는 마지막 값의 id가 없으므로 최대한 큰 수를 적어준다. 그럼 최초 실행에는 적힌 999999보다 작은 id 의 게시글들을 20개 가져오게된다.
getNextPageParam 옵션은 다음 페이지를 불러올 때 pageParam에 들어갈 인자를 전달해준다.
getNextPageParam의 lastPage는 이전 반환 값을 받는다. 우리는 앞서 구현한 getPostInfoListInfinitely 함수에서
{ postList, nextLastPostId: postList[postList.length - 1]?.postId, isLast: postList.length < size } 를
반환해줬으므로 체이닝을 통해 접근할 수 있다.
만약 isLast가 true라면 undefined를 pageParam에 전달하고 아니라면 nextLastPostId 값을 전달하는 방식이다.
이를 통해 무한 스크롤 로직을 구현할 수 있다. (pageParam에 undefined가 전달되면 더 이상 불러오지 않는다.)
지금까지 No-Offset 방식의 무한 스크롤을 구현하는 방법을 알아보았다.
No-Offset 방식의 무한 스크롤은 모바일 유저들에게 좋은 사용자 경험을 제공해줄 수 있을 것이라 생각된다.
레퍼런스가 많이 없어 애를 먹었는데, 이 글이 많은 분들께 도움이 되었으면 좋겠다.
'Front-End > React-Query' 카테고리의 다른 글
[React-Query] useMutation 커스텀 훅 (react query useMutation Custom Hook Typescript) (0) | 2023.03.19 |
---|