Elasticsearch를 활용한 검색 기능 구현

·2022년 6월 10일
2

팀 프로젝트

목록 보기
31/34
post-thumbnail

코드를 볼 수 있는 곳으로 이동합니다! 클릭시 이동

48번째 줄


원래는 이 글을 초반에 썼어야했는데, typeorm으로 구현된게 너무 기뻐서 미뤄졌다.

엘라스틱서치 그 자체에 대한 것은 여기서 다루지 않을 예정이고
Nest.JS에서 사용한 방식을 적는 포스팅이 될 예정이다.

엘라스틱 서치를 사용하게 된 이유

팀 프로젝트에서 검색으로 사용하는 필터

팀프로젝트에서 진행된 웹사이트는 검색이라는 것이 필연적으로 있어야했다.
그런데 다중 AND 조건을 도저히 구현을 못하겠어서 차선책으로 엘라스틱 서치를 선택하게 되었다.

그리고 이런 프로젝트에서는 일어날 수 없지만 수많은 데이터와 트래픽이 있을 경우에는
대표적으로 사용하는 것이 엘라스틱서치라고 하여 겸사겸사 사용을 해보았다.

BoardResolver.ts

 @Query(() => GraphQLJSONObject)
  async fetchBoardTitle(
    @Args('title') title: string
  ) {
    return this.boardService.elasticsearchFindTitle({ title });
  }

  @Query(() => GraphQLJSONObject)
  async fetchBoardContents(
    @Args('contents') contents: string
  ) {
    return this.boardService.elasticsearchFindContents({ contents });
  }

  @Query(() => GraphQLJSONObject)
  async fetchBoardWithTags(
    @Args({ name: 'tags', type: () => [String] }) tags: string[],
  ) {
    return this.boardService.elasticsearchFindTags({ tags });
  }

현재 각 3개의 게시판에서 모두 사용이 되고 있지만,
대표적으로 쓰고 있는 Board 단에서 코드를 가져왔다.

서비스단을 호출하기에 중요한 점이 없어보이지만 확인을 하고 가야하는 것이 있는데,
@Query(() => GraphQLJSONObject) 라고 적혀있는리턴 타입 이다.

엘라스틱서치를 활용해서 검색을 해서 받은 데이터의 타입은 JSON의 형태로 되어있는데

이것을 평소 리턴하던 Entity의 타입으로 되돌리는 것은 엄청나게 복잡한 과정이 필요하다.
그리고 굳이 Entity 타입으로 돌릴꺼면 검색엔진을 쓸 이유가 없다.

다른 방법으로는 [String]으로 감싸는 방법이 있는데
이 경우에는 날짜 데이터라던가 숫자같은 문자열이 아닌 형태들이 다 무너지는 결과를 확인할 수 있다.

그래서 사용하는 것이 GraphQLJSONObject 이라는 리턴타입이며
이것은 라이브러리가 따로 존재한다.

Return type GraphQLJSONObject ?

링크 => https://www.npmjs.com/package/graphql-type-json

이것을 활용하면 그대로 데이터를 보낼 수 있다.

데이터가 상당히 깊숙한 위치에 있기 때문에, 백에서 데이터를 정리해서 보내주는 경우도 있고
프론트에서 알아서 빼쓰는 경우도 있었는데, 우리는 프론트에서 담당하기로 했다.

검색된 결과를 보여주는 것과, 실제 데이터를 보여주는 영역이 다르다

BoardService.ts

async elasticsearchFindTags({ tags }) {
    const sortingData = tags.sort();
    
    const tagsData = sortingData.reduce((acc, cur) => {
      return acc === '' ? acc + cur : acc + ' ' + cur;
    }, '');
    
    const redisInData = await this.cacheManager.get(tagsData);
    
    if (redisInData) {
      return redisInData;
    } else {
    
      const data = await this.elasticsearchService.search({
        index: 'board',
        size: 10000,
        sort: 'createat:desc',
        query: {
          multi_match: {
            query: tagsData,
            type: 'cross_fields',
            operator: 'AND',
            fields: ['tags', 'boardsubject'],
          },
        },
      });
      
      await this.cacheManager.set(tagsData, data, { ttl: 20 });
      return data;
    }
  }

이것은 현재 사용하고 있는 태그로 검색하는 api의 코드다.

프론트로 오는 데이터의 형식은 아래와 같은 형식으로 보내준다.

tags = ["한식","구로구","혼술","REVIEW"]

이제부터는 코드 한줄 한줄에 대한 리뷰를 하면서 진행된다.

검색어 정렬하기

하지만 ["구로구","한식","혼술","REVIEW"] 라고 한들 상단의 검색과 조건이 같은데
문자의 순서가 다르기 때문에 결과는 같더라도 다른 조회가 되어버린다.

다이닝코드의 경우 같은 조건에서 순서가 바뀔 경우 로딩이 걸린다.

그렇기에 나는 애초에 값을 정렬해서 검색을 하는 것이 좋다고 생각했다.
왜냐하면 입력값이 고정되어있기 때문에 정렬을 하더라도 결과값이 달라지지 않기 때문이다.

