18 메인 페이지에 실제 데이터 띄워보기 - 인피니티 스크롤 기능 구현하기

source: categories/study/gatsby/gatsby_9-09.md

인피니티 스크롤 기능 구현하기

일반적으로 많은 컨텐츠를 띄워주는 방식으로 페이지네이션 방식과 인피니티 스크롤, 더보기 버튼 방식이 존재합니다.

각 방식마다 가지고 있는 장단점이 있겠지만, 여기에서는 인피니티 스크롤 방식을 사용할 것이기 때문에 해당 방식의 장단점은 무엇이 있는지 알아봅시다.

먼저, 인피니티 스크롤 방식의 장점은 다음과 같습니다.

  1. 자연스럽게 컨텐츠를 이어 보여주기 때문에 높은 사용자 경험을 제공한다.

    페이지네이션 방식과 더보기 버튼 방식은 더 많은 컨텐츠를 확인하기 위해서는 다음 페이지 버튼 또는 더보기 버튼을 클릭해야만 합니다.

    따라서 사용자는 컨텐츠를 보기 위해서 그만큼의 클릭수를 감안해야 한다는 말입니다.

    하지만, 대부분의 사용자는 불필요한 작업이 적을수록 더 높은 사용자 경험을 느끼게 되므로 클릭수가 적을수록 좋은 경험을 느끼게 될 것입니다.

    그러므로 클릭 없이 단순히 스크롤을 통해 더 많은 컨텐츠를 확인할 수 있는 인피니티 스크롤 방식은 다른 방법에 비해 높은 사용자 경험을 제공할 수 있는 것입니다.

  2. 하나의 페이지에서 모든 컨텐츠를 확인할 수 있다.

    한 페이지에서 컨텐츠를 모두 확인할 수 있다는 말은 컨텐츠를 나눠 띄우기 위한 여러 페이지가 필요하지 않다는 말입니다.

    따라서 사용자 입장에서는 컨텐츠를 찾기 위해 여러 페이지를 확인할 필요가 없습니다.


이런 장점때문에 사용자들은 인피니티 스크롤 방식으로부터 높은 사용자 경험을 제공받지만 장점이 명확한 만큼 단점도 명확하게 드러납니다.

인피니티 스크롤 방식의 단점은 다음과 같습니다.

  1. 사용자가 개별 아이템에 집중하기 어렵다.

    사용자는 계속해서 스크롤을 통해 컨텐츠를 제공받기 때문에 시간이 지날수록 개별 컨텐츠에는 집중하지 않고 목록을 계속 스크롤하기만 할 수 있습니다.

    이는 페이스북이나 인스타그램과 같은 SNS를 확인할 때에도 명확하게 드러나듯이 나중에는 컨텐츠를 제대로 확인하지 않은 채 다음 컨텐츠를 로드하기만 합니다.

  2. **SEO 적용**이 어렵다.

    인피니티 스크롤을 적용한 상태에서 SEO 작업을 하게 된다면, 초기에 로딩된 컨텐츠에 한해서만 검색 결과에 반영되기 때문에 그 외의 컨텐츠에 대한 SEO 작업이 어렵습니다.

  3. Footer 접근이 어렵다.

    스크롤이 바닥에 닿으면 바로 다음 컨텐츠를 불러오기 때문에 사용자는 실질적으로 푸터를 볼 수 있는 시간은 1초 정도밖에 되지 않습니다.

    그나마 모든 컨텐츠가 로드되고 나서 푸터에 접근할 수 있지만, 컨텐츠 로딩 시간이나 속도 측면에서 다른 방식에 비해 낮은 사용자 경험을 느낄 수 밖에 없습니다.

인피니티 스크롤을 구현하기 전에 알아야 할 API

인피니티 스크롤을 구현하는데 있어 가장 핵심적인 기능을 담당하는 API인 IntersectionObserver에 대해 알아야합니다.

쉽게 말해 IntersectionObserver는 타겟 요소가 화면에 노출이 되는지의 여부를 구독할 수 있는 API입니다.

