[Next.js14 프로젝트] 무한스크롤 구현하기

D uuu·2024년 5월 28일
0

Next.js14 프로젝트

목록 보기
5/11

카카오 API 이용한 무한스크롤 구현하기

구현한 모습은 아래와 같다.
스크롤이 교차 지점에 닿으면 그 다음 페이지를 불러온다. 그리고 다음 페이지를 불러올때마다 지도도 지역 중심으로 움직이는 걸 볼 수 있다.

시작하기

카카오 API 를 사용해 음식점 리스트를 불러오는 코드를 구현했다.
keywordSearch 함수를 이용하면 콜백함수 인자로 result, status, pagination 정보를 내려준다.

const searchKeyword = (keyword : string) => {
	const { kakao } = window;
    if(!map || !kakao) return;
    
    const ps = new kakao.maps.services.Places();
    
    ps.keywordSearch(keyword, (result, status, pagination) => {
      if(status === kakao.maps.Services.Status.OK) {
        console.log(result)
        console.log(pagination)
      }
   },
   {
      category_group_code : 'FD6',
      useMapBounds : true
   }
 );
}

pagination 값을 살펴보면 아래와 같다.
한번에 15개씩 데이터를 받아오고, 데이터는 총 45개만 제공해준다.

current: 1
first: 1
gotoFirst: ƒ ()
gotoLast: ƒ ()
gotoPage: ƒ (a)
hasNextPage: true
hasPrevPage: false
last: 3
nextPage: ƒ ()
perPage: 15
prevPage: ƒ ()
totalCount: 45

Intersection Observer API

무한스크롤을 구현하기 위해서 Intersection Observer API 를 사용했다.

Intersection Observer API 에 대해서 검색해보면 글이 많은데, 글만 보고서는 이해가 어려웠다.
그래서 일단 따라서 코드를 작성해봤는데 오히려 작성하면서 동작 원리를 이해하게 되었다.


1) IntersectionObserver 인스턴스 생성

  • new IntersectionObserver()를 통해 인스턴스를 생성한다.
    생성자는 2개의 인수(callback, options) 를 가진다.

const observer = new IntersectionObserver(handleObserver, { threshold: 0 });

  • observer.observe(currentEl) 로 관찰할 대상(요소) 를 등록한다.

const currentEl = observerEl.current;
if (currentEl) {
observer.observe(currentEl);
}

  • 즉, currentEl 요소가 다른 요소나 브라우저 뷰포트와 교차하면 인수로 넣은 callback 함수가 실행된다.

  • 마지막으로 useEffect 의 cleanup 함수를 이용해 요소에 대한 관찰을 중단하도록 했다.

  useEffect(() => {
      const observer = new IntersectionObserver(handleObserver, { threshold: 0 });
      const currentEl = observerEl.current;
      if (currentEl) {
          observer.observe(currentEl);
      }
      return () => {
          if (currentEl) {
              observer.unobserve(currentEl);
          }
      };
  }, [handleObserver]);

2) callback 함수 만들기

currentEl 요소가 다른 요소나 브라우저 뷰포트와 교차하면 인수로 넣은 callback 함수가 실행된다고 했다.
여기서 나는 handleObserver 함수를 생성해 콜백함수 인수로 넣어줬다.

    const handleObserver = useCallback(
        (entries: IntersectionObserverEntry[]) => {
            const target = entries[0];
            if (target.isIntersecting && pagination?.hasNextPage) {
                pagination.gotoPage(pagination.current + 1);
            }
        },
        [pagination]
    );
  • IntersectionObserverEntry 배열을 받아서 첫번째 항목인 entries[0] 을 받아와 target 변수에 넣어준다.

  • 해당 항목이 교차 상태이고 (target.isIntersecting 이 true 일때) , 그리고 다음 페이지가 존재하는 경우 gotoPage 함수를 통해 다음 페이지로 이동시킨다.

3) 전체 코드


const Card = () => {
    const observerEl = useRef<HTMLDivElement>(null);
    const { pagination, resData } = useMap();

    const handleObserver = useCallback(
        (entries: IntersectionObserverEntry[]) => {
            const target = entries[0];
            if (target.isIntersecting && pagination?.hasNextPage) {
                pagination.gotoPage(pagination.current + 1);
            }
        },
        [pagination]
    );

    useEffect(() => {
        const observer = new IntersectionObserver(handleObserver, { threshold: 0 });
        const currentEl = observerEl.current;
        if (currentEl) {
            observer.observe(currentEl);
        }
        return () => {
            if (currentEl) {
                observer.unobserve(currentEl);
            }
        };
    }, [handleObserver]);

    return (
        <List>
            {resData.map((res: any) => (
                <ListBox
                    onClick={() => {
                        router.push(`/${res.id}`);
                    }}
                    key={res.id}
                    top={
                        <div className={styles.title}>
                            <Text typography="t4">{res.place_name}</Text>
                            <Text typography="st3">{res.road_address_name}</Text>
                        </div>
                    }
                    bottom={
                        <div>
                            <Text typography="st3">{res.phone}</Text>
                        </div>
                    }
                />
            ))}
            <div ref={observerEl} />
        </List>
    );
};

