728x90
728x90

무한 스크롤(Infinite Scroll), 스켈레톤(Skeleton) 효과 적용하기 (with React Query)

들어가며

  • React Query를 이용하여 무한 스크롤(Infinite Scroll) 스켈레톤(Skeleton) 효과를 적용한 예제 코드를 올려본다.
  • 개인 미니 프로젝트를 진행하면서 만들었던 코드이다.
    • 검색어를 입력하면, 해당 검색어와 관련된 이미지를 표시하는 사이트
    • Unsplash 공개 API를 이용하여 구현하였다.

 

코드

  • 리액트 쿼리@useInfiniteQuery()@를 이용하여 무한 스크롤(Infinite Scroll) 기능을 구현하였다.
    • @IntersectionObserver@를 사용하여 스크롤이 페이지의 맨 아래에 도달했을 때만 다음 페이지의 데이터를 불러오도록 설정하였다.
  • @useState@를 이용하여 스켈레톤(Skeleton) 기능을 구현하였다.
    • @const [isLoadingSkeleton, setIsLoadingSkeleton] = useState(true);@ 
    • @useInfiniteQuery()@@onSuccess()@ 속성을 이용하여 이미지가 모두 불러와졌을 경우 @isLoadingSkeleton@를 @false@로 설정한다.
    • 만약 이미지가 모두 불러와지지 않았을 경우(@isLoadingSkeleton@ : @false@), 스켈레톤 효과를 적용시킨 요소를 표시한다.

 

Gallery.jsx
  • @useInfiniteQuery()@를 사용하여 무한 스크롤 기능을 구현하였다.
    • @getNextPageParm@은 다음 페이지를 결정하는 함수이다.
    • @onenabled@에는 검색어가 있을 때만 쿼리가 실행되도록 조건을 추가하였다.
      • 느낌표 2개(@!!@)를 사용하면, 첫 번째 @!@로 값을 @falsy@하게 변환한 후, 두 번째 @!@로 다시 @boolean@으로 변환할 수 있다.
      • 예) @searchTerm@이 빈 문자열(@""@)일 경우,
        • @!searchTerm@ → @true@ (빈 문자열은 @falsy@)
        • @!!searchTerm@ → @false@ (최종 결과는 boolean @false@) 
  • @IntersectionObserver@를 사용하여 특정 요소가 뷰포트에 들어오면 다음 페이지를 로드하도록 설정하였다.
import axios from 'axios';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useRef, useCallback, useState } from 'react';

import { useGlobalContext } from '../contexts/context';

const url = `https://api.unsplash.com/search/photos?client_id=${
  import.meta.env.VITE_API_KEY
}`;

const Gallery = () => {
  const { searchTerm } = useGlobalContext();
  const loader = useRef(null);

  // 한 번에 불러올 이미지 개수 설정
  const itemsCountParam = 9;

  const [isLoadingSkeleton, setIsLoadingSkeleton] = useState(true);

  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isError,
    isLoading,
  } = useInfiniteQuery({
    queryKey: ['images', searchTerm],
    queryFn: async ({ pageParam = 1 }) => {
      const response = await axios.get(
        `${url}&query=${searchTerm}&page=${pageParam}&per_page=${itemsCountParam}`
      );
      return response.data;
    },
    getNextPageParam: (lastPage, allPages) => {
      // 다음 페이지가 있는지 확인
      if (lastPage.results.length < 1) return undefined;

      return allPages.length + 1;
    },
    enabled: !!searchTerm, // searchTerm이 있을 때만 쿼리 실행
    onSuccess: () => {
      setIsLoadingSkeleton(false); // 성공 시 skeleton 제거
    },
  });

  // 특정 시간 뒤에 불러오기
  const loadNextPageWithDelay = useCallback(() => {
    if (hasNextPage) {
      setTimeout(() => {
        fetchNextPage();
      }, 3000); // 3초
    }
  }, [fetchNextPage, hasNextPage]);

  const observer = useCallback(
    (node) => {
      if (isLoading || isFetchingNextPage) return;
      if (loader.current) loader.current.disconnect();

      loader.current = new IntersectionObserver((entries) => {
        if (entries[0].isIntersecting && hasNextPage) {
          loadNextPageWithDelay();
        }
      });
      if (node) loader.current.observe(node);
    },
    [isLoading, isFetchingNextPage, fetchNextPage, hasNextPage]
  );

  // 로딩 중일 때
  if (isLoading) {
    return (
      <section className="image-container-wrapper">
        <h4 className="description">Loading...</h4>
      </section>
    );
  }

  // 에러 발생 시
  if (isError) {
    return (
      <section className="image-container-wrapper">
        <h4 className="description">There was an error...</h4>
      </section>
    );
  }

  // 결과가 없을 때
  if (data?.pages[0].results.length < 1) {
    return (
      <section className="image-container-wrapper">
        <h4 className="description">No results found...</h4>
      </section>
    );
  }

  return (
    <section className="image-container-wrapper">
      <div className="image-container">
        {data?.pages.map((page, pageIndex) =>
          page.results.map((item, index) => {
            const url = item?.urls?.regular;

            const isSkeleton =
              isLoadingSkeleton && pageIndex === data.pages.length - 1;

            return isSkeleton & (pageIndex > 0) ? (
              <div key={index} className="img skeleton" />
            ) : (
              <div key={item.id} className="img-wrapper">
                <img src={url} alt={item.alt_description} className="img" />
                <span className="tooltip">{item.alt_description}</span>
              </div>
            );
          })
        )}
      </div>
      <div ref={observer} style={{ height: '20px', marginBottom: '20px' }}>
        {isFetchingNextPage ? (
          <h4 className="description">Loading more...</h4>
        ) : (
          <h4 className="description">Loading...</h4>
        )}
      </div>
    </section>
  );
};

