검색엔진 적용까지의 기록 - Elasticsearch

2025. 5. 7. 17:22·Tech/Develop

상황

검색 API를 개선했음에도 발생하는 병목

https://marstech.tistory.com/17

 

검색 API 최적화 여행기

들어가기 앞서..개발이 끝난 후, 코드를 검증해보기 위해 성능테스트를 진행하고 끔찍한 쿼리의 응답속도와 비효율적인 코드에 의한 병목을 발견하고 개선해나가는 경험을 쓴 기록입니다.해당

marstech.tistory.com

 

앞선 글의 내용과 같이 검색 API를 최적화하여 평균 응답속도를 22초 -> 1.5초까지 줄일 수 있었습니다.

하지만 1500자 이상의 텍스트가 들어간 100만개의 데이터에서 검색시간 1.5초는 너무나도 아쉬운 결과였습니다.

 

왜 이런 결과가 나왔을까요?

 

특정 검색어 검색 시 최악의 경우 20초 이상의 응답 지연 발생

 

거의 대부분의 요청이 600ms 내에 처리가 되고 있지만, 검색 API, 그것도 특정 요청만 이렇게 비정상적으로 응답이 느린 경우가 있었습니다.

따라서, 정확히 어떤 요청들에서 병목이 발생하는지 분석해볼 필요가 있었습니다.

문제 상황 분석

빈도수가 높은 단어 검색 시 Full-Table Scan 발생

MySQL의 Full-Text Index를 활용한 검색은 일반적인 경우에는 성능적으로 검색을 대체하기에 무리가 없습니다.

하지만 Full-Text Index가 무엇인지 다시 생각해본다면 edge case가 있음을 깨닫는 건 어렵지 않습니다.

Full-Text Index: 모든 text를 인덱싱 하여 빠르게 해당 단어를 가지고 있는 행을 탐색하는 전략

 

Full-Text Index의 문제점은 당연하게도 해당 단어를 가지고 있는 행이 다수가 될 때 입니다.

그렇게 된다면 MySQL의 Optimizer는 Index scan이 아닌 Full-Table scan을 하기 때문입니다.

 

해당 내용은 MySQL의 공식 문서에도 나와있습니다.

Optimizer는 MATCH() AGAINST() 연산에서 너무 많은 결과가 매칭될 경우, 인덱스 스캔보다는 전체 테이블을 스캔하는 것이 비용적으로 유리하다고 판단하기 때문에 

 

https://dev.mysql.com/doc/refman/8.0/en/fulltext-natural-language.html

 

MySQL :: MySQL 8.0 Reference Manual :: 14.9.1 Natural Language Full-Text Searches

14.9.1 Natural Language Full-Text Searches By default or with the IN NATURAL LANGUAGE MODE modifier, the MATCH() function performs a natural language search for a string against a text collection. A collection is a set of one or more columns included in a

dev.mysql.com

 

3자 미만의 단어는 Full-Text Index에 포함되지 않음

MySQL의 DB엔진인 InnoDB에서는 3자 미만의 단어는 Full-text Index에 포함되지 않습니다.

따라서, 해당 단어는 인덱스에 저장되지도 않고 인덱스에 의해 검색되지도 않습니다.

 

해당 내용 역시 MySQL의 공식 문서에 나와있습니다.

 

https://dev.mysql.com/doc/refman/8.4/en/fulltext-fine-tuning.html

 

MySQL :: MySQL 8.4 Reference Manual :: 14.9.6 Fine-Tuning MySQL Full-Text Search

14.9.6 Fine-Tuning MySQL Full-Text Search MySQL's full-text search capability has few user-tunable parameters. You can exert more control over full-text searching behavior if you have a MySQL source distribution because some changes require source code mo

dev.mysql.com

 

공식 문서에 따르면, 다음과 같이 설정을 바꾸면 3자 미만의 단어에서도 인덱스에 포함시킬 수 있다고 합니다.

# my.cnf 또는 my.ini
[mysqld]
innodb_ft_min_token_size=1

 

하지만 이렇게 설정하면 다음과 같은 문제가 발생합니다.

  • 모든 글자가 인덱스에 포함되어 인덱스의 장점이 사라짐
    • size = 2로 설정하면 2글자까지는 가능하지만 이 역시 노이즈에 영향에 자유로울 수 없음
  • 글자가 짧을수록 중복이 심해져 인덱스의 크기가 비대해짐

 

