블로그에 검색 기능은 필수이다. algolia 는 SaaS(Service as a service) 형태로 제공되는 검색 서비스, 혹은 관리형 검색 서비스 라고 할 수 있다.
검색 기능을 제대로 구현하려면 손이 매우 많이 간다. 디비 설계, 인덱싱, 쿼리작성, 페이지네이션, 캐시, 다국어 지원, 서버 관리, 각종 인프라 관리, 이 모든것에 대한 고가용성까지 고려한다면 누군가 잘 만들어놓은 것을 돈을 지불하고서라도 쓰고 싶다. (무료인 커뮤니티 플랜도 있다)
algolia 가 바로 그 것이다. algolia 제공되는 API 통해 내부에서 관리되는 DB 에 웹사이트의 모든 콘텐츠를 넣어놓고 검색 API 를 통해 요청하면 응답을 준다. 블로그에 그냥 설치하고 넘어가려 했으나 사용성과 성능에 깜짝 놀라 간단하게 포스팅까지 작성하게 되었다.
algolia 서비스에서 검색 요청을 하게되면 Index
라고 불리는 논리적으로 격리된 DB
기반으로 수행되는데, Index
는 하나의 디비 테이블이라고 생각 할 수 있고, 하나의
row 는 Object
라고 불리며 JSON
데이터로 관리된다. Index
까지의 구조를
살펴보면 하나의 App 은 여러개의 Index
를 가질 수 있고, 하나의 algolia 계정이
여러개의 App 을 가질 수 있다. App 단위로 Price Plan 을 선택하여 과금되며 검색 UI
에 algolia 로고를 노출하면 무료인 Community 버전을 사용 할 수도 있다.
Index 아래 검색이 될 콘텐츠들을 추가해보자. document 페이지를 보면 algolia 는 아래와 같이 많은 언어를 지원하는 것을 볼 수 있고 SDK 를 제공하여 SDK 를 통해 algolia API 를 사용하는 것이다.
nodejs 코드로 Index
안에 Object 를 추가하는 add-object.js
파일을 작성해보자:
const algoliasearch = require('algoliasearch');
const ALGOLIA_APP_ID = process.env.ALGOLIA_APP_ID;
const ALGOLIA_ADMIN_API_KEY = process.env.ALGOLIA_ADMIN_API_KEY;
const ALGOLIA_INDEX_NAME = process.env.ALGOLIA_INDEX_NAME;
const client = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_ADMIN_API_KEY);
const index = client.initIndex(ALGOLIA_INDEX_NAME);
var objects = [{
firstname: 'Jimmie',
lastname: 'Barninger'
}, {
firstname: 'Warren',
lastname: 'Speach'
}];
index.addObjects(objects, function(err, content) {
console.log(content);
});
위 코드에서 사용한 환경변수 예:
export ALGOLIA_INDEX_NAME=my_first_index
export ALGOLIA_APP_ID=VXXXXXXXXI
export ALGOLIA_SEARCH_ONLY_API_KEY=f2xxxxxxxxxxxxxxxxxxxxxxxxxxxx87
export ALGOLIA_ADMIN_API_KEY=7dxxxxxxxxxxxxxxxxxxxxxxxxxxxx9c
환경변수에 사용한 APP_ID
등은 Application 페이지에서 새로운 애플리케이션을
생성하면 FKU76S190V
와 같은 APP_ID
가 나온다. 그런 다음 키
페이지에서(https://www.algolia.com/apps/{APP_ID}/api-keys
) 다른 키 들을 확인
할 수 있다.
테스트 환경을 만들어 실행시켜보자.
$ mkdir algolia-test
$ cd algolia-test
$ echo {} > package.json
$ yarn add algoliasearch
$ vi add-object.js # 코드작성
$ vi .envrc # 실제 생성한 APP_ID 등 작성
$ source .envrc
$ node add-object
{ objectIDs: [ '7447381', '7447391' ], taskID: 7676251291 }
추가한 object 는 firstname
, lastname
뿐이다. 모든 object 는 식별자로서
objectID
를 필수로 가지는데 위의 코드처럼 object 를 생략하는 경우 자동으로
생성된다. objectID 는 string
타입이며 number
타입으로 작성시 자동으로
string
타입으로 형 변환된다. 이 경우 node get-object
를 수행 할 때마다
새로운 objectID
를 가진 object 가 계속해서 생성된다.
위의 output 을 살펴보면 [ '7447381', '7447391' ]
2 개의 objectID
를 가진
object 가 생성되었다.
추가된 object 를 확인하기 위해 get-object.js
파일을 작성해보자:
const algoliasearch = require('algoliasearch');
const ALGOLIA_APP_ID = process.env.ALGOLIA_APP_ID;
const ALGOLIA_ADMIN_API_KEY = process.env.ALGOLIA_ADMIN_API_KEY;
const ALGOLIA_INDEX_NAME = process.env.ALGOLIA_INDEX_NAME;
const client = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_ADMIN_API_KEY);
const index = client.initIndex(ALGOLIA_INDEX_NAME);
index.getObjects(['7447381','7447391'], function(err, content) {
if (err) throw err;
console.log(JSON.stringify(content,null,2));
});
$ node get-object
{
"results": [
{
"firstname": "Jimmie",
"lastname": "Barninger",
"objectID": "7447381"
},
{
"firstname": "Warren",
"lastname": "Speach",
"objectID": "7447391"
}
]
}
[대쉬보드][]를 통해 Web UI 로도 확인 할 수도 있다.
환경변수에 설정한 my_first_index
라는 이름의 Index
가 생성되어 있고,
그 안에 추가한 2개의 object 를 볼 수 있다. Index
를 먼저 생성하지 않았지만
object 추가 API 수행시 해당 Index
가 없으면 자동으로 먼저 생성한다.
첫번째 인자를 키워드로 받아 검색을 수행하는 search.js
을 작성하자:
const algoliasearch = require('algoliasearch');
const ALGOLIA_APP_ID = process.env.ALGOLIA_APP_ID;
const ALGOLIA_SEARCH_ONLY_API_KEY = process.env.ALGOLIA_SEARCH_ONLY_API_KEY;
const ALGOLIA_INDEX_NAME = process.env.ALGOLIA_INDEX_NAME;
const client = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_SEARCH_ONLY_API_KEY);
const index = client.initIndex(ALGOLIA_INDEX_NAME);
index.search({ query: process.argv[2] }, function (err, content) {
if (err) throw err;
console.log(JSON.stringify(content.hits,null,2));
});
Admin API Key
가 client 코드 쪽에 노출되면 절대 안되기 때문에 Search 를 할땐
Admin API Key
대신 Search-Only API Key
를 사용한 것이 중요하다.
$ node search jim
[
{
"firstname": "Jimmie",
"lastname": "Barninger",
"objectID": "7447381",
"_highlightResult": {
"firstname": {
"value": "<em>Jim</em>mie",
"matchLevel": "full",
"fullyHighlighted": false,
"matchedWords": [
"jim"
]
},
"lastname": {
"value": "Barninger",
"matchLevel": "none",
"matchedWords": []
}
}
}
]
jim
이라는 키워드로 검색한 결과 매칭되는 object 들이 Array 형태로 내려온다.
_highlightResult
검색 결과에서 _highlightResult
객체를 볼 수 있는데 이 정보로 어떤 attribute 가
키워드와 매칭되어 내려왔는 지 확인 할 수 있고 "value": "<em>Jim</em>mie"
와
같이 매칭되는 부분이 특정 html 태그로 감싸져서 내려오는데 이 정보로 UI 를
편리하게 구현 할 수 있겠다.
홈쇼핑 등에서 검색 했을때 검색결과에 제품의 썸네일 사진을 목록에 노출하여
사용성을 향상시킬 수 있다. add-object.js
코드로부터 update-object.js
를 만들고 아래와 같이 코드를 살짝 수정해보자.
const algoliasearch = require('algoliasearch');
const ALGOLIA_APP_ID = process.env.ALGOLIA_APP_ID;
const ALGOLIA_ADMIN_API_KEY = process.env.ALGOLIA_ADMIN_API_KEY;
const ALGOLIA_INDEX_NAME = process.env.ALGOLIA_INDEX_NAME;
const client = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_ADMIN_API_KEY);
const index = client.initIndex(ALGOLIA_INDEX_NAME);
var objects = [{
objectID: '7447381', // add-object 수행시 나온 objectID 사용
firstname: 'Jimmie',
lastname: 'Barninger',
hobby: 'photo',
imgUrl: 'photo-8b137455b.png',
}, {
objectID: '7447391', // add-object 수행시 나온 objectID 사용
firstname: 'Warren',
lastname: 'Speach',
hobby: 'piano',
imgUrl: 'photo-4fd43f312.png',
}];
index.addObjects(objects, function(err, content) {
console.log(JSON.stringify(content,null,2));
});
hobby
와 imgUrl
을 추가했다. 자 이제 photo
라는 hobby
를 가진 멤버를
찾기위해 검색해보자.
$ node search photo
[
{
"firstname": "Jimmie",
"lastname": "Barninger",
"hobby": "photo",
"imgUrl": "photo-8b137455b.png",
"objectID": "7447381",
"_highlightResult": {
"firstname": { ...skip... }
"lastname": { ...skip... }
"hobby": {
"value": "<em>photo</em>",
"matchLevel": "full",
"fullyHighlighted": true,
"matchedWords": [
"photo"
]
},
"imgUrl": {
"value": "<em>photo</em>-8b137455b.png",
"matchLevel": "full",
"fullyHighlighted": false,
"matchedWords": [
"photo"
]
}
}
},
{
"firstname": "Warren",
"lastname": "Speach",
"hobby": "piano",
"imgUrl": "photo-4fd43f312.png",
"objectID": "7447391",
"_highlightResult": {
"firstname": { ...skip... }
"lastname": { ...skip... }
"hobby": {
"value": "piano",
"matchLevel": "none",
"matchedWords": []
},
"imgUrl": {
"value": "<em>photo</em>-4fd43f312.png",
"matchLevel": "full",
"fullyHighlighted": false,
"matchedWords": [
"photo"
]
}
}
}
]
의도치않게 멤버의 이미지 파일 이름에 포함된 photo
가 검색되어 Warren
까지
검색되었다. imgUrl
의 파일이름은 이미지가 노출되는 것이지 파일이름은 UX 에
노출되지도 않으므로 일반적인 경우에 사용자가 검색 할 일도 없다. 이럴때 필요한 것이
searchableAttributes
셋팅이다. 간단한 코드이므로 nodejs REPL
을 열어 셋팅해보자.
$ node
> const algoliasearch = require('algoliasearch');
> const client = algoliasearch(process.env.ALGOLIA_APP_ID, process.env.ALGOLIA_ADMIN_API_KEY);
> const index = client.initIndex(process.env.ALGOLIA_INDEX_NAME);
> index.setSettings({ searchableAttributes: ["firstname", "lastname", "hobby"] }).then( console.log )
Promise {
<pending>,
domain:
Domain {
domain: null,
_events: { error: [Function: debugDomainError] },
_eventsCount: 1,
_maxListeners: undefined,
members: [] } }
> { updatedAt: '2018-05-07T13:21:15.797Z', taskID: 7676731361 }
Promise 패턴을 API 이므로 then
메소드를 사용하여 결과를 출력하였다. 참고로
다른 API 들도 Callback 패턴과 Promise 패턴 모두 지원하므로 편한 것을 사용하여
코딩을 할 수 있다. 자 이제 다시 검색해보자.
$ node search photo
[
{
"firstname": "Jimmie",
"lastname": "Barninger",
"hobby": "photo",
"imgUrl": "photo-8b137455b.png",
"objectID": "7447381",
"_highlightResult": {
"firstname": { ...skip... },
"lastname": { ...skip... },
"hobby": {
"value": "<em>photo</em>",
"matchLevel": "full",
"fullyHighlighted": true,
"matchedWords": [
"photo"
]
}
}
}
]
imgUrl
attribute 가 검색대상에서 제외되어 의도한 대로 Jimmie
만 결과로
내려온 것을 확인 할 수 있다.
간단한 API 사용을 알아보았다. 이것들을 응용해서 블로그의 콘텐츠를 모두 object 로
변환하여 올리는 코드를 작성 하여 할 수도 있겠지만 이 또한 간단치 않은 일이 될 수
있다. 다행히 gatsby 를 사용하면 누군가 잘 만들어 놓은 것을 쓸 수가 있는데,
gatsby 플러그인 gatsby-plugin-algolia 을 사용하면 gatsby build
수행시마다 자동으로 graphQL 의 결과를 Algolia object
로 변환 후 addObjects
를 해준다.
지난 포스트에 한 것 처럼 basic-starter
로 프로젝트를 하나 생성하여 진행해보자.
$ gatsby new basic-blog https://github.com/gatsbyjs/gatsby-starter-blog
$ cd basic-blog
$ yarn add gatsby-plugin-algolia
gatsby-config.js
파일에 gatsby-plugin-algolia
관련 코드를 추가해보자:
...
plugins: [
+ {
+ resolve: `gatsby-plugin-algolia`,
+ options: {
+ appId: process.env.ALGOLIA_APP_ID || "",
+ apiKey: process.env.ALGOLIA_ADMIN_API_KEY || "",
+ indexName: process.env.ALGOLIA_INDEX_NAME || "",
+ queries: [{ query, transformer ],
+ },
+ },
{
resolve: `gatsby-source-filesystem`,
...
query
는 algoria object 를 만들기위해 사용할 데이터를 반환받을 수 있는
graphQL
query 이고 transformer
에서는 query 의 결과물을 인자로 받아 algoria
object 로 한번더 가공하여 반환해주는 함수이다. 예제를 위해 가장 간단하게
만들어보자:
const query = `{
allMarkdownRemark {
edges {
node {
objectID: id,
fields {
slug
}
internal {
content
}
frontmatter {
title
}
}
}
}
}`;
const transformer = ({ data }) => {
return data.allMarkdownRemark.edges.map(({ node }) => node);
};
환경변수 셋팅을 위해 위에서 사용한 환경변수에 ALGOLIA_INDEX_NAME
만
변경해서 사용하자:
export ALGOLIA_INDEX_NAME=basic_blog
export ALGOLIA_APP_ID=VXXXXXXXXI
export ALGOLIA_SEARCH_ONLY_API_KEY=f2xxxxxxxxxxxxxxxxxxxxxxxxxxxx87
export ALGOLIA_ADMIN_API_KEY=7dxxxxxxxxxxxxxxxxxxxxxxxxxxxx9c
빌드하게되면 자동으로 allMarkdownRemark
query 로 반환받는 모든 페이지의 내용을
algoria basic_blog
Index 에 추가한다.
$ gatsby build
Dashboard 에 가서 살펴보면 스타터에 기본으로 포함되어 있는 샘플 포스트들의 내용이 잘 올라간 것을 확인 할 수 있다.
object 추가가 잘 되었으면 검색 UI 를 만들어보자. 위와 같이 algoliasearch
SDK
의 search
를 사용하여 직접 구현 할 수도 있겠지만 algolia 에서 오픈소스로
관리하는 react-instantsearch 를 사용할 수 도 있다.
커밋수, 메이저 버전넘버, 최근 활동, 가이드의 퀄리티 등을 따져보았을때 손색이
없을 정도로 매력적인 프로젝트여서 주저 할 이유가 없었다.
$ yarn add react-instantsearch
설치하고 아래와 같은 코드를 적절한 곳에 넣어주자
import { InstantSearch, Hits, SearchBox } from 'react-instantsearch/dom';
<InstantSearch
appId={algolia.APP_ID}
apiKey={algolia.SEARCH_ONLY_API_KEY}
indexName={algolia.INDEX_NAME}
>
<SearchBox />
<Hits hitComponent={Hit} />
</InstantSearch>
검색 결과로 algoria object
가 검색 결과에 매칭되는 algoria object 는 이라는
응답 json 의 hit
이라는 attribute 로 내려온다. hitComponent
는 바로 이
hit
을 렌더해주는 컴포넌트이고 직접 다음과 같이 구현 할 수 있다.
const Hit = ({ hit }) =>
<div>
<div>{hit.frontmatter.title}</div>
<a href={hit.fields.slug}>{hit.fields.slug}</a>
</div>
algoria 와 함께 고려했던 다른 솔루션은 아래와 같다.
google custom search engine 과 lunarjs 이다. google custom search engine 은 설명이 필요없는 구글의 검색엔진을 이용하는 것이다.
Like Solr, but much smaller, and not as bright.
[lunrjs][lunrjs] 는 Solr 와 비슷하나 좀 더 작고 덜 밝다고 소개하고 있고 elasticlunr 은 lunarjs 베이스로 좀 더 elastic 하고 customize 할 수 있게 확장시킨 프로젝트이다.
Elasticlunr.js is a bit like Solr, but much smaller and not as bright, but also provide flexible configuration, query-time boosting, field search and other features.
모든 인덱스 데이터를 브라우저에 로드하는 방식
마지막으로 algoria 의 장단점을 살펴보자.
managed 서비스에서 유료는 당연한 것이니 단점으로 적지 않았다.