export default Gallery;

 

index.css
/* ... */

.image-container-wrapper {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  margin: 5rem auto;
  width: var(--view-width);
  max-width: var(--max-width);
  background: rgba(255, 255, 255, 0.1);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  border-radius: 15px;
  border: 1px solid rgba(255, 255, 255, 0.2);
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}

.image-container {
  margin: 3rem auto;
  display: grid;
  gap: 2rem;
}

.image-container .img {
  height: 10rem;
  border-radius: 0.8rem;
  cursor: pointer;
  transition: all 0.3s;
  box-sizing: border-box;
  border: 1.5px solid rgba(0, 0, 0, 0.2);
}

/* ... */

.skeleton {
  background-color: #e0e0e0;
  background-image: linear-gradient(90deg, #e0e0e0, #f0f0f0, #e0e0e0);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
  cursor: default !important;
}

.skeleton:hover {
  transform: none !important;
  border-color: transparent !important;
}

@keyframes shimmer {
  0% {
    background-position: -200% 0;
  }
  100% {
    background-position: 200% 0;
  }
}

무한 스크롤과 스켈레톤 효과를 적용한 모습

 

개념 정리

useInfiniteQuery()

개념

  • 리액트 애플리케이션에서 무한 스크롤링이나 페이지네이션을 이용할 때 유용한 훅
  • 이 훅을 사용하면 서버에서 데이터를 페이지 단위로 요청하고, 각 페이지마다 받은 데이터를 캐싱하여 관리할 수 있다.

 

기능

  • @useInfiniteQuery()@는 백그라운드에서 데이터를 요청하고, 받은 데이터를 자동으로 캐싱하여 성능을 최적화한다.
    • 각 페이지의 데이터는 쿼리 키(key)에 의해 관리된다.
  • 서버로부터 페이지별 데이터를 요청하며, 새로운 페이지를 로드할 때 이전 페이지의 데이터와 합쳐져 화면에 표시한다.
    • 무한 스크롤링이나 '더 보기' 버튼과 같은 UI 패턴을 구현하는 데 유용하다.
  • 리액트 쿼리는 캐시된 데이터를 자동으로 유지하며, 새로운 데이터 요청 시 이전에 받은 데이터를 재사용할 수 있다.
    • 중복된 요청을 줄이고 애플리케이션의 반응성을 향상시킨다.
  • 필요한 경우 자동으로 데이터를 리패치하여 최신 데이터를 유지한다.
    • 예) 데이터가 변경되거나 사용자의 요청에 따라 데이터를 업데이트할 수 있다.

 

사용 예

  • @useInfiniteQuery()@는 @/api/posts@ 엔드포인트에서 페이지별로 포스트 데이터를 요청하며, 페이지마다 받은 데이터를 화면에 표시한다.
  • @fetchNextPage()@ 함수를 사용하여 추가 페이지를 로드하고, @isFetching@을 사용하여 데이터를 가져오는 동안 로딩 상태를 표시한다.
import { useInfiniteQuery } from 'react-query';

function MyComponent() {
  const fetchPosts = async (key, nextPage = 0) => {
    const response = await fetch(`/api/posts?page=${nextPage}`);
    return response.json();
  };

  const { data, fetchNextPage, hasNextPage, isFetching } = useInfiniteQuery(
    'posts',
    fetchPosts,
    {
      getNextPageParam: (lastPage) => lastPage.nextPage, // 페이지마다 다음 페이지를 결정하는 함수
    }
  );

  return (
    <div>
      {data.pages.map((page, pageIndex) => (
        <React.Fragment key={pageIndex}>
          {page.posts.map((post) => (
            <div key={post.id}>{post.title}</div>
          ))}
        </React.Fragment>
      ))}
      {hasNextPage && (
        <button onClick={() => fetchNextPage()}>더 보기</button>
      )}
      {isFetching && <p>로딩 중...</p>}
    </div>
  );
}

 

IntersectionObserver

개념

  • 웹 API 중 하나로, DOM 요소가 다른 요소(또는 뷰포트)에 교차(Intersect)하는지 감지하는 데 사용된다.
  • 주로 무한 스크롤, lazy loading(지연 로딩), 애니메이션 트리거 등에서 유용하게 활용된다.

 