그럼 어떻게 인피니티 스크롤에 이 API를 적용할 수 있을지 감이 오시나요?

단순하게 현재 로드된 컨텐츠 중에서 가장 마지막 컨텐츠를 IntersectionObserver를 통해 구독하여 노출이 되었다면 다음 컨텐츠를 로드하는 방식으로 사용할 수 있습니다.

그럼 IntersectionObserver는 어떻게 사용할까요?

const callback = (entries, observer) => { console.log("Hi")}

const observer = new IntersectionObserver(callback, options)

// 타겟 요소 관측 시작
observer.observe(TargetElement)

// 타겟 요소 관측 중단
observer.unobserve(TargetElement)

// 모든 요소 관측 중단
observer.disconnect()

IntersectionObserver는 다음과 같이 두 개의 파라미터를 받습니다.

하나는 타겟 요소가 화면에 노출된 경우 실행시킬 콜백 함수이고, 나머지 하나는 IntersectionObserver에 대한 옵션 객체입니다.

콜백 함수는 필수적으로 넘겨야 하지만, 옵션은 넘기지 않을 경우 기본값으로 적용되기 때문에 선택 사항입니다.

이렇게 선언한 IntersectionObserverobserve 메서드를 통해 특정 요소를 관측할 수 있습니다.

이 상태에서 해당 요소가 화면에 보이는 경우에는 콜백 함수를 실행시키는 것입니다.

만약 특정 요소 또는 모든 요소의 관측을 중단하고 싶은 경우에는 unobserve 또는 disconnect 메서드를 사용하면 됩니다.

저희가 해당 API를 사용하기 위해서는 이 정도까지만 알아도 상관없기 때문에 콜백 함수에서 받는 파라미터나 옵션에는 어떤 것이 있는지 궁금하신 분들은 인터넷 검색을 통해 찾아보시기 바랍니다.

인피니티 스크롤을 위한 커스텀 훅 구현 준비하기

기존에는 PostList 컴포넌트에서 선택된 카테고리의 아이템만 필터링을 통해 띄워주도록 구현했습니다.

이번에는 카테고리 별로 아이템을 필터링해줌과 동시에 인피니티 스크롤 기능까지 제공하는 useInfiniteScroll 커스텀 훅을 구현해보겠습니다.

그러기 위해서는 선택된 카테고리와 포스트 리스트 데이터 전부를 PostList 컴포넌트에서 커스텀 훅으로 코드를 옮겨야 합니다.

따라서 PostList 컴포넌트 코드를 다음과 같이 수정하고, src/hooks 디렉토리에 useInfiniteScroll.tsx 파일을 생성해주세요.

  • src/components/Main/PostList.tsx
import React, { FunctionComponent } from 'react'

// ...

const PostList: FunctionComponent<PostListProps> = function ({
  selectedCategory,
  posts,
}) {
  return (
    <PostListWrapper>
      {posts.map(({ node: { id, frontmatter } }: PostType) => (
        <PostItem {...frontmatter} link="<https://www.google.co.kr/>" key={id} />
      ))}
    </PostListWrapper>
  )
}

export default PostList

useRef으로 PostListWrapper 요소 선택하기

인피니티 스크롤 방식은 특정 요소가 화면에 보일 경우, 다음 데이터를 로드하는 방식이라고 했습니다.

따라서 화면에 보이는지 체크하기 위한 특정 요소를 선택하기 위해, 상위 요소인 PostListWrapper를 연결해야합니다.

이를 위해 사용하는 Hook이 useRef이며, 다음과 같이 커스텀 훅에서 ref를 선언한 후 반환값에 추가해줍시다.

  • src/hooks/useInfiniteScroll.tsx
import { MutableRefObject, useRef } from 'react'
import { PostListItemType } from 'types/PostItem.types'

const useInfiniteScroll = function (
  selectedCategory: string,
  posts: PostListItemType[],
) {
  const containerRef: MutableRefObject<HTMLDivElement | null> = useRef<HTMLDivElement>(
    null,
  )

  return { containerRef }
}