따라서, MySQL의 Full-Text Index는 어느정도 수준의 검색까지는 유용하지만 대용량의 텍스트를 처리하기에는 무리가 있다고 판단했습니다.

 

 

해결을 위한 과정

Elasticsearch 검색 엔진 도입

앞선 문제들을 해결하기 위해 검색엔진을 도입하기로 하였습니다.

도입을 하기 전 비교 했던 대상들은 다음가 같습니다.

항목 MySQL Full-Text Redis 역색인 구현 Apache Solr Elasticsearch

항목 MySQL Full-Text Redis 역색인 구현 Apache Solr Elasticsearch
검색 정확도 낮음 (기초 TF 기반) 낮음 (커스텀 구현 필요) 높음 (TF-IDF, BM25 지원) 높음 (BM25, 커스터마이징 가능)
한글 지원 미흡 (ngram 필요) 직접 토크나이징 구현 필요 형태소 분석기 사용 가능 형태소 분석기 지원 (e.g. nori)
짧은 단어 검색 기본 미지원 (설정 필요) 가능하나 구현 필요 설정 필요 지원 (token size 조절 가능)
정렬 및 relevance 기반 랭킹 제한적 구현 복잡 정교함 정교함 (score 기반 정렬)
확장성 (수평 확장) 낮음 (단일 노드 중심) 제한적 (샤딩 구현 필요) 가능 강력 (클러스터샤딩/레플리카)
운영 및 관리 편의성 매우 쉬움 중간 (직접 구현 필요) 복잡 중간 (Kibana 등 지원 도구 多)
커뮤니티/에코시스템 매우 활발 활발 보통 매우 활발 (플러그인 풍부)
시각화 및 모니터링 제한적 없음 제한적 Kibana 통해 시각화 지원

 

MySQL Full-Text

앞서 언급했던 문제들이 발생했기에 생략합니다.

 

Redis 역색인 구현

이미 캐시로 사용하고 있는 Redis를 추가적인 활용으로 역색인을 하여 저장해 두면 효과적으로 응답을 단축시킬 수 있다고 생각했습니다.

하지만 Redis를 통한 역색인 구현은 다음과 같은 문제들이 있습니다.

  • 사실상 MySQL Full-Text와 다름이 없는 역색인 구조
    • 변경점을 주고싶다면 직접 구현이기 때문에 구현 복잡도가 증가
  • 토크나이저 역시 직접 구현해야함
  • 유연성 및 확장성이 경직되어있음
  • 시각화 불가능

결국 Redis라는 인메모리 DB를 두고 모든 검색엔진을 직접 구현하는 것과 다름이 없었기 때문에 아쉽지만 아직 실력이 부족하여 다음으로 미루게 되었습니다..

 

Apache Solr vs Elasticsearch

결국 검색엔진 툴인 이 두가지 중 고민하게 되었습니다.

Elasticsearch와 Apache Solr는 모두 Lucene 기반의 강력한 오픈소스 검색 엔진이지만 다음과 같은 차이점이 존재했습니다.

 

항목 Elasticsearch Apache Solr
색인/검색 속도 Near real-time, 빠름 실시간은 아님, commit 필요
대량 색인 처리 Bulk API로 고속 색인 commit, optimize 수동 트리거 필요
검색 랭킹 기본 BM25, function_score, script_score BM25 지원, 복잡한 function query 필요
쿼리 DSL JSON 기반 DSL, 직관적이며 프로그래밍 친화적 파라미터 기반 쿼리, 복잡한 표현 어려움
조인 기능 제한적 (nested, has_child) → denormalization 유도 역색인 기반 조인 {!join} 지원
다국어 분석기 풍부한 내장 분석기 (예: nori 한글 형태소) 유사 분석기 있음, 설정 복잡
정렬/필터링 성능 doc_values, fielddata 기반 빠른 정렬/필터 유사하지만 deep paging 시 성능 저하 가능
분산 검색 구조 기본 내장 샤딩/레플리카 구조, 자동 리밸런싱 ZooKeeper 필요, 분산 설정 복잡
스코어 커스터마이징 function_score, script_score 등 유연하게 설정 function query 가능하나 불편함

 

이 중 Elasticsearch를 고르게 된 이유는 다음과 같습니다.

  • Solr은 RDBMS는 아니지만 최대한 구조를 비슷하게 하기 위해 설계했고, 이에 따른 성능상의 트레이드 오프가 존재한다.
    • 검색엔진이 조인을 지원한다는 건 RDBMS를 메인으로 사용하는 입장으로서 매우 큰 장점이긴 하다.
  • Elasticsearch에 비해 Solr은 확장성이 좋지 않다.
  • QueryDSL의 유무
  • ES의 뛰어난 병렬성

