#306 ElasticSearch Part 1
- Download:
- source codeProject Files in Zip (83.4 KB)
- mp4Full Size H.264 Video (31.6 MB)
- m4vSmaller H.264 Video (16.1 MB)
- webmFull Size VP8 Video (14.5 MB)
- ogvFull Size Theora Video (44.2 MB)
글 리스트를 보여주는 레일스 어플리케이션의 한 웹페이지가 아래에 있습니다. 이 페이지에 검색 기능을 추가하고자 합니다. 이 글들은 텍스트 기반으로 되어 있기 때문에, SQL 쿼리문 대신에 풀텍스트 검색 엔진을 사용할 것입니다.
이전 연재에서 이 주제를 다룬 바 있습니다. 120번 연재에서는 Thinking Sphinx를 사용하였고 278번 연재에서는 Solr와 함께 Sunspot을 사용하였습니다. 이 연재에서는 Elasticsearch를 사용해서 어플리케이션에 풀텍스트 검색 기능을 추가할 것입니다.
Elasticsearch는 Solr와 매우 흡사하게 아파치 루씬(Apach Lucene) 위에 빌드된 완벽한 검색 엔진입니다. REST API를 가지고 있어서 JSON 형태로 데이터를 주고 받습니다. Elasticsearch는 루비 전용이 아니기 때문에 Tire라는 젬을 이용하여 Elasticsearch와 연결할 것입니다. Tire는 모든 루비 프로젝트에서 사용할 수 있지만, 몇가지 멋진 모델 기능을 가지고 있어서 쉽게 레일스 어플리케이션으로 통합될 수 있습니다. 처음부터 Elasticsearch로 프로젝트를 생성할 수 있도록 레일스 템플릿도 제공해 줍니다.
$ rails new searchapp -m https://raw.github.com/karmi/tire/master/examples/rails-application-template.rb
이 명령으로 새로운 레일스 어플리케이션이 생성되고 Elasticsearch와 Tire가 셋팅될 것입니다. 이 때 사용한 템플릿이 실행 중인 Elasticsearch를 발견하지 못하면 자동으로 다운로드한 후 어플리케이션에 맞게 셋팅해 줍니다. 위의 명령이 실행된 후 http://localhost:3000/
를 방문하면 기본 어플리케이션을 볼 수 있는데, Elasticsearch를 이용해서 레코드를 검색할 수 있습니다. 템플릿 소스코드는 레일스 어플리케이션 템플릿으로 할 수 있는 것에 대한 좋은 예제가 될 수 있기 때문에 살펴 볼만 합니다.
어플리케이션에 Elasticsearch 추가하기
예제 어플리케이션의 동작이 흥미롭지만 실제 어플리케이션에서 Elasticsearch를 어떻게 사용할까요? 첫번째 단계는 Elasticsearch를 설치하는 것입니다. OS X에 Homebrew가 실행되고 있다면, 간단하게 설치할 수 있습니다. 그렇지 않을 경우에는 Elasticsearch 웹사이트에 다운로드하는 방법이 자세히 설명되어 있습니다.
$ brew install elasticsearch
Elasticsearch 설치가 완료되면 실행하는 방법에 대한 설명을 볼 수 있습니다. 아래의 명령으로 실행할 수 있습니다(이것은 각자의 Elasticsearch 버전에 따라 다를 수 있습니다).
$ elasticsearch -f -D es.config=/usr/local/Cellar/elasticsearch/0.18.1/config/elasticsearch.yml
이 명령은 9200 포트에서 서버를 실행하며 원할 경우 JSON REST API를 이용해서 직접 서버와 데이터 통신을 할 수 있습니다. 그러나 Tire를 이용할 것이기 때문에 다음에 해야할 것은 Tire를 설치하는 것입니다. 일반적인 방법과 같이 어플리케이션의 gemfile에 이 젬을 추가하고 bundle
명령를 실행합니다.
source 'http://rubygems.org' gem 'rails', '3.1.3' # Bundle edge Rails instead: # gem 'rails', :git => 'git://github.com/rails/rails.git' gem 'sqlite3' # Gems used only for assets and not required # in production environments by default. group :assets do gem 'sass-rails', '~> 3.1.4' gem 'coffee-rails', '~> 3.1.1' gem 'uglifier', '>= 1.0.3' end gem 'jquery-rails' gem 'tire'
두개의 모듈만 인클루드하므로써 검색을 원하는 모든 모델에 Tire를 추가할 수 있습니다. 글을 검색할 것이기 때문에 어플리케이션의 Article
모델에 이 두 모듈을 추가할 것입니다.
class Article < ActiveRecord::Base belongs_to :author has_many :comments include Tire::Model::Search include Tire::Model::Callbacks end
이 모듈 중의 첫번째 것은 다양한 검색 및 인덱싱 메소드를 추가해 주는 반면, 두번째 모듈은 콜백 메소드를 추가해 주는데, 글이 생성되거나 업데이트 또는 삭제될 때 인덱스가 자동으로 업데이트되도록 해 줍니다.
이미 어플리케이션의 데이터베이스에는 글이 몇개 저장되어 있는데 이것들은 인덱스에 포함되어 있지 않을 것입니다. 그러나, 모든 레코드가 어플리케이션의 seed 파일에 정의되어 있기 때문에 다시 셋업 파일을 실행하면 다시 로드할 때 레코드가 자동으로 인덱싱될 것입니다.
$ rake db:setup
검색 폼 추가하기
이제 글이 모두 인덱싱되었기 때문에 글이 있는 페이지에 검색을 위한 폼을 추가하겠습니다. 이 페이지의 템플릿은 아래와 같습니다.
<h1>Articles</h1> <div id="articles"> <% @articles.each do |article| %> <h2> <%= link_to article.name, article %> <span class="comments">(<%= pluralize(article.comments.size, 'comment') %>)</span> </h2> <div class="info"> by <%= article.author.name %> on <%= article.published_at.strftime('%b %d, %Y') %> </div> <div class="content"><%= article.content %></div> <% end %> </div>
위 페이지의 헤더 아래에 아래와 같은 간단한 폼을 추가합니다. 이 폼은 폼이 위치하는 동일한 글(article) 페이지로 GET 메소드를 이용하여 서밋할 것입니다.
<%= form_tag articles_path, method: :get do %> <p> <%= text_field_tag :query, params[:query] %> <%= submit_tag "Search", name: nil %> </p> <% end %>
검색 폼을 서밋하면, ArticlesController
의 index
액션이 호출되고 모든 글을 반환하게 됩니다. 이 때 파라미터를 점검하는 코드를 추가하여 폼으로부터 넘겨 받는 query
파라미터가 존재할 경우 Tire의 search
메소드가 대신 호출되도록 합니다.
def index if params[:query].present? @articles = Article.search(params[:query]) else @articles = Article.all end end
이제 페이지를 다시 로드하면 검색 폼이 보이게 될 것입니다. 그러나 검색어를 입력하고 폼을 서밋할 경우 에러가 발생할 것입니다.
이 에러는 각 글마다 해당 글에 연결된 댓글의 갯수를 나타내기 위해서 article.comments.size
를 호출하기 때문에 발생하는데 아마도 Tire가 반환하는 글에 대해서 관계선언이 제대로 동작하지 않는 것 같습니다.
Tail(?)은 데이터베이스 접근을 최소화하려고 하는데 Article.search
를 호출할 때 반환되는 것은 실제 액티브레코드 모델이 아니라 검색 인덱스에 저장된 것에 근거한 속성들과 함께 Tire로부터 찾은 결과 셋입니다. 인덱스는 댓글 관계선언을 알지 못하기 때문에 어떻게 셋업해야 할지 모릅니다. 이러한 문제를 해결하기 위해서, load
옵션을 저정한 후 search
메소드를 호출하면 Tire가 데이터베이스로부터 실제 레코드를 로드하도록 합니다.
def index if params[:query].present? @articles = Article.search(params[:query], load: true) else @articles = Article.all end end
이제 검색을 할 때 페이지는 정확한 결과를 로드해서 보여주게 됩니다.
필요로하는 모든 데이터가 검색 인덱스에 포함되어 있는 것이 더 좋은 것인데 이 경우 데이터베이스로부터 레코드를 가져오기 위해서 load: true
를 사용할 필요가 없게 됩니다. 이렇게 할 수 있지만 여기서는 이것에 대해서 다루지 않을 것입니다. 대신에 다음 연재에서 이것에 대해서 보여 줄 것입니다. 다음에 보여줄 것은 옵션을 추가해서 쿼리를 더 다양하게 하는 방법입니다. 이것은 Article
에서 search
모델을 재정의하여 가능하게 되는데 사용자가 넘겨 준 params 해시를 이용하여 재정의하면 됩니다.
def self.search(params) tire.search(load: true) do query { string params[:query]} if params[:query].present? end end
Tire의 search
메소드를 오버라이드할 때 tire.search
를 이용하여 오버라이드된 메소드를 호출하게 되는데, 이 때 실제 모델을 가져오기를 원하기 때문에 load: true
옵션을 사용했습니다. 이 메소드에 검색 파라미터를 직접 넘겨 주는 대신에 블록을 이용하면 더 많은 옵션을 이용하여 쿼리를 변경할 수 있습니다. 이 블록에서 query
를 호출하여 또 다른 블록을 넘겨 줍니다. 이 블록에서는 파라미터가 존재할 경우에만 string
메소드에 파라미터를 넘겨주게 됩니다.
이제 ArticlesController
를 간단하게 할 수 있는데 재정의한 search
메소드를 호출하여 params 해시를 넘겨주면 됩니다.
def index @articles = Article.search(params) end
이제 페이지를 다시 로드하더라도 여전히 동작하게 되고 검색 창을 지우고 “Search”를 클릭하면 모든 글이 반환되는 것을 보게 될 것입니다.
글 중에는 게재일자가 경과되지 않아 포함되어서는 안되는 글이 있습니다. 그래서 search 메소드를 변경해서 아직 게재하지 않은 글은 보여주지 않도록 할 것입니다. 이를 위해서 모델의 search
블록에 필터를 추가할 필요가 있습니다.
def self.search(params) tire.search(load: true) do query { string params[:query]} if params[:query].present? filter :range, published_at: {lte: Time.zone.now } end end
첫번째 인수는 필터의 유형인데, 이 경우에는 range
필터가 됩니다. 다음은, 필터하고자 하는 속성을 포함하는 해시를 넘겨 줍니다. 이 경우에는 published_at
로 필터링하게 되는데 현재 시간보다 이전이거나 동일한 published_at
시간을 가지는 글들만을 포함하게 됩니다.
검색 기능을 다양하게 하기 위해서 넘겨 줄 수 있는 다른 옵션들은 어떤 것이 있는지 궁금할 것입니다. 이 주제에 대한 몇가지 문서가 있지만 여기저기 흩어져 있습니다. 시작하기에 좋은 곳은 Tire의 README 파일인데, 동적으로 매핑되기 때문에 신경쓸 필요가 없는 인덱싱과 매핑에 대한 내용이 시작 부분에서 있기 때문에 다소 혼란스러울 수 있습니다. Tire가 제공하는 또 다른 문서가 있는데 읽어 볼 만 합니다.
Tire의 옵션 대부분은 Elasticsearch와 일대일로 매핑되는데 해당 문서를 살펴보는 것이 좋을 것입니다. Query DSL에 관한 페이지는 전체 섹션을 할애해서 필터링에 대해서 설명하는데 앞서 사용했던 range 필터에 대한 내용이 포함되어 있으며 사용할 수 있는 모든 옵션 목록이 기술되어 있습니다. 코드 조작들이 JSON 형태로 작성되어 있지만 Tire에서 사용하도록 변환하기 쉽게 되어 있습니다.