본문 바로가기
프로젝트/개인 프로젝트 V3

Elasticsearch vs MySQL FullText 인덱싱 성능 비교 테스트

by Thumper 2025. 3. 23.

FullText와 Elasticsearch 비교하려는 목적 : 더 적합한 검색 기능 구현 방식 찾기

기존에 LIKE 방식에서 MySQL FullText 방식으로 변경하면서 검색 성능을 높일 수 있었다.
200만 데이터 기준으로 성능 테스트를 진행한 결과, MySQL FullText 방식이 LIKE 방식보다 35배 빠른 성능을 보였다.

또한, '과일마켓 스티커'를 검색했을 때, LIKE 방식은 '과일마켓'과 '스티커' 단어를 포함한 결과만 반환했지만,
FullText 방식은 '과일', '마켓', '스티커' 단어가 각각 포함된 결과를 가져왔다.

과일', '마켓', '스티커'와 같은 단어들의 적절한 조합을 포함한 검색 결과를 제공할 수 있어, 사용자 입장에서 더 관련성 높은 검색 결과를 얻을 수 있다고 생각하여 FullText 방식으로 변경했었다.

이번에는 Elasticsearch를 도입했을 때 검색 성능 테스트를 해보려고 한다.
Elasticsearch와 MySQL FullText의 검색 속도, 정확성, 확장성 등을 중점적으로 테스트하여, 프로젝트에 적합한 검색 기능 구현 방식이 무엇인지 정리해보자.

Elasticsearch

테스트 데이터로 공공 API 전북특별자치도 전주시_공원 정보를 활용했다.
Docker Compose를 사용하여 Elasticsearch를 다루었는데, 해당 인덱스 생성부터 Unit 테스트 코드까지 정리해보자.

공공 API

공원 정보로 parkGubun(공원분류), parkLoadAddr(주소), parkOrgnm(기관명), String dataTitle(공원이름)을 뽑아서 사용했다.


공공 API POST

 



1. Park의 Index를 생성한다.

1-1. ParkSearch.class

공원 정보로 parkGubun(공원분류), parkLoadAddr(주소), parkOrgnm(기관명), String dataTitle(공원이름) 필드를 갖도록 작성했다.
es-park-settings.json 파일에 Elasticsearch 인덱스의 설정 정보를 작성하고, es-park-mapping.json 파일에 Elasticsearch 인덱스의 필드 매핑 정보를 지정했다.

package study.elasticsearch.model;


import org.springframework.data.annotation.Id;
import lombok.*;
import org.springframework.data.elasticsearch.annotations.*;
@Document(indexName = "park_search")
@Setting(settingPath = "elasticsearch/mappings/es-park-settings.json")
@Mapping(mappingPath = "elasticsearch/mappings/es-park-mapping.json")
@Getter
@Setter
@ToString
@NoArgsConstructor
public class ParkSearch {

    @Id
    private String id;
    @Field(name = "park_gubun", type = FieldType.Text)
    private String parkGubun;

    @Field(name ="park_load_arr", type = FieldType.Text)
    private String parkLoadAddr;

    @Field(name = "park_orgnm", type = FieldType.Text)
    private String parkOrgnm;

    @Field(name = "data_title", type = FieldType.Text)
    private String dataTitle;

    @Builder
    public ParkSearch(String parkGubun, String parkLoadAddr, String parkOrgnm, String dataTitle) {
        this.parkGubun = parkGubun;
        this.parkLoadAddr = parkLoadAddr;
        this.parkOrgnm = parkOrgnm;
        this.dataTitle = dataTitle;
    }
}



1-2. es-park-settings.json

parkLoadAddr로 검색할 수 있도록 구현하기 위해 해당 필드에 설정을 추가했다.
또한, 한국어 키워드 분석을 테스트하기 위해 Nori 플러그인을 적용하고, 동의어 사전으로 address.txt를 설정했습니다

english_stop_filter, korea_stop_filter 불용어 처리를 넣기는 했지만, 이 부분은 다음에 활용해보려고 한다.

