Algolia를 사용한 검색 기능 구현하기

2022-05-29 오전 12:21:35
Nest.js
Next.js
Algolia
검색
Algolia를 사용한 검색 기능 구현하기

검색 방법 선정하기

Develogger에 검색기능을 구현하기 위해 어떤 방법이 좋을지 고민 해보았다.
단순 쿼리문을 사용한 문자열 포함 검색기능은 많이 해보았으므로 다른 방법을 찾기로 했다.
한참 구글링 해본 결과 ElasticsearchAlgolia를 사용하는 방법 2가지를 시도해 보기로 했다.

Elasticsearch는 Apache Lucene( 아파치 루씬 ) 기반의 Java 오픈소스 분산 검색 엔진이다.
검색해보니 거의 도커 기반으로 된 설명이 많았고, 현재 Develogger는 nest.js를 사용하고 있는데
이와 관련된 자료가 많지 않아 Algolia를 사용해 보기로 하였다.


Algolia란?

Algolia는 SaaS(Software as a Service) 모델을 통해 웹 검색 제품을 제공하는 서비스이다.
Algolia 모델은 외부에서 호스팅되는 검색 엔진을 사용하여 클라이언트의 웹사이트에서 웹 검색을 제공 한다.

Algolia 사용법

가장 먼저 회원가입tutorial을 진행 해야 한다.
https://www.algolia.com 에 접속하여 회원가입을 한다.
가입 후 제공되는 간단한 tutorial을 진행하여 중요 기능들을 사용 해보면 대략
어떤 방식으로 돌아가는지 흐름이 보이게 된다.

1-1. Record push

검색이 되어야 할 색인 데이터들을 Algolia에서 제공하는 API를 통해 Push 해주어야 한다.
이때 사용할 API 및 사용법은 공식문서 https://www.algolia.com/doc/ 를 참고한다.

Develogger의 게시글이 검색 되게 하기 위해서는 게시물 데이터를 Push 해 줘야 한다.
Nest.js 기반의 백엔드에서 Algolia가 제공하는 algoliasearch 모듈을 사용하기 위해 nestjs-algolia를 먼저 설치한다.

npm i -D nestjs-algolia

설치 후 사용할 모듈에서 import 해준다.

// module 영역 import { Module } from '@nestjs/common'; import { BlogService } from './blog.service'; import { BlogController } from './blog.controller'; import { UsersService } from 'src/users/users.service'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Users } from 'src/entities/Users'; import { BlogPostsTags } from 'src/entities/blog-posts-tags'; import { BlogPosts } from 'src/entities/blog-posts'; import { BlogPostsLike } from 'src/entities/blog-posts-like'; import { AlgoliaModule } from 'nestjs-algolia'; import dotenv from 'dotenv'; dotenv.config({ path: process.env.NODE_ENV === 'production' ? '.env.production' : '.env.development', }); @Module({ imports: [ TypeOrmModule.forFeature([Users, BlogPostsTags, BlogPosts, BlogPostsLike]), AlgoliaModule.register({ applicationId: process.env.ALGOLIA_APP_ID, apiKey: process.env.ALGOLIA_API_KEY, }), ], controllers: [BlogController], providers: [BlogService, UsersService], exports: [BlogService], }) export class BlogModule {}

applicationId와 apiKey는 회원 가입 후

image

여기서 가져 올 수 있고, 따로 env 파일에서 관리하여 코드와 분리시킨다. ( back-end에서 사용할 apikey는 admin용 )


아래는 service단에서 글쓰기 처리 후 algolia에도 post data를 push 해주는 과정이다.

// service 영역 import { AlgoliaService } from 'nestjs-algolia'; @Injectable() export class BlogService { constructor( // for Algolia private readonly algoliaService: AlgoliaService, ) {} // ... another services code //글쓰기 async createPost(createBlogPostData: CreateBlogPostDto, user: UserDto) { // ... 기존 글쓰기 code // 새로운 Post객체를 최종 DB에 저장하는 부분 const result = await this.blogPostsRepository.save(Post); // algolia - develogger-post는 algolia에 등록한 인덱스 이름 const index = this.algoliaService.initIndex('develogger-post'); const algoliaData = await this.algoliaFindPost(result.id); // 게시글 내용에 html태그를 지운다. (검색 결과에 태그가 나오는것 방지) algoliaData.content = algoliaData.content.replace(/(<([^>]+)>)/gi, ''); // algolia에 데이터 삽입 try { await index.addObject(algoliaData); } catch (error) { console.log('Algolia insert data error : ', error); } return result; } // 글쓰기, 글 수정, 글 삭제 시 algolia와 데이터 싱크를 맞추기 위해 데이터를 보낼때 // 일정한 타입을 유지하기 위해 사용할 서비스 async algoliaFindPost(postId: number) { return await this.blogPostsRepository .createQueryBuilder('posts') .leftJoin('posts.Tags', 'tags') .leftJoin('posts.LikeDisLike', 'likes') .leftJoin('posts.User', 'user') .where('posts.id = :postId', { postId }) .orderBy('posts.updatedAt', 'DESC') .addSelect('tags.tagName') .addSelect('likes.actionType') .addSelect('likes.UserId') .addSelect('user.loginID') .addSelect('user.avatarUrl') .addSelect('user.positionType') .addSelect('user.deletedAt') .getOne(); } }

