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(공원이름)을 뽑아서 사용했다.
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 파일을 추가한다.
명령어 실행
# 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. 동의어 사전
초코에 대한 동의어를 다음과 같이 설정했다.
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 더 빨랐다.
keyword(완산구 중화산동2가), count(200만)일 때
MySQL, Elasticsearch 방식에서 결과적으로 정확도, 검색속도는 비슷했다.
keyword(ABCDEFGHIJKLMNOPQRS), count(200만)일 때
MySQL, Elasticsearch 방식에서 결과적으로 정확도, 검색속도는 비슷했다.
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());
}
}
FullText와 Elasticsearch 비교
| 비교 항목 | MySQL FULL-TEXT | Elasticsearch |
|---|---|---|
| 검색 속도 | 빠름 (200만 건 기준) | MySQL과 비슷함 (200만 건 기준) |
| 검색 정확도 | 텍스트 검색에서는 빠르고 효율적 | 형태소 분석 + 동의어 지원 |
| 운영 | 쉬움 | 클러스터 운영 필요 |
데이터 200만 기준으로 했을 때, MySQL과 Elasticsearch 검색속도는 비슷했다.
Elasticsearch 경우 동의어 지원이 되어 검색결과 정확도면에서는 높았다.
클러스터 운영 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를 다시 고려하면 좋을 것 같다. 좀 더 자료를 찾아보고 성능 테스트하면 좋을 것 같다.
'프로젝트 > 개인 프로젝트 V3' 카테고리의 다른 글
| 동시성 테스트, Apache JMeter를 이용한 부하 테스트 (0) | 2025.06.16 |
|---|---|
| 인덱스를 활용한 쿼리 튜닝과 No OFFSET 방식으로 구조를 개선 (1) | 2025.06.16 |
| Full Text Search를 이용한 DB 성능 개선 (0) | 2025.03.06 |
| 검색 쿼리 리팩토링 (1) | 2024.12.10 |
| 거래요청에 대한 동시성 테스트 (2) | 2024.12.06 |
댓글