Elasticsearch 검색 모듈 구축 실전: 아키텍처 설계부터 정밀 랭킹까지

들어가며

콘텐츠 기반 애플리케이션을 개발하다 보면, 사용자가 검색창에 키워드를 입력했을 때 기대와 전혀 다른 결과를 받는 경우가 종종 있습니다. 원하는 내용을 찾지 못하거나, 결과의 순서가 직관적이지 않은 경우가 대표적입니다. 더 나아가 데이터가 수십만, 수백만 건으로 늘어나면 검색 속도는 급격히 저하되고, 심지어 타임아웃이 발생하기도 합니다.

이는 최근 프로젝트에서 실제로 겪었던 문제입니다. 처음에는 데이터베이스의 LIKE 쿼리에 의존했고, 이후 MySQL의 전문(full-text) 인덱스를 시도했지만 만족스러운 결과를 얻지 못했습니다. 결국 Elasticsearch(이하 ES)를 도입하여 독립적인 검색 모듈을 구축하기로 결정했습니다.

1. Elasticsearch의 핵심 개념 이해

코드 수준으로 들어가기 전에, ES가 무엇이며 어떤 문제를 해결하는지 이해해야 합니다.

1.1 역색인(Inverted Index): ES의 핵심 무기

MySQL과 같은 전통적인 데이터베이스는 정방향 색인(Forward Index)을 사용합니다. 문서 ID를 기준으로 내용을 저장하고, LIKE 연산자를 통해 패턴 매칭을 수행합니다. 데이터가 방대해지면 이는 사실상 전체 테이블 스캔(Full Table Scan)에 가깝습니다.

ES는 역색인(Inverted Index)을 사용합니다. 모든 문서의 내용을 사전에 "토큰화(Tokenize)"하여 "단어 → 문서" 형태의 매핑 테이블을 구축합니다.

단어해당 단어를 포함하는 문서 ID
검색1, 3, 5
엔진1, 2, 4
아키텍처2, 3

"검색 엔진"을 검색하면 ES는 "검색"과 "엔진"에 해당하는 문서 리스트를 빠르게 찾아 교집합(또는 합집합)을 구한 후, 관련성 점수(Relevance Score)에 따라 정렬합니다. 이 검색 과정은 O(1) 수준의 시간 복잡도를 가지며, 문서 수가 증가해도 성능이 선형적으로 저하되지 않습니다.

1.2 토큰화(Tokenization): 한글 검색의 핵심

영어는 공백을 기준으로 자연스럽게 단어가 분리되지만, 한국어는 그렇지 않습니다. 예를 들어 "검색엔진"이라는 단어를 단순히 한 글자씩 분리하면 "검색"이라는 키워드 검색 시 "검"과 "색"이 각각 매칭되어 노이즈가 발생합니다. 반대로 "엔진"을 검색할 때 "검색엔진"이 결과에 포함되지 않을 수도 있습니다.

이것이 바로 토큰화(Tokenization)가 해결해야 할 문제입니다. 우리 프로젝트에서는 한글 형태소 분석기인 은전한닢(Eunjeon) 플러그인을 사용했습니다. (처음에는 IK 분석기를 사용했으나, 한글에 특화된 은전한닢으로 변경했습니다.)

  • 색인 단계 (쓰기): seunjeon(max_length=255)를 사용합니다. 예를 들어 "검색엔진"은 [검색, 엔진, 검색엔진]으로 분리되어 재현율(Recall)이 높아집니다.
  • 검색 단계 (읽기): 사용자의 검색어를 그대로 사용하거나, 동의어 사전(Synonym)을 적용하여 정확도(Precision)를 높입니다.

이러한 "색인은 넓게, 검색은 좁게" 전략은 검색 기능의 핵심 설계 원칙 중 하나입니다.

1.3 관련성 점수: 단순 매칭을 넘어

ES는 기본적으로 BM25 알고리즘을 사용하여 쿼리와 문서 간의 관련성을 계산합니다. 고려 요소는 다음과 같습니다.

  • 단어 빈도(TF): 문서 내에서 검색어가 많이 등장할수록 점수가 높아집니다.
  • 역문서 빈도(IDF): 검색어가 전체 문서에서 드물게 등장할수록 점수가 높아집니다.
  • 필드 길이: 긴 문서보다 짧은 문서에서 검색어가 발견되면 더 높은 점수를 받습니다.

