카테고리 없음

Looky 파이어 베이스 데이터 필터링 / 무한스크롤

인재재 2025. 1. 28. 01:41

파이어 베이스에서 기본적으로 컬렉션, 문서, 정렬, 등등을 구현할 수 있는 다양한 기능을 제공해준다.

 

import {
  collection,
  getDocs,
  query,
  doc,
  where,
  addDoc,
  orderBy,
  updateDoc,
  increment,
  limit,
  deleteDoc,
  QueryDocumentSnapshot,
  startAfter,
  
   . . .
   
   
} from "firebase/firestore";

 

 

1. 데이터 필터링

collection(db, "articles"): Firestore의 데이터베이스(db)에서 articles 컬렉션을 참조

 

Firestore의 query 객체를 생성 후 where절과 맞는 조건들과 일치하는 데이터들을 참조 ,

그리고 쿼리 조건에 맞는 문서를 getDocs(q)로 가져온다음 ( querySnapshot ) 다른 필터링을 프론트단에서 이어서 필터링해주는 방식으로 진행하였다.

 

export async function getArticles(
  filters: ArticleFilter,
  selectedCategories: string[],
  searchTerm: string = "",
): Promise<Article[]> {
  const productRef = collection(db, "articles");
  let q = query(productRef);

  // Firestore 필터 조건 추가
  Object.entries(filters).forEach(([key, value]) => {
    if (value) {
      if (key === "height" || key === "gender") {
        q = query(q, where(`writer.${key}`, "==", value));
      } else {
        q = query(q, where(key, "==", value));
      }
    }
  });
  const querySnapshot = await getDocs(q);

  // Firestore에서 받아온 데이터를 변환
  let articles = querySnapshot.docs.map((doc) => ({
    id: doc.id,
    ...(doc.data() as Omit<Article, "id">),
  }));

  // 카테고리 필터 적용
  if (selectedCategories.length > 0) {
    articles = articles.filter((article) =>
      article.tags.some((tag) => selectedCategories.includes(tag.category)),
    );
  }

  // 검색어 필터 적용
  if (searchTerm.trim()) {
    const lowerSearchTerm = searchTerm.toLowerCase();
    articles = articles.filter((article) =>
      article.title.toLowerCase().includes(lowerSearchTerm),
    );
  }

  return articles;
}

 

원래는  query 객체를 생성 후 where절과 맞는 조건들을 모두 필터링하여 일치하는 데이터들을 참조하려 했었지만 파이어베이스는 " 복합 인덱스 "를 하나하나 생성해줘야되었고 검색어, 카테고리 , TPO, MOOD 등 다양한 필터링으로 각각마다의 게시물을 불러볼 수 있는 LOOKY 프로젝트에는 너무 많은 복합 인덱스를 생성해주어야하였기 때문에 프론트단에서 .filter로 필터링을 하는 방식으로 구현해주었다.

 

 

2. 무한스크롤

IntersectionObserver API를 활용해 구현하였다.

Intersection observer는 브라우저 뷰포트(Viewport)와 원하는 요소(Element)의 교차점을 관찰하며, 요소가 뷰포트에 포함되는지 아닌지 구별하는 기능을 제공한다. 

 

이를 통해 사용자가 페이지를 아래로 스크롤할 때 마지막 게시물(Article)이 화면에 보이면 새로운 게시물을 가져오도록 만든 것이다.

 

observer는 useRef로 생성되어 컴포넌트가 리렌더링되더라도 동일한 Observer 인스턴스를 유지하고

const observer = useRef<IntersectionObserver | null>(null);
 
 

마지막 게시물 감지용 Ref Callback

lastArticleRef는 무한 스크롤의 핵심으로, 마지막 게시물 감지 역할

해당 요소(node)가 화면에 나타났을 때, fetchArticles를 호출하여 추가 데이터를 가져옴

 

콜백은 관찰할 대상 (target)이 등록되거나, 가시성(visibility: 해당 요소가 뷰포트 혹은 특정 요소에서 보이거나 보이지 않을 때)에 변화가 생기면 실행된다.

콜백은 2개의 인수(entries, observer)를 갖는다.

 

ex ) 

const io = new IntersectionObserver((entries, observer) => {}, options)
io.observe(element)
 
const lastArticleRef = useCallback(
  (node: HTMLDivElement | null) => {
    if (observer.current) observer.current.disconnect(); // 기존 Observer 해제

    observer.current = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting && hasMore) { // 마지막 요소가 화면에 나타나면
        fetchArticles(); // 새 데이터 가져오기
      }
    });

    if (node) observer.current.observe(node); // 새로운 Observer로 감지 시작
  },
  [fetchArticles, hasMore],
);

 

데이터 가져오기 및 상태 업데이트

 

fetchArticles 함수는 Firestore에서 기사를 가져오고, 가져온 데이터를 기존 데이터에 추가하거나 필터링하여 articles 상태를 업데이트

 

const fetchArticles = useCallback(
  async (isFilterChanged = false) => {
    if (isLoading) return; // 이미 로딩 중이면 중단

    setIsLoading(true);

    try {
      const { articles: fetchedArticles, lastDoc: newLastDoc } =
        await getArticles(filters, isFilterChanged ? undefined : lastDoc); // 기존 마지막 문서 기준으로 데이터 가져오기

      if (fetchedArticles.length > 0) {
        setArticles((prev) =>
          isFilterChanged
            ? filterArticles(fetchedArticles, selectedCategories, searchTerm) // 필터가 변경된 경우
            : [
                ...prev,
                ...filterArticles(
                  fetchedArticles,
                  selectedCategories,
                  searchTerm,
                ),
              ], // 기존 데이터에 추가
        );
        setLastDoc(newLastDoc); // 새 마지막 문서 설정
      } else {
        setHasMore(false); // 더 이상 가져올 데이터가 없는 경우
      }
    } catch (error) {
      console.error("Error fetching articles:", error);
    } finally {
      setIsLoading(false); // 로딩 상태 해제
    }
  },
  [filters, selectedCategories, searchTerm, lastDoc, isLoading],
);

 

 

startAfterDoc을 활용해 Firestore 쿼리에 페이지네이션을 적용했다,

export async function getArticles(
  filters: ArticleFilter,
  startAfterDoc?: QueryDocumentSnapshot,
  pageSize: number = 4,
): Promise<{
  articles: Article[];
  lastDoc: QueryDocumentSnapshot | undefined;
}> {
  const articlesRef = collection(db, "articles");
  let q = query(articlesRef, limit(pageSize));

  // Firestore 필터 조건 추가
  Object.entries(filters).forEach(([key, value]) => {
    if (value) {
      if (key === "height" || key === "gender") {
        q = query(q, where(`writer.${key}`, "==", value));
      } else {
        q = query(q, where(key, "==", value));
      }
    }
  });

  // 페이지네이션 처리
  if (startAfterDoc) {
    q = query(q, startAfter(startAfterDoc));
  }

  const querySnapshot = await getDocs(q);
  const articles = querySnapshot.docs.map((doc) => ({
    id: doc.id,
    ...(doc.data() as Omit<Article, "id">),
  }));

  const lastDoc =
    querySnapshot.docs.length > 0
      ? querySnapshot.docs[querySnapshot.docs.length - 1]
      : undefined;

  return { articles, lastDoc };
}
 

Observer와 연결된 ref로 로딩 상태 표사

 
{hasMore && 
( <Loading ref={lastArticleRef}> <CircularProgress isIndeterminate color="pink.300" /> )
}