LMS 고도화 프로젝트를 하던 와중에 팀 내에서 검색엔진 구축을 하기로 결정이 되었습니다.

요구사항은 "사용자 화면의 DB기 like 쿼리로 구축된 검색들을 검색엔진 기반 검색으로 변경"이었습니다.

ElasticSearch 인프라 세팅은 솔루션 팀에서 지원을 해주기로 하였고,
저는 클라이언트 개발,색인 데이터 정의, 데이터 색인, 검색 API 구현을 담당하기로 정해졌습니다.

ElasticSearch를 사이드 프로젝트로 간단히 해보는 정도로만 사용해봤는데 실제 프로젝트에는 처음 적용해보는 것이었습니다.

 

먼저 엘라스틱서치 버전은 7.10.2 버전을 사용하였는데 7.11부터는 라이센스 관련 이슈가 있어 해당 버전을 검토하기로 하였고,
스프링 부트로 구현된 기존 LMS에서도 무리없이 구현가능한 것으로 검토하여 해당 버전을 사용하기로 했습니다.

진행 순서는 다음과 같이 진행하였습니다.

  1. 사용자 화면 분석
  2. 검색에 필요한 데이터 정의
  3. 색인 시스템 구축
  4. 엘라스틱서치 세팅
  5. 검색 API 구현

1. 사용자 화면 분석

데이터 분석 단계에서는 사용자 페이지에서 사용하는 검색 API 리스트를 분석하고, 검색에 필요한 DB 컬럼을 정리하였습니다.
또한 고도화 단계에서 디자인이 변경되어 새로 추가된 검색 조건들도 포함하여 같이 분석을 진행하였습니다.

  1. 사용자 페이지에서 사용하는 검색 API 리스트업
  2. 검색 파라미터 및 Response에 필요한 DB 컬럼 분석
  3. 고도화 디자인 변경으로 인한 검색 요구사항 변경 분석

2. 검색에 필요한 데이터 정의

검색 데이터 정의 단계에서는 1번 단계에서 정리한 데이터를 토대로 검색에 필요한 인덱스를 정의하고, 필드를 정의하였습니다.
인덱스는 템플릿으로 관리하기로 하였으며, 템플릿을 썼을 때 장점은 다음과 같습니다.
Elasticsearch에서 템플릿(Template)은 새로운 인덱스를 생성할 때 자동으로 적용되는 설정, 매핑 및 기타 설정들을 정의한 것입니다.

템플릿을 사용하면 여러 인덱스에 동일한 설정을 일관되게 적용할 수 있고, 인덱스를 생성할 때마다 매번 설정을 반복할 필요 없이 미리 정의된 규칙을 따를 수 있습니다.

 