이와 같은 방법을 통해 글 수정, 글 삭제 시에도 algolia와 데이터 싱크를 맞춰주어서
검색 데이터가 최신이 유지 되도록 자동화 한다. ( algolia record 수정, 삭제 api는 docs 참고 )


image

위와 같이 데이터가 성공적으로 Push 된 것을 확인 후 Configuration 탭을 통해 검색할 속성과 우선순위를 설정한다.

image

image

각자의 데이터 상황에 맞게 잘 설정해 준 다음 제공하는 UI-Demo를 통해 검색이 잘 되는지 확인 해본다.

image

여기까지가 Back-end 에서 해줘야 할 영역이고, 이제 Front-end에서 해야할 작업에 대해 알아보자.


2-1. Autocomplete component

현재 Front-end는 Next.js 프로젝트로 구성 되어 있다.
algolia 공식문서에 react로 된 예제가 있지만 제대로 작동하지 않아서
구글링해서 찾은 codepen에 작성된 예제를 사용했다.

먼저 필요한 모듈을 설치한다.

npm i -D @algolia/autocomplete-core
npm i -D @algolia/autocomplete-preset-algolia
npm i -D @algolia/autocomplete-theme-classic
npm i -D @algolia/client-search
npm i -D algoliasearch

_app.tsx에 스타일에 필요한 모듈을 import한다.

import '@algolia/autocomplete-theme-classic';

그리고 새로 Autocomplete component를 생성한다.

