[Next.js 블로그 만들기] - (9)

무한 스크롤로 모든 포스트 페이지 구현하기

Infinite Scroll로 모든 포스트를 조회할 수 있는 페이지를 만듭니다.

YEAHx4

YEAHx4

2024-11-26

지금 제 블로그는 메인 화면에 표시되는 최신 포스트 6개 말고 예전 포스트를 확인할 수 있는 방법이 없습니다. 물론 검색 기능이 있긴 하지만, 제 블로그에 무슨 포스트가 있는지 조차 모르는 분들에겐 그닥 유용하지 않은 기능입니다. 블로그에 있는 모든 글을 한번에 몰아볼 수 있는 기능이 필요했고, 모든 글 페이지를 만들기로 했습니다. 지금은 헤더 오른쪽에서 메뉴를 찾아볼 수 있습니다.

모든 글 페이지는 말 그대로 모든 글을 표시하기 때문에 꽤 많은 양의 정보가 표시됩니다. 그리고, 한번에 모든 글의 데이터를 불러오려면 서버에도 상당한 부하가 가게 됩니다. 그래서 일반적으로 한번에 모든 글을 로드하지 않고 페이지 기능을 도입하거나 더보기 버튼을 넣어서 추가적으로 로딩합니다. 하지만, 페이지는 한 눈에 모든 글을 돌아보기 좋지 않다고 생각했습니다. 사용자가 추가적으로 행동을 해야 다음 글이 로드되는 것도 그렇게 좋지는 않다고 생각했습니다. 더보기 버튼도 마찬가지입니다.

그럼 사용자가 더보기 버튼을 누르기 전에 알아서 추가적으로 더 불러오면 되지 않을까요?

Infinite Scroll

무한 스크롤은 스크롤이 충분히 내려가서 화면에 표시된 콘텐츠가 떨어졌을 때 추가적으로 새 콘텐츠를 불러와서 아래에 덧붙이는 작업입니다. 사용자 입장에서는 스크롤이 계속 늘어나고 무한하게 스크롤이 내려가는 것처럼 보이게 됩니다.

작동방식

제 블로그는 Next.js 14를 기반으로 만들어졌습니다. 그래서 서버 컴포넌트에서 직접 mdx 파일에 접근하는 함수를 사용할 수 있지만, 무한 스크롤에서는 클라이언트 사이드에서 작동해야 하기 때문에 로컬 파일에 직접 접근할 수 없습니다. 이럴 때를 대비해서 Next.js의 API Routes 기능을 사용해 포스트 정보를 클라이언트 사이드에서도 읽을 수 있게 해두었습니다. /api/posts에 포스트 id를 넣어 요청하면 raw 데이터를 얻을 수 있습니다. 메타데이터를 파싱하는 과정은 클라이언트 사이드에서도 수행 가능하고 굉장히 가벼우니 raw 데이터로도 괜찮습니다.

클라이언트 컴포넌트에서 글을 불러오는 문제는 해결되었습니다. 그럼 언제 글을 불러와야 할까요? 이 부분에는 여러 방식이 있는데, 저는 가장 직관적인 방식을 사용하려고 합니다. 눈에 보이지 않는 div를 하나 두고 그 컴포넌트가 화면에 표시되면 페이지의 끝에 도달했다는 뜻이므로 추가적으로 글을 로딩하려고 합니다.

Trigger

client component인 PostInfiniteLoader.tsx를 만들고 몇 state와 ref를 설정하겠습니다.

const [loadedPosts, setLoadedPosts] = useState<PostMeta[]>([]);
const [loading, setLoading] = useState(false);

const triggerRef = useRef<HTMLDivElement>(null);
const shouldLoad = useOnScreen(triggerRef);

여기서 loadedPosts는 이미 로딩된 포스트들로 화면에 표시되는 데이터들입니다. loading은 현재 로딩을 수행하고 있는지 나타내는 state로 로딩이 진행되고 있다면 skeleton을 화면에 표시하게 됩니다. 여기서 제일 중요한 부분은 shouldLoad인데 화면에 triggerRef가 지정된 div가 표시되고 있으면 true로 바뀌는 hook 입니다. 그럼 useOnScreen은 어떻게 구현되었을까요?

"use client";

import { RefObject, useEffect, useState } from "react";

export default function useOnScreen(ref: RefObject<HTMLElement>) {
  const [isIntersecting, setIntersecting] = useState(false);

  useEffect(() => {
    if (typeof window === "undefined" || !ref.current) return;

    const observer = new IntersectionObserver(([entry]) =>
      setIntersecting(entry.isIntersecting)
    );

    observer.observe(ref.current);

    return () => observer.disconnect();
  }, [ref]);

  return isIntersecting;
}

ref를 파라미터로 받아서 IntersectionObserver를 통해 화면에 표시되고 있는지(상호작용 할 수 있는지)를 검사하고 있습니다. useEffect를 통해서 unmount될 때는 이벤트 리스너를 해제하고 있습니다. 이제 useOnScreen을 통해서 div가 화면에 표시되고 있는지를 실시간으로 읽어올 수 있게 되었습니다.

글 불러오기

PostInfiniteLoader 컴포넌트 자체는 client component이기 때문에 시리즈 데이터와 글 목록 조차 읽어올 수 없습니다(목록과 파일에서 읽는 코드가 같은 파일에 있기 때문에 그렇습니다). 그래서 server component인 /posts/page.tsx에서 읽어와 props로 전달해 주고 있습니다.

import { posts, series } from "@/lib/post/posts";
import PostIinfiniteLoader from "@/components/post-whole/PostInfiniteLoader";

export default function Posts() {
  return <PostIinfiniteLoader posts={posts} series={series} />;
}