이는 데이터베이스의 "매칭되거나 매칭되지 않거나"라는 이분법적 로직보다 훨씬 정교합니다.

2. 검색 모듈을 분리해야 하는 이유

기본 개념을 이해했으니, 데이터가 일정 규모 이상으로 증가했을 때 왜 더 이상 데이터베이스에 의존할 수 없는지 살펴보겠습니다.

2.1 데이터베이스 쿼리의 한계

"Elasticsearch 실전"을 검색한다고 가정해 봅시다. MySQL에서 LIKE '%Elasticsearch%실전%'이라는 쿼리를 사용할 때 발생하는 문제점은 다음과 같습니다.

  • 인덱스 활용 불가: 와일드카드 %가 앞에 오면 B+Tree 인덱스를 사용할 수 없습니다.
  • 취약한 토큰화: "검색"으로 검색했을 때 "검색엔진"이라는 단어를 포함한 문서가 매칭되지 않을 수 있습니다.
  • 단순한 정렬: 등록일이나 특정 필드 기준 정렬만 가능하며, "어떤 결과가 더 관련성 높은지"를 반영할 수 없습니다.
  • 깊은 페이지네이션 성능 저하: OFFSET 10000 LIMIT 20과 같은 쿼리는 실제로 앞의 10,000개 행을 모두 스캔한 후 버리기 때문에 성능이 매우 나쁩니다.

MySQL의 FULLTEXT 인덱스를 사용하더라도 다음과 같은 문제가 남습니다.

  • 한국어 지원이 완벽하지 않아 토큰화 결과가 좋지 않습니다.
  • 비즈니스 가중치(예: 좋아요 수, 조회 수가 정렬에 미치는 영향)를 반영하기 어렵습니다.
  • 하이라이트(Highlighting), 검색어 추천(Suggestion)과 같은 고급 기능 지원이 부족합니다.

2.2 우리가 원하는 검색 기능

좋은 검색 기능은 단순히 "결과가 나오는 것"을 넘어 다음과 같은 조건을 만족해야 합니다.

  • 빠르다: 밀리초 단위로 응답해야 합니다.
  • 정확하다: 본문보다 제목이 더 중요하게 평가되어야 하며, 인기 있는 콘텐츠가 더 상위에 노출되어야 합니다.
  • 사용자 경험이 좋다: 키워드 하이라이트, 실시간 검색어 추천 등이 제공되어야 합니다.
  • 안정적이다: 깊은 페이지네이션에서도 성능이 저하되지 않고, 데이터 변경이 빠르게 반영되어야 합니다.

이러한 모든 요구 사항을 데이터베이스만으로 해결하려면 개발 및 유지보수 비용이 매우 높아집니다. Elasticsearch는 이러한 시나리오를 위해 특별히 설계되었습니다.

3. 아키텍처 설계: 읽기/쓰기 분리와 스냅샷

ES를 도입하는 것은 단순히 라이브러리를 추가하는 것 이상으로, 데이터 흐름을 재설계해야 함을 의미합니다.

3.1 읽기/쓰기 분리 패턴

우리는 고전적인 읽기/쓰기 분리 아키텍처를 채택했습니다.

  • 쓰기 측면: 비즈니스 데이터는 MySQL에 기록되고, 비동기 이벤트(예: Kafka 메시지)를 통해 ES에 동기화됩니다. 쓰기 작업은 강한 일관성(Strong Consistency)을 유지하며, 인덱스 업데이트는 최종적 일관성(Eventual Consistency)을 따릅니다.
  • 읽기 측면: 검색 요청은 직접 ES에 전달되며, MySQL을 다시 조회하지 않습니다.

이러한 방식의 장점은 검색 기능의 장애가 핵심 비즈니스 로직(게시글 작성, 댓글 등)에 영향을 미치지 않는다는 점입니다.

3.2 ES 내 필드 중복 저장의 중요성

간과하기 쉬운 문제 중 하나는 "검색 결과 페이지에 무엇을 표시할 것인가"입니다.

우리의 검색 결과 목록은 제목, 요약, 작성자 아바타, 닉네임, 태그, 썸네일 이미지, 좋아요 수, 스크랩 수, 조회 수를 보여줘야 했습니다.

만약 ES에 content_id만 저장하고, 검색할 때마다 MySQL을 조회하여 나머지 정보를 채운다면 다음과 같은 문제가 발생합니다.

  • 페이지당 여러 번의 데이터베이스 쿼리가 발생하여 지연 시간이 누적됩니다.
  • 결과 병합 및 예외 처리 로직이 복잡해집니다.

