[next.js] intersection observer로 무한스크롤 구현하기

profile image pIutos 2023. 6. 29. 01:54

본 글은 Next.js 환경에서 무한스크롤 기능을 intersection observer를 이용해 구현한 방법에 대해 작성하였습니다.

구현 설명

우선 처음 데이터는 Next의 getServersideProps로 받고, 이후 데이터는 intersection event가 발생했을 때 다음 배열을 가져오는 api를 호출하도록 구현하겠습니다.

이때 API는 원하는 page를 쿼리 파라미터에 page=1과 같이 지정하면 해당 page의 데이터를 반환하도록 구현되어있습니다.

상세 구현

타입 지정

프로젝트에서 가져오는 정보들을 memory라 명명하여 type도 연관되게 이름지었습니다.

GetMemoryListRes 타입은 데이터를 fetching해올 때 무한스크롤 할 정보의 response타입입니다.

/** memoryList를 가져오는 api의 response type */
export type GetMemoryListRes = {
  total: number
  content: Array<MemoryType>
  pageable: {
    sort: {
      orders: [
        {
          direction: string
          property: string
          ignoreCase: boolean
          nullHandling: string
        }
      ]
    }
    page: number
    size: number
  }
}

/** Memory Type */
export type MemoryType = {
  id: number
  backgroundImage: string
  text: string
  videoId: string
  createdAt: string
  deletedAt?: string
}
/** Memory Type의 List 형식 */
export type MemoryListType = {
  memoryList: GetMemoryListRes['content']
  currentPage: GetMemoryListRes['pageable']['page']
}

getServerSideProps로 첫 배열 가져오기

import { GetServerSideProps } from 'next'
import { API_URL } from '@/constants'

// server data fetching
export const getServerSideProps: GetServerSideProps<{
  initMemoryList: MemoryListType
}> = async () => {
  /** fetch data */
  const getMemoryList = await fetch(
    `${process.env.NEXT_PUBLIC_SERVER_DEFAULT_END_POINT}post/page?page=1&size=${GET_MEMORY_LIST_DEFAULT_SIZE}&memberId=${MEMBER_ID}`
  )
  const getMemoryListRes: GetMemoryListRes = await getMemoryList.json()

  const initMemoryList: MemoryListType = {
    memoryList: getMemoryListRes.content,
    currentPage: getMemoryListRes.pageable.page,
  }
  return { props: { initMemoryList } }
}

Next.js의 getServerSideProps로 처음(currentPage: 1)에 해당하는 데이터 배열을 가져왔습니다. 

그리고 가져온 정보를 토대로 initMemoryList 변수를 설정하여 prop으로 내려줬습니다.

observer 생성과 무한스크롤 구현

import { GetServerSideProps, InferGetServerSidePropsType } from 'next'

// ...

export default function MemoryList({ initMemoryList }: InferGetServerSidePropsType<typeof getServerSideProps>) {
  const [memoryList, setMemoryList] = useState<MemoryListType>(initMemoryList)

  const targetRef = useRef<HTMLDivElement>(null)

  // 1
  const handleIntersect = useCallback(
    ([entry]: IntersectionObserverEntry[]) => {
      if (entry.isIntersecting) {
        /** fetch data */
        const getMemoryList = axios.get<GetMemoryListRes>(
          `${process.env.NEXT_PUBLIC_SERVER_DEFAULT_END_POINT}post/page?page=${
            memoryList.currentPage + 1
          }&size=${GET_MEMORY_LIST_DEFAULT_SIZE}&memberId=${MEMBER_ID}`
        )
        getMemoryList
          .then((res) => {
            if (res.status !== 200) return
            setMemoryList((prev) => {
              return {
                ...prev,
                memoryList: [...prev.memoryList, ...res.data.content],
                currentPage: res.data.pageable.page,
              }
            })
          })
          .catch((error) => console.error(error))
      }
    },
    [memoryList.currentPage]
  )

  // 2
  /** control observer */
  useEffect(() => {
    const observer = new IntersectionObserver(handleIntersect, {
      threshold: 0.9,
      root: null,
    })

    targetRef.current && observer.observe(targetRef.current)

    return () => {
      observer.disconnect()
    }
  }, [handleIntersect, targetRef.current])

  // 3
  return (
    <S.Wrapper>
      {memoryList?.memoryList.map((memory, index) => (
        <Card key={index} memory={memory} ref={memoryList.memoryList.length === index + 1 ? targetRef : null} />
      ))}
    </S.Wrapper>
  )
}

위 코드는 세가지 부분으로 나누어 설명하겠습니다.

1. intersecting 했을 때 데이터를 가져오는 부분

만약 관찰하는 요소(마지막 Card 컴포넌트)가 intersecting되었다면 getMemoryList를 통해 데이터를 가져옵니다.

이때 page = currentPage + 1을 하여 현재 페이지 다음의 데이터를 가져옵니다.

그리고 가져온 데이터를 memoryList에 붙여줍니다.

2. intersection observer 생성 부분

targetRef는 null일수 있기때문에 targetRef.current가 존재하면 targetRef를 관찰할 대상으로 등록합니다.

언마운팅시에는 observer.disconnect() 함수를 호출해 등록해제합니다.

다음 데이터를 빠르게 보여주기 위해 observer의 threshold를 1이 아니라 모든 요소가 보여지기 직전인 0.8로 설정했습니다. 이렇게하면 유저가 데이터를 기다린다는 느낌을 최소화할 수 있습니다.