export default useInfiniteScroll

Div 요소인 PostListWrapper 컴포넌트를 연결하기 위해 useRef Hook에 HTMLDivElement라는 타입을 제네릭으로 정의해주었습니다.

이렇게 반환한 ref는 다음과 같이 해당 요소에 연결해줄 수 있습니다.

  • src/components/Main/PostList.tsx
// ...

import useInfiniteScroll from 'hooks/useInfiniteScroll'

// ...

const PostList: FunctionComponent<PostListProps> = function ({
  selectedCategory,
  posts,
}) {
  const { containerRef } = useInfiniteScroll(selectedCategory, posts)

  return (
    <PostListWrapper ref={containerRef}>
      {posts.map(({ node: { id, frontmatter } }: PostType) => (
        <PostItem {...frontmatter} link="https://www.google.co.kr/" key={id} />
      ))}
    </PostListWrapper>
  )
}

export default PostList

이렇게 useRef를 통해 특정 요소에 연결하면, containerRef.current 프로퍼티를 통해 ref로 연결된 요소에 접근이 가능합니다.

분할된 포스트 목록을 받아 출력하기

저희는 한 번에 10개의 포스트 아이템을 출력해주겠습니다.

이를 다시 말해 커스텀 훅에서 useState를 통해 10개 단위의 포스트 목록을 얼마나 띄워줄지 정할 것이고, 해당 갯수만큼 파라미터로 받은 posts 데이터를 잘라내 반환할 것입니다.

해당 부분은 다음과 같이 구현할 수 있습니다.

  • src/hooks/useInfiniteScroll.tsx
import { MutableRefObject, useState, useRef, useMemo } from 'react'
import { PostListItemType } from 'types/PostItem.types'

export type useInfiniteScrollType = {
  containerRef: MutableRefObject<HTMLDivElement | null>
  postList: PostListItemType[]
}

const NUMBER_OF_ITEMS_PER_PAGE = 10

const useInfiniteScroll = function (
  selectedCategory: string,
  posts: PostListItemType[],
): useInfiniteScrollType {
  const containerRef: MutableRefObject<HTMLDivElement | null> = useRef<HTMLDivElement>(
    null,
  )
  const [count, setCount] = useState<number>(1)

  const postListByCategory = useMemo<PostListItemType[]>(
    () =>
      posts.filter(({ node: { frontmatter: { categories } } }: PostListItemType) =>
        selectedCategory !== 'All'
          ? categories.includes(selectedCategory)
          : true,
      ),
    [selectedCategory],
  )

  return {
    containerRef,
    postList: postListByCategory.slice(0, count * NUMBER_OF_ITEMS_PER_PAGE),
  }
}

export default useInfiniteScroll

한 번에 띄워줄 포스트 아이템 개수는 따로 상수로 훅 외부에서 정의해주겠습니다.

또, 반환할 값을 표시하기 위해 함수 리턴 타입을 정의해주었습니다.

훅 내부에서는 useState를 통해 포스트 목록 단위 변수를 정의해주었고, 선택된 카테고리에 해당하는 포스트 아이템만 필터링해주어 10개만 postList라는 이름으로 반환해주었습니다.

postListByCategory 변수 부분은 PostList 컴포넌트에서 지운 부분과 거의 유사하지만, useMemo 훅의 두 번째 파라미터가 변경되었습니다.

두 번째 파라미터로 selectedCategory 변수가 들어간 배열을 전달해주었는데, 이는 선택된 카테고리가 변경될 때마다 새롭게 데이터를 필터링해야 하기 때문에 위와 같이 작성했습니다.

이제 PostList 컴포넌트에서 포스트 목록 데이터를 받아 출력해봅시다.

  • src/components/Main/PostList.tsx
import useInfiniteScroll, {
  useInfiniteScrollType,
} from 'hooks/useInfiniteScroll'

// ...

