티스토리 뷰

저는 검.알.못입니다. 검색에 대해서는 잘 알지 못하죠.

하지만 Lucene을 이용한 검색엔진 개발에 있어 Spring Boot와 Hibernate Search를 이용하면 매우 간단하게 구현이 가능하다라는걸 알게되었고 (물론 결과물도 매우 간단했음..) 좋은 경험이라고 생각되어 공유하고자 합니다.


이 글은 Spring Boot와 Spring Data JPA or Hibernate에 대한 기본지식이 있다는 전제하에 작성한 글입니다. 

해당 기술들에 대해서는 인터넷에 많은 예제와 이론들이 있기 때문에 시작에 앞서 충분히 습득 후 보는것을 권장드립니다.





Lucene 로고 (출저 : https://lucene.apache.org)


Lucene 이란?


Lucene은 자바로 개발된 확장 가능한 고성능 오픈 소스 정보 검색(IR, Information Retrieval) 라이브러리입니다.


주로 텍스트 인덱싱과 검색에 중점을 두고 있고, 이메일 검색, 온라인 문서 검색, 웹페이지 검색, 데이터베이스 검색 등 다양한 응용 프로그램에서 검색기능 구현이 가능하죠.


간단히 설명하자면 "데이터를 빠르게 검색할 수 있는 형식으로 변환하는 과정인 텍스트 인덱싱과 인덱싱 된 데이터를 검색하는 기능을 제공하는 라이브러리" 라고 알면 좋을 것 같습니다.


더이상의 설명으로 글이 지루해지는것을 방지하기 위해 바로 예제를 통한 기능구현으로 들어가보도록 하겠습니다.




사용기술


1. Java 1.8+

2. Spring Boot 1.5.1.RELEASE

3. Spring Data JPA

4. Hibernate Search 5.5.6 Final

5. H2




Project Structure


Maven 프로젝트의 표준구조입니다.





Project Dependency


pom.xml


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
 
    <groupId>com.hihoyeho.luceneexample</groupId>
    <artifactId>luceneexample</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <packaging>jar</packaging>
 
    <name>LuceneExample</name>
    <description>Spring Boot + Hibernate Search + Lucene</description>
 
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>1.5.1.RELEASE</version>
        <relativePath/<!-- lookup parent from repository -->
    </parent>
 
    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <java.version>1.8</java.version>
        <org.hibernate.version>5.5.6.Final</org.hibernate.version>
    </properties>
 
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
 
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.hibernate</groupId>
            <artifactId>hibernate-search-orm</artifactId>
            <version>${org.hibernate.version}</version>
        </dependency>
 
    </dependencies>
 
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
 
 
</project>



Model


이번 예제에서는 JTBC 뉴스룸 RSS 데이터를 데이터베이스에 넣고 검색하는 예제를 간단히 구현해보도록 하겠습니다.

먼저 뉴스룸 RSS 데이터 구조에 맞춰 엔티티를 생성하겠습니다.


(뉴스룸 RSS 주소 : http://fs.jtbc.joins.com//RSS/newsroom.xml)


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import org.hibernate.search.annotations.*;
import javax.persistence.*;
 
@Entity(name = "newsroom")
@Indexed
public class Newsroom {
 
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(name = "newsroom_no")
    private Integer newsroomNo; //뉴스룸 고유번호
 
    @Column(name = "link")
    private String link; //링크
 
    @Column(name = "title")
    @Field
    private String title; //제목
 
    @Column(name = "content")
    @Field
    private String content; //본문
 
    //getter setter here
    ....
}



Hibernate Search에서는 텍스트 인덱싱을 위해 다음과 같은 어노테이션을 제공합니다.


@Indexed


인덱싱 해야 할 엔티티를 명시해줍니다.


@Field


색인화할 필드를 명시해줍니다.

추가로 옵션을 제어하여 인덱싱의 결과 제어가 가능합니다만 이번 예제에서는 @Field 선언만 하도록 하겠습니다.




Indexing


기본적으로 인덱싱 작업은 Application이 로드 되는 시점에서 자동으로 이루어집니다.


다만 데이터베이스 데이터가 변경되는 경우 기존에 생성 된 인덱스와 데이터베이스의 동기화 작업이 이루어져야 하는데

FullTextEntityManager를 통해 인덱스를 재생성 할 수 있습니다.


SearchService를 만들어서 인덱스를 재생성하는 기능을 가진 함수를 만들어줍니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import org.hibernate.search.jpa.FullTextEntityManager;
import org.hibernate.search.jpa.Search;
import org.springframework.stereotype.Service;
 
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
 
@Service
public class SearchService {
 
    @PersistenceContext(type = PersistenceContextType.EXTENDED)
    private EntityManager entityManager;
 
    /**
     * 인덱스 재생성
     * @throws InterruptedException
     */
    public void buildSearchIndex() throws InterruptedException {
        FullTextEntityManager fullTextEntityManager = Search.getFullTextEntityManager(entityManager);
        fullTextEntityManager.createIndexer().startAndWait();
    }
 
}



인덱스 기본경로 변경


인덱스가 저장되는 기본 경로는 설정으로 변경 가능합니다. 

application.yml에 spring.jpa.properties.hibernate.search.default.indexBase를 추가하고 경로를 지정해줍니다.


1
2
3
4
5
6
7
8
spring:
  jpa:
    properties:
      hibernate:
        search:
          default:
            directory_provider: filesystem
            indexBase: C:/Temp



Searching


이제 생성 된 인덱스를 검색해보도록 하겠습니다.


검색 방법에는 가장 기본적인 Keyword Queries (키워드로 검색하는 방법)를 사용해보도록 하겠습니다.


Step1. 먼저 FullTextEntityManager (JPA)와 QueryBuilder 객체를 생성합니다.

QueryBuilder 생성시 forEntity()에 @Indexed로 선언 된 클래스를 전달합니다.


1
2
3
4
FullTextEntityManager fullTextEntityManager = Search.getFullTextEntityManager(entityManager);
QueryBuilder queryBuilder = fullTextEntityManager.getSearchFactory().buildQueryBuilder()
    .forEntity(Newsroom.class)
    .get();



Step2. Hibernate Query DSL을 통해 Lucene Query를 생성합니다.


1
2
3
4
Query query = queryBuilder.keyword()
                .onFields("title""content")
                .matching(keyword)
                .createQuery();


onFileds()에 Entity에서 @Field로 선언한 변수명들을 넣어주도록 하고,

matching()의 매개변수로 검색할 키워드를 넣어줍니다.



Step3. Lucene Query를 Hibernate FullTextQuery로 변환시키고, 쿼리를 실행시킨 후 결과를 얻어옵니다.


1
2
FullTextQuery fullTextQuery = fullTextEntityManager.createFullTextQuery(query, Newsroom.class);
List<Newsroom> newsrooms = (List<Newsroom>) fullTextQuery.getResultList();



Note : Lucene은 키워드와의 관련성을 기준으로 결과값을 정렬하여줍니다.



위 과정을 함수로 만들어 SearchService에 구현해보도록 하겠습니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
 * 뉴스룸 검색
 * @param keyword 검색 키워드
 * @return 뉴스룸 목록
 */
public List<Newsroom> searchNewsroom(String keyword) {
    FullTextEntityManager fullTextEntityManager = Search.getFullTextEntityManager(entityManager);
    QueryBuilder queryBuilder = fullTextEntityManager.getSearchFactory().buildQueryBuilder()
        .forEntity(Newsroom.class)
        .get();
    Query query = queryBuilder.keyword()
        .onFields("title""content")
        .matching(keyword)
        .createQuery();
    FullTextQuery fullTextQuery = fullTextEntityManager.createFullTextQuery(query, Newsroom.class);
    List<Newsroom> newsrooms = (List<Newsroom>) fullTextQuery.getResultList();
    return newsrooms;
}



Keyword Queries 외에도 Fuzzy Queries, Wildcard Queries, Phrase Queries 등 Lucene에서 제공하는 다양한 방법이 존재하고 있기 때문에 요구사항에 맞춰 적절하게 사용하면 될 것 같습니다.




한글 검색


여기까지 구현하면 가장 기본적인 검색기능이 완료가 됩니다.

다만 저희가 구현하고자 하는 기능은 영문검색이 아닌 한글검색입니다.


이를 위해서 한글 처리를 위한 Analyzer가 추가되어야 하는데

다행히 수명님께서 Lucene Korean Analyzer를 개발하시고 또한 오픈소스로 제공하고 있습니다.


Note : Lucene Korean Analyzer 프로젝트의 공식명칭은 Arirang입니다.


※ 루씬 한글분석기 오픈소스 프로젝트 : http://cafe.naver.com/korlucene

※ Github : https://github.com/korlucene



Lucene Analyzer에 대한 기본 구성 및 동작 방식, 그리고 Lucene Korean Analyzer의 프로젝트 구성에 대해서는 이 글에서 다루는 것보다 "Elasticsearch에서 아리랑 한글 분석기 사용하기" 문서의 앞부분을 정독해보는것을 추천드립니다.


여기서는 해당 오픈소스를 이용해 Hibernate Search에 어떻게 적용하는지에 대해 다루도록 하겠습니다.


그럼 arirang.lucene-analyzer-xxx.jar와 arirang-morph-xxx.jar 파일을 각각 다운로드 받아 프로젝트에 import 시켜주도록 합니다. 


이제 모든 준비는 마쳤습니다.

인덱싱(Indexing)할 Entity에 Korean Analyzer를 적용시켜보도록 하죠.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import org.apache.lucene.analysis.ko.KoreanFilterFactory;
import org.apache.lucene.analysis.ko.KoreanTokenizerFactory;
import org.hibernate.search.annotations.*;
 
import javax.persistence.*;
 
@Entity(name = "newsroom")
@Indexed
@AnalyzerDef(name = "koreanAnalyzer"
        , tokenizer = @TokenizerDef(factory = KoreanTokenizerFactory.class)
        , filters = { @TokenFilterDef(factory = KoreanFilterFactory.class)})
public class Newsroom {
 
    ...
 
    @Column(name = "title")
    @Field
    @Analyzer(definition = "koreanAnalyzer")
    private String title; //제목
 
    @Column(name = "content")
    @Field
    @Analyzer(definition = "koreanAnalyzer")
    private String content; //본문
 
    //getter setter here
}



@AnalyzerDef


tokenizer는 텍스트를 개별 단위의 token으로 분리하는 역활을 하는데 KoreanTokenizerFactory.class를 선언해줍니다.

filter에는 기본필터인 KoreanFilterFactory.class만 선언해보도록 하겠습니다.


@Analyzer


적용 할 @AnalyzerDef의 name을 정의해줍니다.




테스트


마지막으로 지금까지 구현해본 기능들을 테스트해보도록 하겠습니다.

테스트 도구에는 Spring Boot에서 어플리케이션을 테스트 하기 위해 제공하는 Spring Boot Test를 사용하도록 하겠습니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import com.hihoyeho.luceneexample.domain.Newsroom;
import com.hihoyeho.luceneexample.repository.NewsroomRepository;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import java.util.List;
 
@RunWith(SpringRunner.class)
@SpringBootTest
public class SearchServiceTest {
 
    final static Logger log = LoggerFactory.getLogger(SearchServiceTest.class);
 
    @Autowired
    private NewsroomRepository newsroomRepository;
 
    @Autowired
    private SearchService searchService;
 
    @Before
    public void create() throws InterruptedException {
        Newsroom newsroom = new Newsroom();
        newsroom.setTitle("5월 14일 (월) 뉴스룸 다시보기 2부");
        newsroom.setLink("http://news.jtbc.joins.com/article/article.aspx?news_id=NB11634630");
        newsroom.setContent("뉴스룸의 앵커브리핑을 시작하겠습니다.소리가 없는 것은 차라리 다행이었습니다.38년 만에 소개된 미공개 영상.흑백의 화면만이 남아있을 뿐.음향이 담겨있지 않은 그 영상 속에는 모진 그날을 견뎌낸 5월, 광주의");
        newsroomRepository.save(newsroom);
 
        ..............
        ..............
 
        newsroom = new Newsroom();
        newsroom.setTitle("[팩트체크] 풍계리 핵실험장 폭파하면 방사능 유출?");
        newsroom.setLink("http://news.jtbc.joins.com/article/article.aspx?news_id=NB11634587");
        newsroom.setContent("[조선중앙TV (지난 12일) : 핵시험장 폐기를 투명성 있게 보여주기 위하여 국내 언론기관들은 물론 국제기자단의 현지 취재활동을 허용할 용의가 있다. 중국, 러시아, 미국, 영국, 남조선에서 오는 기자들로 한정시");
        newsroomRepository.save(newsroom);
 
        searchService.buildSearchIndex();
    }
 
    @Test
    public void searchNewsroomTest() {
        String keyword = "미세먼지";
 
        List<Newsroom> newsrooms = searchService.searchNewsroom(keyword);
        for(Newsroom newsroom : newsrooms) {
            log.info("\n제목 : {}\n링크 : {}\n내용 : {} \n---------------------------------\n"
                    , newsroom.getTitle()
                    , newsroom.getLink()
                    , newsroom.getContent());
        }
    }
 
}


먼저 @Before에 데이터베이스에 메타데이터를 insert하는 작업과 insert 후 인덱스를 생성하는 함수인 buildSearchIndex()를 호출해줍니다.


이후 @Test에서 searchNewsroom() 함수 호출을 통해 결과값을 가져와서 로그를 찍어봅니다.


1
2
3
4
5
6
7
8
9
10
2018-05-18 15:54:35.900  INFO 5788 --- [           main] c.h.l.service.SearchServiceTest          : 
제목 : 북핵·지진보다 불안한 건 '미세먼지'…사회문제보다도 높아
링크 : http://news.jtbc.joins.com/article/article.aspx?news_id=NB11634595
내용 : [앵커]오늘(14일)도 전국이 뿌연 하늘이었지요. 우리 국민들이 지진이나 북한의 핵보다도 더 불안해하는 것, 미세먼지로 나타났습니다.오효정 기자입니다. [기자][김세환/서울 상도동 : 창문 열어놓으면 먼지가 뿌 
---------------------------------
2018-05-18 15:54:35.900  INFO 5788 --- [           main] c.h.l.service.SearchServiceTest          : 
제목 : 클로징 (BGM : Five Years - Mychael Danna)
링크 : http://news.jtbc.joins.com/article/article.aspx?news_id=NB11634585
내용 : 15일 오전, 미세먼지 수치가 나쁨 수준입니다.낮 기온은 30도 안팎까지 올라 덥습니다.끝나고 < 소셜라이브 > 가 진행되겠습니다. 오늘은 싱가포르에 가있는 김태영 기자와 박현주 기자를 연결해서 싱가포르에서 소 
---------------------------------


위와 같이 한글 키워드로 검색 시 원하는 결과가 나오게 되는것을 확인 할 수 있습니다.






마치며


지금까지 예제는 Github에서 확인 가능합니다.

한글검색에 필요한 Arirang Analyzer의 jar파일의 경우 예제 소스가 있는 Github에서 함께 제공되지 않으니, 해당 프로젝트에서 다운로드 부탁드립니다.


이번 글에서는 최소한의 기능만 사용해 검색기능을 구현해 보았지만 Lucene이 제공하는 기능은 훨씬 더 다양하고 강력합니다.


Hibernate Search와 Lucene을 이용한 검색기능 구현에 대해 조금 더 심화과정으로 들어가고 싶다면 공식 레퍼런스 문서를 참조하여 구현해보는것을 추천드립니다. 제공하는 기능에 대한 설명, 설정 가능한 옵션, 심지어 아키텍쳐 설계까지.. 개발가이드가 굉장히 잘 되어있기 때문이죠.


- Hibernate Search Reference Guide : http://docs.jboss.org/hibernate/stable/search/reference/en-US/html_single/

최근에 올라온 글
Total
Today
Yesterday
링크