우리는 읽기 최적화(Read Optimization) 전략을 선택했습니다. ES에 인덱싱할 때 검색 결과 목록에 필요한 모든 필드를 함께 저장하는 것입니다. 이렇게 하면 한 번의 ES 쿼리로 완전한 데이터를 얻을 수 있어 검색 API가 매우 단순하고 안정적이 됩니다.

단점은 인덱스 디스크 사용량이 증가하고, 콘텐츠가 업데이트될 때 ES의 중복 필드도 함께 업데이트해야 한다는 점입니다. 하지만 검색 시나리오에서는 이 비용을 충분히 감수할 만한 가치가 있습니다.

4. 인덱스 설계: 필드 타입이 기능의 경계를 결정한다

인덱스 매핑(Mapping) 설계는 검색의 효과와 성능에 직접적인 영향을 미칩니다. 우리는 "검색은 Text + Tokenizer, 필터링/정렬/표시는 Keyword"라는 핵심 원칙을 따릅니다.

4.1 필드 타입 선택

필드 용도타입설명
제목, 본문text + 한글 형태소 분석기전문 검색에 사용되며, 관련성 점수 계산에 참여
상태, 태그keyword정확한 매칭, 필터링, 집계(Aggregation)에 사용
작성자 ID, 카운트long / integer정렬, 필터링에 사용
발행 시간date정렬, 범위 쿼리에 사용
검색어 추천completion접두사 기반 자동 완성 전용, 매우 낮은 지연 시간

4.2 하나의 필드로 여러 요구 사항 처리

때로는 동일한 필드에 대해 전문 검색과 정확한 필터링이 모두 필요할 수 있습니다. 예를 들어 "제목" 필드의 경우:

  • 검색 시 "키워드를 포함"하는지 여부를 기준으로 점수를 매겨야 합니다.
  • 특정 상황에서는 "제목이 정확히 특정 값과 일치"하는지 필터링해야 합니다.

이를 위해 fields 하위 필드를 사용합니다.

{
  "title": {
    "type": "text",
    "analyzer": "seunjeon_index",
    "search_analyzer": "seunjeon_search",
    "fields": {
      "keyword": {
        "type": "keyword",
        "ignore_above": 256
      }
    }
  }
}

이렇게 설정하면 title 필드는 전문 검색에, title.keyword 필드는 정확한 매칭 및 정렬에 사용할 수 있습니다.

5. 검색 로직 구현: 자연스러운 결과를 위한 정렬

진정한 검색 경험의 핵심은 정렬에 있습니다. 검색 API의 핵심 구현을 살펴보겠습니다.

5.1 넓은 재현율: multi_match + 필드 가중치

검색 시 titlebody를 동시에 검색하지만, 제목의 중요성이 본문보다 훨씬 높습니다. ES의 multi_match는 필드 가중치를 지원합니다.

"fields": ["title^3", "body"]

^3은 제목이 본문보다 3배 더 높은 점수를 받는다는 의미입니다. 이 배수는 경험적으로 조정할 수 있습니다.

5.2 비즈니스 가중치: function_score

텍스트 관련성만으로는 충분하지 않습니다. 좋아요 수가 많고 조회수가 높은 콘텐츠는 일반적으로 관련성이 더 높습니다. function_score를 사용하여 BM25 점수에 인기도 요소를 추가합니다.

"script_score": {
  "script": {
    "source": "_score + Math.log1p(doc['like_count'].value) * 2.0 + Math.log1p(doc['view_count'].value) * 1.0"
  }
}

log1p를 사용하는 이유는 로그 함수가 인기도 증가의 한계 효용을 감소시키기 때문입니다. 좋아요 수가 0에서 10으로 증가할 때의 점수 상승 폭이 1000에서 1010으로 증가할 때보다 훨씬 큽니다. 이렇게 하면 특정 콘텐츠가 모든 결과를 압도하는 것을 방지하고, 롱테일(Long-tail) 정확도 높은 콘텐츠도 노출될 기회를 얻습니다.

5.3 필터 조건: bool + filter

모든 콘텐츠가 검색 결과에 나타나야 하는 것은 아닙니다. bool 쿼리의 filter 절을 사용하여 필터링합니다.

  • status = published: 발행된 콘텐츠만 검색합니다.
  • tags: 사용자가 태그를 선택한 경우 terms 쿼리를 추가합니다.

