Fumadocs 한글 검색 기능 구현하기

FumadocsNext.jses-hangul

2025. 8. 18.

문서 사이트를 운영하다 보면 검색 기능은 필수입니다. 특히 한국어 컨텐츠가 많은 사이트에서는 한글에 특화된 검색 기능이 중요합니다. 이번 글에서는 Fumadocs의 기본 검색 엔진인 Orama가 한글을 제대로 지원하지 않는 문제를 해결하기 위해 커스텀 한글 검색 엔진을 구현한 과정을 공유합니다.

문제 발생

😮 : 한글 검색에 문제가 있나?

Fumadocs를 사용해 문서 사이트를 구축했는데, 한 가지 큰 문제가 있었습니다. 한글 검색이 제대로 작동하지 않는 것이었습니다.

원인 분석

Fumadocs는 기본적으로 Orama라는 검색 엔진을 사용하는데, 검색 결과 알게된 사실은…

GitHub Discussion #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를 고려하여 검색 엔진을 직접 구현하기로 결정했습니다.

아키텍처 설계

라이브러리 선택

라이브러리역할특징
hangul-search-js한글 초성 검색ㄷㅈ던전앤파이터
MiniSearch전문 검색 엔진9KB, TF-IDF, 퍼지 검색
es-hangul한글 처리 유틸초성 분리, Toss 제작 (최고입니다 👍)

구현 과정

1단계: 패키지 설치

npm 패키지를 설치합니다.

yarn add hangul-search-js minisearch es-hangul
yarn add -D gray-matter tsx

2단계: 검색 인덱스 생성 스크립트

shell 에서 작동할 스크립트를 작성합니다.