{
  "number_of_shards" : 3,
  "number_of_replicas" : 0,

  "analysis": {
    "analyzer": {
      "park_load_arr_analyzer": {
        "type": "custom",
        "char_filter": ["html_strip"],
        "tokenizer": "nori_discard",
        "filter": ["lowercase", "english_stop_filter", "korea_stop_filter", "address_filter", "nori_pos_filter" ]
      }
    },

    "tokenizer": {
      "nori_discard": {
        "type": "nori_tokenizer",
        "decompound_mode": "discard"
      }
    },

    "filter": {
      "address_filter" : {
        "type": "synonym",
        "synonyms_path": "address.txt"
      },
      "nori_pos_filter" : {
        "type" : "nori_part_of_speech"
      },
      "english_stop_filter": {
        "type": "stop",
        "stopwords": ["a", "an", "the", "is", "at", "on", "in", "of", "and", "or"]
      },
      "korea_stop_filter": {
        "type": "stop",
        "stopwords": ["은", "는", "이", "가", "을", "를", "에", "와", "과", "나", "너", "그", "저"]
      }
    }
  }
}

1-3. es-park-mapping.json

아래의 설정은 Index 생성 시 "mappings"에 들어가는 부분이다.

{
  "properties": {
    "park_gubun": {
      "type": "text"
    },
    "park_load_arr": {
      "type": "text",
      "analyzer": "park_load_arr_analyzer"
    },
    "park_orgnm": {
      "type": "text"
    },
    "data_title": {
      "type": "text"
    }
  }
}



1-4. Docker-compose를 사용하여 엘라스틱 사용

Docker 파일

Elasticsearch를 실행하기 위한 Docker 파일을 추가한다.

image



명령어 실행

docker 파일실행 및 plugin 설치



docker restart



애플리케이션 실행시켜 인덱스 생성



# Docker로 Elasticsearch를 실행
docker-compose down -v --rmi all
docker-compose up -d 

# plugin nori 설치
docker exec -it elasticsearch-elasticsearch-1 bash
bin/elasticsearch-plugin install analysis-nori
bin/elasticsearch-plugin list
exit

# restart
docker restart elasticsearch-elasticsearch-1

# 실행해서 인덱스 생성
./gradlew bootRun

 

인덱스가 생성됨을 아래와 같이 확인할 수 있다.

인덱스 매핑 확인



 

1-5. 동의어 사전

초코에 대한 동의어를 다음과 같이 설정했다.

image

 

resources에 txt파일을 생성했고 컨테이너에 복사해 넣었다.

mkdir -p /usr/share/elasticsearch/config


docker cp "C:\Users\thumper\Downloads\elasticsearch\elasticsearch\src\main\resources\elasticsearch\mappings\address.txt" elasticsearch-elasticsearch-1:/usr/share/elasticsearch/config/address.txt

docker restart elasticsearch-elasticsearch-1

 

2. ElasticsearchRepository

Criteria를 사용하여 조회 쿼리를 작성했으며, matches()를 통해 부분 일치(Full-Text) 검색을 구현했다.

    @Override
    public List<ParkSearch> searchByAddress(String keyword) {
        // Criteria 쿼리 작성
        Criteria criteria = new Criteria("parkLoadAddr").matches(keyword);
        CriteriaQuery query = new CriteriaQuery(criteria);

        // Elasticsearch 검색 실행
        SearchHits<ParkSearch> searchHits = elasticsearchOperations.search(query, ParkSearch.class);

        // 검색 결과 변환 후 반환
        return searchHits.stream()
                .map(hit -> hit.getContent())
                .collect(Collectors.toList());
    }
@Service
@RequiredArgsConstructor
public class ParkService {

    private final ParkSearchRepository parkSearchRepository;
    private final ParkRepository parkRepository;

    ...

    public List<Park> find_with_ft(String keyword) {
        return parkRepository.searchByFullText(keyword);
    }



3. 성능 테스트

테스트 데이터 넣기

공공 API의 예시코드를 참고하여 작성하였고, xml에서 java로 변환하여 MySQL, Elasticsearch에 저장했다.