filter 절의 장점은 관련성 점수 계산에 참여하지 않지만, 캐시되어 매우 효율적으로 실행된다는 점입니다.

5.4 안정적인 페이지네이션: search_after로 깊은 페이지 문제 해결

깊은 페이지네이션은 ES의 고전적인 문제입니다. 전통적인 from/size 방식의 페이지네이션은 ES에서 다음과 같이 동작합니다.

from = 10000, size = 20
→ 각 샤드는 먼저 상위 10020개 문서를 가져옴
→ 코디네이터 노드가 결과를 취합하여 정렬
→ 앞의 10000개를 버리고 마지막 20개 반환

페이지가 깊어질수록 각 샤드가 스캔해야 하는 문서 수가 선형적으로 증가하여 결국 클러스터 성능을 저하시킵니다.

우리의 해결책은 search_after 커서(Cursor) 기반 페이지네이션을 사용하는 것입니다. 이는 오프셋에 의존하지 않고 "이전 페이지의 마지막 위치에서 계속 검색"합니다.

핵심은 정렬 필드가 완전 순서(Total Order)를 보장해야 한다는 것입니다. 그렇지 않으면 페이지가 중복되거나 건너뛸 수 있습니다. 우리는 5단계 정렬을 사용합니다.

1. _score desc       // 관련성 우선
2. publish_time desc // 최신 콘텐츠 우선
3. like_count desc   // 인기 콘텐츠 우선
4. view_count desc   // 조회수 많은 콘텐츠 우선
5. content_id desc   // 유일 ID로 최종 정렬

마지막 단계인 content_id는 완전 순서를 보장하는 핵심 요소입니다. 이 필드가 없으면 앞의 네 필드에 많은 중복 값이 있을 수 있어 search_after의 정확도가 떨어집니다.

5.5 하이라이트: 사용자가 왜 이 결과가 나왔는지 알 수 있게

검색 결과에서 하이라이트된 조각을 반환하면 사용자가 결과의 관련성을 빠르게 판단하는 데 도움이 됩니다. 쿼리 시 highlight를 활성화하여 titlebody에 대한 하이라이트 조각을 반환하고, 서비스 계층에서 이를 하나의 snippet으로 병합합니다.

이 작은 세부 사항은 사용자 경험을 크게 향상시킵니다.

6. 증분 동기화: 이벤트 기반 인덱스 업데이트

인덱스와 데이터베이스 간의 동기화는 어떻게 유지할까요? 우리는 Outbox 패턴 기반의 비동기 동기화 메커니즘을 설계했습니다.

6.1 이중 쓰기(Dual Write)를 피해야 하는 이유

가장 직관적인 방법은 비즈니스 로직에서 MySQL과 ES에 동시에 쓰는 것입니다. 하지만 이는 두 가지 문제를 야기합니다.

  • 결합도 증가: 비즈니스 로직과 인덱싱 로직이 혼재됩니다.
  • 일관성 위험: MySQL 쓰기는 성공했지만 ES 쓰기가 실패하면 데이터 불일치가 발생합니다.

6.2 Outbox 패턴 구현

우리의 해결 방안은 다음과 같습니다.

  1. 비즈니스 로직이 MySQL에 데이터를 쓸 때, 동일한 트랜잭션 내에서 "변경 이벤트"를 outbox 테이블에 기록합니다.
  2. Canal과 같은 별도의 백그라운드 프로세스가 outbox 테이블을 읽어 Kafka에 메시지를 발행합니다.
  3. 검색 컨슈머(Consumer)가 Kafka 메시지를 수신하여 ES를 업데이트합니다.

이를 통해 다음과 같은 이점을 얻을 수 있습니다.

  • 결합도 분리: 비즈니스 코드는 ES의 존재를 인식하지 못합니다.
  • 안정성: outbox 테이블과 비즈니스 테이블은 동일한 트랜잭션에 속하므로, 둘 다 성공하거나 둘 다 실패합니다.
  • 멱등성(Idempotency): 메시지는 중복 소비될 수 있지만, upsert 작업은 본질적으로 멱등성을 가집니다.

6.3 준실시간( Near Real-time) vs 강한 일관성