1// scripts/build-search-index.ts
2import fs from 'fs';
3import path from 'path';
4import matter from 'gray-matter';
5
6interface SearchDocument {
7  id: string;
8  title: string;
9  content: string;
10  url: string;
11  section?: string;
12  anchorId?: string;
13}
14
15function buildSearchIndex() {
16  const docsDir = path.join(process.cwd(), 'content/docs');
17  const documents: SearchDocument[] = [];
18
19  function processFile(filePath: string) {
20    const content = fs.readFileSync(filePath, 'utf-8');
21    const { data: frontmatter, content: markdownContent } = matter(content);
22
23    const relativePath = path.relative(docsDir, filePath);
24    const slug = relativePath.replace(/\.mdx?$/, '');
25    const url = `/docs/${slug}`;
26
27    // 헤딩을 파싱하여 섹션별로 분리
28    const headings = markdownContent.match(/^#{1,6}\s+(.+)$/gm) || [];
29
30    let currentSection = '';
31    let currentContent = '';
32    let sectionIndex = 0;
33
34    const lines = markdownContent.split('\n');
35
36    for (const line of lines) {
37      const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
38
39      if (headingMatch) {
40        // 이전 섹션 저장
41        if (currentContent.trim()) {
42          const anchorId = generateAnchorId(currentSection);
43          documents.push({
44            id: `${slug}-${sectionIndex}`,
45            title: frontmatter.title || 'Untitled',
46            content: currentContent.trim(),
47            url,
48            section: currentSection,
49            anchorId: anchorId
50          });
51          sectionIndex++;
52        }
53
54        // 새 섹션 시작
55        currentSection = headingMatch[2];
56        currentContent = '';
57      } else {
58        currentContent += line + '\n';
59      }
60    }
61
62    // 마지막 섹션 처리
63    if (currentContent.trim()) {
64      const anchorId = generateAnchorId(currentSection);
65      documents.push({
66        id: `${slug}-${sectionIndex}`,
67        title: frontmatter.title || 'Untitled',
68        content: currentContent.trim(),
69        url,
70        section: currentSection,
71        anchorId: anchorId
72      });
73    }
74
75    // 메인 문서 (전체 내용)
76    documents.push({
77      id: `${slug}-main`,
78      title: frontmatter.title || 'Untitled',
79      content: markdownContent,
80      url,
81    });
82  }
83
84  function processDirectory(dir: string) {
85    const files = fs.readdirSync(dir);
86
87    for (const file of files) {
88      const fullPath = path.join(dir, file);
89      const stat = fs.statSync(fullPath);
90
91      if (stat.isDirectory()) {
92        processDirectory(fullPath);
93      } else if (file.endsWith('.mdx') || file.endsWith('.md')) {
94        processFile(fullPath);
95      }
96    }
97  }
98
99  function generateAnchorId(text: string): string {
100    return text
101      .toLowerCase()
102      .replace(/[^\w\s--힣]/g, '') // 한글 포함
103      .replace(/\s+/g, '-')
104      .trim();
105  }
106
107  processDirectory(docsDir);
108
109  // JSON 파일로 저장
110  const outputPath = path.join(process.cwd(), 'public/search-data.json');
111  fs.writeFileSync(outputPath, JSON.stringify(documents, null, 2));
112
113  console.log(`검색 인덱스 생성 완료: ${documents.length}개 문서`);
114  console.log(`저장 위치: ${outputPath}`);
115
116  // 샘플 출력
117  console.log('\n생성된 문서 샘플:');
118  documents.slice(0, 3).forEach(doc => {
119    const preview = doc.content.substring(0, 50) + '...';
120    console.log(`- ${doc.title}: ${preview}`);
121  });
122}
123
124buildSearchIndex();

빌드 스크립트 통합

package.json 을 아래와 같이 업데이트합니다.

1{
2  "scripts": {
3    "build-search": "tsx scripts/build-search-index.ts",
4    "build": "yarn build-search && next build",
5    "dev": "yarn build-search && next dev --turbo"
6  }
7}

3단계: 한글 검색 엔진 구현

한글 검색 엔진을 구현합니다.

1// lib/korean-search.ts
2import HangulSearch from 'hangul-search-js';
3import MiniSearch from 'minisearch';
4import { getChoseong } from 'es-hangul';
5
6export interface SearchResult {
7  id: string;
8  title: string;
9  content: string;
10  url: string;
11  section?: string;
12  anchorId?: string;
13  score: number;
14  highlight: string;
15}
16
17interface SearchDocument {
18  id: string;
19  title: string;
20  content: string;
21  url: string;
22  section?: string;
23  anchorId?: string;
24}
25
26export class KoreanSearchEngine {
27  private miniSearch: MiniSearch;
28  private documents: SearchDocument[] = [];
29  private hangulSearch: HangulSearch;
30  private titleMap: Map<string, SearchDocument[]> = new Map();
31
32  constructor() {
33    // MiniSearch 설정
34    this.miniSearch = new MiniSearch({
35      fields: ['title', 'content', 'section'],
36      storeFields: ['title', 'content', 'url', 'section', 'anchorId'],
37      searchOptions: {
38        boost: { title: 3, section: 2 },
39        fuzzy: 0.2,
40        prefix: true,
41      },
42    });
43
44    // HangulSearch 설정
45    this.hangulSearch = new HangulSearch();
46  }
47
48  async loadDocuments(url: string): Promise<void> {
49    try {
50      const response = await fetch(url);
51      if (!response.ok) {
52        throw new Error(`Failed to fetch search data: ${response.statusText}`);
53      }
54
55      this.documents = await response.json();
56      this.miniSearch.addAll(this.documents);
57      this.buildTitleMap();
58
59      console.log(`검색 엔진 초기화 완료: ${this.documents.length}개 문서`);
60    } catch (error) {
61      console.error('검색 데이터 로드 실패:', error);
62      throw error;
63    }
64  }
65
66  private buildTitleMap(): void {
67    this.titleMap.clear();
68    for (const doc of this.documents) {
69      const title = doc.title;
70      if (!this.titleMap.has(title)) {
71        this.titleMap.set(title, []);
72      }
73      this.titleMap.get(title)!.push(doc);
74    }
75  }
76
77  search(query: string, limit: number = 10): SearchResult[] {
78    if (!query.trim()) return [];
79
80    const trimmedQuery = query.trim();
81
82    // 1. 초성 검색 (ㄱ-ㅎ 문자만 포함된 경우)
83    if (this.isChosung(trimmedQuery)) {
84      return this.searchByChosung(trimmedQuery, limit);
85    }
86
87    // 2. 한글 부분 검색
88    const hangulResults = this.searchByHangul(trimmedQuery, limit);
89
90    // 3. 전문 검색 (MiniSearch)
91    const fullTextResults = this.searchByFullText(trimmedQuery, limit);
92
93    // 결과 병합 및 중복 제거
94    const combinedResults = this.combineResults(
95      hangulResults,
96      fullTextResults,
97      limit
98    );
99
100    return combinedResults;
101  }
102
103  private isChosung(text: string): boolean {
104    return /^[-]+$/.test(text);
105  }
106
107  private searchByChosung(chosung: string, limit: number): SearchResult[] {
108    const matches: SearchResult[] = [];
109
110    for (const [title, docs] of this.titleMap.entries()) {
111      const titleChosung = getChoseong(title);
112      if (titleChosung.includes(chosung)) {
113        // 대표 문서 하나만 반환 (메인 문서 우선)
114        const mainDoc = docs.find(doc => doc.id.includes('-main')) || docs[0];
115        matches.push({
116          ...mainDoc,
117          score: 1.0,
118          highlight: this.highlightChosung(title, chosung),
119        });
120
121        if (matches.length >= limit) break;
122      }
123    }
124
125    return matches;
126  }
127
128  private searchByHangul(query: string, limit: number): SearchResult[] {
129    const results: SearchResult[] = [];
130
131    for (const doc of this.documents) {
132      // 제목에서 검색
133      const titleMatch = this.hangulSearch.search(query, doc.title);
134      if (titleMatch.length > 0) {
135        results.push({
136          ...doc,
137          score: 0.9,
138          highlight: this.highlightText(doc.title, query),
139        });
140        continue;
141      }
142
143      // 섹션에서 검색
144      if (doc.section) {
145        const sectionMatch = this.hangulSearch.search(query, doc.section);
146        if (sectionMatch.length > 0) {
147          results.push({
148            ...doc,
149            score: 0.8,
150            highlight: this.highlightText(doc.section, query),
151          });
152          continue;
153        }
154      }
155
156      // 내용에서 검색
157      const contentMatch = this.hangulSearch.search(query, doc.content);
158      if (contentMatch.length > 0) {
159        results.push({
160          ...doc,
161          score: 0.7,
162          highlight: this.highlightText(doc.content.substring(0, 200), query),
163        });
164      }
165    }
166
167    return results
168      .sort((a, b) => b.score - a.score)
169      .slice(0, limit);
170  }
171
172  private searchByFullText(query: string, limit: number): SearchResult[] {
173    try {
174      const results = this.miniSearch.search(query, { limit });
175
176      return results.map(result => ({
177        id: result.id,
178        title: result.title,
179        content: result.content,
180        url: result.url,
181        section: result.section,
182        anchorId: result.anchorId,
183        score: result.score,
184        highlight: this.highlightText(result.content.substring(0, 200), query),
185      }));
186    } catch (error) {
187      console.warn('전문 검색 실패:', error);
188      return [];
189    }
190  }
191
192  private combineResults(
193    hangulResults: SearchResult[],
194    fullTextResults: SearchResult[],
195    limit: number
196  ): SearchResult[] {
197    const seenIds = new Set<string>();
198    const combined: SearchResult[] = [];
199
200    // 한글 검색 결과 우선 추가
201    for (const result of hangulResults) {
202      if (!seenIds.has(result.id)) {
203        seenIds.add(result.id);
204        combined.push(result);
205      }
206    }
207
208    // 전문 검색 결과 추가 (중복 제거)
209    for (const result of fullTextResults) {
210      if (!seenIds.has(result.id)) {
211        seenIds.add(result.id);
212        combined.push(result);
213      }
214    }
215
216    return combined
217      .sort((a, b) => b.score - a.score)
218      .slice(0, limit);
219  }
220
221  private highlightText(content: string, query: string): string {
222    const regex = new RegExp(
223      `(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`,
224      'gi'
225    );
226    return content.replace(regex, '<mark>$1</mark>');
227  }
228
229  private highlightChosung(title: string, chosung: string): string {
230    // 초성 매치 하이라이팅은 복잡하므로 일단 제목만 반환
231    return title;
232  }
233}
234

4단계: React 훅으로 상태 관리

3단계에서 작성한 Class를 초기화하고 훅을 작성합니다.

1// hooks/useKoreanSearch.ts
2import { useState, useEffect, useCallback } from 'react';
3import { KoreanSearchEngine, type SearchResult } from '@/lib/korean-search';
4
5export function useKoreanSearch() {
6  const [searchEngine, setSearchEngine] = useState<KoreanSearchEngine | null>(null);
7  const [isLoading, setIsLoading] = useState(true);
8  const [error, setError] = useState<string | null>(null);
9
10  // 검색 엔진 초기화
11  useEffect(() => {
12    const initSearchEngine = async () => {
13      try {
14        setIsLoading(true);
15        const engine = new KoreanSearchEngine();
16
17        // basePath 고려 (GitHub Pages용)
18        const basePath = process.env.NODE_ENV === 'production' ? '/neople-sdk-js-docs' : '';
19        await engine.loadDocuments(`${basePath}/search-data.json`);
20
21        setSearchEngine(engine);
22        setError(null);
23      } catch (err) {
24        console.error('검색 엔진 초기화 실패:', err);
25        setError(err instanceof Error ? err.message : '검색 엔진 초기화에 실패했습니다.');
26      } finally {
27        setIsLoading(false);
28      }
29    };
30
31    initSearchEngine();
32  }, []);
33
34  // 검색 실행
35  const search = useCallback((query: string, limit = 10): SearchResult[] => {
36    if (!searchEngine || !query.trim()) {
37      return [];
38    }
39
40    try {
41      return searchEngine.search(query, limit);
42    } catch (err) {
43      console.error('검색 실행 실패:', err);
44      setError(err instanceof Error ? err.message : '검색 중 오류가 발생했습니다.');
45      return [];
46    }
47  }, [searchEngine]);
48
49  return {
50    search,
51    isLoading,
52    error,
53    isReady: !isLoading && !error && searchEngine !== null,
54  };
55}
56

5단계: 검색 UI 컴포넌트 구현

검색 다이얼로그를 작성합니다. 4단계에서 작성한 훅을 여기서 사용합니다.

1// components/KoreanSearchDialog.tsx
2import { useState, useEffect, useRef, useCallback } from 'react';
3import { useKoreanSearch } from '@/hooks/useKoreanSearch';
4import type { SearchResult } from '@/lib/korean-search';
5
6interface Props {
7  open: boolean;
8  onOpenChange: (open: boolean) => void;
9}
10
11export function KoreanSearchDialog({ open, onOpenChange }: Props) {
12  const [query, setQuery] = useState('');
13  const [results, setResults] = useState<SearchResult[]>([]);
14  const [selectedIndex, setSelectedIndex] = useState(0);
15  const inputRef = useRef<HTMLInputElement>(null);
16
17  const { search, isLoading, error, isReady } = useKoreanSearch();
18
19  // 다이얼로그 열릴 때 입력 필드에 포커스
20  useEffect(() => {
21    if (open && inputRef.current) {
22      inputRef.current.focus();
23    }
24  }, [open]);
25
26  // 결과 클릭 핸들러
27  const handleResultClick = useCallback(
28    (result: SearchResult) => {
29      const basePath =
30        process.env.NODE_ENV === 'production' ? '/neople-sdk-js-docs' : '';
31      const targetUrl = result.anchorId
32        ? `${basePath}${result.url}#${result.anchorId}`
33        : `${basePath}${result.url}`;
34
35      window.location.href = targetUrl;
36      onOpenChange(false);
37
38      // 앵커가 있는 경우 부드러운 스크롤 효과
39      if (result.anchorId) {
40        setTimeout(() => {
41          const element = document.getElementById(result.anchorId!);
42          if (element) {
43            element.scrollIntoView({
44              behavior: 'smooth',
45              block: 'start',
46              inline: 'nearest',
47            });
48          }
49        }, 100); // 페이지 로드 후 스크롤
50      }
51    },
52    [onOpenChange]
53  );
54
55  // 키보드 이벤트 처리
56  useEffect(() => {
57    const handleKeyDown = (e: KeyboardEvent) => {
58      if (!open) return;
59
60      switch (e.key) {
61        case 'Escape':
62          onOpenChange(false);
63          break;
64        case 'ArrowDown':
65          e.preventDefault();
66          setSelectedIndex(prev => Math.min(prev + 1, results.length - 1));
67          break;
68        case 'ArrowUp':
69          e.preventDefault();
70          setSelectedIndex(prev => Math.max(prev - 1, 0));
71          break;
72        case 'Enter':
73          e.preventDefault();
74          if (results[selectedIndex]) {
75            handleResultClick(results[selectedIndex]);
76          }
77          break;
78      }
79    };
80
81    document.addEventListener('keydown', handleKeyDown);
82    return () => document.removeEventListener('keydown', handleKeyDown);
83  }, [open, results, selectedIndex, onOpenChange, handleResultClick]);
84
85  // 검색 결과가 변경될 때 선택 인덱스 초기화
86  useEffect(() => {
87    setSelectedIndex(0);
88  }, [results]);
89
90  // 검색 실행
91  useEffect(() => {
92    if (!isReady || !query.trim()) {
93      setResults([]);
94      return;
95    }
96
97    const searchResults = search(query, 10);
98    setResults(searchResults);
99  }, [query, search, isReady]);
100
101  const handleSuggestionClick = (suggestion: string) => {
102    setQuery(suggestion);
103  };
104
105  const highlightText = (text: string, query: string) => {
106    if (!query.trim()) return text;
107
108    const regex = new RegExp(
109      `(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`,
110      'gi'
111    );
112    return text.replace(regex, '<mark>$1</mark>');
113  };
114
115  if (!open) return null;
116
117  return (
118    <div className="fixed inset-0 z-50 bg-black/50 flex items-start justify-center pt-16">
119      <div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[80vh] overflow-hidden">
120        {/* 검색 입력 */}
121        <div className="p-4 border-b border-gray-200 dark:border-gray-700">
122          <input
123            ref={inputRef}
124            type="text"
125            value={query}
126            onChange={(e) => setQuery(e.target.value)}
127            placeholder="검색어를 입력하세요... (예: getCharacter, ㄷㅈ, 던전)"
128            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"
129          />
130
131          {/* 추천 검색어 */}
132          <div className="mt-2 flex flex-wrap gap-2">
133            {['getCharacter', 'ㄷㅈ', '에러 처리', '타입', 'axios'].map((suggestion) => (
134              <button
135                key={suggestion}
136                onClick={() => handleSuggestionClick(suggestion)}
137                className="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
138              >
139                {suggestion}
140              </button>
141            ))}
142          </div>
143        </div>
144
145        {/* 로딩 및 에러 상태 */}
146        {isLoading && (
147          <div className="p-4 text-center text-gray-500">
148            검색 엔진을 로딩 중입니다...
149          </div>
150        )}
151
152        {error && (
153          <div className="p-4 text-center text-red-500">
154            오류: {error}
155          </div>
156        )}
157
158        {/* 검색 결과 */}
159        {isReady && !isLoading && (
160          <div className="max-h-96 overflow-y-auto">
161            {results.length === 0 && query.trim() && (
162              <div className="p-4 text-center text-gray-500">
163                '{query}'에 대한 검색 결과가 없습니다.
164              </div>
165            )}
166
167            {results.map((result, index) => (
168              <div
169                key={result.id}
170                onClick={() => handleResultClick(result)}
171                className={`p-4 border-b border-gray-100 dark:border-gray-700 cursor-pointer transition-colors ${
172                  index === selectedIndex
173                    ? 'bg-blue-50 dark:bg-blue-900/20'
174                    : 'hover:bg-gray-50 dark:hover:bg-gray-700'
175                }`}
176              >
177                <div className="flex items-start space-x-3">
178                  <div className="flex-1">
179                    <h3 className="font-medium text-gray-900 dark:text-white">
180                      {result.title}
181                      {result.section && (
182                        <span className="text-sm text-gray-500 dark:text-gray-400 ml-2">
183{result.section}
184                        </span>
185                      )}
186                    </h3>
187                    <p
188                      className="text-sm text-gray-600 dark:text-gray-300 mt-1 line-clamp-2"
189                      dangerouslySetInnerHTML={{
190                        __html: highlightText(result.highlight || result.content.substring(0, 150) + '...', query),
191                      }}
192                    />
193                    <div className="flex items-center mt-2 text-xs text-gray-400 dark:text-gray-500">
194                      <span>{result.url}</span>
195                      <span className="ml-2 bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 px-1 rounded">
196                        {Math.round(result.score * 100)}% 일치
197                      </span>
198                    </div>
199                  </div>
200                </div>
201              </div>
202            ))}
203          </div>
204        )}
205
206        {/* 안내 */}
207        <div className="p-3 bg-gray-50 dark:bg-gray-700 text-xs text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-600">
208          <div className="flex justify-between">
209            <span>↑↓ 이동 · Enter 선택 · Esc 닫기</span>
210            <span>초성 검색 지원 (: ㄷㅈ → 던전앤파이터)</span>
211          </div>
212        </div>
213      </div>
214    </div>
215  );
216}
217

사이드바에 검색 트리거 버튼 추가

layout.tsx (Next.js App Router) 파일에 작성한 컴포넌트를 주입합니다.

1// src/app/docs/layout.tsx
2import type { ReactNode } from 'react';
3import { DocsLayout } from 'fumadocs-ui/layouts/docs';
4import { pageTree } from '@/lib/source';
5import { CustomSearchTrigger } from '@/components/CustomSearchTrigger';
6import { useSearchDialog } from '@/components/SearchProvider';
7
8export default function Layout({ children }: { children: ReactNode }) {
9  return (
10    <DocsLayout
11      tree={pageTree}
12      sidebar={{
13        banner: (
14          <div className="p-2">
15            <CustomSearchTrigger onClick={() => {
16              // 검색 다이얼로그 열기
17              const event = new KeyboardEvent('keydown', {
18                key: 'k',
19                ctrlKey: true,
20              });
21              document.dispatchEvent(event);
22            }} />
23          </div>
24        ),
25      }}
26    >
27      {children}
28    </DocsLayout>
29  );
30}
31

6단계: 빌드 프로세스 최적화

GitHub Actions 워크플로우

워크플로우 빌드시에도 검색 인덱스를 생성해야합니다. build 전에 build-search 를 실행합니다.

1# .github/workflows/deploy.yml
2name: Deploy to GitHub Pages
3
4on:
5  push:
6    branches: [main]
7  pull_request:
8    branches: [main]
9
10permissions:
11  contents: read
12  pages: write
13  id-token: write
14
15concurrency:
16  group: 'pages'
17  cancel-in-progress: false
18
19jobs:
20  build:
21    runs-on: ubuntu-latest
22    steps:
23      - name: Checkout
24        uses: actions/checkout@v4
25
26      - name: Setup Node.js
27        uses: actions/setup-node@v4
28        with:
29          node-version: '20'
30          cache: 'yarn'
31
32      - name: Install dependencies
33        run: yarn install --frozen-lockfile
34
35      - name: Build search index
36        run: yarn build-search
37
38      - name: Build
39        run: yarn build
40
41      - name: Setup Pages
42        uses: actions/configure-pages@v4
43        with:
44          static_site_generator: next
45
46      - name: Upload artifact
47        uses: actions/upload-pages-artifact@v3
48        with:
49          path: ./out
50
51  deploy:
52    environment:
53      name: github-pages
54      url: ${{ steps.deployment.outputs.page_url }}
55    runs-on: ubuntu-latest
56    needs: build
57    steps:
58      - name: Deploy to GitHub Pages
59        id: deployment
60        uses: actions/deploy-pages@v4
61

Next.js 설정

저처럼 github pages에 배포하실 분들은 basePath 설정에 주의하세요!

1// next.config.mjs
2import { createMDX } from 'fumadocs-mdx/next';
3
4const withMDX = createMDX();
5
6/** @type {import('next').NextConfig} */
7const config = {
8  reactStrictMode: true,
9  output: 'export',
10  trailingSlash: true,
11  images: {
12    unoptimized: true,
13  },
14  basePath: process.env.NODE_ENV === 'production' ? '/neople-sdk-js-docs' : '',
15  assetPrefix: process.env.NODE_ENV === 'production' ? '/neople-sdk-js-docs/' : '',
16};
17
18export default withMDX(config);
19

구현 결과

잘 작동합니다!
잘 작동합니다!

지원하는 검색 패턴

검색 방식입력 예시결과 예시설명
초성 검색ㄷㅈ던전앤파이터한국인이 가장 많이 사용하는 패턴
부분 검색던전던전앤파이터 API단어 일부만 입력해도 검색
함수명 검색getCharactergetCharacter() 설명개발자 친화적

사용자 경험

  1. 빠른 응답: 클라이언트 사이드 검색으로 즉시 결과 표시
  2. 정확한 위치 이동: 검색 결과 클릭 시 해당 함수/섹션으로 바로 이동
  3. 키보드 친화적: Ctrl+K 열기, 화살표 키 네비게이션, Enter 선택
  4. 반응형: 모바일에서도 완벽 동작
  5. 접근성: 스크린 리더 지원, 키보드 네비게이션

성능 지표

메트릭설명
번들 크기~30KB3개 라이브러리 합계
검색 속도<10ms374개 문서 기준
메모리 사용량~2MB검색 인덱스 포함
초기 로딩~100msJSON 파일 로드

결론

“불가능해 보이는 문제도 다른 관점의 접근으로 해결할 수 있다”

직접구현해보자는 마음에 어찌저찌 구현은 했지만, 스스로도 만족스럽지 못한 부분이 많네요. 점점 개선해볼 생각입니다.

추후엔 Orama에서 공식 지원하면 좋겠네요.

그리고 제가 사용한 오픈소스들 모두 훌륭한 라이브러리들이네요. 저도 언젠가 저런걸 만들수 있는 레벨이 되면 좋겠네요.

참고 자료

이 글이 도움되셨나요?

공유해주시면 더 많은 사람들이 볼 수 있어요!