본문 바로가기

개발자 강화/프론트엔드

🌟[상상개발] 무한스크롤을 어떻게 구현하는 게 좋을까?

* 주의: 실제 개발하면서 정리한 것이 아니라, 구현을 어떻게 할 수 있을지 찾아보고 생각을 정리한 글입니다.

 

  • React Query 사용
    • 특징
      • QueryClient라는 객체에서 관리하고, 이는 브라우저 메모리에 존재함.
      • Query Key를 기준으로 저장함. 데이터+상태(status, fetching, updateAt)을 메모리 캐시에 보관함.
      • staleTime으로 캐시된 데이터가 오래된 상태인지 확인하고, 오래됐으면 서버로 데이터 갱신
      • useInfiniteQuery: 페이지네이션 데이터 관리할 때, 각 페이지 데이터를 구분해 메모리 캐시에 저장함.
      • invalidateQueries: 서버 데이터가 변경된 경우, React Query는 invalidateQueries를 통해 캐시를 무효화.
      • Garbage Collection: 일정 시간 동안 사용되지 않는 캐시를 자동으로 제거. cacheTime (기본 5분)
    • 브라우저 새로고침하면 어떻게 됨?
      • 캐시가 메모리 기반으로 저장되어, 브라우저 새로 고침하면 캐시 사라지고, 데이터 다시 가져옴.
      • Persist Query Cache: 브라우저 저장소(localStorage, sessionStorage)에 저장하면 방지할 수 있음.
    • 더보기
      더보기
      더보기
      import { useInfiniteQuery } from "react-query";
      
      const fetchTodos = async ({ pageParam = 1 }) => {
        const res = await fetch(`/api/todos?page=${pageParam}`);
        return res.json();
      };
      
      const InfiniteTodos = () => {
        const {
          data,
          isLoading,
          isError,
          fetchNextPage,
          hasNextPage,
        } = useInfiniteQuery("todos", fetchTodos, {
          getNextPageParam: (lastPage) => lastPage.nextPage || false,
        });
      
        if (isLoading) return <p>Loading...</p>;
        if (isError) return <p>Error loading todos</p>;
      
        return (
          <div>
            {data.pages.map((page, i) => (
              <div key={i}>
                {page.todos.map((todo) => (
                  <p key={todo.id}>{todo.title}</p>
                ))}
              </div>
            ))}
            {hasNextPage && (
              <button onClick={fetchNextPage}>Load More</button>
            )}
          </div>
        );
      };
  • Intersection Observer 사용
    • 특정 요소가 뷰포트에 들어왔을 때 다음 데이터를 불러오도록 구현.
    • 스크롤 이벤트를 직접 사용하는 것보다 효율적이며 성능에 좋음.
    • import { useEffect, useRef } from 'react';
      import axios from 'axios';
      
      const InfiniteScrollWithObserver = () => {
        const [items, setItems] = React.useState([]);
        const [page, setPage] = React.useState(1);
        const [hasMore, setHasMore] = React.useState(true);
      
        const observer = useRef();
      
        const fetchItems = async (page) => {
          const response = await axios.get(`/api/items?page=${page}`);
          if (response.data.length === 0) setHasMore(false);
          setItems((prev) => [...prev, ...response.data]);
        };
      
        const lastItemRef = useRef();
        useEffect(() => {
          fetchItems(page);
        }, [page]);
      
        useEffect(() => {
          if (hasMore && lastItemRef.current) {
            observer.current = new IntersectionObserver(
              (entries) => {
                if (entries[0].isIntersecting) {
                  setPage((prevPage) => prevPage + 1);
                }
              },
              { threshold: 1.0 }
            );
            observer.current.observe(lastItemRef.current);
          }
          return () => observer.current && observer.current.disconnect();
        }, [hasMore, lastItemRef.current]);
      
        return (
          <div>
            {items.map((item, index) => (
              <div key={item.id}>{item.name}</div>
            ))}
            {hasMore && <div ref={lastItemRef}>Loading...</div>}
          </div>
        );
      };
  • Virtualization 라이브러리 사용
    • 많은 양의 데이터를 렌더링할 경우, React Window 같은 가상화 라이브러리 사용.
    • React Window: 화면에 보이는 요소만 렌더링. 나머지 요소는 렌더링 안해 DOM 노드의 개수 제한.
      • Fixed Size List/Fixed Size Grid: 고정된 크기의 리스트/그리드. 높이, 너비 고정 선언.
      • Variable Size List/Variable Size Grid: 가변 크기의 리스트/그리드. 높이, 너비 동적으로 선언.
    • React Virtualized: 대규모 데이터 렌더링 최적화 위한 라이브러리. 
      • AutoSizer: 부모 요소 크기에 맞게 컴포넌트 자동 리사이징. 반응형 레이아웃에 좋음.
      • WindowScroller: 브라우저 전체 스크롤바 사용해 스크롤 가능한 콘텐츠 렌더링.
    • import { FixedSizeList } from 'react-window';
      
      const VirtualizedList = ({ items }) => {
        return (
          <FixedSizeList
            height={500}
            width={300}
            itemSize={50}
            itemCount={items.length}
            itemData={items}
          >
            {({ index, style, data }) => (
              <div style={style}>
                {data[index].name}
              </div>
            )}
          </FixedSizeList>
        );
      };
  • 클라이언트와 서버 간 협업
    • API 응답: 페이지당 데이터 수(limit)와 다음 페이지가 있는지(hasNextPage)를 명시적으로 제공해야 함
    • 로딩 상태 관리: isLoading 및 isFetchingNextPage 같은 상태를 잘 관리해 중복 호출 방지.
  • 성능 최적화
    • 디바운스(Debounce), 쓰로틀(Throttle): 스크롤이벤트 관리
      • Debounce: 이벤트가 연속적으로 발생할 때, 마지막 이벤트가 발생한 후 일정시간 지나야 이벤트 핸들러 실행
      • Throttle: 일정시간 간격 동안 발생한 이벤트 중 첫 번째 or 마지막 이벤트만 처리함(스크롤 구현에 좀 더 좋음)
    • Skeleton UI: 서버 응답 데이터 반영을 기다리는 동안 빈 UI를 보여주어 UX를 향상 시킴.
    • 에러 처리: 데이터 로딩 실패 시 적절한 메시지 표시 및 재시도 기능 제공

 

