--- title: "Fumadocs 한글 검색 기능 구현하기" description: "Fumadocs의 기본 검색 엔진인 Orama가 한글을 제대로 지원하지 않는 문제를 해결하기 위해 커스텀 한글 검색 엔진을 구현한 과정을 공유합니다." date: 2025-08-18 updated: 2026-09-16T01:58:00.000Z tags: [Fumadocs, Next.js, es-hangul] source: https://crowrish.com/blog/fumadocs-korean-search site: "CrowRish Blog" --- ## AI 요약 - Fumadocs의 기본 검색 엔진 Orama가 한글을 지원하지 않아 문서 사이트의 한글 검색이 동작하지 않던 문제에서 출발합니다. - Algolia 도입 대신 hangul-search-js·MiniSearch·es-hangul을 조합해 초성(ㄷㅈ), 부분 단어, 함수명 검색을 지원하는 클라이언트 검색 엔진을 직접 만들었습니다. - 빌드 때 문서를 섹션 단위로 쪼개 인덱스를 만들어 두는 구조이며, 번들 약 30KB에 374개 문서 기준 10ms 미만으로 검색됩니다. ![](https://crowrish.com/blog-images/617ef75114df52a023fb2cd79118278b.webp) 문서 사이트를 운영하다 보면 검색 기능은 필수입니다. 특히 한국어 컨텐츠가 많은 사이트에서는 한글에 특화된 검색 기능이 중요합니다. 이번 글에서는 Fumadocs의 기본 검색 엔진인 Orama가 한글을 제대로 지원하지 않는 문제를 해결하기 위해 커스텀 한글 검색 엔진을 구현한 과정을 공유합니다. ## 문제 발생 > 😮 : 한글 검색에 문제가 있나? Fumadocs를 사용해 문서 사이트를 구축했는데, 한 가지 큰 문제가 있었습니다. **한글 검색이 제대로 작동하지 않는 것**이었습니다. ### 원인 분석 Fumadocs는 기본적으로 **Orama**라는 검색 엔진을 사용하는데, 검색 결과 알게된 사실은… [**GitHub Discussion #748**](https://github.com/orgs/oramasearch/discussions/748)에서 Orama 팀의 답변: > “Korean is a bit tricky, especially without a person who can read and understand it in the team. Let me see if I can find some help.” ”한국어는 특히 팀 내에서 읽고 이해할 수 있는 사람이 없어서 조금 까다롭습니다. 도움을 받을 수 있을까요” 즉, 현재로써는 **Orama는 공식적으로 한글을 지원하지 않습니다.** 😱 ### 다른 방법은 없을까 1. **Algolia 도입**: 유료 서비스로 한글을 지원하지만 비용 부담 2. **검색 엔진 직접 구현하기**: 직접 개발해야 하지만 한글에 최적화 가능 비용과 한글 UX를 고려하여 검색 엔진을 직접 구현하기로 결정했습니다. ## 아키텍처 설계 ```mermaid graph TD A[MDX 문서들] --> B[빌드 시 색인 생성] B --> C[JSON 검색 데이터] C --> D[클라이언트 로딩] D --> E[한글 전처리] E --> F[검색 엔진] F --> G[검색 결과] G --> H[Fumadocs UI] ``` ![](https://crowrish.com/blog-images/9d19ce34288535173df9a4e1c2c6def1.webp) ### 라이브러리 선택 | 라이브러리 | 역할 | 특징 | | --- | --- | --- | | **hangul-search-js** | 한글 초성 검색 | `ㄷㅈ` → `던전앤파이터` | | **MiniSearch** | 전문 검색 엔진 | 9KB, TF-IDF, 퍼지 검색 | | **es-hangul** | 한글 처리 유틸 | 초성 분리, Toss 제작 (최고입니다 👍) | ## 구현 과정 ### 1단계: 패키지 설치 npm 패키지를 설치합니다. ```bash yarn add hangul-search-js minisearch es-hangul yarn add -D gray-matter tsx ``` ### 2단계: 검색 인덱스 생성 스크립트 shell 에서 작동할 스크립트를 작성합니다. ```typescript // scripts/build-search-index.ts import fs from 'fs'; import path from 'path'; import matter from 'gray-matter'; interface SearchDocument { id: string; title: string; content: string; url: string; section?: string; anchorId?: string; } function buildSearchIndex() { const docsDir = path.join(process.cwd(), 'content/docs'); const documents: SearchDocument[] = []; function processFile(filePath: string) { const content = fs.readFileSync(filePath, 'utf-8'); const { data: frontmatter, content: markdownContent } = matter(content); const relativePath = path.relative(docsDir, filePath); const slug = relativePath.replace(/\.mdx?$/, ''); const url = `/docs/${slug}`; // 헤딩을 파싱하여 섹션별로 분리 const headings = markdownContent.match(/^#{1,6}\s+(.+)$/gm) || []; let currentSection = ''; let currentContent = ''; let sectionIndex = 0; const lines = markdownContent.split('\n'); for (const line of lines) { const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); if (headingMatch) { // 이전 섹션 저장 if (currentContent.trim()) { const anchorId = generateAnchorId(currentSection); documents.push({ id: `${slug}-${sectionIndex}`, title: frontmatter.title || 'Untitled', content: currentContent.trim(), url, section: currentSection, anchorId: anchorId }); sectionIndex++; } // 새 섹션 시작 currentSection = headingMatch[2]; currentContent = ''; } else { currentContent += line + '\n'; } } // 마지막 섹션 처리 if (currentContent.trim()) { const anchorId = generateAnchorId(currentSection); documents.push({ id: `${slug}-${sectionIndex}`, title: frontmatter.title || 'Untitled', content: currentContent.trim(), url, section: currentSection, anchorId: anchorId }); } // 메인 문서 (전체 내용) documents.push({ id: `${slug}-main`, title: frontmatter.title || 'Untitled', content: markdownContent, url, }); } function processDirectory(dir: string) { const files = fs.readdirSync(dir); for (const file of files) { const fullPath = path.join(dir, file); const stat = fs.statSync(fullPath); if (stat.isDirectory()) { processDirectory(fullPath); } else if (file.endsWith('.mdx') || file.endsWith('.md')) { processFile(fullPath); } } } function generateAnchorId(text: string): string { return text .toLowerCase() .replace(/[^\w\s-가-힣]/g, '') // 한글 포함 .replace(/\s+/g, '-') .trim(); } processDirectory(docsDir); // JSON 파일로 저장 const outputPath = path.join(process.cwd(), 'public/search-data.json'); fs.writeFileSync(outputPath, JSON.stringify(documents, null, 2)); console.log(`검색 인덱스 생성 완료: ${documents.length}개 문서`); console.log(`저장 위치: ${outputPath}`); // 샘플 출력 console.log('\n생성된 문서 샘플:'); documents.slice(0, 3).forEach(doc => { const preview = doc.content.substring(0, 50) + '...'; console.log(`- ${doc.title}: ${preview}`); }); } buildSearchIndex(); ``` ### 빌드 스크립트 통합 `package.json` 을 아래와 같이 업데이트합니다. ```json { "scripts": { "build-search": "tsx scripts/build-search-index.ts", "build": "yarn build-search && next build", "dev": "yarn build-search && next dev --turbo" } } ``` ### 3단계: 한글 검색 엔진 구현 한글 검색 엔진을 구현합니다. ```typescript // lib/korean-search.ts import HangulSearch from 'hangul-search-js'; import MiniSearch from 'minisearch'; import { getChoseong } from 'es-hangul'; export interface SearchResult { id: string; title: string; content: string; url: string; section?: string; anchorId?: string; score: number; highlight: string; } interface SearchDocument { id: string; title: string; content: string; url: string; section?: string; anchorId?: string; } export class KoreanSearchEngine { private miniSearch: MiniSearch; private documents: SearchDocument[] = []; private hangulSearch: HangulSearch; private titleMap: Map = new Map(); constructor() { // MiniSearch 설정 this.miniSearch = new MiniSearch({ fields: ['title', 'content', 'section'], storeFields: ['title', 'content', 'url', 'section', 'anchorId'], searchOptions: { boost: { title: 3, section: 2 }, fuzzy: 0.2, prefix: true, }, }); // HangulSearch 설정 this.hangulSearch = new HangulSearch(); } async loadDocuments(url: string): Promise { try { const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to fetch search data: ${response.statusText}`); } this.documents = await response.json(); this.miniSearch.addAll(this.documents); this.buildTitleMap(); console.log(`검색 엔진 초기화 완료: ${this.documents.length}개 문서`); } catch (error) { console.error('검색 데이터 로드 실패:', error); throw error; } } private buildTitleMap(): void { this.titleMap.clear(); for (const doc of this.documents) { const title = doc.title; if (!this.titleMap.has(title)) { this.titleMap.set(title, []); } this.titleMap.get(title)!.push(doc); } } search(query: string, limit: number = 10): SearchResult[] { if (!query.trim()) return []; const trimmedQuery = query.trim(); // 1. 초성 검색 (ㄱ-ㅎ 문자만 포함된 경우) if (this.isChosung(trimmedQuery)) { return this.searchByChosung(trimmedQuery, limit); } // 2. 한글 부분 검색 const hangulResults = this.searchByHangul(trimmedQuery, limit); // 3. 전문 검색 (MiniSearch) const fullTextResults = this.searchByFullText(trimmedQuery, limit); // 결과 병합 및 중복 제거 const combinedResults = this.combineResults( hangulResults, fullTextResults, limit ); return combinedResults; } private isChosung(text: string): boolean { return /^[ㄱ-ㅎ]+$/.test(text); } private searchByChosung(chosung: string, limit: number): SearchResult[] { const matches: SearchResult[] = []; for (const [title, docs] of this.titleMap.entries()) { const titleChosung = getChoseong(title); if (titleChosung.includes(chosung)) { // 대표 문서 하나만 반환 (메인 문서 우선) const mainDoc = docs.find(doc => doc.id.includes('-main')) || docs[0]; matches.push({ ...mainDoc, score: 1.0, highlight: this.highlightChosung(title, chosung), }); if (matches.length >= limit) break; } } return matches; } private searchByHangul(query: string, limit: number): SearchResult[] { const results: SearchResult[] = []; for (const doc of this.documents) { // 제목에서 검색 const titleMatch = this.hangulSearch.search(query, doc.title); if (titleMatch.length > 0) { results.push({ ...doc, score: 0.9, highlight: this.highlightText(doc.title, query), }); continue; } // 섹션에서 검색 if (doc.section) { const sectionMatch = this.hangulSearch.search(query, doc.section); if (sectionMatch.length > 0) { results.push({ ...doc, score: 0.8, highlight: this.highlightText(doc.section, query), }); continue; } } // 내용에서 검색 const contentMatch = this.hangulSearch.search(query, doc.content); if (contentMatch.length > 0) { results.push({ ...doc, score: 0.7, highlight: this.highlightText(doc.content.substring(0, 200), query), }); } } return results .sort((a, b) => b.score - a.score) .slice(0, limit); } private searchByFullText(query: string, limit: number): SearchResult[] { try { const results = this.miniSearch.search(query, { limit }); return results.map(result => ({ id: result.id, title: result.title, content: result.content, url: result.url, section: result.section, anchorId: result.anchorId, score: result.score, highlight: this.highlightText(result.content.substring(0, 200), query), })); } catch (error) { console.warn('전문 검색 실패:', error); return []; } } private combineResults( hangulResults: SearchResult[], fullTextResults: SearchResult[], limit: number ): SearchResult[] { const seenIds = new Set(); const combined: SearchResult[] = []; // 한글 검색 결과 우선 추가 for (const result of hangulResults) { if (!seenIds.has(result.id)) { seenIds.add(result.id); combined.push(result); } } // 전문 검색 결과 추가 (중복 제거) for (const result of fullTextResults) { if (!seenIds.has(result.id)) { seenIds.add(result.id); combined.push(result); } } return combined .sort((a, b) => b.score - a.score) .slice(0, limit); } private highlightText(content: string, query: string): string { const regex = new RegExp( `(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi' ); return content.replace(regex, '$1'); } private highlightChosung(title: string, chosung: string): string { // 초성 매치 하이라이팅은 복잡하므로 일단 제목만 반환 return title; } } ``` ### 4단계: React 훅으로 상태 관리 3단계에서 작성한 Class를 초기화하고 훅을 작성합니다. ```typescript // hooks/useKoreanSearch.ts import { useState, useEffect, useCallback } from 'react'; import { KoreanSearchEngine, type SearchResult } from '@/lib/korean-search'; export function useKoreanSearch() { const [searchEngine, setSearchEngine] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); // 검색 엔진 초기화 useEffect(() => { const initSearchEngine = async () => { try { setIsLoading(true); const engine = new KoreanSearchEngine(); // basePath 고려 (GitHub Pages용) const basePath = process.env.NODE_ENV === 'production' ? '/neople-sdk-js-docs' : ''; await engine.loadDocuments(`${basePath}/search-data.json`); setSearchEngine(engine); setError(null); } catch (err) { console.error('검색 엔진 초기화 실패:', err); setError(err instanceof Error ? err.message : '검색 엔진 초기화에 실패했습니다.'); } finally { setIsLoading(false); } }; initSearchEngine(); }, []); // 검색 실행 const search = useCallback((query: string, limit = 10): SearchResult[] => { if (!searchEngine || !query.trim()) { return []; } try { return searchEngine.search(query, limit); } catch (err) { console.error('검색 실행 실패:', err); setError(err instanceof Error ? err.message : '검색 중 오류가 발생했습니다.'); return []; } }, [searchEngine]); return { search, isLoading, error, isReady: !isLoading && !error && searchEngine !== null, }; } ``` ### 5단계: 검색 UI 컴포넌트 구현 검색 다이얼로그를 작성합니다. 4단계에서 작성한 훅을 여기서 사용합니다. ```typescript // components/KoreanSearchDialog.tsx import { useState, useEffect, useRef, useCallback } from 'react'; import { useKoreanSearch } from '@/hooks/useKoreanSearch'; import type { SearchResult } from '@/lib/korean-search'; interface Props { open: boolean; onOpenChange: (open: boolean) => void; } export function KoreanSearchDialog({ open, onOpenChange }: Props) { const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [selectedIndex, setSelectedIndex] = useState(0); const inputRef = useRef(null); const { search, isLoading, error, isReady } = useKoreanSearch(); // 다이얼로그 열릴 때 입력 필드에 포커스 useEffect(() => { if (open && inputRef.current) { inputRef.current.focus(); } }, [open]); // 결과 클릭 핸들러 const handleResultClick = useCallback( (result: SearchResult) => { const basePath = process.env.NODE_ENV === 'production' ? '/neople-sdk-js-docs' : ''; const targetUrl = result.anchorId ? `${basePath}${result.url}#${result.anchorId}` : `${basePath}${result.url}`; window.location.href = targetUrl; onOpenChange(false); // 앵커가 있는 경우 부드러운 스크롤 효과 if (result.anchorId) { setTimeout(() => { const element = document.getElementById(result.anchorId!); if (element) { element.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest', }); } }, 100); // 페이지 로드 후 스크롤 } }, [onOpenChange] ); // 키보드 이벤트 처리 useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (!open) return; switch (e.key) { case 'Escape': onOpenChange(false); break; case 'ArrowDown': e.preventDefault(); setSelectedIndex(prev => Math.min(prev + 1, results.length - 1)); break; case 'ArrowUp': e.preventDefault(); setSelectedIndex(prev => Math.max(prev - 1, 0)); break; case 'Enter': e.preventDefault(); if (results[selectedIndex]) { handleResultClick(results[selectedIndex]); } break; } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [open, results, selectedIndex, onOpenChange, handleResultClick]); // 검색 결과가 변경될 때 선택 인덱스 초기화 useEffect(() => { setSelectedIndex(0); }, [results]); // 검색 실행 useEffect(() => { if (!isReady || !query.trim()) { setResults([]); return; } const searchResults = search(query, 10); setResults(searchResults); }, [query, search, isReady]); const handleSuggestionClick = (suggestion: string) => { setQuery(suggestion); }; const highlightText = (text: string, query: string) => { if (!query.trim()) return text; const regex = new RegExp( `(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi' ); return text.replace(regex, '$1'); }; if (!open) return null; return (
{/* 검색 입력 */}
setQuery(e.target.value)} placeholder="검색어를 입력하세요... (예: getCharacter, ㄷㅈ, 던전)" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white" /> {/* 추천 검색어 */}
{['getCharacter', 'ㄷㅈ', '에러 처리', '타입', 'axios'].map((suggestion) => ( ))}
{/* 로딩 및 에러 상태 */} {isLoading && (
검색 엔진을 로딩 중입니다...
)} {error && (
오류: {error}
)} {/* 검색 결과 */} {isReady && !isLoading && (
{results.length === 0 && query.trim() && (
'{query}'에 대한 검색 결과가 없습니다.
)} {results.map((result, index) => (
handleResultClick(result)} className={`p-4 border-b border-gray-100 dark:border-gray-700 cursor-pointer transition-colors ${ index === selectedIndex ? 'bg-blue-50 dark:bg-blue-900/20' : 'hover:bg-gray-50 dark:hover:bg-gray-700' }`} >

{result.title} {result.section && ( › {result.section} )}

{result.url} {Math.round(result.score * 100)}% 일치
))}
)} {/* 안내 */}
↑↓ 이동 · Enter 선택 · Esc 닫기 초성 검색 지원 (예: ㄷㅈ → 던전앤파이터)
); } ``` ### 사이드바에 검색 트리거 버튼 추가 `layout.tsx` (Next.js App Router) 파일에 작성한 컴포넌트를 주입합니다. ```typescript // src/app/docs/layout.tsx import type { ReactNode } from 'react'; import { DocsLayout } from 'fumadocs-ui/layouts/docs'; import { pageTree } from '@/lib/source'; import { CustomSearchTrigger } from '@/components/CustomSearchTrigger'; import { useSearchDialog } from '@/components/SearchProvider'; export default function Layout({ children }: { children: ReactNode }) { return ( { // 검색 다이얼로그 열기 const event = new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, }); document.dispatchEvent(event); }} /> ), }} > {children} ); } ``` ### 6단계: 빌드 프로세스 최적화 ### GitHub Actions 워크플로우 워크플로우 빌드시에도 검색 인덱스를 생성해야합니다. `build` 전에 `build-search` 를 실행합니다. ```yaml # .github/workflows/deploy.yml name: Deploy to GitHub Pages on: push: branches: [main] pull_request: branches: [main] permissions: contents: read pages: write id-token: write concurrency: group: 'pages' cancel-in-progress: false jobs: build: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: 'yarn' - name: Install dependencies run: yarn install --frozen-lockfile - name: Build search index run: yarn build-search - name: Build run: yarn build - name: Setup Pages uses: actions/configure-pages@v4 with: static_site_generator: next - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: path: ./out deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest needs: build steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 ``` ### Next.js 설정 저처럼 github pages에 배포하실 분들은 basePath 설정에 주의하세요! ```javascript // next.config.mjs import { createMDX } from 'fumadocs-mdx/next'; const withMDX = createMDX(); /** @type {import('next').NextConfig} */ const config = { reactStrictMode: true, output: 'export', trailingSlash: true, images: { unoptimized: true, }, basePath: process.env.NODE_ENV === 'production' ? '/neople-sdk-js-docs' : '', assetPrefix: process.env.NODE_ENV === 'production' ? '/neople-sdk-js-docs/' : '', }; export default withMDX(config); ``` ## 구현 결과 ![잘 작동합니다!](https://crowrish.com/blog-images/2f74d04a1c7ff2b3c63f842de741ff6b.webp) ### 지원하는 검색 패턴 | 검색 방식 | 입력 예시 | 결과 예시 | 설명 | | --- | --- | --- | --- | | **초성 검색** | `ㄷㅈ` | 던전앤파이터 | 한국인이 가장 많이 사용하는 패턴 | | **부분 검색** | `던전` | 던전앤파이터 API | 단어 일부만 입력해도 검색 | | **함수명 검색** | `getCharacter` | getCharacter() 설명 | 개발자 친화적 | ### 사용자 경험 1. **빠른 응답**: 클라이언트 사이드 검색으로 즉시 결과 표시 2. **정확한 위치 이동**: 검색 결과 클릭 시 해당 함수/섹션으로 바로 이동 3. **키보드 친화적**: `Ctrl+K` 열기, 화살표 키 네비게이션, `Enter` 선택 4. **반응형**: 모바일에서도 완벽 동작 5. **접근성**: 스크린 리더 지원, 키보드 네비게이션 ### 성능 지표 | 메트릭 | 값 | 설명 | | --- | --- | --- | | **번들 크기** | ~30KB | 3개 라이브러리 합계 | | **검색 속도** | <10ms | 374개 문서 기준 | | **메모리 사용량** | ~2MB | 검색 인덱스 포함 | | **초기 로딩** | ~100ms | JSON 파일 로드 | ## 결론 **“불가능해 보이는 문제도 다른 관점의 접근으로 해결할 수 있다”** 직접구현해보자는 마음에 어찌저찌 구현은 했지만, 스스로도 만족스럽지 못한 부분이 많네요. 점점 개선해볼 생각입니다. 추후엔 Orama에서 공식 지원하면 좋겠네요. 그리고 제가 사용한 오픈소스들 모두 훌륭한 라이브러리들이네요. 저도 언젠가 저런걸 만들수 있는 레벨이 되면 좋겠네요. ### 참고 자료 - [Orama Korean Language Discussion](https://github.com/orgs/oramasearch/discussions/748) - [hangul-search-js](https://www.npmjs.com/package/hangul-search-js) - [MiniSearch Documentation](https://lucaong.github.io/minisearch/) - [es-hangul by Toss](https://github.com/toss/es-hangul) - [Fumadocs Documentation](https://fumadocs.dev/) --- 원문: https://crowrish.com/blog/fumadocs-korean-search