3. 데이터를 보여주고, Card 컴포넌트에 ref를 할당하는 부분

MemoryList.map을 통해 Card 컴포넌트에 memory에 해당하는 props를 넘겨주었습니다.

이때 ref도 함께 전달하였는데, observer가 마지막 하나의 컴포넌트만 관찰해야하므로

memoryList.memoryList.length === index + 1 ? targetRef : null

해당 코드를 통해 마지막 컴포넌트일때 targetRef를 지정해주도록 하였습니다.

+) Card 컴포넌트의 ref 설정

위 코드에서 Card컴포넌트를 보시면 ref로 targetRef를 전달해줍니다. 하지만 Card 컴포넌트는 우리가 제작한 컴포넌트이므로 별도 설정이 없다면 ref는 제대로 할당되지 않을 것입니다.

type CardProps = {
  memory: MemoryType
} & React.ComponentProps<'div'>

const Card = forwardRef<HTMLDivElement, CardProps>(({ memory }, ref) => {
  return (
    <>
      <S.CardComponentContainer ref={ref} backgroundImage={memory.backgroundImage}>
      // ...

따라서 forwardRef를 이용하여 ref를 컴포넌트 내부에 할당하도록 Card컴포넌트를 작성했습니다.

추가적으로 Card 컴포넌트는 div로 감싸여진 컴포넌트이기에, 추후 확장성을 고려하여 CardProps 타입을 div컴포넌트 props에 해당하는 타입을 확장하여 선언했습니다.

결과물

데이터가 잘 가져와져서 무한스크롤되는 모습을 확인할 수 있습니다.

mock 데이터 사용하기

구현당시 API가 나오지 않았기 때문에 API요청에 따른 데이터를 모킹해서 구현해야했습니다.

그래서 아래와같이 MockMemoryType이라는 모킹 데이터를 반환하는 함수를 만들어 구현했습니다.

/**mock data */
const MockMemoryType = (currentPage: number) => {
  return {
    data: [
      {
        id: currentPage,
        backgroundImage: '',
        youtubeUrl: '',
        text: 'Lorem Ipsum is simply dummy text of the printing and typesetting industry.',
        createdAt: 'July 23',
        deletedAt: 'July 23',
      },
      {
        id: currentPage,
        backgroundImage: '',
        youtubeUrl: '',
        text: 'Lorem Ipsum is simply dummy text of the printing and typesetting industry.',
        createdAt: 'July 23',
        deletedAt: 'July 23',
      },
    ],
    currentPage: currentPage,
  }
}

데이터를 모킹해서 가져올 때는 mock함수 사용과, 컴포넌트 내부에서 mockPageNumber = useRef(2)로 설정하여 page를 가져온다는점 외에는 구현 상 달라진 것은 없습니다. 

intersect해서 데이터를 가져왔을 때 mockPageNumber.current++를 해주어 요청할 페이지 번호를 관리하였습니다.

// server data fetching
export const getServerSideProps: GetServerSideProps<{
  initMemoryList: MemoryListType
}> = async () => {
  /** use mock data */
  const initMemoryList = MockMemoryType(1)
  return { props: { initMemoryList } }
}

export default function MemoryList({ initMemoryList }: InferGetServerSidePropsType<typeof getServerSideProps>) {
  const [memoryList, setMemoryList] = useState<MemoryListType>(initMemoryList)

  const targetRef = useRef<HTMLDivElement>(null)
  const mockId = useRef(2)

  const handleIntersect = useCallback(
    ([entry]: IntersectionObserverEntry[]) => {
      if (entry.isIntersecting) {
        /** use mock data */
        setMemoryList((prev) => {
          return {
            ...prev,
            data: [...prev.data, ...MockMemoryType(mockPageNumber.current).data],
            currentPage: MockMemoryType(mockPageNumber.current).currentPage,
          }
        })
      }
      mockPageNumber.current++
    },
    [targetRef.current]
  )

  /** control observer */
  useEffect(() => {
    const observer = new IntersectionObserver(handleIntersect, {
      threshold: 0.9,
      root: null,
    })

    targetRef.current && observer.observe(targetRef.current)

    return () => {
      observer.disconnect()
    }
  }, [handleIntersect, targetRef.current])

  return (
    <S.Wrapper>
      {memoryList?.data.map((memory, index) => (
        <Card key={index} memory={memory} ref={targetRef} />
      ))}
    </S.Wrapper>
  )
}

TroubleShooting: 리렌더링이 많이되는 문제

구현한 로직을 보며 페이지 성능에 대해 생각하던 중, 기존 memoryList state 배열을 새로운 데이터를 덧붙이듯이 관리하게되면 데이터를 가져올 때 마다 모든 Card 컴포넌트에서 리렌더링이 일어나지 않을까? 라는 생각이 들어서 확인해보았습니다.

React Dev Tools로 확인해 본 결과, 예상대로 데이터를 많이 가져오면 많이 가져올 수록 모든 컴포넌트에서 리렌더링이 발생한다는 것이 확인되었습니다.

모든 Card 컴포넌트가 데이터를 fetching해올 때 렌더링되는 모습

export default React.memo(Card);

따라서 React.memo()를 이용하여 Card컴포넌트를 감싸주었고, 성공적으로 가져온 데이터에 대해서만 렌더링이 일어나도록 구현되었습니다.

이제 데이터 fetching시 새로 불러온 데이터에 대해서만 렌더링이 일어납니다!