const PostList: FunctionComponent<PostListProps> = function ({
  selectedCategory,
  posts,
}) {
  const { containerRef, postList }: useInfiniteScrollType = useInfiniteScroll(
    selectedCategory,
    posts,
  )

  return (
    <PostListWrapper ref={containerRef}>
      {postList.map(({ node: { id, frontmatter } }: PostListItemType) => (
        <PostItem {...frontmatter} link="https://www.google.co.kr/" key={id} />
      ))}
    </PostListWrapper>
  )
}

// ...

useInfiniteScroll 커스텀 훅에서 정의했던 함수 리턴 타입도 불러와 지정해주었습니다.

contents 디렉토리 내의 마크다운 파일을 10개 이상 복사한 다음, 로컬 서버를 실행하여 카테고리 별로 포스트 아이템이 최대 10개까지 뜨는지 확인해보세요.

IntersectionObserver API로 특정 부분 도달 시 데이터 불러오기

이제 가장 핵심적인 부분을 구현해보겠습니다.

저희는 useRef로 연결해준 요소의 자식 노드 중, 가장 마지막 노드가 화면에 보일 경우에 다음 데이터를 로드해주는 기능을 구현할 것입니다.

가장 먼저 다음과 같이 observer를 선언해주겠습니다.

  • src/hooks/useInfiniteScroll.tsx
import { MutableRefObject, useState, useEffect, useRef, useMemo } from 'react'
import { PostListItemType } from 'types/PostItem.types'

export type useInfiniteScrollType = {
  containerRef: MutableRefObject<HTMLDivElement | null>
  postList: PostListItemType[]
}

const NUMBER_OF_ITEMS_PER_PAGE = 10

const useInfiniteScroll = function (
  selectedCategory: string,
  posts: PostListItemType[],
): useInfiniteScrollType {
  const containerRef: MutableRefObject<HTMLDivElement | null> = useRef<HTMLDivElement>(
    null,
  )
  const [count, setCount] = useState<number>(1)

  const postListByCategory = useMemo<PostListItemType[]>(
    () =>
      posts.filter(({ node: { frontmatter: { categories } } }: PostListItemType) =>
        selectedCategory !== 'All'
          ? categories.includes(selectedCategory)
          : true,
      ),
    [selectedCategory],
  )

  const observer: IntersectionObserver = new IntersectionObserver(
    (entries, observer) => {
      if (!entries[0].isIntersecting) return;

      setCount(value => value + 1);
      observer.disconnect();
    },
  )

  return {
    containerRef,
    postList: postListByCategory.slice(0, count * NUMBER_OF_ITEMS_PER_PAGE),
  }
}

export default useInfiniteScroll

저희는 단 하나의 요소만 관측할 것이기 때문에 관측 요소 배열 파라미터에 해당하는 entries 인자에는 하나의 데이터만 존재합니다.

그리고, 배열 내의 데이터에는 isIntersecting이라는 프로퍼티를 통해 화면에 노출되었는지를 확인할 수 있습니다.

따라서 해당 프로퍼티를 통해 화면에 노출된 경우에는 count 값에 1을 더해주어 10개의 데이터가 추가적으로 출력되도록 할 것이고, 그 즉시 해당 요소의 관측을 중단하도록 구현했습니다.

이렇게 observer를 선언했으니, 이를 통해 observe 메서드를 사용하는 부분을 구현해봅시다.

이를 위해 useEffect 훅을 사용할 것이고, count 값이 변경될 때마다 ref로 연결된 요소의 맨 마지막 자식 노드를 관측할 것이기 때문에 다음과 같이 코드를 작성해줘야 합니다.

  • src/hooks/useInfiniteScroll.tsx
import { MutableRefObject, useState, useEffect, useRef, useMemo } from 'react'
import { PostListItemType } from 'types/PostItem.types'

export type useInfiniteScrollType = {
  containerRef: MutableRefObject<HTMLDivElement | null>
  postList: PostType[]
}

const NUMBER_OF_ITEMS_PER_PAGE = 10