이 비동기 패턴은 "최종적 일관성(Eventual Consistency)"을 제공합니다. 사용자가 방금 게시한 콘텐츠가 검색 결과에 나타나기까지 몇 초가 걸릴 수 있습니다. 우리의 비즈니스 시나리오에서는 이 정도의 지연을 감수할 수 있었습니다. 만약 강한 일관성이 필요하다면 동기식 쓰기를 고려할 수 있지만, 이는 성능과 가용성을 희생하는 대가를 치러야 합니다.

7. 검색어 추천: 입력 필드의 실시간 자동 완성

검색창의 "실시간 검색어 추천" 기능을 전문 검색으로 구현하면 성능이 저하됩니다. ES는 Completion Suggester라는 전용 기능을 제공합니다. 이는 FST(Finite State Transducer) 구조를 기반으로 하여 접두사 매칭을 O(1) 시간에 수행할 수 있습니다.

인덱스 매핑에 title_suggest 필드를 completion 타입으로 설정하고 쿼리합니다.

"suggest": {
  "title_suggest": {
    "prefix": "사용자가 입력한 접두사",
    "completion": {
      "field": "title_suggest",
      "size": 10
    }
  }
}

반환된 후보 단어는 드롭다운 힌트로 직접 사용되며, 응답 시간은 일반적으로 밀리초 단위입니다.

8. 히스토리 데이터 재색인(Reindexing)

검색 모듈을 처음 배포하거나 인덱스 매핑을 변경해야 할 때, 기존의 히스토리 데이터를 ES에 대량으로 적재해야 합니다.

우리는 SearchIndexService에서 시작 시 자동 재색인 로직을 구현했습니다.

  1. 인덱스 문서 수가 0인지 확인합니다.
  2. 데이터베이스에서 발행된 콘텐츠를 페이지 단위(예: 500개)로 가져옵니다.
  3. 각 항목에 대해 upsert를 호출하여 ES에 씁니다.
  4. 로그를 기록하고 완료 후 종료합니다.

이 메커니즘은 새로 배포된 인스턴스가 수동 개입 없이 자동으로 인덱스 초기화를 완료할 수 있도록 보장합니다.

9. 마치며

검색 모듈의 설계와 개발 과정을 되돌아보며 몇 가지 핵심적인 교훈을 얻을 수 있었습니다.

도구의 경계 이해하기: Elasticsearch는 매우 강력하지만 만능은 아닙니다. 트랜잭션 작업에는 적합하지 않으며, 데이터 동기화에 지연이 발생합니다. 기술을 선택할 때는 무엇을 잘 해결하고, 무엇을 해결하지 못하는지 명확히 이해해야 합니다.

읽기 최적화는 검색의 핵심 전략: 검색의 본질은 "읽기는 많고, 쓰기는 적다"입니다. 중복 필드를 통해 쿼리 성능을 개선하고, 비동기 동기화를 통해 시스템 안정성을 확보했습니다. 이는 읽기 집약적인 많은 시나리오에 적용될 수 있는 원칙입니다.

정렬이 검색의 '지능'을 결정한다: 사용자가 검색 경험에서 느끼는 만족도는 정렬에 크게 좌우됩니다. 텍스트 관련성, 인기도, 최신성, 고유 ID를 조합한 정렬은 결과를 "관련성 있으면서도 합리적"으로 만듭니다.

페이지네이션에 만능 해결책은 없다: 전통적인 페이지네이션은 간단하지만 대규모 데이터를 지원하지 못합니다. search_after는 깊은 페이지 문제를 해결하지만, 완전 순서 정렬 필드에 의존하고 커서를 클라이언트가 유지해야 합니다. 어떤 페이지네이션을 선택할지는 구체적인 시나리오에 따라 달라집니다.

검색은 "겉보기엔 간단해 보이지만, 실제로는 복잡한" 분야입니다. 토큰화부터 정렬, 하이라이트, 검색어 추천까지 모든 세부 사항을 깊이 있게 고민할 가치가 있습니다. 이 글이 검색 모듈을 설계하거나 기존 시스템을 최적화하는 데 도움이 되기를 바랍니다.

검색의 목표는 단순히 "결과를 찾는 것"이 아니라, 사용자가 "이 시스템이 나를 이해한다"고 느끼게 하는 것입니다. 우리의 임무는 기술을 통해 이 목표에 무한히 가까이 가는 것입니다.

태그: elasticsearch search-engine BM25 function-score search_after

6월 22일 16:43에 게시됨