플랫폼 특성상 확장성을 무시할 수 없기 때문에 샤딩과 레플리카가 용이한 Elasticsearch의 장점을 무시할 수 없었습니다.

 

또한, 기본적으로 성능상 Elasticsearch가 우세했기 때문에 최종적으로 Elasticsearch를 사용하게 되었습니다.

 

Elasticsearch 도입

Elasticsearch를 도입하는데 있어 고민되는 부분은 다음과 같았습니다.

  • 이미 저장되어있는 정규화된 데이터를 두고 중복되는 검색 전용 DB의 구축
  • RDBMS와의 데이터 정합성

 

Hot-Cold Layer 구조 설계

RDBMS의 텍스트 필드를 제거하고 텍스트에 대한 책임을 모두 Elasticsearch에 넘길까도 생각했지만, 정규화된 데이터를 가지고 있어야 한다고 판단하여 중복되는 데이터를 가지고 있기로 했습니다.

그렇기에, 검색을 위해 중복되는 데이터를 DB에 저장하여 구축했기 때문에 효율적인 공간 관리가 필요하다고 느꼈습니다.

 

따라서, 검색DB를 효율적으로 사용하기 위해 Hot-Cold Layer 구조를 설계했습니다.

Hot-Cold Layer란?

Hot-Cold Layer는 데이터의 검색 빈도와 최신성에 따라 인덱스를 분리하여 운영하는 구조

Hot Layer: 최근 생성되었거나, 검색 요청이 자주 발생하는 데이터를 저장하는 인덱스. 빠른 검색 속도를 보장하기 위해 고성능 디스크/노드에 배치.
Cold Layer: 검색 빈도가 낮은 과거 데이터를 저장하는 인덱스로, 상대적으로 저사양 환경에 배치되어 리소스 절약.

 

이를 적용하기 위해 먼저 Hot Index와 Cold Index를 각각 생성했습니다.

PUT _index_template/questions_hot_template
{
  "index_patterns": ["questions-hot-*"],
  "template": {
    "settings": {
      "number_of_shards": 2,
      "number_of_replicas": 0,
      "refresh_interval": "1s",
      "index.lifecycle.name": "questions_policy",
      "index.lifecycle.rollover_alias": "questions-hot-alias",
      "index.routing.allocation.include.box_type": "hot"
    }
  },
  "priority": 100
}


PUT _index_template/cold_index_template
{
  "index_patterns": ["questions-cold-*"],
  "template": {
    "settings": {
      "number_of_shards": 1,
      "number_of_replicas": 0,
      "index.routing.allocation.include.box_type": "cold",
      "refresh_interval": "-1"
    }
  },
  "priority": 50
}

 

  • index_patterns: 해당 이름과 매칭이 되는 인덱스에 Hot 또는 Cold Template를 적용
  • number_of_shards: 인덱스 샤드의 갯수, 샤드 당 병렬로 쿼리가 가능하므로 hot에는 2개 생성
  • number_of_replicas: 복제본의 갯수, 샤드 당 하나씩 생성되므로 replica * shard의 갯수 만큼 노드 생성.
    • MySQL이 폴백으로 존재하기 때문에 레플리카 생성X
  • index.routing.allocation.include.box_type: 샤드를 특정 노드에만 배치하도록 제한
    • 노드를 elasticsearch.yml에서 마운트 되어 있는 디스크에 저장되도록 설정하면
      index.routing.allocation.include.box_type을 통해샤드를 해당 노드에만 저장하게 하여 해당 인덱스를 원하는 저장소에 저장하도록 지정

 

# elasticsearch.yml

node.name: es-hot-1
path.data: /mnt/ssd1/elasticsearch
node.attr.box_type: hot

node.name: es-cold-1
path.data: /mnt/hdd1/elasticsearch
node.attr.box_type: cold

 

  • refresh_interval: 해당 시간을 주기로 색인된 문서를 검색 가능하게 갱신. Cold 인덱스는 새로 색인 되는 경우가 없으므로 비활성화
  • index.lifecycle.name: ILM 정책을 적용하기 위한 설정
    • ILM 정책: 인덱스의 생명주기를 자동으로 관리하기 위한 정책
      hot, cold 등 단계별로 인덱스가 언제 rollover되고, 언제 다른 노드로 이동하며, 언제 삭제될지를 정의
