본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 11. 27. · 0 Views
실전 프로젝트: 시맨틱 검색 엔진
단순 키워드 매칭을 넘어 의미 기반으로 검색하는 시맨틱 검색 엔진을 처음부터 구현합니다. 문서 임베딩부터 벡터 검색, UI 설계, 성능 최적화까지 실전에서 바로 쓸 수 있는 검색 시스템을 만들어봅니다.
목차
1. 문서 임베딩 인덱싱
김개발 씨는 사내 문서 검색 시스템을 담당하게 되었습니다. 기존 검색은 단순 키워드 매칭이라 "휴가 신청 방법"을 검색해도 "연차 사용 절차"라는 제목의 문서는 나오지 않았습니다.
"의미가 비슷한 문서도 찾아주는 검색 엔진을 만들 수 없을까요?"
문서 임베딩은 텍스트를 숫자 벡터로 변환하는 것입니다. 마치 모든 문서에 GPS 좌표를 부여하는 것과 같습니다.
비슷한 의미의 문서는 좌표상에서 가까이 위치하게 됩니다. 이렇게 변환된 벡터를 인덱싱하면 수만 개의 문서 중에서도 순식간에 유사한 문서를 찾을 수 있습니다.
다음 코드를 살펴봅시다.
// OpenAI 임베딩 API를 활용한 문서 인덱싱
const { OpenAI } = require('openai');
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
async function createEmbedding(text) {
// 텍스트를 1536차원 벡터로 변환합니다
const response = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: text.slice(0, 8000) // 토큰 제한 고려
});
return response.data[0].embedding;
}
async function indexDocuments(documents) {
// 모든 문서를 병렬로 임베딩 처리합니다
const indexed = await Promise.all(
documents.map(async (doc) => ({
id: doc.id,
title: doc.title,
content: doc.content,
embedding: await createEmbedding(doc.title + ' ' + doc.content)
}))
);
return indexed;
}
김개발 씨는 입사 6개월 차 백엔드 개발자입니다. 어느 날 팀장님이 다가와 말씀하셨습니다.
"우리 사내 검색 시스템 좀 개선해줄 수 있어요? 직원들이 원하는 문서를 못 찾겠다고 불만이 많아요." 김개발 씨가 기존 시스템을 살펴보니 문제가 바로 보였습니다.
검색어와 문서 제목이 정확히 일치해야만 결과가 나왔던 것입니다. "휴가"로 검색하면 "연차"가 포함된 문서는 나오지 않았습니다.
그렇다면 문서 임베딩이란 정확히 무엇일까요? 쉽게 비유하자면, 임베딩은 마치 도시의 모든 건물에 GPS 좌표를 부여하는 것과 같습니다.
카페들은 서로 비슷한 좌표 근처에 모여 있고, 병원들은 또 다른 구역에 모여 있습니다. 문서도 마찬가지입니다.
"휴가 신청"과 "연차 사용"은 의미가 비슷하므로 벡터 공간에서 가까운 위치에 놓이게 됩니다. 임베딩이 없던 시절에는 어땠을까요?
개발자들은 동의어 사전을 직접 만들어야 했습니다. "휴가 = 연차 = 휴식"처럼 하나하나 등록하는 방식이었습니다.
문제는 이런 작업이 끝이 없다는 것이었습니다. 새로운 용어가 생길 때마다 사전을 업데이트해야 했고, 문맥에 따라 달라지는 의미는 처리할 방법이 없었습니다.
바로 이런 문제를 해결하기 위해 임베딩 기술이 등장했습니다. OpenAI의 text-embedding-3-small 모델은 어떤 텍스트든 1536개의 숫자로 이루어진 벡터로 변환해줍니다.
이 벡터들 사이의 거리를 계산하면 의미적 유사도를 알 수 있습니다. 동의어 사전을 만들 필요가 전혀 없습니다.
위의 코드를 한 줄씩 살펴보겠습니다. 먼저 createEmbedding 함수를 봅시다.
openai.embeddings.create를 호출하면 API가 텍스트를 분석하여 벡터를 반환합니다. 입력 텍스트는 8000자로 제한했는데, 이는 API의 토큰 제한을 고려한 것입니다.
indexDocuments 함수에서는 Promise.all을 사용하여 모든 문서를 병렬로 처리합니다. 문서가 100개라면 100개의 API 호출이 동시에 이루어집니다.
각 문서의 제목과 내용을 합쳐서 임베딩하는 것이 포인트입니다. 제목만 임베딩하면 정보가 부족하고, 내용만 임베딩하면 핵심 키워드를 놓칠 수 있습니다.
실제 현업에서는 어떻게 활용할까요? 전자상거래 사이트의 상품 검색을 예로 들어봅시다.
사용자가 "가벼운 여름 외투"를 검색했을 때, 단순 키워드 매칭으로는 "린넨 재킷"이나 "얇은 가디건"을 찾기 어렵습니다. 하지만 임베딩을 활용하면 의미적으로 유사한 상품들이 자연스럽게 검색 결과에 포함됩니다.
하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 임베딩 API를 매 검색마다 호출하는 것입니다.
문서는 한 번만 임베딩하고 데이터베이스에 저장해두어야 합니다. 검색할 때는 검색어만 임베딩하면 됩니다.
다시 김개발 씨의 이야기로 돌아가 봅시다. API 키를 발급받고 첫 번째 문서를 임베딩하는 데 성공했습니다.
콘솔에 1536개의 숫자가 출력되는 것을 보며 김개발 씨는 뿌듯함을 느꼈습니다. "이제 이 벡터들로 검색을 구현해볼 차례군!"
실전 팁
💡 - 제목과 본문을 합쳐서 임베딩하면 검색 품질이 향상됩니다
- 긴 문서는 청크로 나누어 각각 임베딩하는 것이 효과적입니다
- API 호출 비용을 줄이려면 배치 처리를 활용하세요
2. 벡터 검색 구현
문서 임베딩을 마친 김개발 씨는 다음 단계로 넘어갔습니다. 1000개의 문서가 각각 1536개의 숫자로 변환되어 있습니다.
이제 사용자가 "신입사원 온보딩"을 검색하면 어떻게 가장 비슷한 문서를 찾을 수 있을까요?
벡터 검색은 검색어와 문서 간의 거리를 계산하여 가장 가까운 것을 찾는 방식입니다. 마치 지도에서 현재 위치와 가장 가까운 편의점을 찾는 것과 같습니다.
코사인 유사도라는 수학적 방법으로 두 벡터가 얼마나 비슷한 방향을 가리키는지 계산합니다.
다음 코드를 살펴봅시다.
// 코사인 유사도 기반 벡터 검색 구현
function cosineSimilarity(vecA, vecB) {
// 두 벡터의 내적을 계산합니다
let dotProduct = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < vecA.length; i++) {
dotProduct += vecA[i] * vecB[i];
normA += vecA[i] * vecA[i];
normB += vecB[i] * vecB[i];
}
// 유사도는 -1에서 1 사이 값입니다
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}
async function searchDocuments(query, indexedDocs, topK = 5) {
// 검색어를 임베딩합니다
const queryEmbedding = await createEmbedding(query);
// 모든 문서와 유사도를 계산합니다
const results = indexedDocs.map(doc => ({
...doc,
score: cosineSimilarity(queryEmbedding, doc.embedding)
}));
// 유사도 높은 순으로 정렬하여 상위 K개 반환
return results.sort((a, b) => b.score - a.score).slice(0, topK);
}
선배 개발자 박시니어 씨가 김개발 씨의 자리로 왔습니다. "임베딩은 잘 됐네요.
그런데 이 숫자들로 어떻게 검색할 건지 생각해봤어요?" 김개발 씨는 고민에 빠졌습니다. 1536개의 숫자가 나열된 두 배열이 있을 때, 이 둘이 얼마나 비슷한지 어떻게 판단할 수 있을까요?
그렇다면 코사인 유사도란 정확히 무엇일까요? 쉽게 비유하자면, 두 사람이 각자 손가락으로 방향을 가리키고 있다고 상상해보세요.
두 사람이 완전히 같은 방향을 가리키면 유사도는 1입니다. 정반대 방향을 가리키면 -1이 됩니다.
벡터도 마찬가지입니다. 1536차원 공간에서 두 벡터가 같은 방향을 향할수록 의미가 비슷한 것입니다.
유클리드 거리 대신 코사인 유사도를 쓰는 이유가 있습니다. 유클리드 거리는 벡터의 크기에 영향을 받습니다.
같은 의미의 문서라도 길이가 다르면 거리가 멀어질 수 있습니다. 반면 코사인 유사도는 방향만 비교합니다.
문서 길이와 관계없이 의미의 유사성을 정확하게 측정할 수 있습니다. 위의 코드를 살펴보겠습니다.
cosineSimilarity 함수는 수학 공식을 그대로 구현한 것입니다. 두 벡터의 내적을 각 벡터 크기의 곱으로 나눕니다.
for 루프가 1536번 돌면서 계산을 수행합니다. searchDocuments 함수의 흐름은 간단합니다.
먼저 검색어를 임베딩합니다. 그 다음 모든 문서와 유사도를 계산합니다.
마지막으로 유사도가 높은 순으로 정렬하여 상위 K개를 반환합니다. 하지만 여기서 문제가 보입니다.
문서가 10만 개라면 어떨까요? 매 검색마다 10만 번의 유사도 계산을 해야 합니다.
1536차원 벡터 두 개를 비교하는 것은 생각보다 연산량이 많습니다. 실제 서비스에서는 이런 방식으로는 버틸 수 없습니다.
이 문제를 해결하기 위해 벡터 데이터베이스가 등장했습니다. Pinecone, Weaviate, Milvus 같은 솔루션들은 특수한 인덱스 구조를 사용하여 수백만 개의 벡터 중에서도 밀리초 단위로 유사한 것을 찾아냅니다.
지금은 학습을 위해 직접 구현했지만, 실제 프로젝트에서는 이런 전문 솔루션을 사용하는 것이 현명합니다. 박시니어 씨가 말했습니다.
"기본 원리를 이해했으니 이제 벡터 DB를 붙여보는 건 어떨까요?"
실전 팁
💡 - 소규모 데이터(1만 건 미만)는 직접 구현해도 충분합니다
- 대규모 데이터는 Pinecone이나 Weaviate 같은 벡터 DB를 사용하세요
- topK 값은 보통 5~20 사이가 적당합니다
3. 검색 UI 설계
백엔드 검색 로직이 완성되자 팀장님이 말씀하셨습니다. "이제 직원들이 쓸 수 있게 화면을 만들어주세요." 김개발 씨는 프론트엔드 동료 이프론트 씨와 함께 검색 UI를 설계하기 시작했습니다.
검색 UI는 단순히 입력창과 결과 목록만 있으면 될 것 같지만, 실제로는 고려할 것이 많습니다. 디바운싱으로 불필요한 API 호출을 막고, 로딩 상태를 표시하며, 검색 결과를 직관적으로 보여주어야 합니다.
사용자 경험을 좌우하는 중요한 부분입니다.
다음 코드를 살펴봅시다.
// React 검색 컴포넌트 구현
import { useState, useCallback } from 'react';
import debounce from 'lodash/debounce';
function SemanticSearch() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
// 300ms 디바운싱으로 타이핑 중 API 호출 방지
const debouncedSearch = useCallback(
debounce(async (searchQuery) => {
if (!searchQuery.trim()) return setResults([]);
setLoading(true);
const res = await fetch(`/api/search?q=${encodeURIComponent(searchQuery)}`);
const data = await res.json();
setResults(data.results);
setLoading(false);
}, 300),
[]
);
return (
<div className="search-container">
<input
type="text"
placeholder="무엇이든 검색하세요..."
value={query}
onChange={(e) => {
setQuery(e.target.value);
debouncedSearch(e.target.value);
}}
/>
{loading && <div className="spinner">검색 중...</div>}
<ul className="results">
{results.map(doc => (
<li key={doc.id}>
<h3>{doc.title}</h3>
<p className="score">유사도: {(doc.score * 100).toFixed(1)}%</p>
</li>
))}
</ul>
</div>
);
}
이프론트 씨가 첫 번째 프로토타입을 만들었습니다. 검색창에 글자를 입력하면 바로 API가 호출되는 단순한 구조였습니다.
그런데 테스트를 해보니 문제가 발생했습니다. "시맨틱 검색"이라고 타이핑하는 동안 "시", "시맨", "시맨틱", "시맨틱 ", "시맨틱 검", "시맨틱 검색"까지 총 6번의 API 호출이 발생한 것입니다.
임베딩 API는 호출당 비용이 발생하는데, 이런 식이면 비용이 폭발할 것이 분명했습니다. 이 문제를 해결하는 것이 바로 디바운싱입니다.
디바운싱은 마치 엘리베이터 문과 같습니다. 사람이 계속 타고 있는 동안은 문이 닫히지 않습니다.
마지막 사람이 탄 후 일정 시간이 지나야 문이 닫힙니다. 검색도 마찬가지입니다.
사용자가 타이핑을 멈춘 후 300ms가 지나야 비로소 API를 호출합니다. lodash의 debounce 함수를 사용하면 이 기능을 쉽게 구현할 수 있습니다.
useCallback으로 감싸는 것도 중요합니다. 그렇지 않으면 컴포넌트가 리렌더링될 때마다 새로운 debounce 함수가 만들어져서 의도대로 동작하지 않습니다.
로딩 상태 표시도 빼놓을 수 없습니다. 시맨틱 검색은 임베딩 API 호출이 포함되어 일반 검색보다 시간이 걸립니다.
사용자에게 아무런 피드백 없이 기다리게 하면 시스템이 멈춘 것처럼 느껴집니다. 스피너나 "검색 중..." 메시지를 표시하는 것만으로도 사용자 경험이 크게 개선됩니다.
검색 결과에 유사도 점수를 표시한 것도 눈여겨볼 부분입니다. 기존 키워드 검색에서는 "일치함" 또는 "일치하지 않음" 두 가지뿐이었습니다.
하지만 시맨틱 검색은 유사도라는 연속적인 값을 제공합니다. 95%와 70%는 분명히 다릅니다.
이 점수를 사용자에게 보여주면 검색 결과의 신뢰도를 직관적으로 파악할 수 있습니다. 이프론트 씨가 CSS도 다듬었습니다.
유사도가 높은 결과는 배경색을 약간 다르게 하고, 낮은 결과는 흐리게 표시했습니다. 시각적 구분이 생기니 사용자들이 훨씬 빠르게 원하는 문서를 찾을 수 있게 되었습니다.
팀장님이 결과물을 보고 만족스러워했습니다. "깔끔하네요.
근데 검색 결과 순서는 어떻게 정해지는 거예요?"
실전 팁
💡 - 디바운싱 시간은 200~500ms가 적당합니다
- 검색어가 비었을 때는 API를 호출하지 마세요
- 이전 검색 요청이 진행 중이면 취소하는 로직도 고려하세요
4. 검색 결과 랭킹
유사도만으로 순위를 매기면 문제가 생겼습니다. 10년 전에 작성된 구식 문서가 유사도가 높다고 상위에 노출되는 것입니다.
김개발 씨는 최신 문서, 인기 문서를 우선 보여주는 복합 랭킹 시스템이 필요하다는 것을 깨달았습니다.
검색 결과 랭킹은 여러 요소를 종합하여 최종 순위를 결정하는 것입니다. 유사도 점수에 문서의 최신성, 조회수, 품질 점수 등을 가중치로 반영합니다.
마치 대학 입시에서 수능 점수만이 아니라 내신, 면접, 가산점을 종합하는 것과 같습니다.
다음 코드를 살펴봅시다.
// 복합 랭킹 시스템 구현
function calculateRankScore(doc, similarityScore) {
// 가중치 설정 (합이 1이 되도록)
const weights = {
similarity: 0.6, // 의미 유사도
recency: 0.2, // 최신성
popularity: 0.15, // 인기도
quality: 0.05 // 문서 품질
};
// 최신성 점수: 최근 문서일수록 높음 (0~1)
const daysSinceUpdate = (Date.now() - new Date(doc.updatedAt)) / (1000 * 60 * 60 * 24);
const recencyScore = Math.max(0, 1 - daysSinceUpdate / 365);
// 인기도 점수: 로그 스케일로 정규화
const popularityScore = Math.min(1, Math.log10(doc.viewCount + 1) / 4);
// 품질 점수: 문서 길이, 이미지 유무 등
const qualityScore = doc.hasImages ? 0.8 : 0.5;
// 최종 점수 계산
return (
weights.similarity * similarityScore +
weights.recency * recencyScore +
weights.popularity * popularityScore +
weights.quality * qualityScore
);
}
function rankSearchResults(results) {
return results
.map(doc => ({
...doc,
rankScore: calculateRankScore(doc, doc.score)
}))
.sort((a, b) => b.rankScore - a.rankScore);
}
직원들이 검색 시스템을 사용하기 시작하면서 피드백이 들어왔습니다. "검색 결과가 이상해요.
5년 전 문서가 맨 위에 뜨던데요?" 김개발 씨가 확인해보니 문제의 원인을 알 수 있었습니다. 오래된 문서일수록 내용이 상세하고 길었습니다.
당연히 임베딩 품질도 좋았고, 유사도 점수가 높게 나왔습니다. 하지만 그 문서의 정보는 이미 구식이 되어버린 상태였습니다.
그렇다면 복합 랭킹은 어떻게 설계해야 할까요? 핵심은 가중치 조합입니다.
마치 요리에서 여러 재료의 비율을 맞추는 것처럼, 여러 점수를 적절한 비율로 섞어야 합니다. 유사도가 가장 중요하므로 60%를 차지하고, 최신성 20%, 인기도 15%, 품질 5%를 배분했습니다.
최신성 점수는 업데이트 날짜를 기준으로 계산합니다. 오늘 수정된 문서는 1점, 1년 전 문서는 0점에 가깝습니다.
선형으로 감소하도록 설계했습니다. 365일이라는 기준은 조정 가능합니다.
뉴스 사이트라면 7일, 학술 자료라면 1825일(5년)이 적절할 수 있습니다. 인기도 점수에는 로그 스케일을 적용했습니다.
조회수가 10인 문서와 100인 문서의 차이는 크게 느껴집니다. 하지만 10000과 10100의 차이는 별로 중요하지 않습니다.
Math.log10을 사용하면 이런 인간의 인식과 유사하게 점수를 계산할 수 있습니다. 품질 점수는 간단하게 시작했습니다.
이미지가 포함된 문서는 0.8점, 텍스트만 있는 문서는 0.5점을 부여했습니다. 나중에는 문서 길이, 포맷팅 상태, 링크 유무 등 더 복잡한 기준을 추가할 수 있습니다.
가중치는 어떻게 정할까요? 처음에는 직관으로 설정하고, 사용자 피드백을 받으면서 조정하는 것이 현실적입니다.
A/B 테스트를 통해 어떤 가중치 조합이 클릭률이나 체류 시간 면에서 우수한지 데이터로 검증할 수도 있습니다. 김개발 씨가 새로운 랭킹 시스템을 적용하자 불만이 사라졌습니다.
팀장님이 말했습니다. "이제 좀 쓸만해졌네요.
그런데 검색 결과에서 어느 부분이 매칭된 건지 표시해줄 수 있어요?"
실전 팁
💡 - 가중치의 합은 1이 되도록 설정하면 직관적입니다
- 도메인에 따라 가중치를 다르게 설정하세요 (뉴스는 최신성 중요, 논문은 인용수 중요)
- 가중치는 A/B 테스트로 최적화할 수 있습니다
5. 하이라이팅 처리
검색 결과가 나왔는데, 왜 이 문서가 검색된 건지 알 수가 없었습니다. 키워드 검색이라면 매칭된 단어를 노란색으로 표시하면 됩니다.
하지만 시맨틱 검색은 정확히 일치하는 단어가 없을 수도 있습니다. 김개발 씨는 새로운 방식의 하이라이팅이 필요했습니다.
시맨틱 검색의 하이라이팅은 키워드 매칭과 의미 기반 강조를 결합해야 합니다. 검색어와 동일한 단어는 물론, 유의어나 관련 표현도 함께 표시합니다.
이를 통해 사용자는 왜 이 문서가 검색되었는지 직관적으로 이해할 수 있습니다.
다음 코드를 살펴봅시다.
// 시맨틱 하이라이팅 구현
function highlightText(text, query, relatedTerms = []) {
// 검색어와 관련 용어를 모두 하이라이팅 대상으로
const terms = [query, ...relatedTerms].filter(Boolean);
// 정규식 특수문자 이스케이프
const escaped = terms.map(t =>
t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
);
// 대소문자 무시, 단어 경계 고려
const pattern = new RegExp(`(${escaped.join('|')})`, 'gi');
// HTML mark 태그로 감싸기
return text.replace(pattern, '<mark class="highlight">$1</mark>');
}
// 관련 용어 추출 (간단한 버전)
async function getRelatedTerms(query) {
// 실제로는 임베딩 유사도나 사전 API 활용
const synonymMap = {
'휴가': ['연차', '휴식', '쉬는날'],
'신입사원': ['신입', '새내기', '주니어'],
'급여': ['월급', '연봉', '임금']
};
return synonymMap[query] || [];
}
// 검색 결과에 하이라이팅 적용
async function applyHighlighting(results, query) {
const relatedTerms = await getRelatedTerms(query);
return results.map(doc => ({
...doc,
highlightedTitle: highlightText(doc.title, query, relatedTerms),
highlightedContent: highlightText(
doc.content.slice(0, 200) + '...',
query,
relatedTerms
)
}));
}
이프론트 씨가 질문했습니다. "시맨틱 검색이라서 '휴가'로 검색했는데 '연차'가 포함된 문서가 나온 거잖아요.
그런데 화면에서 아무것도 표시가 안 되면 사용자가 이해할 수 있을까요?" 정말 중요한 지적이었습니다. 키워드 검색에서는 검색어가 문서에 반드시 포함되어 있으니 그 단어를 강조하면 됩니다.
하지만 시맨틱 검색은 다릅니다. 이 문제를 해결하기 위해 이중 하이라이팅 전략을 사용합니다.
첫 번째는 검색어 자체의 하이라이팅입니다. 사용자가 "휴가"를 검색했고 문서에 "휴가"가 있다면 당연히 표시해야 합니다.
두 번째는 관련 용어의 하이라이팅입니다. "연차", "휴식" 같은 유의어도 함께 표시합니다.
관련 용어는 어떻게 찾을까요? 가장 간단한 방법은 동의어 사전입니다.
위 코드의 synonymMap처럼 미리 정의해둘 수 있습니다. 더 발전된 방법은 임베딩을 활용하는 것입니다.
"휴가"의 임베딩과 가장 가까운 단어들을 찾으면 자동으로 관련 용어를 추출할 수 있습니다. 코드를 살펴보면 정규식을 사용합니다.
여러 단어를 OR 조건으로 연결하고(|), 대소문자를 무시하며(gi), 찾은 부분을 mark 태그로 감쌉니다. HTML의 mark 태그는 기본적으로 노란색 배경이 적용되어 하이라이팅 용도로 적합합니다.
주의할 점이 있습니다. 검색어에 정규식 특수문자가 포함될 수 있습니다.
예를 들어 "C++"를 검색하면 +가 정규식에서 특별한 의미를 가지므로 오류가 발생합니다. 따라서 반드시 이스케이프 처리를 해야 합니다.
하이라이팅의 범위도 고려해야 합니다. 전체 문서를 하이라이팅하면 성능 문제가 생길 수 있습니다.
검색 결과 목록에서는 제목과 본문 앞부분 200자 정도만 처리하는 것이 적절합니다. 문서 상세 페이지에서는 전체를 하이라이팅할 수 있습니다.
김개발 씨가 하이라이팅을 적용하자 사용자들의 반응이 달라졌습니다. "아, '휴가'로 검색했는데 '연차'도 같이 표시해주는구나.
그래서 이 문서가 나온 거였어!"
실전 팁
💡 - mark 태그의 기본 스타일을 CSS로 커스터마이징하세요
- 관련 용어는 최대 5개 정도로 제한하는 것이 좋습니다
- 하이라이팅 색상은 접근성(색맹)을 고려해서 선택하세요
6. 성능 최적화
시스템이 잘 돌아가던 어느 날, 문서가 10만 건을 돌파했습니다. 검색 속도가 눈에 띄게 느려지기 시작했습니다.
김개발 씨는 성능 최적화라는 새로운 도전에 직면했습니다. 어떻게 하면 대규모 데이터에서도 빠른 검색을 유지할 수 있을까요?
성능 최적화는 크게 세 가지 방향으로 접근합니다. 첫째, 임베딩 결과를 캐싱하여 중복 계산을 방지합니다.
둘째, 벡터 인덱스를 구축하여 전체 스캔 대신 근사 최근접 이웃 탐색을 수행합니다. 셋째, 청크 단위로 나누어 병렬 처리합니다.
다음 코드를 살펴봅시다.
// Redis 캐싱과 배치 처리를 활용한 최적화
const Redis = require('ioredis');
const redis = new Redis();
// 임베딩 결과 캐싱
async function getCachedEmbedding(text) {
const cacheKey = `emb:${Buffer.from(text).toString('base64').slice(0, 50)}`;
// 캐시에서 먼저 확인
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
// 없으면 새로 생성하고 캐싱 (24시간 만료)
const embedding = await createEmbedding(text);
await redis.setex(cacheKey, 86400, JSON.stringify(embedding));
return embedding;
}
// 배치 임베딩으로 API 호출 최소화
async function batchEmbedding(texts, batchSize = 100) {
const results = [];
for (let i = 0; i < texts.length; i += batchSize) {
const batch = texts.slice(i, i + batchSize);
// OpenAI API는 배열 입력을 지원합니다
const response = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: batch
});
results.push(...response.data.map(d => d.embedding));
}
return results;
}
// 검색 결과 캐싱 (자주 검색되는 쿼리)
async function searchWithCache(query, topK = 5) {
const cacheKey = `search:${query}:${topK}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const results = await searchDocuments(query, indexedDocs, topK);
await redis.setex(cacheKey, 300, JSON.stringify(results)); // 5분 캐시
return results;
}
박시니어 씨가 모니터링 대시보드를 보며 말했습니다. "평균 응답 시간이 3초를 넘어가고 있어요.
임베딩 API 호출이 병목이네요." 김개발 씨가 로그를 분석해보니 같은 검색어로 반복 검색하는 경우가 많았습니다. "팀 회식 장소", "휴가 신청", "재택근무 규정" 같은 인기 검색어들이었습니다.
매번 똑같은 텍스트를 임베딩하느라 시간과 비용을 낭비하고 있었던 것입니다. 캐싱이 첫 번째 해결책입니다.
마치 도서관의 대출 데스크에 인기 도서를 미리 꺼내두는 것과 같습니다. Redis에 임베딩 결과를 저장해두면 같은 텍스트가 다시 들어왔을 때 API를 호출할 필요가 없습니다.
캐시 키는 텍스트를 Base64로 인코딩하여 만들었습니다. 캐시 만료 시간도 중요합니다.
임베딩은 같은 텍스트에 대해 항상 같은 결과를 반환하므로 오래 캐싱해도 됩니다. 24시간(86400초)으로 설정했습니다.
반면 검색 결과 캐시는 새 문서가 추가될 수 있으므로 5분(300초)으로 짧게 설정했습니다. 배치 처리는 두 번째 최적화 기법입니다.
OpenAI API는 한 번에 여러 텍스트를 입력받을 수 있습니다. 100개의 문서를 임베딩할 때 API를 100번 호출하는 것보다 1번 호출하는 것이 훨씬 효율적입니다.
네트워크 왕복 시간이 줄어들고, 병렬 처리 오버헤드도 사라집니다. 하지만 배치 크기에는 한계가 있습니다.
너무 크면 요청이 실패하거나 타임아웃이 발생할 수 있습니다. 100개씩 나누어 처리하는 것이 안정적입니다.
대규모 데이터에서는 벡터 데이터베이스가 필수입니다. 직접 구현한 코사인 유사도 계산은 O(n)의 시간 복잡도를 가집니다.
문서가 10만 개면 10만 번 계산해야 합니다. Pinecone이나 Milvus 같은 벡터 DB는 ANN(Approximate Nearest Neighbor) 알고리즘을 사용하여 O(log n) 수준으로 검색합니다.
ANN은 정확도를 약간 희생하고 속도를 얻는 방식입니다. 정확히 가장 유사한 문서가 아니라 "거의 가장 유사한" 문서를 반환합니다.
실제 서비스에서는 이 정도면 충분합니다. 김개발 씨가 최적화를 적용하자 평균 응답 시간이 200ms 이하로 떨어졌습니다.
팀장님이 흡족한 표정으로 말했습니다. "이제 정말 쓸만한 검색 엔진이 됐네요.
수고했어요!"
실전 팁
💡 - 인기 검색어 상위 100개는 미리 캐싱해두세요
- 벡터 DB 선택 시 셀프호스팅 vs 관리형 서비스를 비교하세요
- 캐시 무효화 전략도 함께 설계해야 합니다
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
MongoDB 집계 파이프라인 고급 완벽 가이드
MongoDB의 고급 집계 파이프라인 기능을 초급 개발자도 쉽게 이해할 수 있도록 설명합니다. $lookup, $unwind, $facet, $bucket, $graphLookup 등 실무에서 자주 사용하는 연산자들을 실제 예제와 함께 다룹니다.
MongoDB 집계 파이프라인 기초
MongoDB의 집계 파이프라인을 처음 접하는 개발자를 위한 가이드입니다. 데이터를 필터링하고, 그룹화하고, 변환하는 방법을 실무 예제와 함께 차근차근 알아봅니다.
MongoDB 인덱스 기초 완벽 가이드
MongoDB에서 쿼리 성능을 획기적으로 개선하는 인덱스의 모든 것을 다룹니다. 단일 필드 인덱스부터 복합 인덱스, 그리고 실무에서 반드시 알아야 할 인덱스 관리 방법까지 초급 개발자도 쉽게 이해할 수 있도록 설명합니다.
MongoDB 업데이트 연산자 완벽 가이드
MongoDB에서 문서를 수정할 때 사용하는 다양한 업데이트 연산자를 학습합니다. $set부터 arrayFilters까지 실무에서 자주 쓰이는 연산자들을 예제와 함께 알아봅니다.
MongoDB 쿼리 연산자 완벽 가이드
MongoDB에서 데이터를 효율적으로 검색하고 필터링하는 쿼리 연산자를 알아봅니다. 비교, 논리, 배열 연산자부터 정규표현식, 프로젝션, 정렬까지 실무에서 바로 활용할 수 있는 내용을 다룹니다.