// Autocomplete component import { AutocompleteOptions, AutocompleteState, createAutocomplete, } from '@algolia/autocomplete-core'; import { getAlgoliaResults } from '@algolia/autocomplete-preset-algolia'; import { Hit } from '@algolia/client-search'; import { Avatar } from '@material-ui/core'; import algoliasearch from 'algoliasearch/lite'; import Link from 'next/link'; import React from 'react'; const searchClient = algoliasearch( process.env.NEXT_PUBLIC_ALGOLIA_APP_ID || '', process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY || '' ); type AutocompleteItem = Hit<{ objectID: string; title: string; content: string; updatedAt: string; Tags: [{ tagName: string }]; User: { loginID: string; avatarUrl: string; positionType: string; deletedAt: string | null; }; id: number; thumbnail: string; UserId: number; createdAt: string; deletedAt: string | null; LikeDisLike: [{ actionType: string; UserId: number }]; }>; export function Autocomplete(props: Partial<AutocompleteOptions<AutocompleteItem>>) { const [autocompleteState, setAutocompleteState] = React.useState< AutocompleteState<AutocompleteItem> >({ collections: [], completion: null, context: {}, isOpen: false, query: '', activeItemId: null, status: 'idle', }); const autocomplete = React.useMemo( () => createAutocomplete< AutocompleteItem, React.BaseSyntheticEvent, React.MouseEvent, React.KeyboardEvent >({ onStateChange({ state }) { setAutocompleteState(state); }, getSources() { return [ { sourceId: 'develogger-post', getItems({ query }) { return getAlgoliaResults({ searchClient, queries: [ { indexName: 'develogger-post', query, params: { hitsPerPage: 300, highlightPreTag: '<mark>', highlightPostTag: '</mark>', }, }, ], }); }, getItemUrl({ item }) { // return item.url; return `${process.env.NEXT_PUBLIC_API_URL}/blog/${item.User.loginID}/${item.id}`; }, }, ]; }, ...props, }), [props] ); const inputRef = React.useRef<HTMLInputElement>(null); const formRef = React.useRef<HTMLFormElement>(null); const panelRef = React.useRef<HTMLDivElement>(null); const { getEnvironmentProps } = autocomplete; React.useEffect(() => { if (!formRef.current || !panelRef.current || !inputRef.current) { return undefined; } const { onTouchStart, onTouchMove } = getEnvironmentProps({ formElement: formRef.current, inputElement: inputRef.current, panelElement: panelRef.current, }); window.addEventListener('touchstart', onTouchStart); window.addEventListener('touchmove', onTouchMove); return () => { window.removeEventListener('touchstart', onTouchStart); window.removeEventListener('touchmove', onTouchMove); }; }, [getEnvironmentProps, formRef, inputRef, panelRef]); return ( <div className="aa-Autocomplete" {...autocomplete.getRootProps({})}> <form ref={formRef} className="aa-Form" {...autocomplete.getFormProps({ inputElement: inputRef.current })}> <div className="aa-InputWrapperPrefix"> <label className="aa-Label" {...autocomplete.getLabelProps({})}> <button className="aa-SubmitButton" type="submit" title="Submit"> <SearchIcon /> </button> </label> </div> <div className="aa-InputWrapper"> <input className="aa-Input" ref={inputRef} {...autocomplete.getInputProps({ inputElement: inputRef.current })} /> </div> <div className="aa-InputWrapperSuffix"> <button className="aa-ClearButton" title="Clear" type="reset"> <ClearIcon /> </button> </div> </form> {autocompleteState.isOpen && ( <div ref={panelRef} className={[ 'aa-Panel', 'aa-Panel--desktop', autocompleteState.status === 'stalled' && 'aa-Panel--stalled', ] .filter(Boolean) .join(' ')} {...autocomplete.getPanelProps({})}> <div className="aa-PanelLayout aa-Panel--scrollable"> {autocompleteState.collections.map((collection, index) => { const { source, items } = collection; return ( <section key={`source-${index}`} className="aa-Source"> {items.length > 0 && ( <ul className="aa-List" {...autocomplete.getListProps()}> {items.map((item) => { return ( <Link href={`/blog/${item.User.loginID}/${item.id}`} as={`/blog/${item.User.loginID}/${item.id}`} key={item.objectID}> <a> <li className="aa-Item" {...autocomplete.getItemProps({ item, source })}> <div className="aa-ItemWrapper"> <div className="aa-ItemContent"> <div className="aa-ItemIcon aa-ItemIcon--picture aa-ItemIcon--alignTop"> <img src={`${process.env.NEXT_PUBLIC_API_URL}/${item.thumbnail}`} alt={item.title} width="40" height="40" /> </div> <div className="aa-ItemContentBody"> <div className="aa-ItemContentTitle" dangerouslySetInnerHTML={{ __html: item._highlightResult!.title!.value, }} /> <div className="aa-ItemContentTitle" dangerouslySetInnerHTML={{ __html: item._highlightResult!.content!.value, }} /> <div className="aa-ItemContentDescription" style={{ display: 'flex', alignItems: 'center' }}> <Avatar color="default" alt="User Profile Icon" src={`${item.User.avatarUrl || ''}`} style={{ height: '20px', width: '20px', marginRight: '4px', }} /> <strong style={{ paddingTop: '2px' }}> {item.User.loginID} </strong> {/* in{' '}<strong>{item.categories[0]}</strong> */} </div> </div> </div> <div className="aa-ItemActions"> <button className="aa-ItemActionButton aa-DesktopOnly aa-ActiveOnly" type="button" title="Select" style={{ pointerEvents: 'none' }}> <svg fill="currentColor" viewBox="0 0 24 24"> <path d="M18.984 6.984h2.016v6h-15.188l3.609 3.609-1.406 1.406-6-6 6-6 1.406 1.406-3.609 3.609h13.172v-4.031z" /> </svg> </button> </div> </div> </li> </a> </Link> ); })} </ul> )} </section> ); })} </div> </div> )} </div> ); } function ClearIcon(props: React.SVGProps<SVGSVGElement>) { return ( <svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor" {...props}> <path d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" fillRule="evenodd" clipRule="evenodd" /> </svg> ); } function SearchIcon(props: React.SVGProps<SVGSVGElement>) { return ( <svg width="20" height="20" viewBox="0 0 20 20" {...props}> <path d="M14.386 14.386l4.0877 4.0877-4.0877-4.0877c-2.9418 2.9419-7.7115 2.9419-10.6533 0-2.9419-2.9418-2.9419-7.7115 0-10.6533 2.9418-2.9419 7.7115-2.9419 10.6533 0 2.9419 2.9418 2.9419 7.7115 0 10.6533z" fill="none" fillRule="evenodd" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.4" /> </svg> ); }

추가적으로 css 스타일 작업을 해주어야하고, 위 소스코드는 각자 데이터에 맞게 변경하여 사용해야한다. apikey는 마찬가지로 algolia dashboard에서 가져올 수 있고, env로 분리하여 관리한다.

그리고 검색창을 사용할 컴포넌트 내에

// debug 속성은 개발시에만 사용하고 배포시에는 빼야 한다. (검색결과 영역이 안닫힘) <Autocomplete placeholder="Search" openOnFocus={true} debug={true} />

만 추가해주면 된다.



그러면 아래와 같이 예쁘고 잘 동작하는 검색 input과 dropdown이 완성된다. image




후기

검색기능에 크게 어려움이 없을거라 생각했으나, Elasticsearch에 대해 알아보면서
형태소 분석이나, 데이터를 빠르게 불러오기 위한 인덱싱 구조 같은 것들을 새롭게 알게 되었다.
algolia를 통해 조금 더 편하게 검색 기능을 만들 수 있었고, 꽤나 성능이 좋은 것 같다.
현재는 Free liscence를 사용중이나, 트래픽이 커진다면 Elasticsearch로 넘어가보고 싶다.



User Profile Icon
LKHcoding
Front-End Developer