ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 엘라스틱 서치 적용기
    카테고리 없음 2019. 6. 10. 01:43

    요즘 뚝딱뚝딱 만들고 있는 개인 앱에 검색 기능이 들어갑니다.

    개인앱이 서비스하는 내역은 굉장히 프라이빗해서 설명하기는 조금 그렇지만,

    5000개 정도 되는 서적 정보에서 제목, 설명, 작가, 출판사 등을 검색하는 기능이 필수적이었습니다.

    클라이언트에 주로 경험이 있는 저로서는 서버 개발에 많이 능숙하지 않았고,

    일단 mysql의 %like% 쿼리를 이용해 검색을 구현하려고 했습니다.

    mysql 의 %like%가 인덱싱이 안 된다는 것을 차치하더라도,

    한글을 검색하는 데 굉장히 부적절했습니다. 임시로 사용할 정로도 잘 안 되었습니다.

    이것저것 찾아본 결과, match 키워드로 검색하는 방법도 mysql 에서 있다는 점을 알았습니다.

    하지만 match 키워드는 풀텍스트 기반이었으므로, 적용하려면 텍스트 스키마를 varchar 가 아닌 text 로 바꿔야 했습니다.

    text 타입에 대한 거부감이 훅 들어왔습니다.

    그래서 엘라스틱 서치를 달아보자고 결정. 연동해서 기초적인 서치를 구현하는 데에 1일 정도 걸렸습니다.

    참고 자료

    엘라스틱 서치의 경우 대중적으로 사용된다는 느낌은 확 받았는데, 실제적으로 연동하는 것에 관련되서 체계적으로 적힌 내용은 찾기가 힘들다는 느낌을 받았습니다.

    도서관에서 '시작하세요! 엘라스틱 서치' 라는 책을 빌려서 스윽 개념을 잡았으며 그 전에는 개념조차 잡기가 좀 힘들었습니다.
    다행히 해당 책은 개념이 잘 정리되어 있었습니다.

    https://wikibook.co.kr/elasticsearch/

    그 외에도 아래 자료들이 구현하는데 큰 도움이 되었습니다.

    https://geniedev.tistory.com/6

    https://www.sysnet.pe.kr/2/0/11664

    https://wedul.site/517

    엘라스틱 서치를 사용했던 방법

    로그스태시

    데이터는 mysql에 담고 있었고, 엘라스틱 서치에 저는 익숙하지 않았으므로 백엔드 전체를 엘라스틱 서치로 구현하고 싶지 않았습니다.
    따라서 사용한 것이 로그스태시입니다.

    로그스테시에 mysql 커넥터를 연결하면,
    주기적으로 mysql 에 SELECT 명령을 보냅니다. 그리고 그 결과를 json 로 감싸서 엘라스틱 서치에 집어넣습니다.

    주 저장소인 mysql을 지속적으로 엘라스틱 서치와 동기화해주는 것입니다.

    input {    
        jdbc {        
            jdbc_driver_library => "lib/mysql-connector-java-5.1.47.jar"        
            jdbc_driver_class => "com.mysql.jdbc.Driver"        
            jdbc_connection_string => "jdbc:mysql://${ES_MYSQL_HOST}:3306/light_novel_database"        
            jdbc_user => "${ES_MYSQL_USER}"        
            jdbc_password => "${ES_MYSQL_PASSWORD}"        
            statement => "SELECT `light_novel`.*,
           `author`.`id`                                     AS `author.id`,
           `author`.`name`                                   AS `author.name`,
           `author`.`created_at`                             AS `author.created_at`,
           `author`.`updated_at`                             AS `author.updated_at`,
           `publisher`.`id`                                  AS `publisher.id`,
           `publisher`.`name`                                AS `publisher.name`,
           `publisher`.`created_at`                          AS `publisher.created_at`,
           `publisher`.`updated_at`                          AS `publisher.updated_at`
    FROM (SELECT `light_novel`.*
          FROM `light_novel` AS `light_novel`
          LIMIT 300) AS `light_novel`
           LEFT OUTER JOIN `author` AS `author` ON `light_novel`.`author_id` = `author`.`id`
           LEFT OUTER JOIN `publisher` AS `publisher` ON `light_novel`.`publisher_id` = `publisher`.`id`;
            "        
            schedule => "* * * * *"                
        }    
    }
    output {    
        elasticsearch {        
            hosts => ["localhost:9200"]   
            index => "light_novel" 
            document_id => "%{id}"
        }    stdout {        
            codec => rubydebug    
        }
    }
    

    제가 사용한 코드입니다.
    주기적으로 mysql 에 조인을 합친 셀렉트 문을 날리며, 그 값을 엘라스틱에 박아넣습니다. document_id 로 mysql 데이터의 PK 를 지정하여, 두 데이터가 서로 충분히 식별가능하도록 설정합니다.

    로그스태시는
    https://www.elastic.co/kr/downloads/logstash 로 설치하고,

    mysql-connector-java-5.1.47 다운 후 lib 폴더에 jar 파일 이동하여 설정합니다.

    한글 토크나이저 설정

    그냥 엘라스틱 서치를 사용하면 한글 검색이 절대 의미있게 되지 않습니다.
    따라서 한글 분석기를 따로 플러그인으로 붙여 주어야 합니다.

    https://wedul.site/517
    저는 이 블로그를 참고하여 한글 분석기를 연동했습니다.

    그리고

    curl -XPUT "http://localhost:9200/light_novel/" -H "Content-Type: application/json" -d "{ \"light_novel\": { \"settings\" : { \"index\" : { } } } }"

    로 데이터를 생성하고,

    curl -XPUT "http://localhost:9200/light_novel/logs/_mapping" -H "Content-Type: application/json" -d "{\"logs\":{\"properties\":{\"@timestamp\":{\"type\":\"date\"},\"@version\":{\"type\":\"text\",\"fields\":{\"keyword\":{\"type\":\"keyword\",\"ignore_above\":256}}},\"adult\":{\"type\":\"boolean\"},\"aladin_id\":{\"type\":\"long\"},\"author_id\":{\"type\":\"long\"},\"created_at\":{\"type\":\"date\"},\"description\":{\"type\":\"text\",\"fields\":{\"keyword\":{\"type\":\"keyword\",\"ignore_above\":256}},\"analyzer\":\"openkoreantext-analyzer\"},\"hit_rank\":{\"type\":\"long\"},\"id\":{\"type\":\"long\"},\"isbn\":{\"type\":\"text\",\"fields\":{\"keyword\":{\"type\":\"keyword\",\"ignore_above\":256}}},\"isbn13\":{\"type\":\"text\",\"fields\":{\"keyword\":{\"type\":\"keyword\",\"ignore_above\":256}}},\"link\":{\"type\":\"text\",\"fields\":{\"keyword\":{\"type\":\"keyword\",\"ignore_above\":256}}},\"publication_date\":{\"type\":\"date\"},\"publisher_id\":{\"type\":\"long\"},\"recommend_rank\":{\"type\":\"long\"},\"sales_point\":{\"type\":\"long\"},\"sales_price\":{\"type\":\"long\"},\"standard_price\":{\"type\":\"long\"},\"thumbnail\":{\"type\":\"text\",\"fields\":{\"keyword\":{\"type\":\"keyword\",\"ignore_above\":256}}},\"title\":{\"type\":\"text\",\"fields\":{\"keyword\":{\"type\":\"keyword\",\"ignore_above\":256}},\"analyzer\":\"openkoreantext-analyzer\"},\"updated_at\":{\"type\":\"date\"}}}}"

    로 매핑을 설정하고 로그스태시를 실행해 데이터를 주입했습니다.

    매핑은 데이터가 들어오기 전에 설정해야 하고, 데이터가 들어가면 중간에 수정할 수 없습니다.
    한글이 들어갈 만한 곳에 \"analyzer\":\"openkoreantext-analyzer\" 를 지정하면 검색때 자동적으로 한글 분석기가 사용되었습니다.

    백엔드에 연결

    엘라스틱 서치와 백엔드는 별개의 서비스로 돌고 있습니다.
    백엔드는 nodejs 의 코아를 쓰고 있습니다.
    먼저 nodejs 에서 요청을 받고, 요청에 적절하게 localhost 의 엘라스틱 서치에 질의를 보낸 뒤
    질의 결과를 적당히 가공해서 내보내는 방법을 사용했습니다.

    사용한 라이브러리는 요청에 axios, 데이터 가공에 immutable 을 썼습니다.

    const { LightNovel, Author, Publisher, Category } = require('../../../models');
    var Sequelize = require('sequelize')
    var axios = require('axios')
    const { Map, fromJS, toJS } = require('immutable');
    
    
    const url = 'http://localhost:9200/light_novel/_search'
    
    const fetch = async (query) => {
        try {
            const response = await axios.post(url, {
                query : {
                    multi_match: {
                        fields: [ 
                            "title",
                            "description", 
                            "author.name", 
                            "publisher.name" 
                        ],
                        query: query
                    }
                }
            })
            const data = response.data
            const results = data.hits.hits
            const lightNovelInfos = results.map(getLightNovelInfo)
            return lightNovelInfos
        } catch (e) {
            console.log(e);
        }
    } 
    
    const getLightNovelInfo = (result) => {
        delete result._index
        delete result._type
        delete result._id
        delete result._score
        const source = result._source
        delete source["@timestamp"]
        delete source["@version"]
        const data = fromJS(source)
    
        return data
        .appendAuthorInfo()
        .appendPublisherInfo()
        .toJS()
    
    }
    
    Map.prototype.appendAuthorInfo = function() {
        const id = this.get("author.id")
        const name = this.get("author.name")
        const created_at = this.get("author.created_at")
        const updated_at = this.get("author.updated_at")
    
        const author = Map({
            id: id,
        name: name,
        created_at: created_at,
        updated_at: updated_at })
    
        return this
            .delete("author.id")
            .delete("author.name")
            .delete("author.created_at")
            .delete("author.updated_at")
            .set("author", author)
    };
    
    Map.prototype.appendPublisherInfo = function() {
        const id = this.get("publisher.id")
        const name = this.get("publisher.name")
        const created_at = this.get("publisher.created_at")
        const updated_at = this.get("publisher.updated_at")
    
        const publisher = Map({
            id: id,
        name: name,
        created_at: created_at,
        updated_at: updated_at })
    
        return this
            .delete("publisher.id")
            .delete("publisher.name")
            .delete("publisher.created_at")
            .delete("publisher.updated_at")
            .set("publisher", publisher)
    };
    
    
    
    exports.list = async (ctx) => {
        try {
            const query = ctx.query.query;
            const list = await fetch(query)
            const body = {
                code: 200,
                message: "Success",
                data: {
                    list: list
                }
            }
            ctx.body = body;
        } catch (e) {
            console.log(e);
        }
    }

    적용 결과

    빠른 시간내에 후다닥 검색 엔진을 붙일 수 있었고, mysql 의 like 보다 훨씬 우수한 한글 검색 퍼포먼스를 보였습니다.
    하지만 구글 같은 상용 검색 엔진보다는 뒤떨어진다는 느낌은 체감했습니다.

    구글에서는 '채' 만 입력해도, 검색 제안으로 '책', '채썰기' 등이 나타나는데,
    엘라스틱 서치는 최소한 두 글자까지 완전히 입력해야 의미있는 결과가 나타난다고 느꼈습니다.

Designed by Tistory.