where("name", ">=", query) 같은 편법은 다국어 데이터를 만나면 바로 깨지더라고요.keywords 배열을 저장하고, array-contains를 활용해 1차 필터링을 수행합니다.OR, LIKE 연산을 지원하지 않아서 인덱스를 직접 만들어야 했습니다. 저는 extractStringFields로 문자열, 문자열 배열 필드를 긁어와 createKeywords로 변환해 저장했어요.cleaningText 함수에 맡겼습니다. 언어별 모양이 달라도 알파벳과 숫자만 남도록 정리해 검색 안정성을 확보했습니다.텍스트 정규화 함수 만들기
Firestore가 대소문자 구분을 하기 때문에, 먼저 모든 입력을 소문자+알파벳만 남기도록 정리했습니다.
// src/utils/utils.ts
export const cleaningText = (text: string): string => {
const regEx = /[`~!@#$%^&*()_|+\-=?;:'",.<>\{\}\[\]\\\/ ]/gim;
return text.replace(regEx, "").toLowerCase();
};
이 함수 덕분에 화이트 셔츠와 white-shirt가 같은 키워드로 합쳐졌어요.
n-gram 키워드 생성
createKeywords는 문자열 배열을 받아 2글자 이상 10글자 이하의 모든 부분 문자열을 Set으로 모읍니다.
// src/utils/utils.ts
export const createKeywords = (texts: string[]): string[] => {
const keywords = new Set<string>();
texts.forEach((text) => {
const cleanText = cleaningText(text);
for (let i = 0; i < cleanText.length; i++) {
let temp = "";
for (let j = i; j < cleanText.length && j < i + 11; j++) {
temp += cleanText[j];
if (temp.length >= 2) keywords.add(temp);
}
}
});
return Array.from(keywords);
};
처음엔 1글자 키워드도 넣었는데, “a”, “e” 같은 값으로 컬렉션이 뒤덮여서 쿼리 속도가 떨어졌습니다. 그래서 현재처럼 길이를 제한했어요.
문서 저장 시 키워드 주입
상품을 추가하거나 업데이트할 때, 문자열 필드만 추려서 키워드를 생성한 뒤 Firestore에 저장합니다.
// src/utils/firebase/mutation.ts
const stringFields = extractStringFields(rest);
const keywords = createKeywords(stringFields);
await addDoc(collection(db, "products"), {
...newProductData,
keywords,
createdAt: serverTimestamp(),
});
extractStringFields가 문자열 배열까지 펼쳐주기 때문에, 색상이나 스타일 태그도 자동으로 인덱스에 포함됩니다.
쿼리 제한 우회하기
Firestore는 array-contains를 여러 개 동시에 쓰지 못합니다. 저는 첫 키워드만 서버에서 걸고, 나머지는 클라이언트에서 필터링했어요.
// src/utils/firebase/query.ts
if (filters.keywords && Array.isArray(filters.keywords)) {
productsQuery = query(
productsQuery,
where("keywords", "array-contains", cleaningText(filters.keywords[0]))
);
}
const snapshot = await getDocs(productsQuery);
let products = snapshot.docs.map((doc) => ({ ...doc.data(), productId: doc.id }));
if (filters.keywords.length > 1) {
const additionalKeywords = filters.keywords.slice(1);
products = products.filter((product) =>
additionalKeywords.every((keyword: string) =>
product.keywords.includes(cleaningText(keyword))
)
);
}
덕분에 “oversized blazer black”처럼 여러 단어를 입력해도 꽤 쓸 만한 결과를 보여줄 수 있습니다. 물론 완벽한 풀텍스트 검색은 아니지만, MVP 단계에서는 충분했어요.
키워드 샘플링으로 정렬 보정
extractFromKeywords라는 헬퍼로 키워드 중 일부를 고르게 뽑아 노출 순서를 조정했습니다. 검색 결과 추천 키워드를 만들 때 균형 있게 분포시키는 데 도움을 줬습니다.