개요
주류 큐레이션 플랫폼 서비스 ‘술자리’ 프로젝트에서 검색 API 서버를 개발하기 위해 Spring Boot + elasticsearch의 기술 스택을 구성하였다.
셋업부터 초기 개발을 진행하는 과정에서 구글링을 진행했는데 elasticsearch의 빠른 버전 변경과 그에 따른 기술 사용 형태의 변경으로 2~3년 전 작성된 블로그의 글이, 거의 최신 버전을 사용하려는 지금 프로젝트에 바로 손쉽게 적용하기 어려웠다.
그래서 직접 이것저것 찾아다 연동과 개발을 진행하면서 혹시라도 추후의 나에게 또는 다른 사람에게 도움이 될 수 있도록 내용을 정리해보았다.
0. 버전 정보
Spring Data Elasticsearch와 elasticsearch 간의 버전 호환성을 잘 체크해야한다!
- Spring Boot v3.1.3
- Spring Data Elasticsearch v5.1.3
- elasticsearch v8.7.1
1. build.gradle
에 dependencies 추가
build.gradle
dependencies {
...
implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch'
}
spring-boot-starter-data-elasticsearch
는 spring boot와 동일한 버전으로 지정spring-boot-starter-data-elasticsearch
v3.1.3는spring-data-elasticsearch
v5.1.3와 매핑
spring-data-elasticsearch
v5.1.3는 elasticsearch v8.7.1와 매핑
2. application.yml
에 설정값 추가
application.yml
spring:
elasticsearch:
username: [elastic 유저명]
password: [패스워드]
uris:
[ip:port],
[ip:port], ...
spring.elasticsearch.uris
는 컴마(,) 분리 리스트 스트링으로 작성- 리스트 값을 컴마(,) 분리 형태로 작성하는 것은 YAML 파일의 문법!
- 한 줄로 작성해도 되고, 예시와 같이 멀티라인으로 작성해도 된다.
3. Elastic Config 세팅
ElasticsearchConfig.java
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.elasticsearch.client.ClientConfiguration;
import org.springframework.data.elasticsearch.client.elc.ElasticsearchConfiguration;
@Configuration
public class ElasticsearchConfig extends ElasticsearchConfiguration {
@Value("${spring.elasticsearch.username}")
private String username;
@Value("${spring.elasticsearch.password}")
private String password;
@Value("${spring.elasticsearch.uris}")
private String[] esHost;
@Override
public ClientConfiguration clientConfiguration() {
return ClientConfiguration.builder()
.connectedTo(esHost)
.withBasicAuth(username, password)
.build();
}
}
@Value
어노테이션으로application.yml
파일에 지정한 연결 설정 값(host, username, password)을 가져와 연결에 사용spring.elasticsearch.uris
는 컴마(,)로 구분하는 스트링 형태의 리스트로 작성할 수 있어서,String[]
자료형으로 받으면 각 스트링을 분리해서 리스트 형태로 매핑됨- elasticsearch 8.x 버전에서는 기본 authentication이 있어서
withBasicAuth
메소드로 인증 처리를 진행
4. index 매핑을 위한 document 클래스 작성
AlcoholDocument.java
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;
import org.springframework.data.elasticsearch.annotations.Setting;
import java.util.ArrayList;
import java.util.List;
@Getter
@Setter
@Document(indexName = "alcohols")
@Setting(replicas = 0)
public class AlcoholDocument {
@Id
private String id;
@Field(type = FieldType.Long, index = false, docValues = false)
private Long alcoholId;
@Field(type = FieldType.Text, analyzer = "nori")
private String title;
@Field(type = FieldType.Keyword, index = false, docValues = false)
private String category;
@Field(type = FieldType.Object)
private List<TagDocument> tags = new ArrayList<>();
@Field(type = FieldType.Object)
@WriteOnlyProperty
private List<AlcSearchKeyDocument> searchKeys = new ArrayList<>();
}
Inner Object class TagDocument
& AlcSearchKeyDocument
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;
@Getter
@NoArgsConstructor
public class TagDocument {
@Field(type = FieldType.Keyword, index = false, docValues = false)
private String title;
public TagDocument(String title) {
this.title = title;
}
}
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;
@Getter
@NoArgsConstructor
public class AlcSearchKeyDocument {
@Field(type = FieldType.Keyword, index = false, docValues = false)
private String key;
public AlcSearchKeyDocument(String key) {
this.key = key;
}
}
@Document
어노테이션- 인덱스 이름(
indexName
) 설정 - 인덱스를 자동 생성하는 옵션인
createIndex
는 디폴트 값이true
이므로 명시하지 않아도 됨
- 인덱스 이름(
@Setting
어노테이션- 인덱스의 레플리카 개수 조절 (
replicas = 0
) - 운영 시에는 노드 갯수를 늘리고 레플리카의 개수를 늘려 안정적으로 서빙할 수 있도록 해야함
- 그러나 지금은 개발 환경이니까 일단 없도록 설정
- 인덱스의 레플리카 개수 조절 (
@Field
어노테이션type
설정으로 각 필드의 타입을 매핑하고, 분석기 (analyzer
) 설정 가능- 복합적인 설정이 필요한 경우
FieldType.Object
를 달아주고 inner class를 새로 생성해서 클래스 내부에서 타입 매핑
- 복합적인 설정이 필요한 경우
- 인덱싱을 원하지 않는 필드는
index = false
적용- 인덱싱을 하지 않아도 쿼리가 가능한 필드가 있으나, 쿼리 속도는 느림
- https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-index.html
@Id
어노테이션을 특정 속성에 달아주거나, 속성명이id
인 필드 값을 생성하면 elasticsearch의_id
와 자동으로 연결_id
값과 연결되면 타입과 인덱싱이 강제되기 때문에, String 형태의id
필드를 생성해주고 DB의 id값을 담는 필드는alcoholId
로 따로 빼줌
@WriteOnlyProperty
어노테이션searchKeys
리스트는 추후 검색 조건으로 사용되지만, 검색 결과에는 띄우지 않는 속성- 따라서 어노테이션을 달아 인덱싱할 때는 넣어주고, 추후 검색 결과를 불러올 때는 값을 가져오지 않음
5. 생성한 Document에 매핑되는 repository 선언
AlcoholElasticsearchRepository.java
import com.merseongsanghoe.sooljarisearchengine.domain.AlcoholDocument;
import org.springframework.data.elasticsearch.core.SearchHits;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
public interface AlcoholElasticsearchRepository extends ElasticsearchRepository<AlcoholDocument, String> {
SearchHits<AlcoholDocument> findByTitle(String title);
}
- JPA repository와 유사하게 선언
ElasticsearchRepository<>
인터페이스를 상속하는 인터페이스 선언
- 지금 단계에서는
title
타겟으로만 검색하므로findByTitle()
메소드 정의 - 결과 값은
SearchHits<T>
객체를 활용하여 총 결과 개수를 받아올 수 있음- 옵션으로는
List<T>
,Stream<T>
,List<SearchHit<T>>
,Stream<SearchHit<T>>
,SearchPage<T>
등 사용 가능
- 옵션으로는
6. 검색하거나 인덱싱하는 비즈니스 코드를 담은 service 구현
import com.merseongsanghoe.sooljarisearchengine.DAO.AlcoholElasticsearchRepository;
import com.merseongsanghoe.sooljarisearchengine.DAO.AlcoholRepository;
import com.merseongsanghoe.sooljarisearchengine.DTO.AlcoholDTO;
import com.merseongsanghoe.sooljarisearchengine.domain.*;
import lombok.RequiredArgsConstructor;
import org.springframework.data.elasticsearch.core.SearchHit;
import org.springframework.data.elasticsearch.core.SearchHits;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class AlcoholService {
private final AlcoholRepository alcoholRepository;
private final AlcoholElasticsearchRepository alcoholElasticsearchRepository;
/**
* title 검색어를 활용해 title 필드 타겟으로 elasticsearch에 검색 쿼리
* @param title title 필드 타겟의 검색어
* @return responseBody에 바로 적재할 수 있는 형태의 Map 객체
*/
public Map<String, Object> searchTitle(String title) {
// elasticsearch 검색
SearchHits<AlcoholDocument> searchHits = alcoholElasticsearchRepository.findByTitle(title);
// 리턴할 결과 Map 객체
Map<String, Object> result = new HashMap<>();
// 결과 개수
result.put("count", searchHits.getTotalHits());
// 결과 컨텐츠
List<AlcoholDTO> alcoholDTOList = new ArrayList<>();
for(SearchHit<AlcoholDocument> hit : searchHits) {
// from AlcoholDocument to AlcoholDTO 변환
}
result.put("data", alcoholDTOList);
return result;
}
/**
* 데이터베이스 속 alcohol 데이터 전부 elasticsearch에 인덱싱
*/
@Transactional(readOnly = true)
public void indexAll() {
List<Alcohol> alcohols = alcoholRepository.findAllWithSearchKeys();
for (Alcohol alcohol : alcohols) {
// alcohol 엔티티를 인덱싱할 형태로 변환
AlcoholDocument alcoholDocument = new AlcoholDocument();
...
alcoholElasticsearchRepository.save(alcoholDocument);
}
}
}
alcoholElasticsearchRepository.findByTitle()
메소드로 엘라스틱서치에 검색 쿼리alcoholElasticsearchRepository.save(document)
메소드로 엘라스틱서치에 도큐먼트 인덱싱- 데이터 하나씩 인덱싱하는 방식이라 전체 인덱싱 성능 이슈 발생 가능
- 추후 한 번에 많은 데이터를 효과적으로 인덱싱하는 방식 적용 필요