{
  "index_patterns": ["{index_name}-*"],
  "order": 1,

  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 1,

    "refresh_interval": "1s",

    "index.search.slowlog.threshold.query.warn": "10s",
    "index.search.slowlog.threshold.query.info": "5s",
    "index.search.slowlog.threshold.query.debug": "2s",
    "index.search.slowlog.threshold.query.trace": "500ms",

    "index.indexing.slowlog.threshold.index.warn": "10s",
    "index.indexing.slowlog.threshold.index.info": "5s",
    "index.indexing.slowlog.threshold.index.debug": "2s",
    "index.indexing.slowlog.threshold.index.trace": "500ms",
    "index.blocks.read_only_allow_delete": null,
    "index.max_result_window": 30000,

    "index.default_pipeline": "split_pipeline",

    "analysis":{
      "analyzer": {
        "nori_custom_analyzer": {
          "tokenizer": "nori_mixed",
          "filter": ["lowercase", "stop", "snowball", "search_nori_stop", "nori_readingform", "replace_shingle", "one_token_remover"]
        }
      },
      "tokenizer": {
        "nori_none": {
          "type": "nori_tokenizer",
          "decompound_mode": "none"
        },
        "nori_discard": {
          "type": "nori_tokenizer",
          "decompound_mode": "discard"
        },
        "nori_mixed": {
          "type": "nori_tokenizer",
          "decompound_mode": "mixed"
        }
      },
      "filter": {
        "search_nori_stop": {
          "type": "nori_part_of_speech",
          "stoptags": [
            "E", "J", "IC",
            "MAJ", "MM", "MAG",
            "SP", "SSC", "SSO", "SC", "SE",
            "XPN", "XSA", "XSN", "XSV",
            "UNA", "NA", "VSV", "NNB", "NNBC",
            "NR", "SF", "SY", "UNKNOWN", "VX", "VCN", "VCP", "NP", "VV"
          ]
        },
        "replace_shingle": {
          "type": "shingle",
          "token_separator": ""
        },
        "one_token_remover": {
          "type":"length",
          "min": 2
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "id": {"type": "keyword", "ignore_above": 256},
      "description": {"type": "keyword", "copy_to": "contents"},
      "name": {"type": "text", "analyzer": "nori_custom_analyzer"},
      "keywords": {"type": "text", "analyzer": "nori_custom_analyzer"},
	 
      "contents": {"type": "text", "analyzer": "nori_custom_analyzer", "store": true},
      "latest": {"type": "date", "format": "strict_date_optional_time", "store": true},
      "popular": {"type":"long", "store": true}
    }
  }
}

실제 데이터 부분은 제외하였습니다.

3. 색인 시스템 구축

색인 시스템 설계에 대해서는 정말 고민이 많았던 것 같습니다.
주요 이슈는 다음과 같았습니다. 

 

  1. 색인 관리 테이블 구조
  2. 인덱스 구조
  3. 색인한 데이터에 대한 생성,수정,삭제되는 데이터 색인 방안
  4. 검색 결과에 집계되는 조회수, 좋아요 수 등은 어떻게 관리할 것인지?

먼저 색인 관리 테이블은 스프링 배치 테이블을 봤던 기억이 나서 참고하여 만들면 좋을 것 같아서 참고하면서 테이블을 만들었습니다.

참조 https://jojoldu.tistory.com/326

public class ElasticSearchBatchJob extends BaseUuidEntity {
    @Convert(converter = JobResultStatusConverter.class)
    // 준비(Ready), 성공(SUCCESS), 실패(FAIL), 스킵(SKIP)
    private JobResultStatus jobStatus;
    @Convert(converter = JobTypeConverter.class)
    // 배치, 벌크, 즉시, 전체
    private JobType jobType;
    // 활동명
    private String jobName;
	// 실행 시작 시간
    private LocalDateTime executionStartDateTime;
    // 실행 종료 시간
    private LocalDateTime executionEndDateTime;
    // 기준 시간
    private LocalDateTime standardDateTime;

    private String errorMessage;
    private String errorCode;

 

모든 색인 시에 엘라스틱서치 배치잡 테이블에 데이터를 저장하며 관리합니다.
색인에는 전체, 즉시(단일), 벌크, 배치 색인으로 구분하였습니다.
전체 색인은 수동으로만 가능하고, 초기데이터 인덱싱 또는 이슈가 있는 경우에만 사용하였습니다.
배치는 일 별로 배치 인덱싱을 하는 경우에만 활용하였고, 즉시(단일)는 바로 인덱싱이 필요한 경우에 사용하였습니다.
마지막으로, 벌크는 스프링 스케줄러 어노테이션을 활용한 주기적인 인덱싱인 경우에 활용하였으며, 자세한 내용은 아래에 서술합니다.

 

2번 인덱스 구조는 인덱스를 일별 단위로 {index_name}-{yyyy-MM-dd} 형태로 인덱싱하기로 하였고, 배치 인덱싱을 통하여 매일 자정에 새로 전체 인덱싱을 하도록 구현하였습니다. 또한 Alias를 활용하여 인덱스를 동적으로 관리할 수 있도록 하였습니다.

 

Alias를 활용하였을 때 장점은 다음과 같습니다.
인덱스를 변경하거나 추가할 때 애플리케이션에서 직접 인덱스 이름을 변경할 필요 없이 항상 같은 alias를 사용하여 데이터를 조회할 수 있습니다.
예를 들면 채널이란 인덱스의 channel-2024-12-22가 있고,시간이 지나 12월 23일이 돼서 스케줄러가 새로 channel-2024-12-23을 생성한다고 가정하면 기존 Alias만 삭제 후, 업데이트하면 인덱스 이름 변경할 필요 없이 최신 인덱스를 조회할 수 있습니다.
문제가 생겼을 때 이전 인덱스를 보여주게도 가능하며, 색인에 문제가 생겨 새로 전체 인덱싱을 하는 경우에도 검색의 중단 없이 전체 인덱싱 후 변경하면 되므로 애플리케이션의 변경이나 중단없이 관리가 가능하다는 장점이 있습니다.

실제로 구축할 때도  전체 색인 완료시에 alias를 삭제하고 alias를 다시 추가하는 방식으로 진행했습니다.
일별로 전체 색인을 진행하며, 전체 색인이 성공했을 때만 Alias 변경을 진행한다. 

private void deleteAliasAndAddNewAlias(JobType jobType, IndicesAliasesRequest deleteAliasesRequest, RestHighLevelClient client, LocalDateTime now) throws IOException {
    if (jobType.equals(JobType.ALL)) {
        deleteAliases(deleteAliasesRequest, client);
        client.indices().updateAliases(setAddAliasesRequest(now), RequestOptions.DEFAULT);
    }
}

 

private void deleteAliases(IndicesAliasesRequest deleteAliasesRequest, RestHighLevelClient client) throws IOException {
    if (deleteAliasesRequest.getAliasActions() != null && !deleteAliasesRequest.getAliasActions().isEmpty()) {
        client.indices().updateAliases(deleteAliasesRequest, RequestOptions.DEFAULT);
    }
}
private IndicesAliasesRequest setAddAliasesRequest(LocalDateTime now) {
    IndicesAliasesRequest addAliasesRequest = new IndicesAliasesRequest();
    for (ElasticSearchIndex alias : ElasticSearchIndex.values()) {
        IndicesAliasesRequest.AliasActions aliasAction = new IndicesAliasesRequest.AliasActions(IndicesAliasesRequest.AliasActions.Type.ADD).alias(alias.getName());
        aliasAction.index(getIndexName(alias, JobType.ALL, now));
        addAliasesRequest.addAliasAction(aliasAction);
    }
    return addAliasesRequest;
}

 

public static String getIndexName(ElasticSearchIndex index, JobType jobType, LocalDateTime now){
    if(jobType.equals(JobType.ALL)){
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd");
        String formattedDate = now.format(formatter);
        return index.getName() + "-" + formattedDate;
    }
    return index.getName();
}

 

3,4번 이슈를 해결하기 위해서 먼저 기존 테이블을 분석하였습니다.
데이터 색인 관리 방안은 교육 과정 ,차수 테이블 등 중요한 데이터들은 수정 사항이 인덱스에 바로 반영되어야 한다고 생각하여 데이터 업데이트 시에 바로 즉시(단일) 인덱싱을 하기로 결정하였습니다.


다음 4번 집계 관련 이슈를 해결하기 위해서 회의를 하였고, 즉시 갱신할 필요가 없다고 결론이 나서 기존 테이블의 모든 테이블에서 생성, 수정 시에 마지막 수정시간(lastModifiedDateTime) 컬럼을 업데이트하고 있었는데 해당 컬럼을 활용하여 주기적으로 벌크 인덱싱을 하여 인덱스를 맞추기로 하였습니다.

 

구체적인 방법은 다음과 같습니다.
스프링 스케줄러 어노테이션을 활용하여 3분 단위로 벌크 인덱싱을 수행하는데 배치잡 테이블의 기준 시간이라는 컬럼을 활용합니다.
기준 시간은 인덱싱이 성공한 경우 executionStartDateTime(실행 시작 시간)을 저장합니다.

스케줄러가 실행되면, 단일 인덱싱이 아닌 배치잡 중에서 가장 마지막에 성공한 기준 시간을 가져옵니다.
해딩 기준 시간을 기준으로 테이블 별로 가져올 테이블의 마지막 수정시간이 기준시간보다이후인 데이터를 조회하여 색인합니다.


다음과 같은 구조로 했을 때의 장점은 크게 두가지로 생각합니다.
첫 번째, 주요 테이블의 조회수, 좋아요 수 등 집계 데이터를 바뀔 때마다 갱신하지 않아도 된다.
두 번째, 주기적으로 기준시간과 마지막 수정시간을 비교하면서 조회하므로 자연스럽게 인덱스와 DB 데이터가 일치하는 지 검증이 가능하다.

public void bulkIndex(CreateElasticSearchBulkIndexRequest createElasticSearchBulkIndexRequest) {
    if (createElasticSearchBulkIndexRequest == null) {
        createElasticSearchBulkIndexRequest = new CreateElasticSearchBulkIndexRequest();
    }

    ElasticSearchBatchJobResponse latestBatchJob = elasticSearchBatchJobService.findLastSuccessBatchJobWhereJobTypeNotInSingle();

    JobType jobType = getJobType(createElasticSearchBulkIndexRequest);
    LocalDateTime standardDateTime = null;
    LocalDateTime now = LocalDateTime.now();

    if (latestBatchJob == null || jobType.equals(JobType.ALL)) {
        jobType = JobType.ALL;
    } else {
        standardDateTime = latestBatchJob.getStandardDateTime();
        createElasticSearchBulkIndexRequest.setStandardDateTime(latestBatchJob.getStandardDateTime());
    }

    ElasticSearchBatchJob elasticSearchBatchJob = elasticSearchBatchJobService.save(BULK_INDEX_JOB_NAME, standardDateTime, jobType);

    elasticSearchBatchJob.jobStart();

    IndicesAliasesRequest deleteAliasesRequest = new IndicesAliasesRequest();

    try (RestHighLevelClient client = elasticSearchClientUtil.getRestClient()) {

        if (jobType.equals(JobType.ALL)) {
            getDeleteAliasList(client, deleteAliasesRequest);
        }

        List<List<IndexRequest>> indexRequestsLists = new ArrayList<>();

		/** 데이터 조회해서 indexRequestsLists에 담기 **/

        int flag = 1;
        for (List<IndexRequest> indexRequests : indexRequestsLists) {
            flag = callBulkIndexing(indexRequests, client, flag);
        }

        updateJobStatus(flag, elasticSearchBatchJob);

        deleteAliasAndAddNewAlias(jobType, deleteAliasesRequest, client, now);

        elasticSearchBatchJob.jobEnd();
        client.close();
        updateElasticSearchBatchJob(elasticSearchBatchJob);

    } catch (IOException ioException) {
        handleIOException(ioException, elasticSearchBatchJob);
    }

 

private int callBulkIndexing(List<IndexRequest> indexRequests, RestHighLevelClient client, int flag) {
    if (indexRequests == null || indexRequests.isEmpty()) return 1;
    if (flag == 0) return 0;

    for (IndexRequest indexRequest : indexRequests) {
        IndexResponse indexResponse = elasticSearchClientUtil.callCreate(client, indexRequest);
        if (indexResponse.getShardInfo().getFailed() > 0) {
            return 0;
        }
    }
    return 1;
}

 

flag: 인덱싱이 성공했는지 실패했는지에 대한 플래그입니다. 처음에는 1로 설정되며, 실패할 경우 0으로 변경됩니다.

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CreateElasticSearchBulkIndexRequest {

    private LocalDateTime standardDateTime;
    private JobType jobType;

}

 

4. 엘라스틱서치 세팅

이 프로젝트는 모든 애플리케이션이 도커로 구성되어 있으며, 엘라스틱서치도 도커로 구성하였습니다.

docker-compose.yml

version: '3.8'

services:
  elasticsearch:
    image: elasticsearch:7.10.2
    container_name: 'elasticsearch'
    restart: always
    environment:
      - node.name=es-node
      - cluster.name=es-cluster
      - cluster.initial_master_nodes=es-node
      - bootstrap.memory_lock=true
      - "ES_JAVA_OPTS=-Xms4g -Xmx4g"
      - node.master=true
      - node.data=true
      - http.port=9400     
      - path.logs=/var/log/elasticsearch
      - network.host=_site_
      # 보안 관련 설정은 생략
    volumes:
      - ./elastic/log:/var/log/elasticsearch
      - ./elastic/data:/usr/share/elasticsearch/data
      # 인증서 관련 경로는 생략
    ulimits:
      memlock:
        soft: -1
        hard: -1
    ports:
      - 9400:9400
      - 9500:9500
    networks:
      - es-network
  
  kibana:
    image: docker.elastic.co/kibana/kibana:7.10.2
    container_name: kibana
    ports:
      - 5601:5601
    environment:
      ELASTICSEARCH_URL: http://elasticsearch:9400
      ELASTICSEARCH_HOSTS: http://elasticsearch:9400
    networks:
      - es-network

volumes:
  elastic-data:
    driver: local

networks:
  es-network:
    driver: bridge

키바나는 특별히 기능을 쓰진 않았고, 검색, 템플릿, 인덱싱 테스트 용도로만 활용하였습니다.

다음에 이어서 검색 쿼리 구현 관련 및 썼던 API를 정리하고 아쉬웠던 점을 적어보려고 합니다.

 

Reference

https://jojoldu.tistory.com/326

 

3. Spring Batch 가이드 - 메타테이블엿보기

이번 시간에는 Spring Batch의 메타 테이블에 대해 좀 더 자세히 살펴보겠습니다. 작업한 모든 코드는 Github에 있으니 참고하시면 됩니다. 지난 시간에 Spring Batch의 메타 테이블을 살짝 보여드렸는데

jojoldu.tistory.com

 

+ Recent posts