개인 블로그에 AI 검색 달기 (3) - 프론트엔드와 운영 최적화
검색 모달 UI 설계
검색 기능의 백엔드가 완성되었으니, 이제 사용자가 실제로 사용할 프론트엔드를 만들 차례입니다.
Cmd+K (Mac) 또는 Ctrl+K (Windows)로 열리는 검색 모달을 구현했습니다. Notion이나 VS Code에서 익숙한 패턴입니다.
검색 모달의 핵심 기능
- 키보드 중심 인터랙션 - 마우스 없이도 검색부터 선택까지 가능
- 실시간 검색 - 타이핑하면서 바로 결과 확인
- 디바운스 - 과도한 API 호출 방지
- 에러 상태 처리 - Rate limit 등 에러 시 사용자 안내
React 컴포넌트 구현
검색 모달의 상태 관리입니다:
const SearchModal: React.FC<SearchModalProps> = ({ isOpen, onClose }) => {
const [query, setQuery] = useState('');
const [results, setResults] = useState<HybridSearchResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const [error, setError] = useState<'rate_limit' | 'unknown' | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const t = useTranslations('Search');
const locale = useLocale();
// 모달 열릴 때 상태 초기화 및 포커스
useEffect(() => {
if (isOpen) {
setQuery('');
setResults([]);
setSelectedIndex(0);
setError(null);
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [isOpen]);
};키보드 네비게이션
화살표 키로 결과를 탐색하고, Enter로 선택하는 기능입니다:
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isOpen) return;
if (e.key === 'Escape') {
onClose();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex(prev => Math.min(prev + 1, results.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(prev => Math.max(prev - 1, 0));
} else if (e.key === 'Enter' && results[selectedIndex]) {
window.location.href = `/${locale}/blog/${results[selectedIndex].slug}`;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose, results, selectedIndex, locale]);디바운스 처리
사용자가 타이핑할 때마다 API를 호출하면 서버에 부담이 됩니다. 700ms 디바운스를 적용해서 타이핑이 멈춘 후에만 검색을 실행합니다:
useEffect(() => {
// 2글자 미만이면 검색하지 않음
if (!query.trim() || query.trim().length < 2) {
setResults([]);
setError(null);
setIsLoading(false);
return;
}
setIsLoading(true);
// 700ms 디바운스
const timer = setTimeout(async () => {
try {
const response = await hybridSearch(query.trim(), locale, 10);
setResults(response.results);
setError(response.error || null);
setSelectedIndex(0);
} catch (err) {
console.error('Search error:', err);
setResults([]);
setError('unknown');
} finally {
setIsLoading(false);
}
}, 700);
return () => clearTimeout(timer);
}, [query, locale]);검색 API 클라이언트
429 에러를 구분해서 처리하는 API 클라이언트입니다:
export interface HybridSearchResponse {
results: HybridSearchResult[];
query: string;
totalResults: number;
searchType: 'hybrid' | 'keyword' | 'vector';
error?: 'rate_limit' | 'unknown';
}
export async function hybridSearch(
query: string,
language: string = 'ko',
limit: number = 10
): Promise<HybridSearchResponse> {
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
try {
const params = new URLSearchParams({
q: query,
language,
limit: String(limit),
});
const res = await fetch(`${apiUrl}/blog-posts/hybrid-search?${params}`, {
cache: 'no-store',
});
if (!res.ok) {
console.error('Hybrid search failed:', res.status, res.statusText);
// 429 에러 구분
if (res.status === 429) {
return {
results: [], query, totalResults: 0,
searchType: 'hybrid', error: 'rate_limit'
};
}
return {
results: [], query, totalResults: 0,
searchType: 'hybrid', error: 'unknown'
};
}
return res.json();
} catch (error) {
console.error('Error in hybrid search:', error);
return {
results: [], query, totalResults: 0,
searchType: 'hybrid', error: 'unknown'
};
}
}Rate Limiting 설정
벡터 검색은 일반 쿼리보다 리소스를 많이 사용합니다. 악의적인 요청이나 봇으로부터 서버를 보호하기 위해 rate limiting을 설정했습니다.
NestJS Throttler 설정
NestJS에서는 @nestjs/throttler 패키지로 간단하게 rate limiting을 적용할 수 있습니다:
// app.module.ts
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
@Module({
imports: [
ThrottlerModule.forRoot([
{
ttl: 60000, // 60초
limit: 60, // 분당 60회 (글로벌 기본값)
},
]),
],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
})검색 엔드포인트에 더 엄격한 제한
검색 API는 글로벌 설정보다 더 엄격하게 분당 5회로 제한했습니다:
// blog-posts.controller.ts
@Get('hybrid-search')
@Throttle({ default: { limit: 5, ttl: 60000 } }) // 분당 5회
hybridSearch(
@Req() req: Request,
@Query('q') query: string,
@Query('language') language?: string,
@Query('limit') limit?: string
) {
// ...
}분당 5회면 적어 보일 수 있지만, 프론트엔드에서 700ms 디바운스를 적용하고 있어서 정상적인 사용자는 충분합니다. 빠르게 연속 검색해도 디바운스 덕분에 실제 API 호출은 분당 2-3회 정도입니다.
429 에러 사용자 메시지
Rate limit에 걸렸을 때 "검색 결과 없음"으로 표시하면 사용자는 왜 결과가 없는지 알 수 없습니다. 명확한 안내 메시지를 보여주기로 했습니다.
// SearchModal.tsx - 결과 영역
{error === 'rate_limit' ? (
<div className="py-12 text-center">
<p className="text-amber-600 dark:text-amber-400">
{t('rateLimitError')}
</p>
</div>
) : results.length > 0 ? (
// 검색 결과 표시
) : query && query.length >= 2 ? (
// 결과 없음
) : (
// 검색 힌트
)}i18n 메시지:
// messages/ko.json
{
"Search": {
"rateLimitError": "검색 요청이 많습니다. 잠시 후 다시 시도해주세요."
}
}
// messages/en.json
{
"Search": {
"rateLimitError": "Too many search requests. Please try again later."
}
}amber 색상을 사용해서 에러지만 너무 강하지 않은 톤으로 안내합니다. 사용자가 당황하지 않고 잠시 기다리면 된다는 것을 알 수 있습니다.
검색 결과 UI
검색 결과는 제목, 요약, 태그를 보여줍니다. 현재 선택된 항목은 배경색으로 구분합니다:
<ul className="py-2">
{results.map((result, index) => (
<li key={result.slug}>
<Link
href={`/${locale}/blog/${result.slug}`}
onClick={onClose}
className={`flex flex-col gap-1 px-4 py-3 transition-colors ${
index === selectedIndex
? 'bg-lilac-50 dark:bg-lilac-900/20'
: 'hover:bg-gray-50 dark:hover:bg-gray-800'
}`}
>
<span className="line-clamp-1 font-medium text-gray-900 dark:text-gray-100">
{result.title}
</span>
<span className="line-clamp-1 text-sm text-gray-500 dark:text-gray-400">
{result.excerpt}
</span>
<div className="mt-1 flex gap-2">
{result.tags.slice(0, 3).map(tag => (
<span key={tag} className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600 dark:bg-gray-800 dark:text-gray-400">
{tag}
</span>
))}
</div>
</Link>
</li>
))}
</ul>검색 힌트와 하단 안내
검색창이 비어있을 때는 사용자에게 검색 힌트를 보여줍니다:
// 검색어 입력 전 힌트
<div className="px-4 py-8">
<p className="text-center text-sm text-gray-500 dark:text-gray-400">
{t('hint')}
{/* "서버 운영", "CI/CD" 같은 자연어로도 검색할 수 있습니다 */}
</p>
</div>하단에는 키보드 단축키 안내와 "AI 기반 검색" 표시를 추가했습니다:
<div className="flex items-center justify-between border-t bg-gray-50 px-4 py-2 dark:bg-gray-800/50">
<div className="flex items-center gap-4 text-xs text-gray-400">
<span className="flex items-center gap-1">
<kbd className="rounded bg-gray-200 px-1.5 py-0.5 text-[10px] dark:bg-gray-700">↑↓</kbd>
{t('navigate')}
</span>
<span className="flex items-center gap-1">
<kbd className="rounded bg-gray-200 px-1.5 py-0.5 text-[10px] dark:bg-gray-700">Enter</kbd>
{t('select')}
</span>
</div>
<span className="text-xs text-gray-400">
{t('poweredBy')} {/* AI 기반 검색 */}
</span>
</div>시리즈를 마치며
3부에 걸쳐 개인 블로그에 하이브리드 검색을 구현하는 과정을 정리했습니다.
구현한 것들
- 벡터 검색 + 키워드 검색 - RRF 알고리즘으로 결합
- MongoDB Atlas Vector Search - 별도 벡터 DB 없이 통합
- Gemini Embeddings - 텍스트를 3072차원 벡터로 변환
- 유사도 임계값 0.7 - 노이즈 필터링
- 검색 로그 - 품질 분석용 데이터 수집
- Rate Limiting - 분당 5회 제한으로 서버 보호
- 검색 모달 UI - 키보드 네비게이션, 디바운스, 에러 핸들링
개선 여지
아직 개선할 부분이 있습니다:
- 검색어 자동완성 - 인기 검색어 제안
- 검색 결과 하이라이팅 - 매칭된 키워드 강조
- 관련 글 추천 최적화 - $vectorSearch로 관련 글 계산 (현재는 메모리에서 계산)
- 검색 분석 대시보드 - 로그 데이터 시각화
개인 블로그 규모에서 시작했지만, 글이 늘어나도 벡터 검색 덕분에 검색 품질은 유지될 것입니다. "서버 운영"을 검색하면 이제 정말 서버 운영과 관련된 글들이 나옵니다.
작은 개선이지만, 블로그를 찾아주시는 분들에게 조금이나마 더 나은 경험을 제공할 수 있게 되었습니다.
관련 글
개인 블로그에 AI 검색 달기 (1) - 왜 하이브리드 검색인가
블로그 검색 기능을 개선하면서 키워드 검색의 한계를 느꼈습니다. 벡터 검색과 키워드 검색을 결합한 하이브리드 검색을 선택한 이유와 아키텍처 설계 과정을 공유합니다.
개인 블로그에 AI 검색 달기 (2) - MongoDB Atlas Vector Search 구현
MongoDB Atlas Vector Search 인덱스 설정부터 NestJS에서 하이브리드 검색을 구현하는 과정. $vectorSearch의 null 필터 제한사항과 RRF 알고리즘, 유사도 임계값 튜닝까지.
내가 Next.js ISR을 선택한 이유: 블로그 SEO, 그 고민의 시작과 해결
Next.js ISR을 선택하여 블로그 SEO 문제를 해결하는 방법을 알아보세요. React CSR의 한계를 극복하고, 검색 엔진 최적화와 소셜 미리보기를 완벽 지원하는 ISR의 핵심 원리를 소개합니다.