DEVLOG

Next.js ํ”„๋กœ์ ํŠธ :: infinite scroll ๊ตฌํ˜„ํ•˜๊ธฐ ๋ณธ๋ฌธ

frontend/next.js

Next.js ํ”„๋กœ์ ํŠธ :: infinite scroll ๊ตฌํ˜„ํ•˜๊ธฐ

meroriiDev 2023. 7. 12. 09:59
728x90
๋ฐ˜์‘ํ˜•
๐Ÿ“–  ๋ชฉ์ฐจ

     

    apiํ˜•ํƒœ ํ™•์ธํ•˜๊ธฐ

    ํด๋ผ์ด์–ธํŠธ ๊ฐœ๋ฐœ์„ ํ•˜๊ธฐ์ „ apiํ˜•ํƒœ๋ฅผ ํ™•์ธํ•ด์•ผํ•œ๋‹ค. api๊นŒ์ง€ ์ง์ ‘ ๋งŒ๋“ค๋ฉด ์ข‹์ง€๋งŒ,, ์ผ๋‹จ ํ”„๋ก ํŠธ์—”๋“œ ๊ฐœ๋ฐœ์— ์ง‘์ค‘ํ•˜๋ ค๊ณ  ์˜คํ”ˆAPI๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ ๋•Œ๋ฌธ์— api๊ฐ€ ์–ด๋–ป๊ฒŒ ๋“ค์–ด์˜ค๋Š”์ง€ ํ™•์ธํ•ด๋ณด์ž!

    pagination์œผ๋กœ ๊ฐœ๋ฐœ์„ ํ•˜๋“  infinite scroll๋กœ ๊ฐœ๋ฐœ์„ ํ•˜๋“  ๋“ค์–ด์˜ค๋Š” api์—์„œ return๊ฐ’์œผ๋กœ ๋ฐ›์•„์•ผ ํ•˜๋Š” ๊ฐ’์ด ์žˆ๋‹ค.

    • ํ˜„์žฌ ํŽ˜์ด์ง€์™€ ์ „์ฒด ํŽ˜์ด์ง€์˜ ๊ฐœ์ˆ˜
    • ๋‹ค์Œ ํŽ˜์ด์ง€๊ฐ€ ์กด์žฌํ•˜๋Š”์ง€์— ๋Œ€ํ•œ ์—ฌ๋ถ€

    ๋‘๊ฐ€์ง€ ์ค‘ ํ•œ๊ฐ€์ง€๊ฐ€ ๊ฐ’์œผ๋กœ ๋“ค์–ด์™€์•ผํ•œ๋‹ค. ์ „์ฒด ๋ฐ์ดํ„ฐ๋ฅผ ์ „๋ถ€ ๋ถˆ๋Ÿฌ์™€์„œ ํด๋ผ์ด์–ธํŠธ์—์„œ ์ชผ๊ฐœ๋Š”๊ฒŒ ์•„๋‹ˆ๋ผ ํ•„์š”ํ•œ ๋งŒํผ๋งŒ ๋ฐ์ดํ„ฐ๋ฅผ ํ˜ธ์ถœํ•ด์•ผํ•˜๊ธฐ ๋•Œ๋ฌธ!

    ๋‚ด๊ฐ€ ์‚ฌ์šฉํ•˜๋Š” TMDB api์—์„œ๋Š” ํ˜„์žฌ ํŽ˜์ด์ง€์™€ ์ „์ฒด ํŽ˜์ด์ง€์ˆ˜๋ฅผ ์•Œ ์ˆ˜ ์žˆ์—ˆ๋‹ค.

    ๐ŸŽˆ useInfiniteQuery

    useInfiniteQuery๋กœ ๋ฐ์ดํ„ฐ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ

    react-query๋ฅผ ์‚ฌ์šฉํ•˜๊ฒŒ ๋œ ์žฅ์  ์ค‘ ํ•˜๋‚˜์ธ ๋ฌดํ•œ์Šคํฌ๋กค ๊ตฌํ˜„์ด ๊ฐ€๋Šฅํ•œ useInfiniteQuery๋ฅผ ์‚ฌ์šฉํ•ด์„œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ๋‚ด๋ณด์•˜๋‹ค.
    useInfiniteQuery๋Š” ํŒŒ๋ผ๋ฏธํ„ฐ ๊ฐ’๋งŒ ๋ณ€๊ฒฝํ•˜์—ฌ ๋™์ผํ•œ useQuery๋ฅผ ๋ฌดํ•œ์ • ํ˜ธ์ถœํ•  ๋•Œ ์‚ฌ์šฉํ•˜๋ฉฐ ์‚ฌ์šฉ๋ฒ•์€ useQuery์™€ ๋น„์Šทํ•˜๋‹ค.

    useInfiniteQuery์—์„œ ์‚ฌ์šฉ๋˜๋Š” pageParam์€ useInfiniteQuery๊ฐ€ ํ˜„์žฌ ์–ด๋–ค ํŽ˜์ด์ง€์— ์žˆ๋Š”์ง€๋ฅผ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋Š” ํŒŒ๋ผ๋ฏธํ„ฐ ๊ฐ’์ด๋‹ค.
    ๋‹ค์Œ api๋ฅผ ์š”์ฒญํ•  ๋•Œ ์‚ฌ์šฉ๋  pageParam๊ฐ’์€ getNextPageParam ์—์„œ ์ •ํ•  ์ˆ˜ ์žˆ๋‹ค.

    ์ด์— ๋Œ€ํ•œ ์ž์„ธํ•œ ๋‚ด์šฉ์€ ์•„๋ž˜ ๋งํฌ์—์„œ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค!
    useInfiniteQuery

      const {
        data,
        fetchNextPage,
        hasNextPage = true,
      } = useInfiniteQuery(
        ["searchDatas"],
        async ({ pageParam }) => {
          const data = await searchMovie({
            page: pageParam || 1,    //์ดˆ๊ธฐ์—๋Š” pageParam์ด undefined์ด๋ฏ€๋กœ 1๋Œ€์ž…
            keyword: keyword,
          });
          return {
            data: data.results,
            total: data.total_pages,
            page: data.page,
          };
        },
        {
          getNextPageParam: (lastPage) => {
            if (lastPage.total >= lastPage.page) {
              return lastPage.page + 1;
            }
            return undefined;
          },
          refetchOnMount: false,
          refetchOnWindowFocus: false,
          refetchOnReconnect: false,
        }
      );
      console.log(data);

    ์œ„ ์ฝ”๋“œ๋กœ ์ถœ๋ ฅ๋˜๋Š” data๋ฅผ ์ฐ์–ด๋ณธ ๊ฒฐ๊ณผ page์™€ pageParams์— ๋ฐ์ดํ„ฐ๊ฐ€ ๊ฐ๊ฐ ๋ฐฐ์—ด์•ˆ์— ์ด์ค‘๋ฐฐ์—ด๋กœ ์Œ“์ด๋Š” ๊ฒƒ์„ ํ™•์ธํ–ˆ๋‹ค.

    data?.pages.map((item: InfiniteDataProps) => item.data).flat()

    ๊ทธ๋ž˜์„œ ์ด์ค‘์œผ๋กœ ๋“ค์–ด๊ฐ„ ๋ฐฐ์—ด์„ ํ•˜๋‚˜๋กœ ํ’€์–ด๋‚ด๊ธฐ ์œ„ํ•ด flat()์„ ์ด์šฉํ–ˆ๋‹ค.

      return (
        <MainLayout>
          <section className="search-section">
            <p>
              &#34;{keyword}&#34; ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ {total_results}๊ฐœ ์žˆ์Šต๋‹ˆ๋‹ค.
            </p>
            <CardList
              data={data?.pages.map((item: any) => item.data).flat() || []}
            ></CardList>
          </section>
          <button onClick={() => fetchNextPage()}>๋” ๋ถˆ๋Ÿฌ์˜ค๊ธฐ</button>
        </MainLayout>
      );

    intersectionObserver ๋ฅผ ์ถ”๊ฐ€ํ•˜๊ธฐ ์ „ ์ž„์‹œ๋กœ ๋” ๋ถˆ๋Ÿฌ์˜ค๊ธฐ ๋ฒ„ํŠผ์„ ๋งŒ๋“ค์–ด ํ…Œ์ŠคํŠธ ํ•ด๋ณด์•˜๋‹ค!


    ํ…Œ์ŠคํŠธ๊ฒฐ๊ณผ

    ๋” ๋ถˆ๋Ÿฌ์˜ค๊ธฐ ๋ฒ„ํŠผ ํด๋ฆญ์‹œ ์•„๋ž˜๋กœ ๋ฐ์ดํ„ฐ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ๋” ๋ถˆ๋Ÿฌ์กŒ๋‹ค!

    useInfiniteQuery Hook ๊ฐœ๋ฐœ

    ๊น”๋”ํ•œ ์ฝ”๋“œ์™€ ์ฝ”๋“œ์žฌ์‚ฌ์šฉ์„ ์œ„ํ•ด ํ•ด๋‹น๋ถ€๋ถ„์„ hook์œผ๋กœ ์ฒ˜๋ฆฌํ•ด๋ณด์•˜๋‹ค.


    hook ์ฝ”๋“œ

    export const useInfiniteQueryList = (
      //hook์„ ๊ณตํ†ต์œผ๋กœ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด ํ•จ์ˆ˜๋ฅผ parameter๋กœ ๋ฐ›์•„์„œ ์‚ฌ์šฉ
      callback: (keyword: string, page: number) => Promise<movieListResult>,
      keyword: string
    ) => {
      const {
        data: pages,
        fetchNextPage,
        hasNextPage = true,
      } = useInfiniteQuery(
        ["searchDatas"],
        async ({ pageParam }) => {
          const res = await callback(keyword, pageParam || 1);
          return {
            data: res.results,
            total_pages: res.total_pages,
            total_result: res.total_results,
            page: res.page,
          };
        },
        {
          getNextPageParam: (lastPage) => {
            if (lastPage.total_pages > lastPage.page) {
              return lastPage.page + 1;
            }
            return undefined;
          },
          refetchOnMount: false,
          refetchOnWindowFocus: false,
          refetchOnReconnect: false,
        }
      );
    
      return {
        pages,
        fetchNextPage,
        hasNextPage,
      };
    };
    

     

    ์‚ฌ์šฉ ์ฝ”๋“œ

      const { pages, fetchNextPage } = useInfiniteQueryList({callbackํ•จ์ˆ˜}, {๊ฒ€์ƒ‰ ํ‚ค์›Œ๋“œ});
      const { pages, fetchNextPage } = useInfiniteQueryList(searchMovie, keyword);



    ๐ŸŽˆ intersectionObserver

    ์ด์ œ ๋”๋ถˆ๋Ÿฌ์˜ค๊ธฐ ๋ฒ„ํŠผ์ด ์•„๋‹ˆ๋ผ ํŽ˜์ด์ง€์— ํ•˜๋‹จ์— ๋„๋‹ฌํ–ˆ์„๋•Œ๋Š” ์ฒดํฌํ•˜๋Š” ์ด๋ฒคํŠธ๋ฅผ ์ธ์‹ํ•ด์„œ ํ•˜๋‹จ์— ๋„๋‹ฌํ•˜๋ฉด ์œ„์—์„œ ๋งŒ๋“  hook์„ ์‹คํ–‰ํ•˜๋Š” ๋ถ€๋ถ„์„ ๊ฐœ๋ฐœํ•  ์˜ˆ์ •์ด๋‹ค.

    ํ•˜๋‹จ์— ๋„๋‹ฌํ–ˆ์Œ์„ ์•Œ ์ˆ˜ ์žˆ๋Š” ์ด๋ฒคํŠธ๋Š” ๋‹ค์Œ ์„ธ๊ฐ€์ง€๊ฐ€ ์žˆ๋‹ค.

    • Scroll Event
    • Throttle
    • requestAnimationFrame
    • Intersection Observer API

    ๊ฐ๊ฐ์— ๋Œ€ํ•œ ๊ตฌํ˜„๊ณผ ์žฅ๋‹จ์ ์€ ์•„๋ž˜ ๋งํฌ ์นด์นด์˜ค ๊ธฐ์ˆ ๋ธ”๋กœ๊ทธ์—์„œ ์ƒ์„ธํ•˜๊ฒŒ ํ™•์ธํ•  ์ˆ˜ ์žˆ์—ˆ๋‹ค! ๊ฐ“์นด์˜ค!
    ๋ฌดํ•œ์Šคํฌ๋กค๊ตฌํ˜„ํ•˜๊ธฐ

    Intersection_Observer_API

    export function useIntersectionObserver({
      target, // ๊ฐ์ง€ํ•  ๋Œ€์ƒ, ref๋ฅผ ๋„˜๊ธธ ์˜ˆ์ •
      onIntersect, // ๊ฐ์ง€ ์‹œ ์‹คํ–‰ํ•  callback ํ•จ์ˆ˜
      root = null, // ๊ต์ฐจํ•  ๋ถ€๋ชจ ์š”์†Œ (์•„๋ฌด๊ฒƒ๋„ ๋„˜๊ธฐ์ง€ ์•Š์œผ๋ฉด document๊ฐ€ ๊ธฐ๋ณธ)
      rootMargin = "0px", // root์™€ target์ด ๊ฐ์ง€ํ•˜๋Š” ์—ฌ๋ฐฑ์˜ ๊ฑฐ๋ฆฌ
      threshold = 1.0, // ์ž„๊ณ„์ . 1.0์ด๋ฉด root๋‚ด์—์„œ target์ด 100% ๋ณด์—ฌ์งˆ ๋•Œ callback์ด ์‹คํ–‰๋œ๋‹ค.
    }: any) {
      useEffect(() => {
        let observer: any;
    
        if (target && target.current) {
          observer = new IntersectionObserver(onIntersect, {
            root,
            rootMargin,
            threshold,
          });
          observer.observe(target.current);
        }
    
        return () => observer && observer.disconnect();
      }, [target, rootMargin, threshold, onIntersect, root]);
    }


    ๊ธฐ์กด์— ๋” ๋ถˆ๋Ÿฌ์˜ค๊ธฐ ๋ฒ„ํŠผ์ด ์žˆ๋˜ ๊ณณ์„ ์•„๋ž˜์— ๋„๋‹ฌํ•œ ๊ฒƒ์„ ์ฒดํฌํ•˜๊ธฐ ์œ„ํ•œ ref๋ฅผ ๋งŒ๋“ค์–ด์ค€๋‹ค. ์ด ref๋Š” useIntersectionObserver hook์— target์œผ๋กœ ๋„˜๊ฒจ์ค„ ๊ฒƒ ์ด๋‹ค.

    <CardList data={data?.pages.map((item: any) => item.data).flat() || []}></CardList>
    <div ref={bottom} />

    ์ธํ”ผ๋‹ˆํ‹ฐ ์Šคํฌ๋กค ๊ตฌํ˜„ ๋!
    ์ด ์—ญ์‹œ hook์„ ํ†ตํ•ด ๋ฌถ์–ด๋†”์„œ ์•ž์œผ๋กœ ํ•„์š”ํ•œ ๊ณณ์—๋Š” ์•„๋ž˜ ์ฝ”๋“œ๋งŒ ์‚ฌ์šฉํ•˜๋ฉด ๋œ๋‹ค!

    ์‚ฌ์šฉ ์ฝ”๋“œ

      const { data, fetchNextPage } = useInfiniteQueryList({callbackํ•จ์ˆ˜}, {ํ‚ค์›Œ๋“œ});
    
      const bottom = useRef(null);
      const onIntersect = ([entry]: any) => entry.isIntersecting && fetchNextPage();
      useIntersectionObserver({
        target: bottom,
        onIntersect,
      });
    
      ...
    
      return(
          ...
          <div ref={bottom} />
        ...
      );
    

     

    ๐Ÿ‘‰ Next?

    ์ง€๊ธˆ๊นŒ์ง€ ๋งŒ๋“  ํ”„๋กœ์ ํŠธ๋ฅผ vercel์„ ์ด์šฉํ•ด ๋ฐฐํฌํ•ด๋ณด๋ ค๊ณ  ํ•œ๋‹ค!




    ์ฐธ๊ณ ์‚ฌ์ดํŠธ

    https://velog.io/@hdpark/React-Query%EC%99%80-%ED%95%A8%EA%BB%98%ED%95%98%EB%8A%94-Next.js-%EB%AC%B4%ED%95%9C-%EC%8A%A4%ED%81%AC%EB%A1%A4
    https://been.tistory.com/55

    728x90
    ๋ฐ˜์‘ํ˜•
    Comments