상황에 맞는 방법?

  • 간단한 구현: React Query
  • 최적화 및 성능 우선: Intersection Observer + React Window 조합
  • 큰 데이터 세트 처리: React Window, React Virtualized

 

만약에 paginiation을 할 때, 이전에 이미 불러온 page 중 하나가 지워진다면 어떤 식으로 해결하는 게 좋을까?

  • 시나리오를 구상해보자! (왜 저렇게 구현함?;;이 아니라 이런 시나리오가 있다고 생각해보자.
    • 블로그 페이지가 있다. 서버 DB에는 1번부터 20번까지 글이 있고, 클라이언트는 한 번에 10개씩 보여준다.
    • 클라이언트는 현재 1페이지이며, 10개의 글(1번~10번)을 보여준다.
    • 서버 관리자가 DB에서 5번 글을 삭제했다!
    • 클라이언트는 이 사실을 모른다. 다음 10개의 페이지를 불러와달라고 요청한다.
    • DB에서는 5번 글이 지워졌으니, 첫 10개의 글은 1번~11번이고, 그 다음 10개 글은 12번~22번이라 생각함.
    • DB에서 10개의 글(12번~22번)을 보내준다.
    • 클라이언트는 현재 UI에 남아있는 1번부터 10번까지 글 뒤에 12번부터 22번 글을 붙여서 보여준다.
    • 문제! 클라이언트 측에서는 서버에 없는 5번을 화면에 보여주고 있고, 서버에 있는 11번을 안보여주고 있음..
  • 문제를 해결해보자
    • 다음 데이터 호출에 관한 문제
      • cursor-based 페이지네이션 : DB에서 중간 데이터가 삭제 되어도 정확히 다음 10개를 불러오자  
        • 삭제된 데이터가 있어도 데이터의 고유한 식별자를 기준으로 다음 데이터 로드함.
        • api요청 시 파라미터에 cursor=${lastItem.id}를 넣어서, 이를 기준으로 다음 데이터를 로드함.
        • 중간에 데이터가 삭제되어도, 마지막 아이템의 id를 기준으로 다음 것을 불러와서 상관 없음.
        • 일단 다음 데이터를 호출하는 것은 성공했으니, 서버에 있는 11번을 안보여주는 문제는 해결했음
    • 삭제된 데이터가 여전히 화면에 남아있는 문제
      • 여전히 1번부터 10번 글이 전부 보이고 있고, 5번 글은 화면에서 사라지지 않았는걸!
      • 낙관적 업데이트
        • 사용자가 5번 글 누르면 그때 DB 확인하지 뭐~
        • 5번 글 누름 -> DB에 없는 글. "삭제된 글입니다." 팝업.
        • fetch API 써도 리소스 감당 가능하면 1번부터 10번(5번 없음)글을 다시 보내서 클라이언트 data fetch
        • 문제! 만약 민감한 데이터 잘못 올렸다면, 오랫동안 사용자 측에 민감 데이터가 남아있을 수도 있음.
        • 민감 데이터가 올라갈 가능성이 있다면, 리소스가 들더라도 동기화를 자주 해주는 구현을 써야 함.
      • 서버에서 불일치 데이터 필터링.
        • 클라이언트가 가지고 있는 데이터 상태를 인식하고, 삭제된 데이터 필터링해서 반환.
        • 클라이언트는 cursor=10과 현재 가지고 있는 글의 id인 [1,2,3,...,10]을 함께 보냄.
        • 서버는 클라이언트가 가진 id 중 5번이 DB에 없는 걸 발견함.
        • 클라이언트에서 deletedIds:[5]라고 알려주고, 다시 랜더링할 부분+새로운 10개 글을 반환함.
          결과적으로 6번~20번 포스트를 반환함.
        • 만약 클라이언트에서 항상 한 페이지 10개 기준을 만족해야 한다면
          빠진 5번 글을 감안해서 6번~21번 포스트 반환하도록 설계해야할 듯?
      • 클라이언트 캐시 무효화, 데이터 재요청
        • React Query 사용한 경우
        • 5번 글이 삭제되면, 해당 페이지 데이터를 무효화(invalidateQueries)해서 전체 페이지 다시 가져옴.
        • 전체 페이지 데이터를 통째로 re로드 하는거라 동기화는 좋지만, 네트워크 비용은 많이 나감.
      • Soft Delete
        • DB에서 is_deleted 필드 추가함. 클라이언트 측에서 이 값을 기준으로 UI에서 제거해서 보여줌.
        • DB 변화사항을 서버에서 감지함. 클라이언트에게 변경 사항을 알림.
        • 서버 응답 중 changes 정보 안에 있는 id 글들을 클라이언트 캐시에서 제거함.
        • 이는 설계에 따라 다를 것 같은데, 사용자가 다음 페이지를 요청했을 때 응답으로 같이 알려줄 수 있을 것 같고, 아니면 주기를 정해서 한 번씩 변경 사항을 fetch할 수도 있을 것 같음.
  • 이미지 최적화
    • 적절한 크기의 이미지 제공
      • 브라우저에서 렌더링 되는 이미지의 크기만큼만 서버에서 제공
      • 원본 이미지가 아니라 리사이징.
      • 요청에 따라 이미지 크기를 동적으로 생성해 필요한 크기만 반환. ?w=300&h=200
    • Lazy Loading
      • 이미지가 화면에 나타날 때만 로드하도록. 초기 로딩 속도를 크게 개선할 수 있음.
      • SSR과 병행해서 초기 페이지 로드 시 중요한 이미지만 로드하도록 개선할수도 있음.
    • 이미지 포맷 최적화
      • 브라우저 호환성 고려.
      • WebP는 JPEG보다 약 30~50% 더 효율적인 압축을 제공함.
      • AVIF는 WebP보다 더 높은 압축률을 제공하며, 최신 브라우저에서 지원함.
      • 이미지 처리 서버나 CDN에서 자동으로 최적화된 포맷을 제공
    • 이미지 압축
      • 무손실 압축: 이미지 품질 유지하며 크기 줄임. ImageOptim, TinyPNG
      • 손실 압축: 품질 약간 낮추고, 크기 더 줄임. Squoosh, Karken.io
      • 서버에서 압축: Nginx(이미지 압축), ImageMagick(이미지 크기와 압축을 동적으로 조절)
    • 이미지 캐싱: 동일한 이미지에 대해 중복 요청 방지
      • HTTP 캐싱: 이미지 응답 헤더에 캐싱 지시어 추가. Cache-Control: public, max-age=31536000
      • CDN(Content Delivery Network): 사용자와 가까운 서버에서 이미지 서빙.(CloudFront, Cloudflare)
    • 반응형 이미지: srcset(여러 이미지와 해상도 옵션 제공), sizes(뷰포트 크기에 따라 사용할 이미지 크기 정의)
    • UX 개선
      • Placeholder 사용: Blurred Placeholder(로딩 중 블러처리된 작은 이미지 표시)
      • Skeleton UI: 이미지를 로드하는 동안 스켈레톤 애니메이션 표시.