본문 바로가기
프로젝트 기록

[Next.js] 게시판 프로젝트에 무한 스크롤 적용하기 (+ React-Query, useInfiniteQuery) (feat. App Router ver.)

by dygreen 2024. 9. 11.

 

[ 게시판 프로젝트 ]
Stack : Next.js (14 App Router - Client / Serverless API 직접 구현), MongoDB
Info : 기록하고 싶은 글을 자유롭게 남길 수 있는 게시판 서비스

 

목차

  • React-Query 적용 이유
  • 무한 스크롤 적용 (useInfiniteQuery + react-intersection-observer)
  • 최종 구현 화면

 

📌 React-Query 적용 이유

게시판 프로젝트는 Next.js 14 App Router 를 사용하고 있다.

기존에는 일부 클릭 이벤트 같은 client 동작이 있는 컴포넌트를 제외하곤 server component 에서 동작하였다.

그러나 메인 페이지에 접속할 때마다 전체 게시글 데이터를 조회하는 것은 (데이터가 많아질 경우) 서버에 무리가 갈 수 있기 때문에

React-Query 를 활용해 무한 스크롤 기능을 적용하면 좋을 것 같았다.

 

 

📌 무한 스크롤 적용

무한 스크롤을 적용하기에 앞서, React-Query 는 클라이언트에서 동작하는 상태 관리 라이브러리이다.

따라서 React-Query 를 사용하려면 모든 컴포넌트를 server component → client component 로 바꿔주는 작업이 필요했다.

client component 를 사용하려면 컴포넌트 최상단에 'use client' 를 넣으면 된다.

 

먼저 React-Query 를 사용하기 위한 세팅이 필요하다.

QueryClientProviderapp 폴더 내부의 layout.tsx 에서 감싸주면 된다.

app/layout.tsx
'use client'

import '@src/app/globals.scss'
import { Inter } from 'next/font/google'
import Header from '@layout/Header/Header'
import Footer from '@layout/Footer/Footer'
import React, { useEffect, useState } from 'react'
import { QueryClientProvider, QueryClient } from '@tanstack/react-query'
import { SessionProvider } from 'next-auth/react'

export default function RootLayout({
    children,
}: {
    children: React.ReactNode
}) {
    const [queryClient] = useState(
        () =>
            new QueryClient({
                defaultOptions: {
                    queries: {
                        staleTime: Infinity,
                    },
                },
            }),
    )

    return (
        <html lang="en">
            <body className={inter.className}>
                <SessionProvider>
                    <Header />
                    <QueryClientProvider client={queryClient}>
                        <main>{children}</main>
                    </QueryClientProvider>
                    <Footer />
                </SessionProvider>
            </body>
        </html>
    )
}
  • queryClientstaleTime 은 게시판 프로젝트의 특성 상, 주기적인 리프레시가 필요없기 때문에 Infinity 로 설정하였다.

 

React-Query 에서 제공하는 useInfiniteQuery 를 통해서 무한 스크롤 기능을 구현할 수 있다.

페이징 단위는 데이터 10개로 설정했다.

app/page.tsx
'use client'

import { useInfiniteQuery } from '@tanstack/react-query'
import ArticleItem from '@components/board/ArticleItem'

export default function Home() {
    const handleGetArticle = async (page: number) => {
        try {
            const response = await fetch(`/api/board/get-article?page=${page}`)
            const data = await response.json()

            if (response.status === 200) {
                return data
            } else if (response.status === 500) {
                alert(data.message)
            }
        } catch (e) {
            console.error(e)
        }
    }

    const { data, fetchNextPage, isLoading } = useInfiniteQuery({
        queryKey: ['articles'],
        queryFn: ({ pageParam = 1 }) => handleGetArticle(pageParam),
        initialPageParam: 1,
        getNextPageParam: (lastPage, allPages) => {
            return lastPage.length === 10 ? allPages.length + 1 : undefined
        },
    })

    return (
        <ArticleItem articles={data?.pages.flat() || []} />
    )
}

