결과적으로
useSWRInfinite()와throttle을 이용해서 구현했습니다.
velog의 메인 페이지를 보면 일정 구간 이하로 스크롤을 내릴 때마다 게시글을 추가로 가져오게 됩니다.
해당 기능을 useSWRInfinite(), throttle, 스크롤 이벤트를 이용해서 구현했습니다.

이전에 댓글 더 불러오기를 구현할 때 설명과 예시를 작성한 적이 있기 때문에 이번 포스팅에서는 실제 적용 코드만 첨부하겠습니다.
// /api/posts?page=[page]&offset=[offset]&kinds=[kinds] 형태로 요청
const { data: responsePosts, setSize } = useSWRInfinite<ApiResponseOfPosts>(
  (pageIndex, previousPageData) => {
    if (previousPageData && previousPageData.posts.length !== offset) {
      setHasMorePost(false);
      return null;
    }
    if (previousPageData && !previousPageData.posts.length) {
      setHasMorePost(false);
      return null;
    }
    return `/api/posts?page=${pageIndex}&offset=${offset}&kinds=popular`;
  }
);현재 코드에서 사용한 방식은 throttle 방식입니다.
그 이유는 debounce를 적용하면 계속 스크롤을 할 경우 요청이 계속 지연되는 문제가 발생하기 때문입니다.
throttle이란 한번 요청하면 일정 시간 동안 해당 요청을 무시하는 것을 말합니다.
아래 코드에서는setTimeOut()을 이용해 첫 요청 이후 400ms 이후에 새로운 요청을 받도록 구현했습니다.
import { useCallback, useEffect, useState } from "react";
type Props = {
  condition: boolean;
  // useSWRInfinite()가 반환하는 setSize()
  setSize: (size: number | ((_size: number) => number)) => any;
};
const useInfiniteScroll = ({ condition, setSize }: Props) => {
  const [throttle, setThrottle] = useState(false);
  // 2022/05/06 - 인피니티 스크롤링 함수 - by 1-blue
  const infiniteScrollEvent = useCallback(() => {
    if (!condition) return;
    if (
      window.scrollY + document.documentElement.clientHeight >=
      document.documentElement.scrollHeight - 400
    ) {
      if (throttle) return;
      setThrottle(true);
      setTimeout(() => {
        setThrottle(false);
        setSize((prev) => prev + 1);
      }, 400);
    }
  }, [condition, setSize, throttle, setThrottle]);
  // 2022/05/06 - 무한 스크롤링 이벤트 등록/해제 - by 1-blue
  useEffect(() => {
    window.addEventListener("scroll", infiniteScrollEvent);
    return () => window.removeEventListener("scroll", infiniteScrollEvent);
  }, [infiniteScrollEvent]);
};
export default useInfiniteScroll;
debounce란 연속적으로 들어온 요청 중에 가장 마지막 요청만 실행하는 것을 의미합니다.
아래 코드는setTimeout()을 이용해서 400ms 이내에 연속적으로 요청이 들어오면clearTimeout()을 이용해서 타이머를 해제하고 다시 등록하는 방식으로 동작합니다.
import { useCallback, useEffect, useState, useRef } from "react";
type Props = {
  condition: boolean;
  setSize: (size: number | ((_size: number) => number)) => any;
};
// 2022/05/06 - useSWRInfinite() + 무한 스크롤링을 적용하는 훅 - by 1-blue
const useInfiniteScroll = ({ condition, setSize }: Props) => {
  // 2022/05/06 - 타이머 아이디 - by 1-blue
  const timerId = useRef<any>(null)
  // 2022/05/06 - 인피니티 스크롤링 함수 - by 1-blue
  const infiniteScrollEvent = useCallback(() => {
    if (!condition) return;
    if (
      window.scrollY + document.documentElement.clientHeight >=
      document.documentElement.scrollHeight - 400
    ) {
      if (timerId.current) clearTimeout(timerId.current);
      timerId.current = setTimeout(() => setSize((prev) => prev + 1), 400);
    }
  }, [condition, setSize, throttle, setThrottle]);
  // 2022/05/06 - 무한 스크롤링 이벤트 등록/해제 - by 1-blue
  useEffect(() => {
    window.addEventListener("scroll", infiniteScrollEvent);
    return () => window.removeEventListener("scroll", infiniteScrollEvent);
  }, [infiniteScrollEvent]);
};
export default useInfiniteScroll;useSWRInfinite()로 인해 이차원배열 형태로 데이터가 들어온다.grid )// 2차원 배열이므로 Array.prototype.map()을 두 번 사용해서 배치
// 발생한 문제: <ul> 집합끼리만 grid가 적용돼서 중간에 게시글이 빈 부분이 생김
{responsePosts?.map(({ posts }, index) => (
  <ul
    key={index}
    className="grid gird-col-1 gap-x-8 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
  >
    <>
      {posts.map((post, i) => (
        <Post
          key={post.id}
          post={post}
          photoSize="w-full h-[300px]"
          $priority={i < 4}
        />
      ))}
    </>
  </ul>
))}// <ul>안에 <Post />를 렌더링해서 배치는 정상적으로 작동함
// 하지만 responsePosts?.map()에서 key를 사용하는 부분이 없기 때문에 경고가 발생함
<ul className="grid gird-col-1 gap-x-8 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
  {responsePosts?.map(({ posts }) => (
    <>
      {posts.map((post, i) => (
        <Post
          key={post.id}
          post={post}
          photoSize="w-full h-[300px]"
          $priority={i < 4}
        />
      ))}
    </>
  ))}
</ul>// <Post />만 넣는 배열을 따로 만들어서 렌더링함
// 배치도 정상적이고, key에 대한 경고도 사라짐
const [list, setList] = useState<any>([]);
useEffect(() => {
  setList(
    responsePosts?.map(({ posts }) =>
      posts.map((post, i) => (
        <Post
          key={post.id}
          post={post}
          photoSize="w-full h-[300px]"
          $priority={i < 4}
        />
      ))
    )
  );
}, [responsePosts]);
// jsx
<ul className="grid gird-col-1 gap-x-8 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
  {list}
</ul>스크롤 이벤트는 초당 수십번에서 수백 번까지 발동해서 그런지 throttle이나 denouncedebounce를 적용해도 가끔 무시하고 여러 번 요청되는 경우가 발생합니다.
이 문제는 제가 코드를 잘못 짜서 그런 건지 스크롤 이벤트가 너무 단기간에 많이 발동해서인지 정확한 원인은 파악하지 못했고 어떻게 해결할지도 찾지 못해서 일단 기록하고 넘어가겠습니다.