기능

  • 비동기적으로 작동하며, 특정 요소의 가시성(화면에 보이는지 여부)을 감지한다.
  • 스크롤 이벤트 리스너를 사용하는 대신, @IntersectionObserver@를 사용하면 성능을 개선할 수 있다.
    • 스크롤 시 매번 이벤트를 호출하는 것이 아니라, 요소가 화면에 들어오거나 나갈 때만 호출되기 때문이다.
  • 관찰할 요소의 비율, 마진 등 다양한 옵션을 설정할 수 있다.
    • 예) 특정 비율 이상으로 요소가 화면에 나타났을 때만 콜백 함수를 실행하도록 설정할 수 있다.

 

사용 예

IntersectionObserver 생성
const observer = new IntersectionObserver((entries, observer) => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            // 요소가 화면에 보일 때
            console.log('Element is visible!');
        } else {
            // 요소가 화면에서 사라질 때
            console.log('Element is not visible.');
        }
    });
}, {
    root: null, // viewport 기준
    rootMargin: '0px', 
    threshold: 1.0 // 100% 보일 때만 콜백 실행
});

 

관찰할 요소 지정
const target = document.querySelector('.target-element');
observer.observe(target); // 특정 요소 관찰 시작

 

관찰 중단
observer.unobserve(target); // 특정 요소의 관찰 중단

 

관찰자 해제
observer.disconnect(); // 모든 관찰 중단

 

리액트에서의 사용 예제
  • @LazyImage@ 컴포넌트
    • @isVisible@ 상태를 관리하여 이미지가 보일 때만 로드되도록 한다.
    • @imageRef@를 사용하여 해당 DOM 요소를 참조한다.
    • @observerCallback@ 함수에서 요소가 화면에 들어오면 @isVisible@을 @true@로 설정하고, 더 이상 관찰하지 않도록 한다.
  • @useEffect@ 훅을 이용하여 @IntersectionObserver@를 생성하고, 관찰할 요소를 지정한다.
    • 컴포넌트가 언마운트될 때 @unobserve@를 호출하여 메모리 누수를 방지한다.
import { useEffect, useRef, useCallback, useState } from 'react';

const LazyImage = ({ src, alt }) => {
  const [isVisible, setIsVisible] = useState(false);
  const imageRef = useRef(null);

  const observerCallback = useCallback((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        setIsVisible(true);
        observer.unobserve(imageRef.current); // 관찰 중단
      }
    });
  }, []);

  useEffect(() => {
    const observer = new IntersectionObserver(observerCallback, {
      root: null, // viewport 기준
      rootMargin: '0px',
      threshold: 0.1, // 10% 보일 때
    });

    if (imageRef.current) {
      observer.observe(imageRef.current); // 요소 관찰 시작
    }

    return () => {
      if (imageRef.current) {
        observer.unobserve(imageRef.current); // 컴포넌트 언마운트 시 관찰 중단
      }
    };
  }, [observerCallback]);

  return (
    <div ref={imageRef} style={{ minHeight: '200px', marginBottom: '20px' }}>
      {isVisible ? <img src={src} alt={alt} style={{ width: '100%' }} /> : <div className="skeleton" style={{ height: '100%' }} />}
    </div>
  );
};

const Gallery = () => {
  const images = [
    { src: 'https://via.placeholder.com/600x400/FF5733/FFFFFF?text=Image+1', alt: 'Image 1' },
    { src: 'https://via.placeholder.com/600x400/33FF57/FFFFFF?text=Image+2', alt: 'Image 2' },
    { src: 'https://via.placeholder.com/600x400/3357FF/FFFFFF?text=Image+3', alt: 'Image 3' },
  ];

  return (
    <div>
      {images.map((image, index) => (
        <LazyImage key={index} src={image.src} alt={image.alt} />
      ))}
    </div>
  );
};

export default Gallery;

 

정리

  • 리액트 라우터(React Router)의 @useInfiniteQuery@를 이용하여 무한 스크롤(Infinite Scroll) 기능을 구현할 수 있다.
  • @IntersectionObserver@를 이용하여 스켈레톤(Skeleton) 효과를 구현할 수 있다.
    • @IntersectionObserver@는 웹 API이며, DOM 요소가 다른 요소(또는 뷰포트)에 교차(Intersect)하는지 감지하는데 사용한다.
    • 사용자가 스크롤할 때 이미지가 뷰포트에 들어오면 이미지를 로드하고, 그렇지 않으면 로딩 스켈레톤을 표시한다.

 

참고 사이트

 

TanStack | High Quality Open-Source Software for Web Developers

Headless, type-safe, powerful utilities for complex workflows like Data Management, Data Visualization, Charts, Tables, and UI Components.

tanstack.com

 

IntersectionObserver - Web API | MDN

Intersection Observer API의 IntersectionObserver 인터페이스는 대상 요소와 상위 요소, 또는 대상 요소와 최상위 문서의 뷰포트가 서로 교차하는 영역이 달라지는 경우 이를 비동기적으로 감지할 수 있는

developer.mozilla.org

 

728x90
728x90