useInfiniteQuery 내부 코드

  • getNextPageParam
    • lastPage : 가장 마지막으로 불러온 페이지의 데이터 의미. queryFn 에서 반환된 데이터를 나타냄.
    • allPages : 지금까지 불러온 모든 페이지들의 배열. 예를 들어, allPages[0] 은 첫 번째 페이지, allPages[1] 은 두 번째 페이지 데이터를 포함함. (따라서, 배열의 길이를 사용해 현재까지 몇 개의 페이지가 불러와졌는지 알 수 있다)
    • lastPage.length === 10 : 현재 페이지(= lastPage) 의 데이터가 10개인 경우에만 다음 페이지를 불러오도록 설정한 것. 만약 데이터가 10개보다 적다면 더 이상 불러올 데이터가 없다고 간주하고 undefined 를 반환하여 다음 페이지를 불러오지 않게 된다.
    • allPages.length + 1 : 다음에 불러올 페이지 번호를 나타냄. allPages.length 는 현재까지 불러온 페이지 수를 의미함.

 

 

문제는 사용자의 스크롤이 페이지의 가장 마지막 부분에 도달했는지를 체크하는 것이다.

이 부분을 체크하기 위해서 react-intersection-observer 라이브러리를 사용하고자 한다.

직접 코드를 작성하려고 했는데 client component 에서 window 를 인식하지 못하는 이슈가 있는 것 같아서 안정화되기까지 라이브러리로 우선 구현했다.

 

라이브러리까지 적용한 상태의 코드

app/page.tsx
'use client'

import React, { useEffect } from 'react'
import { useInfiniteQuery } from '@tanstack/react-query'
import ArticleItem from '@components/board/ArticleItem'
import { useInView } from 'react-intersection-observer'
import Loading from '@src/app/loading'

export default function Home() {
    const [ref, inView] = useInView()

    const handleGetArticle = async (page: number) => {
        try {
            const response = await fetch(`/api/board/get-article?page=${page}`)
            const data = await response.json()

            if (response.status === 200) {
                return data
            } else if (response.status === 500) {
                alert(data.message)
            }
        } catch (e) {
            console.error(e)
        }
    }

    const { data, fetchNextPage, isLoading } = useInfiniteQuery({
        queryKey: ['articles'],
        queryFn: ({ pageParam = 1 }) => handleGetArticle(pageParam),
        initialPageParam: 1,
        getNextPageParam: (lastPage, allPages) => {
            return lastPage.length === 10 ? allPages.length + 1 : undefined
        },
    })

    useEffect(() => {
        if (inView) fetchNextPage()
    }, [inView])

    if (isLoading) return <Loading />
    return (
        <>
            <ArticleItem articles={data?.pages.flat() || []} />
            <div ref={ref}></div>
        </>
    )
}
  • ref : ref 를 통해 해당 객체가 화면에 보이면 특정 코드를 실행시킬 수 있다.
    • 해당 객체가 화면에 보이면(inView) 다음 페이지를 호출하는 코드를 넣었다.

app/api/board/get-article/route.ts
import { connectDB } from '@util/database'
import { NextResponse } from 'next/server'
import { ArticleItemFlag } from '@util/interface'
import { NextRequest } from 'next/server'

export async function GET(req: NextRequest) {
    try {
        const db = (await connectDB).db('board')

        const { searchParams } = new URL(req.url)
        const page = parseInt(searchParams.get('page') || '1', 10)
        const limit = 10

        const response = await db
            .collection<ArticleItemFlag>('article')
            .find()
            .sort({ isBookmarked: -1, regDate: -1 })
            .skip((page - 1) * limit)
            .limit(limit)
            .toArray()

        return NextResponse.json(response)
    } catch (e) {
        return NextResponse.json(
            { message: '게시글을 가져오는 중 오류가 발생했습니다.' },
            { status: 500 },
        )
    }
}

MongoDB method

  • limit : 한 번에 가져올 문서의 수를 제한함.
  • skip : 특정 페이지에 해당하는 문서들을 가져오기 위해 몇 개의 문서를 건너뛸지 계산함. limit 으로 리소스를 분할해서 가져오도록 설정을 하고, 그 다음에 후속 조치를 취하지 않으면 중복된 데이터를 계속 가져오게 된다. 이를 방지하기 위해 사용함.

 

최종 구현 화면

 

 

728x90

댓글