    @Test
    void save_openAPI() throws JAXBException, IOException {

        // open api data(전주 공원) save
        String serviceKey = "KsEvdobJWI2yWs8eSRPiuwSb765kegi26hizXP55qSWmASdZPCjMk4WOCRmdlQr5fhdbiUE6JRcsyukPdv1aKA%3D%3D";
        StringBuilder urlBuilder = new StringBuilder("http://openapi.jeonju.go.kr/rest/park/getParkList");
        urlBuilder.append("?" + URLEncoder.encode("serviceKey", "UTF-8") + "=" + serviceKey);
        urlBuilder.append("&startPage=").append(0);  // startPage 값 추가
        urlBuilder.append("&pageSize=").append(300); // pageSize 값 추가

        URL url = new URL(urlBuilder.toString());
        HttpURLConnection conn = (HttpURLConnection) url.openConnection();
        conn.setRequestMethod("GET");
        conn.setRequestProperty("Content-type", "application/xml");

        log.info("Response code = {}", conn.getResponseCode());

        BufferedReader rd;
        if (conn.getResponseCode() >= 200 && conn.getResponseCode() <= 300) {
            rd = new BufferedReader(new InputStreamReader(conn.getInputStream()));
        } else {
            rd = new BufferedReader(new InputStreamReader(conn.getErrorStream()));
        }

        StringBuilder sb = new StringBuilder();
        String line;
        while ((line = rd.readLine()) != null) {
            sb.append(line);
        }
        rd.close();
        conn.disconnect();

        //log.info("API Response: {}", sb.toString());

        // XML을 Java 객체로 변환
        JAXBContext jaxbContext = JAXBContext.newInstance(ParkApiResponse.class);
        Unmarshaller unmarshaller = jaxbContext.createUnmarshaller();
        ParkApiResponse response = (ParkApiResponse) unmarshaller.unmarshal(new StringReader(sb.toString()));

        // response -> entity, index
        response.getBody().getData().getParks().forEach(dto -> {
            Park parkEntity = parkRepository.save(new Park(dto.getDataTitle(), dto.getParkGubun(), dto.getParkLoadAddr(), dto.getParkOrgnm())); // 엔티티
            ParkSearch parkSearch = parkSearchRepository.save(parkEntity.toSearch()); // 인덱스
        });

        log.info("entity size={}", parkRepository.count());
        log.info("elasticsearch index = {}", parkSearchRepository.count());
    }

위의 데이터 count가 249개라서 아래와 같이 데이터를 추가로 넣어서 총 200만 데이터를 저장했다.

    @Test
    void save_bigdata()  {

        Park dto = new Park("푸른대공원", "구분", "조회 타겟", "전북특별자치도 전주시 덕진구청");

        String characters = "팔공팔공";
        Random random = new Random();
        List<Park> entityList = new ArrayList<>();
        List<ParkSearch> indexList = new ArrayList<>();
        for (int i = 0; i < 900_000; i++) { // 90만 개 생성

            String modifiedAddr = characters + " " + (random.nextInt(99999) + 1); // 주소 변형

            Park parkEntity = new Park(dto.getDataTitle(), dto.getParkGubun(), modifiedAddr, dto.getParkOrgnm());

            entityList.add(parkEntity);
            indexList.add(parkEntity.toSearch());
        }

        parkRepository.saveAll(entityList);

        parkService.bulk_insert(indexList);
    }



keyword(팔복동), count(249)일 때

MySQL, Elasticsearch 방식에서 결과적으로 정확도는 동일했고, MySQL이 평균적으로 300ms 더 빨랐다.

fx



es 1

 




keyword(완산구 중화산동2가), count(200만)일 때

MySQL, Elasticsearch 방식에서 결과적으로 정확도, 검색속도는 비슷했다.

ft_검색어_ 완산구 중화산동2가



es_검색어_ 완산구 중화산동2가

 




keyword(ABCDEFGHIJKLMNOPQRS), count(200만)일 때

MySQL, Elasticsearch 방식에서 결과적으로 정확도, 검색속도는 비슷했다.

어ft_검색_ABCDEFGHIJKLMNOPQRS



es_검색_ABCDEFGHIJKLMNOPQRS

 




keyword(초코), count(200만)일 때

Elasticsearch에서 동의어 사전을 사용했을 때, "초코"와 "초콜릿"을 같은 의미로 처리해 검색 결과를 포함되어 조회되었다.

