Intersection Observer API를 이용하여 Infinite Scroll 구현하기

iskkiri2024년 09월 20일
React
Infinite Scroll
무한 스크롤
Intersection Observer
Intersection Observer API를 이용하여 Infinite Scroll 구현하기

Intersection Observer API를 활용해 무한 스크롤(Infinite Scroll)을 구현하는 방법을 소개하겠습니다.

 

Intersection Observer API는 간단히 말해, 특정 요소가 화면에 보이는지 여부를 관찰할 수 있게 해주는 웹 API입니다. 

자세한 내용은 MDN 문서를 참고해주세요.

 

두 가지 방식으로 무한 스크롤을 구현할 예정입니다.

 

  1. Intersection Observer API를 직접 사용하는 방법
  2. react-intersection-observer 라이브러리를 활용하는 방법

 

이 두 가지 방법을 통해 무한 스크롤 기능을 단계별로 구현해 보겠습니다.

 

아래에서 설명할 예제의 동작과 전체 코드는 Codesandbox에서 확인할 수 있습니다.

 

 

MSW 설정

 

서버 및 DB를 구축하고 진행할 수도 있지만 간단하게 예제를 설명하기 위해 msw를 이용하여 진행합니다. Codesandbox에는 이미 msw가 설정되어 있으므로 Codesandbox의 코드를 확인한다면 이 단계는 건너뛰어도 좋습니다.

 

1. 설치

 

이번 예제에서는 게시글 목록을 가져오기 위해 msw를 사용할 것입니다. 먼저, msw를 설치하려면 다음 명령어를 입력합니다.

 

npm install msw@latest --save-dev

 

CRUD 동작을 쉽게 처리하기 위해 @mswjs/data 도 설치합니다.

 

npm install @mswjs/data --save-dev 

 

faker를 이용하여 모킹 데이터를 생성할 것입니다. faker를 설치합니다.

 

npm install @faker-js/faker --save-dev

 

2. 워커 스크립트 생성

 

npx msw init public --save

 

3. 설정

 

// src/mocks/browser.ts

import { setupWorker } from 'msw/browser';
import { postHandlers } from './handlers/postHandler';

export const worker = setupWorker(...postHandlers);

 

// src/mocks/db.ts

import { factory, primaryKey } from '@mswjs/data';

const db = factory({
  post: {
    id: primaryKey(Number),
    title: String,
    content: String,
  },
});

export default db;

 

// src/mocks/handlers/postHandlers.ts

import { http, HttpResponse } from 'msw';
import db from '../db';
import { faker } from '@faker-js/faker';

Array.from({ length: 100 }).forEach((_, index) => {
  db.post.create({
    id: index + 1,
    title: faker.lorem.words(),
    content: faker.lorem.paragraphs(),
  });
});

export const postHandlers = [
  http.get('/api/posts', ({ request }) => {
    const url = new URL(request.url);
    const searchParams = new URLSearchParams(url.search);

    const page = Number(searchParams.get('page')) || 1;
    const pageSize = Number(searchParams.get('pageSize')) || 10;

    const posts = db.post.findMany({
      take: pageSize,
      skip: (page - 1) * pageSize,
    });

    const totalPages = Math.ceil(db.post.count() / pageSize);

    return HttpResponse.json({
      data: posts,
      pagination: {
        totalItems: db.post.count(),
        pageSize,
        totalPages,
        currentPage: page,
        hasNextPage: page < totalPages,
        hasPreviousPage: page > 1,
      },
    });
  }),
];

 

Intersection Observer Api 를 사용하는 방법

 

먼저, 라이브러리 없이 직접 Intersection Observer API를 사용하는 방법을 살펴보겠습니다.

 

api요청의 경우 axios를 이용하여 진행할 것이기 때문에 axios를 설치합니다. axios를 사용하지 않고 fetch를 사용한다면 axios를 설치하지 않아도 됩니다.

 

npm i axios

 

