저희 프로젝트에서는 세미나의 각 조건별로 세미나를 검색할 수 있는 기능을 제공하는데요. 적재된 세미나 데이터가 많아진다면 발생할 수 있는 성능이슈를 ElasticSearch 를 활용함으로써 해결해보는 과정을 담아보려고 합니다.

ElasticSearch에 대한 관심이 생긴 계기는, 데이터베이스의 인덱스를 커버링 인덱스로 적용해보며입니다. 데이터베이스의 인덱스 구조를 공부해면서, '%키워드%' 일경우에는 Full Scan 으로 검색한다는것을 알게되었는데요. 또한, 관계형 데이터베이스에서는 처음 데이터 Row부터 끝 데이터 Row까지 모두 Full Scan 해야한다는 특성 때문에 이러한 검색량이 많아질수록 늦어질 수 밖에 없습니다.
세미나의 인덱스를 모델링하는 과정과 ElasticSearch를 Spring Data ElasticSearch를 검색하는 과정을 담아보았습니다.
1. 먼저 Seminar의 Index를 생성합니다.
1-1. SeminarDocument.class
ElasticSearch에 적재할 데이터 Mapping으로 선언합니다.
실질적인 매핑은 es-seminar-mapping.json에서 mapping을 직접적으로 선언합니다.
@Getter @Setter @Document(indexName = Indices.SEMINAR_INDEX) @Setting(settingPath = "elasticsearch/mappings/es-seminar-settings.json") @Mapping(mappingPath = "elasticsearch/mappings/es-seminar-mapping.json") @ToString @Builder public class SeminarDocument { @Id @Field(type = FieldType.Keyword) private Long seminar_no; @Field(type = FieldType.Text) private String seminar_name; @Field(type = FieldType.Text) private String seminar_explanation; @Field(type = FieldType.Keyword) private Long seminar_price; @Field(type = FieldType.Keyword) private Long seminar_max_participants; @Field(type = FieldType.Date, format = DateFormat.date_hour_minute_second_millis) private LocalDateTime inst_dt; @Field(type = FieldType.Date, format = DateFormat.date_hour_minute_second_millis) private LocalDateTime updt_dt; }
1-2. 사용할 Index의 구조입니다.
아래의 REST API 호출을 통해 Index를 생성합니다.
PUT seminar { "settings": { "index": { "routing": { "allocation": { "include": { "_tier_preference": "data_content" } } }, "number_of_shards": "1", "number_of_replicas": "1", "analysis": { "analyzer": { "seminar_explanation_analyzer": { "type": "custom", "char_filter": ["html_strip"], "tokenizer": "nori_tokenizer_discard", "filter": ["lowercase", "english_stop_filter", "snowball", "nori_part_of_speech"] }, "seminar_name_analyzer": { "type": "custom", "char_filter": ["html_strip"], "tokenizer": "nori_tokenizer_discard", "filter": ["lowercase", "english_stop_filter", "snowball"] } }, "filter": { "english_stop_filter": { "type": "stop", "stopwords": [ "a", "an", "the", "is", "at", "on", "in", "of", "and", "or" ] }, "nori_part_of_speech": { "type": "nori_part_of_speech", "stoptags": [ "E", "IC", "J", "MAG", "MAJ", "MM", "SP", "SSC", "SSO", "SC", "SE", "XPN", "XSA", "XSN", "XSV", "UNA", "NA", "VSV" ] } }, "tokenizer": { "nori_tokenizer_discard": { "type": "nori_tokenizer", "decompound_mode": "discard" } } } } }, "mappings": { "properties": { "_class": { "type": "text", "fields": { "keyword": { "type": "keyword", "ignore_above": 256 } } }, "inst_dt": { "type": "date" }, "seminar_explanation": { "type": "text", "analyzer": "seminar_explanation_analyzer" }, "seminar_max_participants": { "type": "long" }, "seminar_name": { "type": "text", "fields": { "keyword": { "type": "keyword", "ignore_above": 256 } }, "analyzer": "seminar_name_analyzer" }, "seminar_no": { "type": "long" }, "seminar_price": { "type": "long" }, "updt_dt": { "type": "date" } } } }
Index의 텍스트 분석 과정은 Char Filter, tokenizer, filter 이렇게 3가지의 순서를 거쳐서 진행됩니다.
Index를 analyzer, tokenizer, filter 를 순서대로 설명해보겠습니다. Char Filter는 이미 구현된 구현체를 사용했습니다.
1-2-1. 선언한 Analyzer 입니다.
"analyzer": { "seminar_explanation_analyzer": { "type": "custom", "char_filter": ["html_strip"], "tokenizer": "nori_tokenizer_discard", "filter": ["lowercase", "english_stop_filter", "snowball", "nori_part_of_speech"] }, "seminar_name_analyzer": { "type": "custom", "char_filter": ["html_strip"], "tokenizer": "nori_tokenizer_discard", "filter": ["lowercase", "english_stop_filter", "snowball"] } },
- seminar_name_analyzer : 세미나의 이름 전용의 analyzer입니다.
- type: "custom" 사용자 설정으로써 Custom입니다..
- char_filter: ["html_strip"] HTML 태그를 없애주기 위한 Character Filter 입니다.
- tokenizer : "nori_tokenizer_discard" 로 한글 형태소 분석기의 Nori의 tokenizer를 사용합니다. (아래에 추가로 설명합니다.)
- "filter": ["lowercase", "english_stop_filter", "snowball", "nori_part_of_speech"]
- "lowercase" : 대문자를 소문자로 변환합니다.
- "english_stop_filter" : 영어 불용어를 stop word로 지정하여 인덱스에 넣지 않습니다.
- "snowball" : 언어처리를 위한 스태밍(Stemming) 알고리즘 중 하나입니다. 단어에서 접미사나 어미를 제거하여 어간을 추출하는 과정을 의미합니다.
- 예로 들어보면, "running", "runs", "ran"과 같은 단어들이 모두 "run" 이라는 어간을 가지고 있습니다. 이러한 단어들을 하나의 "run"이라는 어간으로 검색하면 모두 검색됩니다.
- seminar_explanation_analyzer : 세미나의 설명 전용의 analyzer 입니다.
- type: "custom" 사용자 설정으로써 Custom입니다..
- char_filter: ["html_strip"] HTML 태그를 없애주기 위한 Character Filter 입니다.
- tokenizer : "nori_tokenizer_discard" 로 한글 형태소 분석기의 Nori의 tokenizer를 사용합니다. (아래에 추가로 설명합니다.)
- "filter": ["lowercase", "english_stop_filter", "snowball", "nori_part_of_speech"]
- "lowercase" : 대문자를 소문자로 변환합니다.
- "english_stop_filter" : 영어 불용어를 stop word로 지정하여 인덱스에 넣지 않습니다.
- "snowball" : 언어처리를 위한 스태밍(Stemming) 알고리즘 중 하나입니다. 단어에서 접미사나 어미를 제거하여 어간을 추출하는 과정을 의미합니다.
- 예로 들어보면, "running", "runs", "ran"과 같은 단어들이 모두 "run" 이라는 어간을 가지고 있습니다. 이러한 단어들을 하나의 "run"이라는 어간으로 검색하면 모두 검색됩니다.
1-2-2 이제 "tokenizer"에 대해 알아보겠습니다.
먼저 제가 사용한 토크나이저에 대해 알아보기전에 먼저, 한국어 텍스트를 처리하기 위해 Nori Tokenizer에 대해 간략히 설명해보겠습니다.
"nori toeknizer"에는 "decompound_mode"의 옵션을 통해 한국어 복합어의 저장방식을 결정할 수 있습니다.
3가지의 설정이 있는데요.
Elastic 공식문서의 설명을 통해 이해해보았습니다.
https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-nori-tokenizer.html
- "decompound_mode" : "none"
- 어근을 분리하지 않고 완성된 합성어만 저장합니다.
- 예시로 들어보면, "가거도항 가곡역" 라고 입력되었다고 하면, ["가거도항", "가곡역"] 과 같이 그대로 토큰화됩니다.
- 추가 예시를 들어보면, "달린다" 라고 입력되었다고하면, ["달린다"] 와 같이 그대로 토큰화됩니다.
- "decompound_mode" : "discard"
- 합성어를 분리하여 각 어근만 저장합니다.
- 예시로 들어보면, "가거도항 가곡역" 라고 입력되었다고 하면, ["가거도", "항", "가곡", "역"] 과 같이 토큰화됩니다. 조금 헷갈릴 수 있는데요
- 추가 예를들어보면, 합성어를 분리하여 각 어근만 저장한다는 말은 예로 들면 "달린다" 가 있을때 ["달리", "다"] 이렇게 구분된다는 의미입니다.
- discard 옵션이 기본 Default 옵션입니다. 만약에 nori_tokenizer를 바로 따로 tokenizer를 선언하지 않고 사용한다면 discard옵션이 사용됩니다.
- "decompound_mode" : "mixed"
- 어근과 합성어를 모두 저장합니다.
- 예시로 들어보면, "가거도항 가곡역"라고 입력되었다면, ["가거도항", "가거도", "항", "가곡역", "가곡", "역"] 과 같이 토큰화됩니다.
- 추가 예를들어보면, "달린다" 가 있을때 ["달린다", "달리", "다"] 과 같이 토큰화됩니다.
- 이 옵션을 사용할경우 많은 인덱스가 생성되어 다양한 검색에는 좋을 것이고, 용량에는 부족함이 있을 수 있습니다.
nori 플러그인을 사용하기 위해 아래의 명령어를 통해 analysis-nori Plugin을 먼저 다운합니다.
$ bin/elasticsearch-plugin install analysis-nori
사용할 tokenizer입니다.
"tokenizer": { "nori_tokenizer_discard": { "type": "nori_tokenizer", "decompound_mode": "discard" } },
- "nori_tokenizer_discard":
- "type": "nori_tokenizer"
- 기본적으로 nori_tokenizer를 사용할것이라고 선언합니다.
- "decompound_mode" : "discard"
- 합성어를 분리하여 각 어근만 저장하는 옵션을 사용합니다.
- "type": "nori_tokenizer"
1-2-3. Filter
"filter": { "english_stop_filter": { "type": "stop", "stopwords": ["a", "an", "the", "is", "at", "on", "in", "of", "and", "or"] }, "nori_part_of_speech": { "type": "nori_part_of_speech", "stoptags": [ "E", "IC", "J", "MAG", "MAJ", "MM", "SP", "SSC", "SSO", "SC", "SE", "XPN", "XSA", "XSN", "XSV", "UNA", "NA", "VSV" ] } }
- "english_stop_filter" : 영어 불용어를 처리하기 위한 필터입니다.
- type : stop
- 불용어를 처리하는 filter라는것을 명시합니다.
- "stopwords": ["a", "an", "the", "is", "at", "on", "in", "of", "and", "or"]
- 불용어로 처리할 단어를 직접 설정합니다.
- type : stop
- "nori_part_of_speech" : Elasticsearch의 Nori 플러그인에서 제공하는 필터입니다. 한글 형태소를 분석하며 특정 품사 (Part of Speech)를 필터링하여 색인하는데 사용됩니다.
- 예를 들어보겠습니다. 위의 예시 중하나인 "가거도항 가곡역" 을 분석해봅니다.
- "가거도"는 "NNP(Proper Noun)으로써 고유 명사를 의미합니다.
- "항"는 "NNP(Proper Noun)으로써 고유 명사를 의미합니다.
- "가곡"는 "NNP(Proper Noun)으로써 고유 명사를 의미합니다.
- "역"는 "NNP(Proper Noun)으로써 고유 명사를 의미합니다.
- 이번 예시는 모두 고유명사를 의미합니다. 만약에 전치사이거나 동사, 대명사일경우로 나뉘기도 하겠습니다.
- 만약에 동사일경우는 "VV", 대명사일경우는 " NP" 등등이 있게습니다.
- 제가 설정한 stoptags 는 default 불용어입니다.
- 예를 들어보겠습니다. 위의 예시 중하나인 "가거도항 가곡역" 을 분석해봅니다.
만약 단어의 형태를 알고싶다면 아래와 같이 "explain" 옵션을 활용하여 확인할 수 있습니다.
"가거도항 가곡역"을 한글형태소로 분석요청합니다. GET seminar/_analyze { "tokenizer": "nori_discard", "text": [ "가거도항 가곡역" ], "explain": true } 형태소 분석결과 { "detail": { "custom_analyzer": true, "charfilters": [], "tokenizer": { "name": "nori_discard", "tokens": [ { "token": "가거도", "start_offset": 0, "end_offset": 3, "type": "word", "position": 0, "bytes": "[ea b0 80 ea b1 b0 eb 8f 84]", "leftPOS": "NNP(Proper Noun)", "morphemes": null, "posType": "MORPHEME", "positionLength": 1, "reading": null, "rightPOS": "NNP(Proper Noun)", "termFrequency": 1 }, { "token": "항", "start_offset": 3, "end_offset": 4, "type": "word", "position": 1, "bytes": "[ed 95 ad]", "leftPOS": "NNG(General Noun)", "morphemes": null, "posType": "MORPHEME", "positionLength": 1, "reading": null, "rightPOS": "NNG(General Noun)", "termFrequency": 1 }, { "token": "가곡", "start_offset": 5, "end_offset": 7, "type": "word", "position": 2, "bytes": "[ea b0 80 ea b3 a1]", "leftPOS": "NNP(Proper Noun)", "morphemes": null, "posType": "MORPHEME", "positionLength": 1, "reading": null, "rightPOS": "NNP(Proper Noun)", "termFrequency": 1 }, { "token": "역", "start_offset": 7, "end_offset": 8, "type": "word", "position": 3, "bytes": "[ec 97 ad]", "leftPOS": "NNG(General Noun)", "morphemes": null, "posType": "MORPHEME", "positionLength": 1, "reading": null, "rightPOS": "NNG(General Noun)", "termFrequency": 1 } ] }, "tokenfilters": [] } }
이로써 제가 Seminar Index에 사용할 옵션들을 모두 정리했습니다.
1-3 Seminar Index를 Spring에서 관리해보기
이제는, Spring에서 Spring Data ElasticSearch 5.x 를 활용하여 마치 ORM 처럼, Index를 자동으로 생성해주는 설정을 진행해보겠습니다.
그렇기 위해서는 es-seminar-settings.json과 es-member-settings.json이 사용됩니다. 물론 위의 SeminarDocument에서 설정을 하기도하였지만, 좀 더 세밀한 설정을 위해서 아래의 json 파일을 사용합니다.
json 파일을 만들다보면, setting.json 같은경우 "setting"으로 묶여있지 않고, mapping.json은 "mapping"으로 묶여있지 않습니다. Spring에서 설정하기 위해서는 아래와 같이 사용해야합니다.
1-3-1 es-seminar-settings.json
아래의 설정은 Index 생성시 "settings"에 들어가는 부분입니다.
{ "number_of_shards" : "1", "number_of_replicas" : "1", "analysis": { "analyzer": { "seminar_name_analyzer": { "type": "custom", "char_filter": ["html_strip"], "tokenizer": ["nori_discard" ], "filter": ["lowercase", "english_stop_filter", "standard", "snowball" ,"nori_part_of_speech"] }, "seminar_explanation_analyzer": { "type": "custom", "char_filter": [], "tokenizer": ["nori_discard"], "filter": ["lowercase", "english_stop_filter", "snowball", "standard", "nori_part_of_speech"] } }, "tokenizer": { "nori_discard": { "type": "nori_tokenizer", "decompound_mode": "discard" } }, "filter": { "english_stop_filter": { "type": "stop", "stopwords": ["a", "an", "the", "is", "at", "on", "in", "of", "and", "or"] }, "korea_stop_filter": { "type": "stop", "stopwords": ["은", "는", "이", "가", "을", "를", "에", "와", "과", "나", "너", "그", "저"] "stoptags": ["E", "J"] } } } }
1-3-2. es-member-settings.json
아래의 설정은 Index 생성시 "mappings"에 들어가는 부분입니다.
{ "properties": { "seminar_no" : { "type" : "long" }, "seminar_name": { "type": "text", "analyzer": "seminar_name_analyzer", "fields" : { "keyword": { "type": "keyword", "ignore_above" : 256 } } }, "seminar_explanation" : { "type" : "text", "analyzer": "seminar_explanation_analyzer" }, "seminar_max_participants" : { "type" : "long" }, "inst_dt" : { "type" : "date" }, "updt_dt" : { "type" : "date" } } }
2. 검색기능을 만들어봅니다.
seminar_name과 seminar_explanation 에 따라서 검색할 수 있도록 구현합니다.
2-1. PageRequestDTO.java
검색 시 조건분기에 사용할 PageRequestDTO 입니다.
@Builder @AllArgsConstructor @Data public class PageRequestDTO { private int page; private int size; private String type; private String keyword; public PageRequestDTO(){ this.page = 1; this.size = 10; } public Pageable getPageable(Sort sort){ return PageRequest.of(page -1, size, sort); } }
2-1. SeminarElasticSearchServiceImpl.java [ Criteria를 활용할경우 ]
Criteria를 활용하여 검색합니다. 이때 깔끔한 코드 구성을 위해 CriteriaQuery를 createSearchCriteriaQuery에서 생성하도록 합니다.
@Override public SearchHits<SeminarDocument> searchByKeywordAndType(PageRequestDTO pageRequestDTO, Pageable pageable) { CriteriaQuery query = createSearchCriteriaQuery(pageRequestDTO,pageable); SearchHits<SeminarDocument> searchHits = elasticsearchOperations.search(query, SeminarDocument.class); return searchHits; } private CriteriaQuery createSearchCriteriaQuery(PageRequestDTO pageRequestDTO, Pageable pageable) { CriteriaQuery query = new CriteriaQuery(new Criteria()); if(!StringUtils.hasText(pageRequestDTO.getKeyword())){ return query; } String keyword = pageRequestDTO.getKeyword(); if(pageRequestDTO.getType().contains("seminar_name")){ query.addCriteria(Criteria.where("seminar_name").is(keyword)); } if(pageRequestDTO.getType().contains("seminar_explanation")){ query.addCriteria(Criteria.where("seminar_explanation").is(keyword)); } query.setPageable(pageable); return query; }
2-2 SeminarElasticSearchRepositoryTests.java
검색테스트를 진행합니다.
@DisplayName("elasticsearch search test") @Test public void testSeminarSearch(){ Long startTime = System.currentTimeMillis(); PageRequestDTO pageRequestDTO = PageRequestDTO.builder() // .keyword("엘라스틱 서치 검색 테스트에요") .keyword("eastwood") .type("seminar_explanation seminar_name") // .type("seminar_name") .page(0) .size(10) .build(); Pageable pageable = PageRequest.of(0, 10); SearchHits<SeminarDocument> searchHits = seminarElasticSearchService.searchByNativeQueryKeywordAndType(pageRequestDTO, pageable); Long endTime = System.currentTimeMillis(); System.out.println("Execution Time:"+ (endTime - startTime) + "ms"); System.out.println(searchHits.getTotalHits()); System.out.println(searchHits.getMaxScore()); for (SearchHit<SeminarDocument> searchHit : searchHits) { System.out.println(searchHit.getContent().toString()); } }
이와 같이 Criteria를 사용할경우 깔끔하게 코드를 작성할 수 있습니다만, Criteria의 경우 Match 쿼리가 발동하면서 Filter 쿼리로써의 처리가 불가합니다.
아래의 실행사항을 보면, MaxScore가 보이며 ElasticSearch 검색알고리즘에서 점수를 계산한 값을 볼 수 있습니다.

Score에 따라서 값을 반환해주는 Match 쿼리도 필요하지만, Score와 상관없이 해당하는 값을 Searching 할때 사용할 수 있는 NativeQuery를 활용하여 진행해보겠습니다.
2-3. SeminarElasticSearchServiceImpl.java [ NativeQuery 를 활용할경우 ]
Native Query를 활용하여 검색합니다. 비교적 깔끔한 코드 구성이 어렵습니다. 각 조건별로 함수를 만들어서 진행하여도 되지만, 직관적인 모습으로 코딩을 해보겠습니다.
@Override public SearchHits<SeminarDocument> searchByNativeQueryKeywordAndType(PageRequestDTO pageRequestDTO, Pageable pageable) { Query query = NativeQuery.builder() .withQuery(q -> q .bool(b -> b .filter(f -> { if (pageRequestDTO.getType().contains("seminar_name") && pageRequestDTO.getType().contains("seminar_explanation")) { // seminar_name과 seminar_explanation이 둘다 주어진경우 return f.bool(b1 -> b1 .should(mq -> mq .match(mq1 -> mq1 .field("seminar_name") .query(pageRequestDTO.getKeyword()))) .should(mq -> mq .match(mq1 -> mq1 .field("seminar_explanation") .query(pageRequestDTO.getKeyword())))); } else if(pageRequestDTO.getType().contains("seminar_name") && !pageRequestDTO.getType().contains("seminar_explanation")){ // seminar_name만 주어진 경우 return f.bool(b1 -> b1 .should(mq -> mq .match(mq1 -> mq1 .field("seminar_name") .query(pageRequestDTO.getKeyword())))); } else if(!pageRequestDTO.getType().contains("seminar_name") && pageRequestDTO.getType().contains("seminar_explanation")){ // seminar_explanation만 주어진 경우 return f.bool(b1 -> b1 .should(mq -> mq .match(mq1 -> mq1 .field("seminar_explanation") .query(pageRequestDTO.getKeyword())))); } return f.bool(b1 -> b1); } ) ) ) .withPageable(pageable) .build(); SearchHits<SeminarDocument> searchHits = elasticsearchOperations.search(query, SeminarDocument.class); return searchHits; }
2-4 SeminarElasticSearchRepositoryTests.java
검색테스트를 진행합니다.
@DisplayName("elasticsearch search test") @Test public void testSeminarSearch(){ Long startTime = System.currentTimeMillis(); PageRequestDTO pageRequestDTO = PageRequestDTO.builder() // .keyword("엘라스틱 서치 검색 테스트에요") .keyword("eastwood") .type("seminar_explanation seminar_name") // .type("seminar_name") .page(0) .size(10) .build(); Pageable pageable = PageRequest.of(0, 10); SearchHits<SeminarDocument> searchHits = seminarElasticSearchService.searchByNativeQueryKeywordAndType(pageRequestDTO, pageable); Long endTime = System.currentTimeMillis(); System.out.println("Execution Time:"+ (endTime - startTime) + "ms"); System.out.println(searchHits.getTotalHits()); System.out.println(searchHits.getMaxScore()); for (SearchHit<SeminarDocument> searchHit : searchHits) { System.out.println(searchHit.getContent().toString()); } }
Native Query를 사용할경우 아래와 같이 최대 MaxScore가 0 이다. Filter가 올바르게 적용되었습니다.

마무리
이로써 ElasticSearch 에 Seminar의 데이터 분석 모델링을 진행해보고, Spring Data ElasticSearch의 API를 활용하여 개발을 완료했습니다.
이러한 ElasticSearch는 역인덱스 구조로 저장됨으로써 많은 데이터가 생기더라도 빠르게 검색할 수 있고, 이러한 성능차이는 데이터가 많아질수록 기존의 RDBMS와 성능차이가 눈에 띄게 커질 것 입니다.
Spring Data ElasticSearch와 관련된 정보는 아래의 글에서 확인할 수 있습니다.
https://docs.spring.io/spring-data/elasticsearch/docs/current/reference/html/
Spring Data Elasticsearch - Reference Documentation
The Spring Data infrastructure provides hooks for modifying an entity before and after certain methods are invoked. Those so called EntityCallback instances provide a convenient way to check and potentially modify an entity in a callback fashioned style. A
docs.spring.io