Firebase에서 검색 기능 구현하기 - 삽질 끝에 찾은 해결책

김민석·2024년 9월 9일
0

Tech Deep Dive

목록 보기
2/58
post-thumbnail

들어가며

Firebase 는 실시간 데이터베이스와 간편한 백엔드 서비스로 많은 개발자들의 사랑을 받고 있습니다. 하지만 모든 것이 완벽할 순 없죠. Firebase 를 사용하다 보면 한 가지 큰 벽에 부딪히게 됩니다. 바로 검색 기능입니다.

문제 상황: Firebase 의 한계

Firebase 는 기본적으로 완전 일치 검색만을 지원합니다. 예를 들어, " 안녕하세요 " 라는 데이터가 있다면 " 안녕 " 으로는 검색이 불가능합니다. 이는 사용자 경험을 크게 저하시키는 요인이 되죠.

그렇다면 이 문제를 어떻게 해결할 수 있을까요? 여기 제가 삽질 끝에 찾아낸 해결책을 소개합니다.

해결 방안: 키워드 생성 전략

문제 해결의 핵심은 다음과 같습니다:

  1. 검색 가능한 모든 키워드 조합을 미리 생성
  2. 생성된 키워드를 배열로 저장
  3. 배열에 포함된 키워드로 검색

이제 이 방법을 구현하는 코드를 자세히 살펴보겠습니다.

구현 코드 상세 분석

1. 텍스트 정제 함수

먼저, 입력된 텍스트를 검색에 적합한 형태로 변환하는 함수를 만듭니다.