PUT _ilm/policy/questions_policy
{
  "policy": {
    "phases": {
      "hot": {
        "actions": {
          "rollover": {
            "max_age": "30d",
            "max_size": "5gb"
          }
        }
      },
      "cold": {
        "min_age": "30d",
        "actions": {
          "allocate": {
            "include": {
              "box_type": "cold"
            }
          },
          "freeze": {}
        }
      }
    }
  }
}

 

Logstash 기반 주기적 색인 동기화 구성

데이터 저장에 대한 책임을 가지고 있는 MySQL과 검색의 책임을 가지고 있는 Elasticsearch에는 당연하게 데이터가 다를 수 밖에 없습니다.

이를 해결하기 위해 두가지 방안을 고민했습니다.

 

항목 Logstash (JDBC input) Kafka CDC (Debezium)
작동 방식 MySQL을 주기적으로 조회(polling) MySQL의 binlog(트랜잭션 로그)를 실시간 스트리밍
지연 시간 초~분 단위 거의 실시간 (수 밀리초~초)
변경 감지 방식 SELECT ... WHERE created_at > :last_value 같은 쿼리 binlog 기반 row-level 변경 감지
정합성 간헐적인 snapshot 기반 → 누락 위험 존재 트랜잭션 로그 기반 → 높은 정합성
복잡도 간단하고 설정 쉬움 Kafka, Zookeeper, Debezium 필요 → 구축 복잡
성능 데이터량 많으면 쿼리 비용 증가 고성능, 대량 트래픽에 적합
장점 설치/운영 쉬움, 작은 프로젝트에 적합 확장성 우수, 이벤트 기반 처리에 적합
단점 정밀한 변경 추적 어려움, polling 누락 가능 인프라 복잡도 높음, Kafka 운영 부담 있음

 

실시간 검색이 필요하다면 Kafka CDC를 적용하는 것을 충분히 고려해볼만 하지만, 저는 다음과 같은 이유로 Logstash로 결정했습니다.

  • Kafka CDC 적용을 위한 인프라를 별도 구축해야함 + 러닝커브
  • 실시간 검색을 굳이 지원하지 않아도 됨 -> 초~분 단위의 데이터 불일치 트레이드 오프
  • 실제 서비스 시 트래픽을 예측할 수 없으므로 설치/운영이 비교적 쉬운 Logstash로 출발
  • 인프라적 요소이기 때문에 처음부터 오버엔지니어링을 할 이유가 없음
  • 추후 Kafka CDC 적용을 고려

 

따라서, 이 데이터 간 정합성 문제를 해소하기 위해 Logstash 기반의 주기적 색인 갱신 파이프라인을 구성하였습니다.

input {
  jdbc {
    jdbc_driver_class => "com.mysql.cj.jdbc.Driver"
    jdbc_connection_string => "jdbc:mysql://zzuag-mysql:3306/zzuag"
    jdbc_user => "zzuag"
    jdbc_password => "zzuag"
    jdbc_driver_library => "/usr/share/logstash/mysql-connector-j-8.0.33.jar"

    schedule => "* * * * *"  # 매 1분마다
    statement => "
      SELECT id, title, content, like_count, review_count, created_at, updated_at, user_id
      FROM question
      WHERE is_deleted = 0
      AND updated_at > :sql_last_value
    "
    use_column_value => true
    tracking_column => "updated_at"
    tracking_column_type => "timestamp"
    last_run_metadata_path => "/usr/share/logstash/.logstash_jdbc_last_run"
    clean_run => false
  }
}

filter {
  mutate {
    rename => {
      "like_count" => "likeCount"
      "review_count" => "reviewCount"
      "created_at" => "createdAt"
      "updated_at" => "updatedAt"
      "user_id" => "userId"
    }
  }
}

output {
  elasticsearch {
    hosts => "http://elasticsearch:9200"
    index => "questions-hot-alias"
    document_id => "%{id}"
    user => "logstash_internal"
    password => "${LOGSTASH_INTERNAL_PASSWORD}"
  }
}

 

Logstash의 JDBC 입력 플러그인을 활용한 주기적 색인 동기화 파이프라인을 구성하였습니다.
매 1분마다 MySQL의 최신 데이터를 기준으로 변경된 질문 데이터를 Elasticsearch의 questions-hot-* 인덱스로 색인하며, created_at 컬럼을 기준으로 증분 처리가 이루어집니다.

 