혹여나 정밀검색이라 스코어를 계산하는 것이라면 정렬은 절대로 사용하지 못할 것이다.

검색어 사이에 공백주기

엘라스틱서치는 공백으로 토큰화를 하는 것이 특징인데
이것을 활용해서 값 사이에 공백을 주는 것으로 다중 검색을 할 수 있도록 구현하였다.

tags의 값이 ["구로구","한식","혼술","REVIEW"] 로 온다면

1차적으로 정렬이 되면서 [ 'REVIEW', '구로구', '한식', '혼술' ]
2차적으로 공백이 생기기 때문에 'REVIEW 구로구 한식 혼술' 의 형식이 된다.

이렇게해서 REVIEW 구로구 한식 혼술이 포함되어있는 게시글을 검색할 수 있게 구성을 하였다.

Redis 적용

Redis는 메모리 기반의 DB로 key와 values의 형식으로 저장이 되며, 저장되는 시간을 넣을 수 있어서
흔히 휘발성 데이터, 캐시로 사용을 많이 하는 DB이다.

그래서 나는 바로 검색에 도입을 하는 것이 맞다고 생각했고
1. 검색어로 찾을 경우에 존재한다면, 속에 있는 데이터를 바로 클라이언트로 반환하고
2. 존재하지 않는다면 검색하는 로직을 만들어놨다.

NestJS에서 Elasticsearch 사용하기

이 글을 쓰게 된 본론, 엘라스틱 서치를 활용한 검색이다.

일단 NestJS에서 엘라스틱서치를 활용하기 위해서는 아래 라이브러리를 설치해야한다.

@nestjs/elasticsearch

그리고 본인이 검색엔진에서 사용할 위치에 아래 사진처럼 엘라스틱모듈을 이식한다.

위 사진을 보면 node가 환경변수로 가려져있는데, 자신이 가져올 엘라스틱서치의 URL을 넣으면 된다.

또한 Serive단에서도 작업을 해줘야하는데
ElasticsearchService를 생성자에 넣어서 호출을 해줘야한다.

이렇게 준비를 하면 NestJS에서 엘라스틱 서치 사용을 할 준비가 끝났다.

Elasticsearch DSL query를 사용하여 검색하기

이제 사용법을 알아보자.

const data = await this.elasticsearchService.search({
        index: 'board',
        size: 10000,
        sort: 'createat:desc',
        query: {
          multi_match: {
            query: tagsData,
            type: 'cross_fields',
            operator: 'AND',
            fields: ['tags', 'boardsubject'],
          },
        },
      });

위의 코드가 현재 팀프로젝트 상에서 태그 검색으로 활용되고 있는 코드다.

1. board의 인덱스 값을 board로 해놓은 상태라 index : 'board'가 적혀있다.
2. size는 SQL의 limit와 동일한 옵션이다. 프론트에서 무한스크롤한다고 한번에 다 달라길래 그냥 최대 개수인 만개를 적어놨다(...)
3. 최신글의 순서로 정렬을 하기 위하여 sort : 'createat:dest' 옵션을 넣어줬다.
4. 2개의 필드에서 검색이 이루어지기 때문에 multi_match 쿼리를 사용한다.
5. 찾는 검색어는 위에 있었던 tags가 정렬되고 공백 간격으로 만들어진 문자열이다.
6. 검색어를 한번에 찾아야 하기때문에 2개의 필드를 type: 'cross_fields' 를 사용하여 1개로 합쳤다.
7. 내가 원하는 것은 or 조건이 아닌 and 조건이였기 때문에 operator : 'AND' 를 넣었다.
8. 필드는 태그가 모여있는 tags와 말머리가 적혀있는 boardsubject 를 넣었다.

이렇게 사용하여 원하는 태그와 말머리를 모두 가진 게시글을 검색할 수 있는 로직을 만들 수 있었다.

하지만 여기서 주의할 점이 존재한다.

모든 글자는 소문자로 변하는 Elasticsearch 내부

위의 단어들을 보면 모든것이 소문자로 되어있는 모습을 볼 수 있다.

이러한 이유는 엘라스틱서치가 역인덱싱을 한 후 데이터를 집어넣는 과정에서 검색이 더 원활하도록 설정이 달려있다.

그것은 Analyzer라고 부르는데, 그 속의 TokenFilter가 기본 값으로 lowercase를 통하여 소문자로 변환하기 때문이다.
그렇기 때문에 백엔드의 경우에는 검색을 할 때 저 부분을 유의하고
프론트의 경우에는 값을 꺼내쓸 경우에 저것을 변경해줘야한다.

물론 저것을 사용하지 않게 할 순 있지만, 커스텀 템플릿을 만들어서 직접 넣어줘야 하기 때문에 과정이 번거로워지는 편이다.


내용이 몹시 길어져서 한번 끊어갑니다.

다음 글 커스텀 템플릿 구현하기

profile
물류 서비스 Backend Software Developer

0개의 댓글