서버에 요청하는 URL은 '실행중인 URL경로/api/posts' (ex. http://localhost:3000/api/posts)이고, 이 요청에 대한 응답은 다음과 같습니다.

 

{
    "pagination": {
        "totalItems": 100,
        "pageSize": 10,
        "totalPages": 10,
        "currentPage": 1,
        "hasNextPage": true,
        "hasPreviousPage": false
    },
    "data": [
        {
            "id": 1,
            "title": "tonsor adsum cogo",
            "content": "Ater adflicto spectaculum. Curto arx quas complectus. Talis tergum caput aestivus maxime adaugeo.\nVirgo acervus porro crapula harum coniuratio. Validus trucido caecus tabernus. Suggero bardus audacia.\nCattus conventus veritas impedit terga supplanto. Adsum vere cattus. Sumptus currus cervus arbor velit."
        },
								.
								.
								.
        {
            "id": 10,
            "title": "animadverto inflammatio calamitas",
            "content": "Vitium tabgo super cribro aggero. Altus defetiscor sustineo astrum aeneus pauper. Dapifer sequi civitas fugit vesica.\nAro consequuntur voluptatem pax adipisci suscipio aspernatur. Surgo claro curriculum magni cervus compello cresco creta. Corrumpo repellendus caterva.\nVoluptas sodalitas vitae. Succurro cubicularis speciosus demo sodalitas. Spargo admoveo confero."
        }
    ]
}

 

위 응답을 기반으로 DTO를 정의합니다.

 

// /dtos/pagination.dto.ts

export interface PaginationRequestDto {
  page?: number;
  pageSize?: number;
}

export interface PaginationResponse<TData> {
  data: TData[];
  pagination: Pagination;
}

interface Pagination {
  totalItems: number;
  pageSize: number;
  totalPages: number;
  currentPage: number;
  hasNextPage: boolean;
  hasPreviousPage: boolean;
}

 

// /dtos/getPosts.dto.ts

export interface Post {
  userId: number;
  id: number;
  title: string;
  content: string;
}

 

그 다음, client 인스턴스와 getPostsApi 함수를 정의합니다. 

 

그 다음, 게시글을 조회하는 getPostsApi 함수를 정의합니다. pageSize=10을 사용하여 한 번에 10개의 포스트를 가져오며, page(페이지 번호)에 따라 1~10, 11 ~ 20, .. 91~100 번의 게시글을 요청할 수 있습니다.

 

// /api/client.ts

import axios from "axios";

export const client = axios.create({
  baseURL: "/api",
});

 

// /api/post.api.ts

import { client } from "./client";
import type { PaginationRequestDto, PaginationResponse } from "../dtos/pagination.dto";
import type { Post } from "../dtos/getPosts.dto";

export async function getPostsApi({ page = 1, pageSize = 10 }: PaginationRequestDto) {
  const { data } = await client.get<PaginationResponse<Post>>("/posts", {
    params: {
      page,
      pageSize,
    },
  });
  return data;
}

 

이제 관찰할 요소를 생성하고, useRef를 사용해 지정합니다.

 

export default function InfiniteScroll() {
  const observerRef = useRef<HTMLDivElement>(null);

  return (
     <div ref={observerRef} />
  );
}

 

 

서버에서 받아온 포스트들을 state에 저장하며, hasNextPage 상태를 추가해 다음 페이지가 있는지 체크합니다. 

 

참고로 state를 관리하기 위해 redux, zustand와 같은 flux 패턴의 상태관리 라이브러리를 사용해도 되고, recoil, jotai와 같은 atomic 패턴의 상태 관리 라이브러리를 사용해도 됩니다. 저는 서버 상태의 경우에는 tanstack-query를 이용하여 상태관리를 하기 때문에 tanstack-query를 이용하는 편입니다. 포스팅에서는 예제를 간단히 하기 위해서 useState를 사용했습니다.

 

export default function InfiniteScroll() {
  const observerRef = useRef<HTMLDivElement>(null);

  const [posts, setPosts] = useState<Post[]>([]);
  const [hasNextPage, setHasNextPage] = useState(true);
  const page = useRef(1);
  
  							.
  							.
  							.

 

IntersectionObserver의 첫 번째 인수에는 콜백 함수가 들어갑니다. 이 콜백 함수는 세 가지 경우에 호출됩니다.

 

  1. 관찰 대상(target)이 등록될 때, 즉 io.observe 메서드가 호출되었을 때
  2. 관찰 대상이 화면 밖에 있다가 화면에 나타날 때
  3. 관찰 대상이 화면에 있다가 화면에서 사라질 때

 

entries는 observer entry의 배열로, 각 entry에는 관찰하고 있는 요소의 정보와 루트 요소의 정보가 포함되어 있습니다. 루트 요소는 따로 지정하지 않으면 기본값으로 html이 사용됩니다.

 

이번 예제에서는 관찰하는 요소가 하나이기 때문에 entries[0]을 사용했습니다. 만약 여러 개의 관찰 요소가 있다면 entries 배열의 길이도 그만큼 늘어나게 됩니다.

 

각 entry는 다양한 속성을 가지고 있는데, 그 중 isIntersecting 속성은 관찰 대상이 화면에 보이면 true, 보이지 않으면 false를 반환합니다.

 

즉, 아래 코드는 스크롤을 내리면서 지정된 관찰 대상이 화면에 나타날 때 서버에 포스트 데이터를 요청하는 방식으로 동작합니다.
 

export default function InfiniteScroll() {
  const observerRef = useRef<HTMLDivElement>(null);

  const [posts, setPosts] = useState<Post[]>([]);
  const [hasNextPage, setHasNextPage] = useState(true);
  const page = useRef(1);

  useEffect(() => {
    if (!observerRef.current || !hasNextPage) return;

    const io = new IntersectionObserver((entries, _observer) => {
      if (entries[0].isIntersecting) {
        getPostsApi({ page: page.current }).then((reponse) => {
          if (!reponse) return;

          setPosts((prevPosts) => [...prevPosts, ...reponse.data]);
          setHasNextPage(reponse.pagination.hasNextPage);

          if (reponse.pagination.hasNextPage) {
            page.current = page.current + 1;
          }
        });
      }
    });
    io.observe(observerRef.current);

    return () => {
      io.disconnect();
    };
  }, [hasNextPage]);

  return (
  							.
  							.
  							.
          <div ref={observerRef} />
  )

 

jsx가 포함된 전체 코드는 InfiniteScroll.tsx에서 확인할 수 있습니다.

 

 

react-intersection-observer 라이브러리를 사용하는 방법

 

이번에는 intersection observer api를 기반으로 만들어진 react-intersection-observer 라이브러리를 설치합니다.

 

npm i react-intersection-observer

 

첫 번째 방식에서 크게 변하지도 않고, 더 간단합니다. useInView 훅을 이용하여 ref와 inView를 가져옵니다.

ref는 관찰할 대상에 설정하고, inView는 타겟이 화면에 보이지 않으면 false, 화면에 보이면 true를 갖습니다.

첫 번째 방식에서 entry의 isIntersecting과 같은 역할입니다.

 

 const [ref, inView] = useInView();

 

 

앞선 예제에서는 스크롤이 가장 아래에 도달했을 때야 동작했었습니다. 그보다 조금 더 위에서 동작하게 하려면 그보다 위에 있는 요소에 타겟을 지정할 수도 있지만 css의 position을 이용해서 간단하게 해결할 수도 있습니다.

타겟의 부모 엘리먼트에서 position을 relative로 설정하고, 타겟의 position을 absolute로 설정하여 위치를 조정하면 별도의 엘리먼트를 추가하지 않고도 타겟의 위치를 조정할 수 있습니다.

 

export default function InfiniteScrollWithReactIntersectionObserver() {
								.
								.
								.
  return (
    <>
      <h1 style={{ textAlign: 'center', marginBottom: '50px' }}>Infinite Scrolling Example</h1>
      <div style={{ position: 'relative' }}>
								.
								.
								.
        <div ref={ref} style={{ position: 'absolute', bottom: '100px' }} />
      </div>
    </>
  );
}

 

jsx가 포함된 전체 코드는 InfiniteScrollWithReactIntersectionObserver.tsx에서 확인할 수 있습니다.

 

저의 경우에는 react-intersection-observer 라이브러리를 이용하는 편입니다. 그럼에도 Intersection Observer API를 직접 사용하는 방법을 소개한 이유는 라이브러리 없이 API를 활용하는 것이 기본 원리를 이해하는 데 도움이 되며, 특정 상황에서는 더 세밀한 제어가 필요할 수 있기 때문입니다. 

Intersection Observer API를 이용하여 Infinite Scroll 구현하기