Elasticsearch Java 검색 쿼리 구성

앞서 문제가 되었던 부분을 모두 해결했기 때문에 이제 본격적으로 Elasticsearch를 이용한 검색 API를 만들어 보겠습니다.

Spring 서버에서 Elasticsearch에 적용할 수 있는 복잡한 쿼리를 빌드하기 위해 Elasticsearch Java API Client의 Fluent DSL을 활용하여 bool, match, range 등의 쿼리를 조합하는 방식으로 구현을 진행했습니다.

 

@Override
    public QuestionIdResult searchQuestionIdsByKeyword(String keyword, Pageable pageable) throws IOException {
        try {

            SearchResponse<Void> response = elasticsearchClient.search(s -> s
                            .index("questions-hot-*")
                            .query(q -> q
                                    .multiMatch(m -> m
                                            .query(keyword)
                                            .fields("title", "content")
                                    )
                            )
                            .from((int) pageable.getOffset())
                            .size(pageable.getPageSize())
                            .sort(sort -> sort
                                    .field(f -> f
                                            .field("createdAt")
                                            .order(co.elastic.clients.elasticsearch._types.SortOrder.Desc)
                                    )
                            )
                            .source(src -> src.filter(f -> f.includes(List.of())))
                    , Void.class);

            List<Long> ids = response.hits().hits().stream()
                    .map(Hit::id).filter(Objects::nonNull)
                    .map(Long::parseLong)
                    .toList();

            long total = response.hits().total() != null ? response.hits().total().value() : 0;

            return QuestionIdResult.builder()
                    .ids(ids)
                    .totalCount(total)
                    .build();

        } catch (Exception e) {
            log.error(e.getMessage());
            throw e;
        }
    }

 

최종 결과 정리

API 응답속도 17초 -> 0.3초 98.24% 개선

 

검색 서버 파이프라인

[MySQL]  
   │  
   ▼  
[updated_at 기반 SELECT by Logstash JDBC Input]  
   │  
   ▼  
[Logstash]  
   │  
   ├─ rename 필드 (likeCount, userId ...)  
   │  
   ▼  
[Elasticsearch 색인]  
   │   (index = questions-hot-alias, document_id = id)
   ▼  
[Elasticsearch Hot 인덱스]
   │   └─ questions-hot-000001
   │           ▲ alias: questions-hot-alias (is_write_index: true)
   │
   └───[ILM 정책]
          ├─ hot: rollover (30d or 5GB)
          ├─ cold: 30d 후 box_type=cold 노드로 이동
          └─ freeze: cold 인덱스 리소스 최소화

=== 검색 요청 흐름 ===

[Spring Search API]  
   │  
   ▼  
[ElasticsearchClient]  
   │  
   ▼  
.search()
   .index("questions-hot-*,questions-cold-*")   ← 전체 인덱스 대상
   .query(multiMatch(title, content))
   .sort(createdAt DESC)
   .from/size(paging)
   │
   ▼  
[Elasticsearch]  
   └─ 모든 relevant 문서 조회

'Tech > Develop' 카테고리의 다른 글

캐시를 적용해보자! 본격 캐시 적용기  (0) 2025.04.13
검색 API 최적화 여행기  (0) 2025.04.11
[Spring Boot] '멀티 모듈이.. 뭐지..?' - 멀티 모듈 적용기(1)  (0) 2025.02.26
MSA 환경 CQRS 패턴 적용기 - (2) 프로젝트에 적용해보기  (0) 2024.08.07
MSA 환경 CQRS 패턴 적용기 - (1) CQRS  (0) 2024.08.04
'Tech/Develop' 카테고리의 다른 글
  • 캐시를 적용해보자! 본격 캐시 적용기
  • 검색 API 최적화 여행기
  • [Spring Boot] '멀티 모듈이.. 뭐지..?' - 멀티 모듈 적용기(1)
  • MSA 환경 CQRS 패턴 적용기 - (2) 프로젝트에 적용해보기
마스9
마스9
마스의 개발블로그
  • 마스9
    Mars Tech
    마스9
  • 전체
    오늘
    어제
    • 분류 전체보기 (17)
      • Tech (6)
        • Spring (0)
        • Develop (6)
      • Study (6)
        • CS (5)
        • 알고리즘 (1)
      • Reference (5)
        • MySQL (5)
      • Do (0)
      • Daily (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
마스9
검색엔진 적용까지의 기록 - Elasticsearch
상단으로

티스토리툴바