Elasticsearch
고친 사람 github-actions[bot]
Elasticsearch 는 쌓아 둔 글 더미에서 낱말로 빠르게 찾아 주는 검색 서버입니다. 글을 넣을 때 어느 낱말이 어느 글에 들었는지 미리 정리해 둡니다. 그래서 글이 수억 건이어도 검색어가 든 글을 금세 추려 냅니다. 데이터가 늘면 서버를 더 붙여 나눠 담습니다.
쉽고 빠른 이해
Elasticsearch 는 글을 낱말로 찾아 주는 서버입니다. 쇼핑몰 검색창에 「무선 이어폰」을 치면 상품 설명에 그 낱말이 든 상품을 잘 맞는 순서로 돌려줍니다.
데이터베이스로 같은 일을 하면 모든 행의 글을 처음부터 끝까지 훑어야 합니다. 데이터가 커질수록 느려집니다. 어느 결과가 더 잘 맞는지도 알려 주지 않습니다.
어떻게 도는가:
- 글을 넣을 때 낱말로 잘게 쪼갭니다
- 낱말마다 그 낱말이 든 글의 목록을 적어 둡니다
- 검색하면 목록부터 찾아 글을 추립니다
- 추린 글에 잘 맞는 순서로 점수를 매깁니다
대가가 있습니다. 넣은 글은 잠깐 뒤에야 검색에 걸립니다. 함께 바뀌어야 하는 여러 건을 한꺼번에 성공하거나 한꺼번에 실패하게 만드는 기능도 약합니다. 그래서 원본 데이터는 보통 다른 데이터베이스에 따로 둡니다.
상세
Elasticsearch 는 문서를 저장하고 검색하는 서버 프로그램입니다. 내려받아 띄우면 네트워크로 요청을 받습니다. 글이 많이 든 데이터에서 낱말로 찾는 일, 곧 전문 검색을 맡습니다. 쇼핑몰의 상품 검색, 서비스 로그에서 오류 메시지 찾기가 대표적인 쓰임입니다.
속을 들여다보면 Lucene 이라는 자바 검색 라이브러리가 들어 있습니다. Lucene 은 한 기계 안에서 글을 찾기 쉽게 정리해 두고 찾아 주는 일을 합니다. Elasticsearch 는 그 위에 네트워크 요청 처리와 여러 서버로 나눠 담는 기능을 얹었습니다. 라이브러리를 서버로 감싸 누구나 요청만 보내면 쓰게 만든 것입니다.
이 절은 네 묶음으로 갑니다.
- 데이터베이스 검색이 막히는 대목, 그 막힘을 푸는 찾기용 표, 글을 낱말로 쪼개는 방법
- 데이터를 담는 단위, 요청을 보내는 방법, 결과 순서를 정하는 점수
- 여러 서버에 나눠 담고 검색하는 방식, 넣은 문서가 늦게 보이는 까닭
- 이 물건이 포기한 것과 쓰는 곳
데이터베이스 검색이 막히는 대목
관계형 데이터베이스에서 글 안의 낱말을 찾으려면 보통 LIKE '%이어폰%' 같은 조건을 씁니다. 앞에 % 가 붙으면 낱말이 글의 어디에 있어도 된다는 뜻입니다. 이런 조건에는 데이터베이스가 조회를 빠르게 하려고 따로 만들어 두는 인덱스도 도움이 안 됩니다. 결국 모든 행의 글을 하나씩 열어 봐야 합니다.
막히는 것이 속도만은 아닙니다. 「무선 블루투스 이어폰」을 찾는 사람에게는 세 낱말이 다 든 상품이 한 낱말만 든 상품보다 앞에 와야 합니다. LIKE 는 들었나 안 들었나만 답합니다. 얼마나 잘 맞는지는 알려 주지 않습니다.
역색인
Elasticsearch 가 검색어가 든 문서를 바로 추려 내는 바탕은 역색인입니다. 색인은 찾기 쉽게 미리 정리해 둔 표입니다. 역색인은 문서에서 낱말로 가지 않고 낱말에서 문서로 거꾸로 가는 색인입니다.
책 뒤쪽의 찾아보기를 떠올리면 됩니다. 「이어폰 → 12쪽, 87쪽」처럼 낱말을 찾으면 그 낱말이 나온 쪽이 바로 나옵니다.
문서 셋을 넣었을 때 역색인이 어떻게 생기는지 표로 보겠습니다. 1번은 「무선 이어폰」, 2번은 「유선 이어폰」, 3번은 「무선 충전기」라는 이름을 가진 상품입니다.
| 낱말 | 이 낱말이 든 문서 |
|---|---|
| 무선 | 1 · 3 |
| 유선 | 2 |
| 이어폰 | 1 · 2 |
| 충전기 | 3 |
「무선 이어폰」을 검색하면 표에서 두 줄만 찾으면 됩니다. 「무선」은 1과 3, 「이어폰」은 1과 2를 가리킵니다. 두 낱말이 다 든 1번이 맨 앞에 옵니다. 한 낱말씩만 든 2번과 3번은 뒤에 옵니다. 문서가 몇 건이든 표에서 낱말 두 줄을 찾는 일은 거의 늘지 않습니다.
분석기
역색인을 만들려면 먼저 글을 낱말로 쪼개야 합니다. 이 일을 하는 것이 분석기입니다. 분석기는 문서를 넣을 때 글을 받아 낱말 목록으로 바꿉니다. 검색어가 들어올 때도 같은 분석기를 거칩니다.
쪼개는 방법이 검색 품질을 가릅니다. 영어는 띄어쓰기로 자르고 대소문자를 하나로 맞추면 대개 됩니다. 한국어는 「이어폰을」 · 「이어폰이」처럼 조사가 붙어서 띄어쓰기만으로는 「이어폰」과 안 맞습니다. 그래서 낱말에서 조사와 어미를 떼어 내는 형태소 분석기를 따로 붙여 씁니다.
넣을 때와 찾을 때 분석기가 다르면 검색이 조용히 빗나갑니다. 「이어폰을」로 색인된 글은 「이어폰」 검색에 안 걸립니다. 오류가 나지 않아서 결과가 비었을 때 원인을 찾기 어렵습니다.
문서와 인덱스
Elasticsearch 가 저장하는 한 건은 문서입니다. 문서는 JSON(JavaScript Object Notation) 객체 하나입니다. 관계형 데이터베이스의 행 한 줄에 해당합니다. 상품 하나가 {"name": "무선 이어폰", "price": 39000} 같은 문서 하나가 됩니다.
문서를 모아 두는 단위는 인덱스입니다. 데이터베이스의 테이블에 가깝습니다. 데이터베이스에서 「인덱스」는 조회를 빠르게 하려고 따로 만드는 구조를 뜻합니다. 여기서는 뜻이 달라서 문서 모음을 가리킵니다.
그래서 이 문서는 두 말을 갈라 씁니다. 찾기 쉽게 정리한 표는 「색인」, 문서 모음은 「인덱스」입니다. 인덱스 하나가 자기 문서들의 역색인을 품고 있습니다.
인덱스마다 필드의 타입을 정해 두는 것을 매핑이라고 부릅니다. 테이블의 스키마에 해당합니다. 같은 글 필드라도 낱말로 쪼개 검색할 글인지, 주문번호처럼 통째로 맞춰 볼 값인지를 매핑이 정합니다.
위 표는 처음 익힐 때 쓰는 대응입니다. 구조까지 같지는 않습니다. 인덱스끼리 조인하는 기능이 약하다는 점이 뒤의 「포기한 것」 절에서 다시 나옵니다.
요청을 보내는 방법
Elasticsearch 에는 HTTP(HyperText Transfer Protocol)로 요청을 보냅니다. 요청 본문과 응답이 모두 JSON 입니다. 그래서 드라이버를 따로 깔지 않아도 curl 한 줄로 검색을 해 볼 수 있습니다.
아래는 products 인덱스의 name 필드에서 「무선 이어폰」을 찾는 요청입니다. 주소의 _search 는 검색을 뜻합니다. 본문의 match 는 분석기를 거쳐 낱말로 찾으라는 뜻입니다.
GET /products/_search
{
"query": {
"match": { "name": "무선 이어폰" }
}
}
응답에는 걸린 문서들이 점수 순서로 담겨 옵니다. 문서마다 원본 JSON 과 함께 얼마나 잘 맞는지를 나타내는 점수가 붙습니다.
관련도 점수
검색어가 문서에 얼마나 잘 맞는지를 나타내는 수가 관련도 점수입니다. 점수가 있어야 「세 낱말이 다 든 상품이 앞에 온다」가 성립합니다.
점수를 매기는 식은 몇 가지 직관을 따릅니다. 검색 낱말이 문서에 여러 번 나오면 점수가 오릅니다. 모든 문서에 흔히 나오는 낱말은 점수에 적게 보탭니다. 짧은 필드에 든 낱말이 긴 본문에 묻힌 낱말보다 더 무겁게 셉니다.
이 직관들을 식 하나로 묶어 널리 쓰는 것이 BM25(Best Matching 25)입니다. 문서마다 이 식으로 수를 구해 큰 순서로 결과를 세웁니다.
노드와 클러스터
Elasticsearch 서버 프로세스 하나가 노드입니다. 노드 여러 개가 서로 알고 함께 일하는 무리가 클러스터입니다. 데이터가 한 기계에 다 안 들어가거나 요청이 한 기계로 감당이 안 될 때 노드를 늘립니다.
노드들은 역할을 나눠 맡습니다. 마스터 노드는 어느 인덱스가 있고 그 데이터가 어느 노드에 놓였는지 같은 클러스터 상태를 관리합니다. 데이터 노드는 실제 문서를 담고 검색을 수행합니다. 한 노드가 여러 역할을 겸할 수도 있습니다.
샤드와 레플리카
인덱스 하나가 한 노드에 다 안 들어갈 수 있습니다. 그래서 인덱스를 여러 조각으로 나눠 여러 노드에 흩어 둡니다. 이 조각이 샤드입니다. 조각으로 나누는 일은 샤딩이라고 합니다.
샤드 하나는 인덱스의 문서 가운데 자기 몫만 담습니다. 그 문서들의 역색인도 샤드마다 따로 가집니다. 그래서 샤드 하나만 떼어 놓고도 그 안에서 검색할 수 있습니다.
조각 가운데 원본을 주 샤드라고 부릅니다. 주 샤드를 다른 노드에 복사해 둔 것이 레플리카 샤드입니다. 줄여서 레플리카라고 부릅니다.
레플리카는 두 가지 일을 합니다. 주 샤드가 든 노드가 죽으면 레플리카가 주 샤드로 올라서서 데이터를 지킵니다. 평소에는 검색 요청을 나눠 받아 읽기 부하를 덜어 줍니다.
아래 그림은 주 샤드 둘과 레플리카 둘을 노드 셋에 놓은 모습입니다. 같은 샤드의 원본과 복사본은 한 노드에 같이 놓이지 않습니다.
flowchart TD
subgraph C["클러스터"]
subgraph N1["노드 1"]
P0["주 샤드 0"]
end
subgraph N2["노드 2"]
P1["주 샤드 1"]
R0["레플리카 0"]
end
subgraph N3["노드 3"]
R1["레플리카 1"]
end
end
P0 -.복사.-> R0
P1 -.복사.-> R1
노드 2가 죽어도 주 샤드 0은 노드 1에, 샤드 1의 복사본은 노드 3에 남습니다. 그래서 인덱스의 문서가 하나도 사라지지 않습니다. 원본과 복사본을 한 노드에 두면 그 노드 하나만 죽어도 둘이 함께 사라집니다.
주 샤드 개수는 인덱스를 만들 때 정합니다. 문서가 갈 샤드는 문서 식별자를 수로 바꾼 값을 주 샤드 개수로 나눈 나머지로 고르기 때문입니다. 식별자에서 나온 수가 5인 문서는 주 샤드가 둘이면 나머지가 1이라 샤드 1로 갑니다.
주 샤드를 셋으로 바꾸면 같은 문서의 나머지가 2가 됩니다. 샤드 1에 든 문서를 샤드 2에서 찾게 되어 위치 계산이 전부 어긋납니다. 그래서 주 샤드 개수는 만든 뒤에 쉽게 못 바꿉니다. 레플리카 개수는 복사본 수일 뿐이라 언제든 바꿀 수 있습니다.
검색 요청이 도는 순서
샤드가 여러 노드에 흩어져 있으니 검색 한 번이 여러 노드를 거칩니다. 요청을 받은 노드가 이 흐름을 이끄는 코디네이터 노릇을 합니다. 흐름은 두 단계로 나뉩니다.
첫 단계에서는 샤드마다 자기 안에서 검색해 점수 높은 문서의 식별자와 점수만 돌려줍니다. 요청을 받은 노드가 그것들을 모아 점수 순서로 한 줄로 세웁니다. 둘째 단계에서 최종으로 남은 문서들의 본문만 해당 샤드에 달라고 합니다.
sequenceDiagram
participant 앱
participant 받은노드 as 요청을 받은 노드
participant 샤드0 as 샤드 0
participant 샤드1 as 샤드 1
앱->>받은노드: 검색 요청
받은노드->>샤드0: 점수 높은 문서를 찾아라
받은노드->>샤드1: 점수 높은 문서를 찾아라
샤드0-->>받은노드: 식별자와 점수
샤드1-->>받은노드: 식별자와 점수
Note over 받은노드: 모아서 점수 순서로 정렬
받은노드->>샤드0: 남은 문서의 본문을 달라
샤드0-->>받은노드: 문서 본문
받은노드-->>앱: 검색 결과
그림의 둘째 단계는 샤드 0에만 갑니다. 이 예에서는 최종으로 남은 문서가 모두 샤드 0에 들어 있기 때문입니다.
두 단계로 나누는 것은 전송량 때문입니다. 샤드마다 본문까지 보내면 결국 버릴 문서의 본문까지 네트워크를 탑니다. 식별자와 점수만 먼저 받으면 최종 결과에 들어갈 문서의 본문만 옮기면 됩니다.
넣은 문서가 바로 안 보이는 까닭
문서를 넣고 바로 검색하면 그 문서가 안 나올 때가 있습니다. 넣은 문서는 먼저 메모리에 모입니다. 이 단계의 문서는 아직 검색에 걸리지 않습니다.
샤드 안의 역색인은 한 덩어리가 아닙니다. 여러 번에 나눠 만든 작은 역색인 묶음이 쌓여 있습니다. 이 묶음 하나가 세그먼트입니다. 검색은 샤드 안의 세그먼트를 모두 훑어 결과를 합칩니다.
메모리에 모인 문서를 새 세그먼트로 묶어 내는 작업이 짧은 간격으로 돕니다. 이 작업이 지나야 검색에 걸립니다. 이 작업이 리프레시입니다.
이렇게 넣은 뒤 잠깐 뒤에 보이는 성질을 준실시간 검색이라고 합니다. 문서 한 건마다 세그먼트를 새로 만들면 쓰기가 크게 느려집니다. 모아서 한 번에 묶는 쪽을 고른 대가로 보이기까지 지연이 생깁니다.
게시글을 올리고 돌아온 목록 화면에 그 글이 보여야 하면 이 지연이 문제가 됩니다. 쓰기 요청에 refresh 옵션을 붙여 리프레시를 강제할 수 있습니다. 다만 자주 쓰면 작은 세그먼트가 잔뜩 쌓입니다. 검색 한 번이 훑어야 할 세그먼트가 늘어 전체가 느려집니다.
포기한 것
Elasticsearch 는 검색을 빠르게 하려고 몇 가지를 내려놓았습니다. 그래서 보통 원본 데이터를 맡기는 저장소로 쓰지 않습니다. 원본은 데이터베이스에 두고 Elasticsearch 에는 검색용 사본을 넣는 구성이 흔합니다.
| 포기한 것 | 무엇이 곤란해지나 |
|---|---|
| 여러 문서를 묶는 트랜잭션 | 주문과 재고처럼 함께 바뀌어야 하는 두 문서가 한쪽만 저장될 수 있습니다 |
| 넣은 직후 읽기 | 방금 쓴 문서가 검색에 안 나올 수 있습니다 |
| 인덱스끼리의 조인 | 관계를 따라가야 하면 한 문서에 미리 합쳐 넣어야 합니다 |
| 적은 자원 | 역색인과 원본 JSON 을 둘 다 담아 디스크와 메모리를 많이 씁니다 |
원본과 사본을 따로 두면 새 숙제가 하나 생깁니다. 데이터베이스가 바뀔 때 Elasticsearch 도 따라 바꿔야 합니다. 둘 사이가 어긋나면 검색에는 나오는데 실제로는 없는 상품이 생깁니다. 이 동기화 방법을 정하는 것이 도입할 때 가장 먼저 부딪히는 일입니다.
쓰는 곳
상품이나 게시글 검색처럼 글을 낱말로 찾아야 하는 서비스가 가장 흔한 쓰임입니다. 오타를 어느 정도 봐주는 검색과 입력 중에 뒷말을 채워 주는 자동 완성도 여기서 만듭니다.
또 하나 큰 쓰임은 로그 검색입니다. 서버 여러 대의 로그를 모아 넣으면 오류 메시지의 낱말로 한꺼번에 찾을 수 있습니다. 로그를 모아 나르는 Logstash, 화면으로 보여 주는 Kibana와 함께 묶어 쓰는 구성이 널리 알려져 있습니다.
관련 항목
Elasticsearch 안에서 검색을 떠받치는 구조
Lucene · 역색인 · 세그먼트 · 분석기 · 토크나이저 · 형태소 분석 · 관련도 점수 · BM25 · TF-IDF
Elasticsearch 가 데이터를 담는 단위
문서 · 인덱스 · 매핑 · 필드 · JSON · 인덱스 템플릿 · 인덱스 별칭
Elasticsearch 클러스터를 이루는 구성 요소
노드 · 클러스터 · 마스터 노드 · 데이터 노드 · 코디네이터 · 샤드 · 주 샤드 · 레플리카 샤드
Elasticsearch 가 데이터를 나누고 지키는 방식
샤딩 · 복제 · 리프레시 · 준실시간 검색 · 리인덱스 · 스냅샷 · 분단
Elasticsearch 에 보내는 요청과 질의
HTTP · REST · Query DSL · 전문 검색 · 집계 · 자동 완성
Elasticsearch 를 둘러싸고 함께 쓰는 도구
Kibana · Logstash · Beats · ELK 스택 · 로그 · JVM
Elasticsearch 를 원본 저장소와 맞추는 수단
관계형 데이터베이스 · 동기화 · 트랜잭션 · 조인 · 변경 데이터 캡처 · 이중 쓰기
Elasticsearch 와 같은 역할을 두고 겨루는 검색 엔진
OpenSearch · Apache Solr · Meilisearch · Typesense · Apache Lucene
Elasticsearch 가 속하는 상위 분류
다른 이름: 엘라스틱서치