
검색 시스템: Elasticsearch vs 자체 구현
SQL LIKE 검색으로 시작했다가 한계를 느꼈고, Elasticsearch를 도입했다가 운영 비용에 놀랐다. 검색 시스템의 트레이드오프를 정리했다.

SQL LIKE 검색으로 시작했다가 한계를 느꼈고, Elasticsearch를 도입했다가 운영 비용에 놀랐다. 검색 시스템의 트레이드오프를 정리했다.
미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

DB 설계의 기초. 데이터를 쪼개고 쪼개서 이상 현상(Anomaly)을 방지하는 과정. 제1, 2, 3 정규형을 쉽게 설명합니다.

업다운 게임으로 배우는 이진 탐색 트리. 왜 데이터베이스는 해시 테이블 대신 B-Tree를 쓸까? AVL 트리, 레드블랙 트리, 그리고 Splay Tree까지.

왜 CPU는 빠른데 컴퓨터는 느릴까? 80년 전 고안된 폰 노이만 구조의 혁명적인 아이디어와, 그것이 남긴 치명적인 병목현상에 대해 정리했습니다.

제품이 론칭되고 며칠 뒤, 고객 문의가 들어왔다. "아이폰 케이스를 검색했는데 아무것도 안 나와요." DB를 확인해보니 제품명은 "iPhone 15 Pro 케이스"였다. 사용자는 한글로 "아이폰"을 검색했고, 우리 검색은 정확히 일치하는 것만 찾고 있었다.
SELECT * FROM products
WHERE name LIKE '%아이폰%';
-- 결과: 0건 (실제 DB에는 "iPhone"으로 저장됨)
첫 대응은 간단했다. 양쪽에 %를 붙이면 부분 일치가 되니까, 대소문자 구분도 없애면 되겠지 싶었다.
SELECT * FROM products
WHERE LOWER(name) LIKE '%' || LOWER(:query) || '%';
그런데 이번엔 다른 문제가 생겼다. 누군가 "케이스"를 검색하면 모든 종류의 케이스가 나왔다. 갤럭시 케이스, 맥북 케이스, 에어팟 케이스... 사용자가 원하는 건 "아이폰 케이스"인데 말이다. 관련성 순서도 없고, 그냥 DB에 들어간 순서대로 나왔다.
더 큰 문제는 성능이었다. 제품이 수천 개를 넘어가면서 검색이 눈에 띄게 느려졌다. LIKE '%keyword%' 패턴은 인덱스를 타지 못했다. Full table scan이 일어나고 있었던 것이다.
그때 깨달았다. 검색은 생각보다 훨씬 복잡한 문제였다.
Elasticsearch를 처음 접했을 때, "Inverted Index"라는 개념이 가장 와닿았다. 한국어로는 "역색인"인데, 이름만 들어서는 뭔지 모르겠다가도 비유를 들으니 단번에 이해됐다.
옛날 도서관에는 카드 목록함이 있었다. 제목, 저자, 주제별로 정리된 작은 카드들. "검색"이라는 주제 카드를 뽑으면, 그 주제를 다루는 모든 책의 위치가 적혀있었다. 책 한 권 한 권을 뒤지지 않아도, 카드만 보면 원하는 책을 바로 찾을 수 있었던 것이다.
Inverted Index가 바로 이것이다.
일반적인 DB는 이렇게 생겼다:
Document 1: "iPhone 15 Pro 케이스"
Document 2: "갤럭시 S24 케이스"
Document 3: "iPhone 충전기"
Inverted Index는 이걸 뒤집는다:
"iPhone" → [Document 1, Document 3]
"15" → [Document 1]
"Pro" → [Document 1]
"케이스" → [Document 1, Document 2]
"갤럭시" → [Document 2]
"S24" → [Document 2]
"충전기" → [Document 3]
사용자가 "iPhone 케이스"를 검색하면? "iPhone"이 들어간 문서와 "케이스"가 들어간 문서의 교집합을 찾으면 된다. 전체 문서를 훑을 필요가 없다. 미리 만들어둔 색인을 보기만 하면 되는 것이다.
이게 검색 엔진의 핵심이었다. 데이터를 저장할 때 검색에 최적화된 형태로 변환해두는 것. 마치 책을 도서관에 꽂으면서 동시에 카드 목록을 업데이트하는 것처럼.
Elasticsearch에서 데이터는 JSON 형태의 "Document"로 저장된다. 그리고 이 Document들이 모인 것이 "Index"다. RDB로 비유하자면 Document는 Row, Index는 Table 정도로 생각할 수 있다. 하지만 실제로는 훨씬 다르다.
// products 인덱스에 저장되는 문서
{
"id": "1",
"name": "iPhone 15 Pro 케이스",
"category": "스마트폰 액세서리",
"price": 29000,
"description": "투명 하드 케이스로 iPhone 15 Pro 전용입니다"
}
이 문서가 인덱싱될 때, Elasticsearch는 각 필드를 분석(analyze)한다. name 필드는 어떻게 쪼갤지, price는 숫자로 취급할지, category는 정확히 일치하는 것만 찾을지 등을 결정한다.
Mapping은 각 필드가 어떤 타입인지, 어떻게 분석될지를 정의한다. RDB의 스키마와 비슷하지만, 검색을 위한 옵션이 훨씬 많다.
{
"mappings": {
"properties": {
"name": {
"type": "text",
"analyzer": "standard",
"fields": {
"keyword": {
"type": "keyword"
}
}
},
"price": {
"type": "integer"
},
"category": {
"type": "keyword"
}
}
}
}
여기서 text 타입은 전문 검색(full-text search)용이다. 문장을 단어로 쪼개서 검색 가능하게 만든다. 반면 keyword 타입은 정확히 일치하는 것만 찾는다. 필터링이나 정렬에 쓰인다.
신기한 건 name 필드에 두 가지를 다 쓸 수 있다는 점이다. name으로 검색하면 부분 일치가 되고, name.keyword로 검색하면 정확히 일치하는 것만 나온다. 하나의 필드, 두 가지 용도.
Analyzer는 텍스트를 어떻게 처리할지 결정한다. 크게 세 단계로 나뉜다:
{
"settings": {
"analysis": {
"analyzer": {
"my_analyzer": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase", "stop"]
}
}
}
}
}
영어는 띄어쓰기로 쪼개면 되니 간단하다. "iPhone 15 Pro"는 ["iphone", "15", "pro"]가 된다. 하지만 한국어는 다르다.
"아이폰15프로케이스"를 어떻게 쪼개야 할까? 띄어쓰기도 없고, 조사도 붙는다. "케이스를", "케이스의", "케이스에"... 전부 "케이스"로 검색 가능해야 한다.
이게 형태소 분석의 영역이다. 한국어 플러그인(Nori)을 쓰면 문장을 형태소 단위로 쪼갤 수 있다. "아이폰을 샀어요"는 ["아이폰", "을", "사", "았", "어요"]로 분해된다. 조사와 어미를 제거하면 ["아이폰", "사"]가 남는다.
검색 결과가 수백 개라면? 뭘 먼저 보여줄까? 여기서 등장하는 게 관련성 점수(relevance score)다.
Elasticsearch는 기본적으로 BM25 알고리즘을 쓴다. 그 전에는 TF-IDF를 썼는데, 개념은 비슷하다.
TF-IDF의 아이디어:예를 들어, "iPhone 케이스"를 검색했을 때:
BM25는 여기에 문서 길이 정규화를 추가했다. 짧은 문서에서 한 번 나온 것과 긴 문서에서 한 번 나온 것은 다르게 취급한다.
{
"query": {
"match": {
"name": {
"query": "iPhone 케이스",
"operator": "and"
}
}
}
}
이 쿼리는 "iPhone"과 "케이스"가 둘 다 들어간 문서를 찾되, BM25 점수가 높은 순서로 정렬한다. 마치 도서관 사서가 "이 책이 당신이 찾는 것에 더 가까울 것 같아요"라고 추천해주는 것처럼.
Elasticsearch는 강력하지만 무겁다. JVM 위에서 돌아가고, 메모리를 많이 먹고, 클러스터 관리가 복잡하다. AWS에서 Elasticsearch 서비스를 쓰면 비용이 만만치 않다.
프로젝트 규모와 필요에 따라 다른 선택지들도 있다:
Meilisearch: Rust로 만들어져서 가볍고 빠르다. API가 직관적이고, 설정이 간단하다. 오타 허용(typo tolerance)이 기본으로 들어있다. 수십만 건 규모라면 이게 더 나을 수 있다.
Typesense: Meilisearch와 비슷하지만 C++로 작성됐다. 성능에 더 집중했고, 멀티 테넌트 지원이 좋다.
Algolia: 완전 관리형 서비스. 검색 UI까지 제공한다. 빠르고 편하지만 비싸다. 검색이 비즈니스의 핵심이고 예산이 있다면 고려할 만하다.
PostgreSQL Full-Text Search: 이미 PostgreSQL을 쓰고 있다면 별도 시스템 없이 검색을 구현할 수 있다. tsvector와 tsquery를 쓰면 inverted index와 비슷한 것을 만들 수 있다. 규모가 크지 않고 복잡한 검색이 필요 없다면 충분하다.
-- PostgreSQL FTS 예시
ALTER TABLE products
ADD COLUMN search_vector tsvector;
UPDATE products
SET search_vector = to_tsvector('english', name || ' ' || description);
CREATE INDEX idx_search ON products USING GIN(search_vector);
SELECT * FROM products
WHERE search_vector @@ to_tsquery('iPhone & case');
검색 엔진을 도입하면 데이터가 두 곳에 존재하게 된다. PostgreSQL에도 있고, Elasticsearch에도 있다. 이 둘을 어떻게 동기화할까?
방법 1: Application Layer Sync 제품을 생성/수정/삭제할 때, DB 업데이트 후 Elasticsearch도 업데이트한다.
async function createProduct(productData) {
// 1. DB에 저장
const product = await db.products.create(productData);
// 2. Elasticsearch에 인덱싱
await esClient.index({
index: 'products',
id: product.id,
document: {
name: product.name,
description: product.description,
price: product.price,
category: product.category
}
});
return product;
}
간단하지만 문제가 있다. DB 저장은 성공했는데 ES 인덱싱이 실패하면? 데이터 불일치가 생긴다. 트랜잭션으로 묶을 수도 없다. 별도 시스템이니까.
방법 2: Change Data Capture (CDC) DB의 변경사항을 감지해서 자동으로 ES에 반영한다. Debezium 같은 도구를 쓰면 PostgreSQL의 WAL(Write-Ahead Log)을 읽어서 변경사항을 Kafka로 보낼 수 있다. 그걸 소비해서 ES에 반영한다.
복잡하지만 확실하다. DB가 single source of truth로 남고, ES는 그걸 따라가기만 한다.
방법 3: Scheduled Batch Sync 주기적으로 DB 전체를 ES에 다시 인덱싱한다. 가장 단순하지만 데이터가 커지면 비효율적이다. 실시간성도 떨어진다.
결국 이해한 것: 완벽한 방법은 없다. 트레이드오프를 이해하고 상황에 맞게 선택해야 한다.
검색 시스템을 구축하면서 배운 것들을 정리하면:
SQL LIKE로 충분한 경우:결국 검색은 단순한 기능이 아니라 시스템이었다. 도서관의 카드 목록처럼, 찾기 쉽게 정리하는 것이 핵심이었다. LIKE 쿼리로 시작했다가 Elasticsearch까지 오면서, 검색의 복잡성과 아름다움을 동시에 이해하게 됐다.
지금은 Meilisearch를 쓴다. 우리 규모에는 이게 딱 맞았다. Elasticsearch는 나중에, 정말 필요할 때 다시 꺼내볼 생각이다.