export default Card;

useKeyword hook

마지막으로 useKeyword 훅을 수정해줬다.

  • 검색 할때마다 resData 배열이 초기화가 되어야 하므로 setState 함수를 이용해 초기화 작업을 수행해준다.
 if (resData.length > 0) {
      setResData([]);
      setPagination(null);
  }
  • 페이지가 바뀌어도 이전 페이지의 데이터도 보여주기 위해 아래와 같이 구현했다.

setResData((prev) => [...prev, ...data]);

const searchKeyword = useCallback(
  (keyword: string) => {
      const { kakao } = window;
      if (!map || !kakao) return;
       const ps = new kakao.maps.services.Places();

// resData.length 배열 값이 있으면 초기화 작업을 해줘야한다. 

      if (resData.length > 0) {
          setResData([]);
          setPagination(null);
      }

      ps.keywordSearch(
          keyword,
          (data, status, pagination: any) => {
              if (status === kakao.maps.services.Status.OK) {
                  const bounds = new kakao.maps.LatLngBounds();
        
                  let markers = [];

                  for (let i = 0; i < data.length; i++) {
                  // @ts-ignore
                  markers.push({
                     position: {
                         lat: data[i].y,
                         lng: data[i].x,
                         },
                                content: data[i].place_name,
                            });

                            // @ts-ignore
                            bounds.extend(new kakao.maps.LatLng(data[i].y, data[i].x));
                        }

                        setMarkers(markers);

                        setPagination(pagination);

                        // 페이지가 바뀌어도 기존 데이터를 모두 보여주기 위해 아래와 같이 작성
                        setResData((prev) => [...prev, ...data]);

                        map.setBounds(bounds);
                    }
                },
                {
                    category_group_code: 'FD6',
                    useMapBounds: true,
                }
            );
        },
        [setPagination, setResData, resData, setMarkers, map]
    );

    return { searchKeyword };
};

useInfiniteScoll hook 으로 만들기

컴포넌트 안에 Intersection Observer API 로직이 함께 작성되어 이 로직을 훅으로 빼기로 했다.

콜백함수와 다음 페이지가 있는지 여부를 확인할 수 있는 hasNextPage 를 인자로 받는 함수로 관찰 요소를 설정할 수 있는 observeEl 과 isLoading 을 반환한다.

import { useState, useEffect, useRef, useCallback } from 'react';

type InfiniteScrollType = {
    callbackFn: () => void;
    hasNextPage: boolean;
};

export const useInfiniteScroll = ({ callbackFn, hasNextPage }: InfiniteScrollType) => {
    const [isLoading, setIsLoading] = useState<boolean>(false);
    const observerEl = useRef<HTMLDivElement>(null);

    const handleObserver = useCallback(
        (entries: IntersectionObserverEntry[]) => {
            const target = entries[0];
            if (target.isIntersecting && !isLoading && hasNextPage) {
                setIsLoading(true);
                callbackFn();
                setIsLoading(false);
            }
        },
        [callbackFn, isLoading, hasNextPage]
    );

    useEffect(() => {
        const observer = new IntersectionObserver(handleObserver, { threshold: 0 });
        const currentEl = observerEl.current;
        if (currentEl) {
            observer.observe(currentEl);
        }
        return () => {
            if (currentEl) {
                observer.unobserve(currentEl);
            }
        };
    }, [handleObserver]);

    return { observerEl, isLoading };
};

useInfiniteScroll 훅을 사용해 아래와 같이 수정했다.

const Card = () => {

  const { pagination, resData } = useMap();  
  
    const fetchNextPage = () => {
        pagination?.gotoPage(pagination.current + 1);
    };
    
   onst { observerEl } = useInfiniteScroll({ callbackFn: fetchNextPage, hasNextPage: pagination?.hasNextPage! });

    return (
        <List>
            {resData.map((res: any) => (
                <ListBox
                    onClick={() => {
                        router.push(`/${res.id}`);
                    }}
                    key={res.id}
                    top={
                        <div className={styles.title}>
                            <Text typography="t4">{res.place_name}</Text>
                            <Text typography="st3">{res.road_address_name}</Text>
                        </div>
                    }
                    bottom={
                        <div>
                            <Text typography="st3">{res.phone}</Text>
                        </div>
                    }
                />
            ))}
            <div ref={observerEl} />
        </List>
    );
};

export default Card;

완성본

스크롤을 끝까지 내려서 교차 지점에 닿으면 gotoPage 함수가 실행되어 새로운 데이터가 불러와진다.
이렇게 kakao API 를 활용한 무한스크롤을 구현하는 방법에 대해 정리해봤다.

profile
배우고 느낀 걸 기록하는 공간

0개의 댓글