
React에서 커스텀 블로그 검색 구현하기
(수정: 2025년 12월 24일 오후 02:41)
React에서 커스텀 블로그 검색 구현하기
블로그 포스트가 늘어나면서 검색 기능이 필요해졌습니다. 단순 텍스트 검색뿐 아니라 시리즈, 카테고리, 태그별 필터링과 AND/OR 조합까지 지원하는 검색 시스템을 구현한 과정입니다.
요구사항
- 제목, 내용에서 키워드 검색
- 시리즈 필터:
!시리즈명 - 카테고리 필터:
@카테고리명 - 태그 필터:
#태그명 - AND 조건:
React & TypeScript - OR 조건:
!ADP | !React - 그룹화:
(#공부 | #개발) & React - URL 쿼리 파라미터 연동
검색 문법 설계
연산자와 우선순위
| 연산자 | 의미 | 우선순위 |
|---|---|---|
() | 그룹화 | 최고 |
& | AND (모두 만족) | 높음 |
| | OR (하나라도 만족) | 낮음 |
사용 예시
# 단순 검색 React → "React" 포함된 포스트 # 필터 검색 !ADP → ADP 시리즈 @개발 → 개발 카테고리 #공부 → 공부 태그 # 복합 검색 React & TypeScript → 둘 다 포함 React | Vue → 둘 중 하나 포함 !ADP & React → ADP 시리즈 중 React 포함 # 그룹화 (#공부 | #개발) & React → (공부 또는 개발 태그) AND React !ADP | (!React & 훅) → ADP 시리즈 OR (React 시리즈 AND 훅)
파서 구현
데이터 구조
검색 쿼리를 AST(Abstract Syntax Tree)로 변환합니다.
type FilterType = "text" | "series" | "category" | "tag"; interface SearchToken { type: FilterType; value: string; } interface SearchNode { type: "token" | "and" | "or"; token?: SearchToken; left?: SearchNode; right?: SearchNode; }
토큰 파싱
접두사에 따라 필터 타입을 결정합니다.
function parseToken(term: string): SearchToken { const trimmed = term.trim(); if (trimmed.startsWith("!")) { return { type: "series", value: trimmed.slice(1).toLowerCase() }; } if (trimmed.startsWith("@")) { return { type: "category", value: trimmed.slice(1).toLowerCase() }; } if (trimmed.startsWith("#")) { return { type: "tag", value: trimmed.slice(1).toLowerCase() }; } return { type: "text", value: trimmed.toLowerCase() }; }
재귀 하강 파서
연산자 우선순위 처리
OR이 가장 낮은 우선순위이므로 먼저 분할하고, 그 다음 AND를 처리합니다.
function parseQuery(query: string): SearchNode | null { const trimmed = query.trim(); if (!trimmed) return null; // 1. OR 연산자 분할 (가장 낮은 우선순위) // 괄호 밖의 | 찾기 (오른쪽에서부터) let depth = 0; let orIndex = -1; for (let i = trimmed.length - 1; i >= 0; i--) { if (trimmed[i] === ")") depth++; if (trimmed[i] === "(") depth--; if (depth === 0 && trimmed[i] === "|") { orIndex = i; break; } } if (orIndex !== -1) { const left = parseQuery(trimmed.slice(0, orIndex)); const right = parseQuery(trimmed.slice(orIndex + 1)); if (left && right) { return { type: "or", left, right }; } return left || right; } // 2. AND 연산자 분할 depth = 0; let andIndex = -1; for (let i = trimmed.length - 1; i >= 0; i--) { if (trimmed[i] === ")") depth++; if (trimmed[i] === "(") depth--; if (depth === 0 && trimmed[i] === "&") { andIndex = i; break; } } if (andIndex !== -1) { const left = parseQuery(trimmed.slice(0, andIndex)); const right = parseQuery(trimmed.slice(andIndex + 1)); if (left && right) { return { type: "and", left, right }; } return left || right; } // 3. 괄호 처리 if (trimmed.startsWith("(") && trimmed.endsWith(")")) { // 전체가 괄호로 감싸진 경우만 벗기기 const inner = trimmed.slice(1, -1); let checkDepth = 0; let isFullyWrapped = true; for (let i = 0; i < inner.length; i++) { if (inner[i] === "(") checkDepth++; if (inner[i] === ")") checkDepth--; if (checkDepth < 0) { isFullyWrapped = false; break; } } if (isFullyWrapped && checkDepth === 0) { return parseQuery(inner); } } // 4. 단일 토큰 const token = parseToken(trimmed); if (token.value) { return { type: "token", token }; } return null; }
오른쪽에서 왼쪽으로 탐색하면 좌결합(left-associative) 연산이 됩니다.
노드 평가
토큰 매칭
각 토큰 타입에 따라 포스트와 매칭합니다.
function matchToken(token: SearchToken, post: BlogPost): boolean { switch (token.type) { case "series": return post.series?.toLowerCase().includes(token.value) ?? false; case "category": return post.category.toLowerCase().includes(token.value); case "tag": return post.tags.some((tag) => tag.toLowerCase().includes(token.value) ); case "text": return ( post.title.toLowerCase().includes(token.value) || post.content.toLowerCase().includes(token.value) ); } }
재귀적 노드 평가
AST를 순회하며 AND/OR 논리를 적용합니다.
function evaluateNode(node: SearchNode, post: BlogPost): boolean { if (node.type === "token" && node.token) { return matchToken(node.token, post); } if (node.type === "and" && node.left && node.right) { return evaluateNode(node.left, post) && evaluateNode(node.right, post); } if (node.type === "or" && node.left && node.right) { return evaluateNode(node.left, post) || evaluateNode(node.right, post); } return true; }
메인 검색 함수
export function searchPosts(posts: BlogPost[], query: string): BlogPost[] { const trimmedQuery = query.trim(); if (!trimmedQuery) return posts; const searchTree = parseQuery(trimmedQuery); if (!searchTree) return posts; return posts.filter((post) => evaluateNode(searchTree, post)); }
사용 예시
import { searchPosts } from "@/lib/searchParser"; // 단순 검색 searchPosts(posts, "React"); // 복합 검색 searchPosts(posts, "!ADP & (#공부 | #개발)"); // 시리즈 OR 검색 searchPosts(posts, "!ADP | !React");
React 컴포넌트 연동
URL 쿼리 파라미터
import { useSearchParams } from "react-router-dom"; import { searchPosts } from "@/lib/searchParser"; const Blog = () => { const [searchParams, setSearchParams] = useSearchParams(); const query = searchParams.get("q") || ""; const handleSearchChange = (value: string) => { if (value) { setSearchParams({ q: value }); } else { setSearchParams({}); } }; const filteredPosts = searchPosts(posts, query); return ( <div> <SearchInput value={query} onChange={handleSearchChange} placeholder="검색: !시리즈 @카테고리 #태그 & | ()" /> <PostList posts={filteredPosts} /> </div> ); };
태그 클릭 연동
const BlogPostCard = ({ post }: BlogPostCardProps) => { const navigate = useNavigate(); const handleTagClick = (e: React.MouseEvent, path: string) => { e.preventDefault(); e.stopPropagation(); navigate(path); }; return ( <NavLink to={`/blog/${post.slug}`}> <article> {post.series && ( <span onClick={(e) => handleTagClick(e, `/blog?q=!${encodeURIComponent(post.series)}`) }> {post.series} </span> )} {post.category && ( <span onClick={(e) => handleTagClick(e, `/blog?q=@${encodeURIComponent(post.category)}`) }> {post.category} </span> )} {post.tags.map((tag) => ( <span key={tag} onClick={(e) => handleTagClick(e, `/blog?q=%23${encodeURIComponent(tag)}`) }> {tag} </span> ))} </article> </NavLink> ); };
검색 도움말
사용자에게 검색 문법을 안내합니다.
export const searchHelp = { text: "제목/내용 검색", series: "!시리즈명", category: "@카테고리명", tag: "#태그명", and: "& (AND)", or: "| (OR)", group: "() (그룹)", }; // 도움말 UI const SearchHelp = () => ( <div className="text-xs text-muted-foreground"> <span>!시리즈</span> <span>@카테고리</span> <span>#태그</span> <span>& AND</span> <span>| OR</span> <span>() 그룹</span> </div> );
마무리
재귀 하강 파서로 복잡한 검색 쿼리를 지원합니다:
- AST 기반: 쿼리를 트리 구조로 변환하여 논리 연산 처리
- 연산자 우선순위:
()>&>|순서로 평가 - 확장 가능: 새로운 필터 타입이나 연산자 추가 용이
- 부분 매칭:
includes()로 유연한 검색 지원