상품 1000만 개가 든 MySQL에 WHERE name LIKE '%갤럭시%'를 날렸더니 8초가 걸렸다. 인덱스를 걸어도 똑같았다. 같은 검색을 Elasticsearch에 넣으니 20ms에 끝났다.
같은 데이터인데 400배 차이가 난다. 이게 단순히 “ES가 검색에 특화돼서”가 아니라, 데이터를 저장하는 방향 자체가 반대이기 때문이다.
RDB로 검색하면 왜 죽나?
LIKE '%갤럭시%'는 인덱스를 못 탄다. B-tree 인덱스는 정렬된 값의 앞부분(prefix) 으로만 탐색할 수 있다. LIKE '갤럭시%'(뒤 와일드카드)는 인덱스를 타지만, '%갤럭시%'(앞 와일드카드)는 시작점을 모르니 전체를 다 훑는다.
그래서 1000만 행을 한 줄씩 읽으며 문자열을 비교한다. 풀스캔이다. 데이터가 늘면 선형으로 느려진다.
게다가 형태소 분석, 오타 보정, 관련도 랭킹은 RDB로 아예 불가능하다. 검색은 다른 자료구조가 필요하다.
역색인이 뭔가?
RDB는 “문서 → 그 안의 단어”로 저장한다. ES는 이걸 뒤집는다.
정방향(RDB): doc1 → "갤럭시 케이스"
doc5 → "갤럭시 폰 케이스"
역방향(ES): "갤럭시" → [doc1, doc5]
"케이스" → [doc1, doc5]
"폰" → [doc5]
ES(정확히는 그 밑의 Lucene)는 두 자료구조로 역색인을 만든다.
Term Dictionary (단어 사전): 모든 단어를 정렬해서 저장한다. 빠른 조회를 위해 FST(Finite State Transducer) 라는 압축 트라이 구조로 메모리에 올린다. “갤럭시”가 사전에 있는지 O(단어 길이)에 찾는다.
Posting List (등장 문서 목록): 각 단어가 나온 문서 ID 목록 + 빈도(term frequency) + 위치(position, 구문 검색용)를 담는다. 정수 압축(delta encoding + bit packing)으로 작게 저장한다.
“갤럭시 케이스”를 검색하면 “갤럭시” posting과 “케이스” posting을 가져와 교집합을 구한다. 문서 1000만 개를 보는 게 아니라 단어 두 개의 목록만 본다. 그래서 문서 수와 거의 무관하게 빠르다.
색인했는데 왜 검색에 바로 안 보이나?
상품을 등록하고 바로 검색하면 안 나올 때가 있다. 버그가 아니라 ES의 구조다.
Lucene 인덱스는 불변(immutable) 세그먼트들의 모음이다. 문서를 색인하면 이런 흐름을 탄다.
문서 색인 → 인메모리 버퍼 + translog(장애 복구용 로그)에 기록
→ refresh(기본 1초)마다 버퍼를 새 세그먼트로 만들어 디스크 캐시에 올림
→ 이때부터 검색 가능 ← 여기가 "near real-time"의 정체
→ flush 시 translog 비우고 세그먼트를 디스크에 확정
즉 색인 즉시가 아니라 다음 refresh(최대 1초 뒤)에야 검색에 반영된다. 이게 ES가 “실시간”이 아니라 “준실시간”인 이유다.
여기서 헷갈리기 쉬운 게 “검색 가능”과 “안 잃어버림”은 다른 문제라는 점이다. refresh는 검색 가능 시점을 정하고, 내구성(durability)은 translog가 담당한다. 쓰기는 인메모리 버퍼와 translog에 먼저 기록되는데, translog는 기본 설정(index.translog.durability: request)에서 매 요청마다 fsync된다. 그래서 refresh 전이라 아직 세그먼트로 안 내려갔어도, 노드가 죽으면 재시작 시 translog를 재생(replay)해 복구한다. 이걸 async로 바꾸면 fsync를 주기적으로(sync_interval)만 해서 처리량은 올라가지만, 마지막 fsync 이후 쓰기는 장애 시 유실될 수 있는 트레이드오프가 있다.
운영에서 중요한 점: 대량 bulk 색인을 할 때 refresh가 1초마다 돌면 작은 세그먼트가 수없이 생기고, 그걸 병합하는 비용이 폭발한다. 그래서 bulk 전에 끈다.
PUT /products/_settings
{ "index": { "refresh_interval": "-1" } }
# ... bulk 색인 ...
PUT /products/_settings
{ "index": { "refresh_interval": "1s" } }
POST /products/_refresh
세그먼트가 불변이면 수정은 어떻게 하나?
세그먼트는 한 번 쓰면 못 고친다. 그래서 update는 사실 삭제 마킹 + 새 문서 추가다.
문서 수정 = 기존 문서를 "삭제됨"으로 표시(.del 파일) + 새 버전을 새 세그먼트에 추가
문제는 삭제 마킹된 죽은 문서가 디스크에 계속 남는다는 것. update가 많은 인덱스는 죽은 문서가 쌓여 용량을 먹고 검색도 느려진다.
이걸 정리하는 게 Segment Merge다. 백그라운드에서 작은 세그먼트들을 큰 세그먼트로 병합하면서 삭제 문서를 진짜로 제거한다. 머지는 CPU와 디스크 I/O를 많이 먹는 작업이라, 색인이 끝난 인덱스는 force_merge로 세그먼트를 하나로 합쳐 검색을 최적화하기도 한다.
정렬과 집계는 왜 다른 구조를 쓰나?
역색인은 “이 단어가 든 문서 찾기”에는 최적이지만, “이 문서의 가격 필드 값은?” 같은 문서 → 값 조회에는 비효율이다. 방향이 반대니까.
그래서 ES는 정렬·집계·스크립트용으로 doc_values라는 별도 구조를 둔다. 컬럼 지향(같은 필드 값을 모아서)으로 디스크에 저장해, “가격 내림차순 정렬”이나 “카테고리별 개수 집계”를 빠르게 한다.
→ 그래서 검색만 하는 필드는 doc_values: false로 꺼서 용량을 아끼고, 정렬·집계가 많은 필드는 켜두는 식으로 튜닝한다. text 타입은 분석되기 때문에 정렬이 불가능하고, 정렬·집계하려면 keyword 타입(doc_values 사용)을 써야 한다.
분산은 어떻게 되나?
인덱스 하나는 샤드(shard) 여러 개로 쪼개져 노드에 분산된다. 샤드 하나가 곧 하나의 Lucene 인덱스다.
문서가 어느 샤드로 갈지는 라우팅으로 결정된다.
shard = hash(_routing) % number_of_primary_shards
기본 _routing은 문서 ID다. 여기서 중요한 함정 하나.
프라이머리 샤드 수는 생성 후 변경 불가다. 샤드 수를 바꾸면 위 해시의 나머지 결과가 전부 달라져서 기존 문서를 못 찾는다. 그래서 샤드 수를 바꾸려면 새 인덱스를 만들고 reindex하는 수밖에 없다. → 데이터량을 예측해서 처음에 잘 잡아야 한다.
레플리카는 프라이머리의 복제본이다. 가용성(프라이머리 죽으면 승격)과 읽기 분산을 담당한다. 레플리카 수는 언제든 바꿀 수 있다.
검색은 scatter-gather 방식이다.
1. 코디네이터 노드가 모든 샤드에 쿼리를 뿌린다 (scatter)
2. 각 샤드가 자기 top-K를 반환한다
3. 코디네이터가 결과를 합쳐 최종 정렬한다 (gather)
그래서 샤드가 너무 많으면 매 검색마다 모든 샤드에 흩뿌리는 오버헤드가 커지고, 너무 적으면 병렬도와 확장성이 떨어진다.
최적화 정리
- bulk 색인 시 refresh 끄기:
refresh_interval: -1후 일괄 색인, 끝나고 refresh. 세그먼트 폭발 방지. - 불필요한 doc_values 끄기: 검색만 하는 필드는
doc_values: false. - text vs keyword 구분: 전문 검색은
text, 정렬·집계·정확일치는keyword. 한 필드에 멀티필드로 둘 다 두는 게 흔한 패턴. - 샤드 수 신중히: 변경 불가. 데이터량 예측해서 설계. 시계열이면 인덱스를 시간 단위로 롤오버.
- 색인 끝난 인덱스는 force_merge: 세그먼트 병합으로 검색 최적화 + 삭제 문서 제거. 단 색인 중인 핫 인덱스엔 하면 안 된다.
운영상 주의사항
“방금 등록한 상품이 검색에 안 나와요”라는 문의의 대부분은 두 가지다. refresh 타이밍(최대 1초 지연)이거나, DB→ES 동기화 파이프라인의 지연. ES는 원본(SoT)이 아니라 검색용 복제본이라는 걸 잊으면 안 된다. 원본은 RDB에 있고, ES는 CDC나 이벤트로 따라가는 사본이다. 동기화가 실패하면 “DB엔 있는데 검색엔 없는” 불일치가 생기므로, 정합성 모니터링과 주기적 전체 재색인 잡을 함께 둔다.