    @Test
    void same_word() {

        Park parkA = parkRepository.save(new Park("나는 제목", "나는 분류", "초코", "위치"));
        Park parkB = parkRepository.save(new Park("나는 제목", "나는 분류", "바닐라", "위치"));
        Park parkC = parkRepository.save(new Park("나는 제목", "나는 분류", "초콜릿", "위치"));

        parkSearchRepository.save(parkA.toSearch());
        parkSearchRepository.save(parkB.toSearch());
        parkSearchRepository.save(parkC.toSearch());     

        List<ParkSearch> indexList = parkService.find_with_elastic("초코");
        List<Park> entityList = parkService.find_with_ft("초코");

        for (Park park : entityList) {
            log.info("mysql full text 검색 -> {}", park.getParkLoadAddr());
        }

        for (ParkSearch park : indexList) {
            log.info("elasticsearch 검색 -> {}", park.getParkLoadAddr());
        }


    }
image



FullText와 Elasticsearch 비교

비교 항목 MySQL FULL-TEXT Elasticsearch
검색 속도 빠름 (200만 건 기준) MySQL과 비슷함 (200만 건 기준)
검색 정확도 텍스트 검색에서는 빠르고 효율적 형태소 분석 + 동의어 지원
운영 쉬움 클러스터 운영 필요

데이터 200만 기준으로 했을 때, MySQL과 Elasticsearch 검색속도는 비슷했다.
Elasticsearch 경우 동의어 지원이 되어 검색결과 정확도면에서는 높았다.

클러스터 운영 shard 이슈

shard 이슈



테스트 과정에서 MySQL Full-Text와 달리, Elasticsearch에서는 클러스터 샤드 관련 오류(‘all shards failed’, ElasticsearchException)가 발생했었다.

$ curl -X GET "localhost:9200/_cat/shards?v"
index         shard prirep state        docs  store dataset ip         node
carindex      0     p      STARTED         0   249b    249b 172.18.0.2 d3223d762660
carindex      0     r      UNASSIGNED
seminar_index 0     p      STARTED         0   249b    249b 172.18.0.2 d3223d762660
seminar_index 0     r      UNASSIGNED
park_search   0     p      STARTED    299693 10.3mb  10.3mb 172.18.0.2 d3223d762660
park_search   0     r      UNASSIGNED
park_search   1     p      STARTED    300801 10.3mb  10.3mb 172.18.0.2 d3223d762660
park_search   1     r      UNASSIGNED
park_search   2     p      STARTED    299755 10.2mb  10.2mb 172.18.0.2 d3223d762660
park_search   2     r      UNASSIGNED 

chatGPT로 해당 shards 상태를 질문했을 때, "현재 발생하는 오류는 Elasticsearch 인덱스의 replicas (r shards)이 할당되지 않았기 때문인 것 같다"라는 답변을 얻었다.
위의 테스트 결과 예외 로그처럼, replica shards이 할당되지 않으면 search_phase_exception 오류가 발생할 수 있는 것 같다.

우선 Elasticsearch에서 replica를 다음과 같이 지정했다.

curl -X PUT "localhost:9200/carindex/_settings" -H 'Content-Type: application/json' -d '{
  "index": {
    "number_of_replicas": 0
  }
}'

curl -X PUT "localhost:9200/seminar_index/_settings" -H 'Content-Type: application/json' -d '{
  "index": {
    "number_of_replicas": 0
  }
}'

curl -X PUT "localhost:9200/park_search/_settings" -H 'Content-Type: application/json' -d '{
  "index": {
    "number_of_replicas": 0
  }
}'

이제 클러스터 상태가 이제 green으로 표시되므로 replica shards이 성공적으로 할당되고 클러스터가 정상 상태로 돌아왔다.

$ curl -X GET "localhost:9200/_cluster/health?pretty"
{
  "cluster_name" : "docker-cluster",
  "status" : "green",
  "timed_out" : false,
  "number_of_nodes" : 1,
  "number_of_data_nodes" : 1,
  "active_primary_shards" : 5,
  "active_shards" : 5,
  "relocating_shards" : 0,
  "initializing_shards" : 0,
  "unassigned_shards" : 0,
  "unassigned_primary_shards" : 0,
  "delayed_unassigned_shards" : 0,
  "number_of_pending_tasks" : 0,
  "number_of_in_flight_fetch" : 0,
  "task_max_waiting_in_queue_millis" : 0,
  "active_shards_percent_as_number" : 100.0
}

운영부분에서는 MySQL이 쉬운 것 같다.

결론

현재 상황을 고려했을 때는 MySQL FullText 방식을 유지하는게 좋을 것 같다.
상품 페이징 조회 시, 상품에 연관 테이블이 많기 때문에 JOIN을 통해 한 번의 쿼리로 여러 테이블을 함께 조회하는 방식이 유리하다.
반면, Elasticsearch는 페이징 과정에서 데이터를 다시 정렬하거나 검색 결과를 필터링할 때 복잡한 처리가 필요하다.

하지만 Elasticsearch는 형태소 분석, 동의어 지원 등 검색 기능에서 더 유리한 점이 있을 수 있기 때문에, 향후 대규모 데이터 처리나 복잡한 검색 기능이 필요해질 경우 Elasticsearch를 다시 고려하면 좋을 것 같다. 좀 더 자료를 찾아보고 성능 테스트하면 좋을 것 같다.

댓글