export const cleaningText = (text: string): string => {
  const regEx = /[`~!@#$%^&*()_|+\-=?;:'",.<>\{\}\[\]\\\/ ]/gim;
  return text.replace(regEx, "").toLowerCase();
};

이 함수는 특수 문자와 공백을 제거하고, 모든 문자를 소문자로 변환합니다. 이를 통해 "Hello, World!" 와 "hello world" 가 모두 "helloworld" 로 변환되어 일관된 검색이 가능해집니다.

코드 분석:

  1. const regEx = /[`~!@#%^&*()_|+\-=?;:'",.<>\{\}$$$\\/ ]/gim;

    • 이 정규표현식은 제거할 특수문자와 공백을 정의합니다.
    • g: 전역 검색 (모든 일치 항목 찾기)
    • i: 대소문자 구분 없음
    • m: 다중 행 모드
  2. text.replace(regEx, "")

    • replace 메서드는 정규표현식에 일치하는 모든 문자를 빈 문자열로 대체합니다.
  3. .toLowerCase()

    • 모든 문자를 소문자로 변환합니다.

2. 키워드 생성 함수

다음으로, 검색 가능한 모든 부분 문자열을 생성하는 함수를 만듭니다.

export const createKeywords = (texts: string[]): string[] => {
  const keywords = new Set<string>();

  texts.forEach((text) => {
    const cleanText = cleaningText(text);
    const length = cleanText.length;

    for (let i = 0; i < length; i++) {
      let temp = "";
      for (let j = i; j < length && j < i + 11; j++) {
        temp += cleanText[j];
        if (temp.length >= 2) {
          keywords.add(temp);
        }
      }
    }
  });

  return Array.from(keywords);
};

이 함수는 입력된 텍스트의 모든 2~10 글자 부분 문자열을 생성합니다. 예를 들어, "hello" 에서 "he", "hel", "hell", "hello", "el", "ell", "ello", "ll", "llo", "lo" 와 같은 키워드가 생성됩니다.

코드 분석:

  1. const keywords = new Set<string>();

    • 중복을 자동으로 제거하는 Set 객체를 생성합니다.
  2. texts.forEach((text) => { … })

    • 입력받은 각 텍스트에 대해 반복합니다.
  3. const cleanText = cleaningText(text);

    • 각 텍스트를 정제합니다.
  4. 이중 for 루프:

    • 외부 루프 for (let i = 0; i < length; i++):
      - 모든 가능한 시작 위치를 순회합니다.
    • 내부 루프 for (let j = i; j < length && j < i + 11; j++):
      - 각 시작 위치에서 최대 10 글자까지의 부분 문자열을 생성합니다.
  5. if (temp.length >= 2) { keywords.add(temp); }

    • 2 글자 이상의 부분 문자열만 키워드로 추가합니다.
  6. return Array.from(keywords);

    • Set 을 배열로 변환하여 반환합니다.

3. 검색 쿼리 함수

마지막으로, 실제 검색을 수행하는 함수를 구현합니다.

export const getProducts = async (
  startAfterDoc: DocumentSnapshot | null,
  limitNumber = 10,
  filters: any = {},
  order: {
    field: string;
    direction: "asc" | "desc";
  } = { field: "name", direction: "asc" }
): Promise<{
  products: Product[];
  lastVisible: DocumentSnapshot | null;
  hasMore: boolean;
}> => {
  try {
    const productsRef = collection(db, "products");
    let productsQuery = query(productsRef);

    // 키워드 필터링
    if (filters.keywords) {
      if (typeof filters.keywords === "string") {
        productsQuery = query(
          productsQuery,
          where("keywords", "array-contains", cleaningText(filters.keywords))
        );
      } else if (Array.isArray(filters.keywords) && filters.keywords.length > 0) {
        productsQuery = query(
          productsQuery,
          where("keywords", "array-contains", cleaningText(filters.keywords[0]))
        );
      }
    }

    // 페이지네이션 및 정렬 적용
    if (startAfterDoc) {
      productsQuery = query(productsQuery, startAfter(startAfterDoc));
    }
    productsQuery = query(productsQuery, limit(limitNumber));

    const snapshot = await getDocs(productsQuery);
    let products: Product[] = [];
    snapshot.forEach((doc) => {
      products.push({ ...doc.data(), productId: doc.id } as Product);
    });

    // 클라이언트 측 추가 필터링 (다중 키워드 AND 연산)
    if (Array.isArray(filters.keywords) && filters.keywords.length > 1) {
      const additionalKeywords = filters.keywords.slice(1);
      products = products.filter((product) =>
        additionalKeywords.every((keyword: string) =>
          product.keywords.includes(cleaningText(keyword))
        )
      );
    }

    const lastVisible = snapshot.docs[snapshot.docs.length - 1] || null;
    const hasMore = snapshot.docs.length === limitNumber;
    return { products, lastVisible, hasMore };
  } catch (err) {
    console.error(err);
    return { products: [], lastVisible: null, hasMore: false };
  }
};

이 함수는 키워드 필터링, 페이지네이션, 그리고 다중 키워드에 대한 AND 연산을 지원합니다. Firebase 의 쿼리 제한을 우회하기 위해 클라이언트 측에서 추가 필터링을 수행하는 점이 특징입니다.

코드 분석:

  1. 함수 매개변수:

    • startAfterDoc: 페이지네이션을 위한 시작 문서
    • limitNumber: 한 번에 가져올 문서 수
    • filters: 검색 필터 (키워드 등)
    • order: 정렬 옵션
  2. 쿼리 구성:

    const productsRef = collection(db, "products");
    let productsQuery = query(productsRef);
    • 'products' 컬렉션에 대한 참조를 생성하고 초기 쿼리를 설정합니다.
  3. 키워드 필터링:

    if (filters.keywords) {
      if (typeof filters.keywords === "string") {
        productsQuery = query(
          productsQuery,
          where("keywords", "array-contains", cleaningText(filters.keywords))
        );
      } else if (Array.isArray(filters.keywords) && filters.keywords.length > 0) {
        productsQuery = query(
          productsQuery,
          where("keywords", "array-contains", cleaningText(filters.keywords[0]))
        );
      }
    }
    • 단일 키워드의 경우: array-contains 쿼리를 사용합니다.
    • 다중 키워드의 경우: 첫 번째 키워드로 필터링합니다.
  4. 페이지네이션:

    if (startAfterDoc) {
      productsQuery = query(productsQuery, startAfter(startAfterDoc));
    }
    productsQuery = query(productsQuery, limit(limitNumber));
    • startAfter: 이전 페이지의 마지막 문서 이후부터 쿼리합니다.
    • limit: 결과 수를 제한합니다.
  5. 결과 처리:

    const snapshot = await getDocs(productsQuery);
    let products: Product[] = [];
    snapshot.forEach((doc) => {
      products.push({ ...doc.data(), productId: doc.id } as Product);
    });
    • 쿼리를 실행하고 결과를 Product 객체 배열로 변환합니다.
  6. 클라이언트 측 추가 필터링:

    if (Array.isArray(filters.keywords) && filters.keywords.length > 1) {
      const additionalKeywords = filters.keywords.slice(1);
      products = products.filter((product) =>
        additionalKeywords.every((keyword: string) =>
          product.keywords.includes(cleaningText(keyword))
        )
      );
    }
    • 다중 키워드의 경우, 클라이언트 측에서 추가 필터링을 수행합니다.
    • every 메서드를 사용하여 모든 키워드가 포함된 제품만 필터링합니다.
  7. 반환 값:

    const lastVisible = snapshot.docs[snapshot.docs.length - 1] || null;
    const hasMore = snapshot.docs.length === limitNumber;
    return { products, lastVisible, hasMore };
    • products: 필터링된 제품 목록
    • lastVisible: 마지막으로 반환된 문서 (다음 페이지 쿼리에 사용)
    • hasMore: 더 많은 결과가 있는지 여부

사용 방법

이제 이 기능을 어떻게 사용하는지 살펴보겠습니다:

  1. 데이터를 저장할 때 createKeywords 함수로 키워드 배열을 생성합니다.
  2. 생성된 키워드 배열을 데이터와 함께 Firebase 에 저장합니다.
  3. 검색 시 getProducts 함수를 호출하여 결과를 가져옵니다.

예를 들어, 제품을 저장할 때는 다음과 같이 사용할 수 있습니다:

const product = {
  name: "스마트폰",
  description: "최신 기술이 적용된 스마트폰입니다.",
  price: 1000000
};

const keywords = createKeywords([product.name, product.description]);

await addDoc(collection(db, "products"), {
  ...product,
  keywords: keywords
});

검색을 수행할 때는 다음과 같이 사용합니다:

const { products, lastVisible, hasMore } = await getProducts(
  null,
  10,
  { keywords: "스마트" }
);

console.log(products); // 검색된 제품 목록
console.log(hasMore); // 더 많은 결과가 있는지 여부

장단점 분석

이 방법은 Firebase 의 한계를 극복할 수 있지만, 완벽한 해결책은 아닙니다.

장점

  • Firebase 의 기본 기능만으로 부분 문자열 검색 구현
  • 다중 키워드 검색 지원
  • 페이지네이션으로 대량 데이터 처리 가능

단점

  • 데이터 중복으로 저장 공간 증가
  • 데이터 업데이트 시 키워드 배열도 함께 업데이트 필요
  • 대규모 데이터셋에서 성능 저하 가능성
  • 클라이언트 측 필터링으로 인한 추가 처리 부담

성능 최적화 팁

  1. 키워드 길이 제한:
    현재 구현에서는 2~10 글자의 키워드를 생성하고 있습니다. 프로젝트의 특성에 따라 이 범위를 조정할 수 있습니다. 예를 들어, 3~8 글자로 제한하면 키워드 수를 줄일 수 있습니다.javascript

    if (temp.length >= 3 && temp.length <= 8) { keywords.add(temp); }

  2. 불용어 제거:
    "the", "a", "an" 같은 흔한 단어들을 키워드에서 제외하여 저장 공간을 절약할 수 있습니다.javascript

    const stopWords = new Set(["the", "a", "an", "in", "on", "at", "for"]); if (!stopWords.has(temp)) { keywords.add(temp); }

  3. 인덱싱 최적화:
    Firebase 에서 keywords 필드에 인덱스를 생성하여 검색 속도를 향상시킬 수 있습니다.

  4. 캐싱 도입:
    자주 검색되는 키워드의 결과를 클라이언트 또는 서버 측에서 캐싱하여 반복적인 쿼리를 줄일 수 있습니다.

확장 가능성

이 방법을 기반으로 더 복잡한 검색 기능을 구현할 수 있습니다:

  1. 가중치 기반 검색:
    제목, 설명 등 필드별로 가중치를 부여하여 더 정확한 검색 결과를 제공할 수 있습니다.
  2. 오타 교정:
    Levenshtein 거리 알고리즘 등을 사용하여 간단한 오타를 교정할 수 있습니다.
  3. 자동 완성:
    키워드 배열을 활용하여 검색어 자동 완성 기능을 구현할 수 있습니다.

대안 솔루션

프로젝트의 규모가 커지거나 더 복잡한 검색 기능이 필요한 경우, 다음과 같은 대안을 고려해볼 수 있습니다:

  1. Algolia:
    Firebase 와 쉽게 통합할 수 있는 강력한 검색 엔진입니다. 복잡한 쿼리와 실시간 검색을 지원합니다.
  2. Elasticsearch:
    대규모 데이터셋에 적합한 분산형 검색 엔진입니다. 풍부한 기능을 제공하지만, 설정과 관리가 복잡할 수 있습니다.
  3. Firebase Extensions:
    Firebase 에서 제공하는 확장 기능 중 검색 관련 솔루션을 활용할 수 있습니다.

마치며

Firebase 로 검색 기능을 구현하는 과정은 쉽지 않았지만, 이 방법을 통해 만족스러운 결과를 얻을 수 있었습니다. 물론, 프로젝트의 규모가 커지거나 복잡한 검색 요구사항이 생긴다면 앞서 언급한 대안 솔루션을 고려해볼 필요가 있습니다.이 글에서 소개한 방법은 완벽한 해결책은 아니지만, Firebase 의 한계를 창의적으로 극복하는 방법을 보여줍니다. 개발 과정에서 마주치는 문제들을 해결하는 과정은 때로는 힘들지만, 그만큼 성장의 기회가 되기도 합니다.여러분의 프로젝트에서 이 방법이 도움이 되길 바랍니다. 그리고 여러분만의 창의적인 해결책이 있다면, 꼭 공유해주세요. 우리는 서로의 경험을 나누며 함께 성장할 수 있습니다.

참고 자료

이 글이 Firebase 에서 고군분투하고 계신 개발자 여러분께 도움이 되었기를 바랍니다. 여러분의 경험이나 추가 팁이 있다면 댓글로 공유해주세요. 함께 성장하는 개발자 커뮤니티를 만들어갑시다!

profile
동업자와 함께 창업 3년차입니다. Nextjs 위주의 프로젝트를 주로 하며, React Native, Supabase, Nestjs를 주로 사용합니다. 인공지능 야간 대학원을 다니고 있습니다.

0개의 댓글