const useInfiniteScroll = function (
  selectedCategory: string,
  posts: PostListItemType[],
): useInfiniteScrollType {
  const containerRef: MutableRefObject<HTMLDivElement | null> = useRef<HTMLDivElement>(
    null,
  )
  const [count, setCount] = useState<number>(1)

  const postListByCategory = useMemo<PostListItemType[]>(
    () =>
      posts.filter(({ node: { frontmatter: { categories } } }: PostListItemType) =>
        selectedCategory !== 'All'
          ? categories.includes(selectedCategory)
          : true,
      ),
    [selectedCategory],
  )

  const observer: IntersectionObserver = new IntersectionObserver(
    (entries, observer) => {
      if (!entries[0].isIntersecting) return;

      setCount(value => value + 1);
      observer.disconnect();
    },
  )

  useEffect(() => {
    if (
      NUMBER_OF_ITEMS_PER_PAGE * count >= postListByCategory.length ||
      containerRef.current === null ||
      containerRef.current.children.length === 0
    )
      return

    observer.observe(
      containerRef.current.children[containerRef.current.children.length - 1],
    )
  }, [count, selectedCategory])

  return {
    containerRef,
    postList: postListByCategory.slice(0, count * NUMBER_OF_ITEMS_PER_PAGE),
  }
}

export default useInfiniteScroll

count 값과 선택된 카테고리가 값이 변경될 때마다 관측 요소를 변경하기 위해 useEffect 훅의 두 번째 파라미터로 count 변수와 selectedCategory 변수가 있는 배열을 넘겨주었습니다.

그리고 ref로 요소에 제대로 연결되어있는지와 더 불러올 데이터가 있는지 확인한 후, 조건을 충족하면 선택한 요소의 맨 마지막 자식 노드를 관측해줍니다.

여기까지 작성한 후 로컬 서버를 실행시켜 확인해보면 인피니티 스크롤 기능은 잘 구현되지만, 다른 카테고리를 선택한 경우에 포스트 아이템이 10개 넘게 출력되는 것을 확인할 수 있습니다.

따라서 선택된 카테고리가 변경된 경우에는 count 값을 1로 변경해주는 코드를 추가해줍시다.

  • src/hooks/useInfiniteScroll.tsx
import { MutableRefObject, useState, useEffect, useRef, useMemo } from 'react'
import { PostListItemType } from 'types/PostItem.types'

export type useInfiniteScrollType = {
  containerRef: MutableRefObject<HTMLDivElement | null>
  postList: PostListItemType[]
}

const NUMBER_OF_ITEMS_PER_PAGE = 10

const useInfiniteScroll = function (
  selectedCategory: string,
  posts: PostListItemType[],
): useInfiniteScrollType {
  const containerRef: MutableRefObject<HTMLDivElement | null> = useRef<HTMLDivElement>(
    null,
  )
  const [count, setCount] = useState<number>(1)

  const postListByCategory = useMemo<PostListItemType[]>(
    () =>
      posts.filter(({ node: { frontmatter: { categories } } }: PostListItemType) =>
        selectedCategory !== 'All'
          ? categories.includes(selectedCategory)
          : true,
      ),
    [selectedCategory],
  )

  const observer: IntersectionObserver = new IntersectionObserver(
    (entries, observer) => {
      if (!entries[0].isIntersecting) return;

      setCount(value => value + 1);
      observer.disconnect();
    },
  )

  useEffect(() => setCount(1), [selectedCategory])

  useEffect(() => {
    if (
      NUMBER_OF_ITEMS_PER_PAGE * count >= postListByCategory.length ||
      containerRef.current === null ||
      containerRef.current.children.length === 0
    )
      return

    observer.observe(
      containerRef.current.children[containerRef.current.children.length - 1],
    )
  }, [count, selectedCategory])

  return {
    containerRef,
    postList: postListByCategory.slice(0, count * NUMBER_OF_ITEMS_PER_PAGE),
  }
}

export default useInfiniteScroll

이제 인피니티 스크롤 기능이 잘 작동하는지 확인해보세요.