export default function PostIinfiniteLoader({
  posts,
  series,
}: {
  posts: string[];
  series: {
    [key: string]: {
      name: string;
      description: string;
      posts: string[];
    };
  };
}) {
  // ...
}

위에서 살펴본 대로 shouldLoad가 있으니 이제 글을 불러와 봅시다.

useEffect(() => {
  if (loading || loadedPosts.length === posts.length || !shouldLoad) return;

  const loadNextPost = async () => {
    setLoading(true);
    const start = Date.now();

    const nextPost = posts[loadedPosts.length];
    if (!nextPost) {
      setLoading(false);
      return;
    }

    try {
      const res = await fetch("/api/posts", {
        method: "POST",
        body: JSON.stringify({ path: nextPost }),
      });

      const { content } = await res.json();
      const meta = parsePost(content).meta;

      const end = Date.now();
      if (end - start < 200) {
        await new Promise((resolve) =>
          setTimeout(resolve, 200 - (end - start))
        );
      }

      setLoadedPosts((prev) => [...prev, meta]);
    } catch (error) {
      console.error("Failed to load post:", error);
    } finally {
      setLoading(false);
    }
  };

  loadNextPost();
}, [shouldLoad, loading]);

일단, 지금 불러와야 하는지 확인하고 불러올 글이 있는지 확인합니다. 모든 조건이 갖춰지면 try-catch 블록을 통해 새 포스트를 읽어옵니다. 글을 불러오는 동안 loading state가 true로 설정되고 skeleton이 보이게 되는데, 로딩 속도가 굉장히 빠르기 때문에 skeleton이 깜빡거리다 사라져서 최소 200ms까지 기다리게 구현했습니다.

마지막으로 useEffect의 dependencies를 [shouldLoad, loading]로 설정한 이유는 처음 빈 페이지에서 글을 불러올 때, shouldLoad가 계속 true로 유지되어 있기 때문입니다. 그러면 하나의 글을 불러온 뒤 더이상 새로운 글을 로드하지 않습니다. 그래서 loading이 변한 이후에도 한번 더 체크해서 계속 글을 불러오도록 구현했습니다.

글 표시하기

이제 ref를 연결하고, state에 맞춰 글 목록을 표시하기만 하면 됩니다.

return (
  <div className="max-w-5xl w-full mx-auto px-4">
    <div className="flex flex-col gap-8">
      {loadedPosts.map((post, index) => (
        <Link href={`/posts/${posts[index]}`} key={index}>
          <PostSummary
            meta={post}
            seriesName={post.series && series[post.series].name}
          />
        </Link>
      ))}
    </div>

    {loading ? (
      <div className="mt-8">
        <PostSkeleton />
      </div>
    ) : (
      <div ref={triggerRef} />
    )}
  </div>
);

Skeleton

스켈레톤은 글을 자연스럽게 로딩하기 위해서 사용하는 컴포넌트입니다. 실제로 로딩될 콘텐츠의 모양에 맞춰 표시합니다. 빙글빙글 돌아가는 spinner loader나 loading... 같은 문구를 사용해도 같은 효과를 낼 수 있지만, 스켈레톤을 사용하면 로드되기 전에 미리 형태를 어느정도 알 수 있고 로딩이 더 빠른듯한 느낌을 받을 수 있습니다. 그리고 큰 컴포넌트가 로딩되기 전에 미리 자리를 차지해서 갑자기 레이아웃이 변하는 cumulative layout shift 문제를 줄일 수 있습니다.

스켈레톤은 딱히 어려운 기능은 없고, 다른 컨텐츠의 레이아웃과 비슷하게 만들어서 배경이 일렁거리며 로딩되는 애니메이션을 주면 됩니다.

import cn from "@yeahx4/cn";

export default function PostSkeleton() {
  return (
    <div
      className={cn(
        "flex flex-col md:flex-row items-start md:items-center p-4",
        "gap-4 md:gap-8 bg-white dark:bg-dark-bg rounded-lg shadow-lg",
        "dark:bg-gray-800 transition-all duration-300 hover:shadow-xl",
        "h-48 animate-pulse"
      )}
    >
      <div className="flex items-center justify-center w-48 h-40 bg-gray-300 rounded sm:w-96 dark:bg-gray-700">
        <svg
          className="w-10 h-10 text-gray-200 dark:text-gray-600"
          aria-hidden="true"
          xmlns="http://www.w3.org/2000/svg"
          fill="currentColor"
          viewBox="0 0 20 18"
        >
          <path d="M18 0H2a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2Zm-5.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm4.376 10.481A1 1 0 0 1 16 15H4a1 1 0 0 1-.895-1.447l3.5-7A1 1 0 0 1 7.468 6a.965.965 0 0 1 .9.5l2.775 4.757 1.546-1.887a1 1 0 0 1 1.618.1l2.541 4a1 1 0 0 1 .028 1.011Z" />
        </svg>
      </div>
      <div className="w-full">
        <div className="h-2.5 bg-gray-200 rounded-full dark:bg-gray-700 w-48 mb-4"></div>
        <div className="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[480px] mb-2.5"></div>
        <div className="h-2 bg-gray-200 rounded-full dark:bg-gray-700 mb-2.5"></div>
        <div className="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[440px] mb-2.5"></div>
        <div className="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[460px] mb-2.5"></div>
        <div className="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[360px]"></div>
      </div>
      <span className="sr-only">Loading...</span>
    </div>
  );
}

맨 밑에 sr-only 클래스네임이 붙은 span이 하나 있는데, sr-only는 Screen Reader only의 약자입니다. 스크린 리더를 사용해서 화면에 스켈레톤이 보이고 있는지 알 수 없는 분들을 위해 넣어주었습니다.