과제
진행 중인 프로젝트에서 검색 기능 구현 과제를 맡게 되었다. 키워드(ex. 헤어)를 인풋으로 받아 요청하면, 키워드가 포함된 포트폴리오 리스트들을 반환받아야 한다. 단순히 MySQL의 LIKE, OR 문법을 사용하여 구현할 수 있지만, OpenSearch(ElasticSearch) 검색엔진을 사용하게 되면 역인덱싱이 가능하기에 훨씬 빠르게 검색 기록을 가져올 수 있다. ElasticSearch(OpenSearch)의 역인덱싱 로직에 대한 설명은 이전 블로그 글에서 참고할 수 있다.
AWS OpenSearch vs ElasticSearch
AWS OpenSearch는 ElasticSearch를 기반으로 만들어진 툴이다. ElasticSearch와 OpenSearch의 역사(?)를 알아보면 재미있는 내용들이 많이 나오는데, 결론적으로 설명하면 ElasticSearch는 기존에 모두가 사용할 수 있는 오픈소스 였으나, 2021년 v7.10부터 SSPL로 전환하면서 이 ElasticSearch를 마음대로 가져다가 사용하는 것이 허용되지 않게 되었다. 그 이후 AWS는 ElasticSearch의 v7.10 에 자체적인 여러 기능들을 붙여 서비스하게 되었고, 이것이 AWS OpenSearch이다.
나는 ElasticSearch가 아닌 OpenSearch를 선택해 사용하게 되었다. 물론 ElasticSearch 가 레퍼런스가 더 많고, 사용해본 경험도 있으나, 현재 배포방식이 Beanstalk 속에 jar 파일로 배포되어있기 때문에 Docker 환경에 불리하고, Docker 환경으로 바꿔 배포한다고 하더라도 무중단 배포(Rolling배포 방식) 시 EC2가 기존에 한 개라 EC2 서버를 또 새로 만드는 BlueGreen 방식으로 동작하기 때문에 Docker 컨테이너간의 동기화 이슈가 있다고 한다.. 여기에서 이 모든 문제들을 엿볼 수 있었다.
따라서 AWS ElasticBeanstalk과 호환도 잘 되는 AWS 자체 검색 엔진인 OpenSearch를 사용해보기로 하였다.
ElasticSearch 공식 문서에 실제로 위와 같이 적혀 있다. 너무 OpenSearch 질문을 많이 받아서 그런가..ㅋㅋ싶기도 하지만 나도 실제로 제대로 알아보기 전에 이 둘이 너무 헷갈렸다.. 구현방법도 다르니 혼동하지 말자 ! 특히나 OpenSearch의 경우 레퍼런스가 정말 없으므로 꼭 OpenSearch 사이트 를 같이 보면서 팩트 체크 하면서 구현하길..
구현 과정
opensearch-java User Guide 와 Java Client for Opensearch 문서 를 참고하여 구현하였다. Deprecated된 것들(ex. RestClientTransport) 도 있으니 문서를 한 번 쭉 잘 읽어보는 것을 추천한다. 또한 Springboot 버전에 따라 호환되는 FrameWork 버전들이 다르니 이 또한 확인해야 한다. 꼭..! 꼭 잘 확인하세요 Spring Data OpenSearch와 OpenSearch Client 중에 구현 방법을 고민하다가 포맷이 좀 더 직관적으로 와닿는 OpenSearch Client를 사용하였다.
build.gradle 의존성
우선 build.gradle 에 OpenSearch 관련 의존성을 추가해준다.
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.0'
id 'io.spring.dependency-management' version '1.1.5'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
repositories {
mavenCentral()
}
dependencies {
//OpenSearch 의존성 추가
implementation("org.opensearch.client:opensearch-rest-client:2.11.0")
implementation("org.opensearch.client:opensearch-java:2.7.0")
implementation("jakarta.json:jakarta.json-api")
...
OpenSearchConfig
이후 OpenSearchConfig를 설정해준다. org.springframework.web.client 패키지 하위의 RestClient.Builder를 사용하여 외부 서비스(AWS OpenSearch)와의 통신을 허용해준다.
@Configuration
public class OpenSearchConfig {
@Value("${spring.elasticsearch.uris}")
private String host;
@Value("${spring.elasticsearch.username}")
private String username;
@Value("${spring.elasticsearch.password}")
private String password;
@Bean
public OpenSearchClient openSearchClient() {
BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider();
credentialsProvider.setCredentials(
AuthScope.ANY,
new UsernamePasswordCredentials(username, password)
);
RestClientBuilder builder = RestClient.builder(new HttpHost(host, 443, "https"))
.setHttpClientConfigCallback(new RestClientBuilder.HttpClientConfigCallback(){
@Override
public HttpAsyncClientBuilder customizeHttpClient(HttpAsyncClientBuilder httpClientBuilder) {
return httpClientBuilder
.setDefaultCredentialsProvider(credentialsProvider)
.setDefaultIOReactorConfig(IOReactorConfig.custom()
.setIoThreadCount(1)
.build());
}
});
RestClient restClient = builder.build();
return new OpenSearchClient(new RestClientTransport(restClient, new JacksonJsonpMapper(new ObjectMapper())));
}
}
Service 계층 로직
도메인 엔드포인트에 저장되는 양식을 참고하여 indexing/search/delete 해주는 로직을 작성하면 된다. 코드는 직관적이니까 설명 패스 하겠다
@Service
public class PortfolioSearchService {
private final OpenSearchClient openSearchClient;
private static final String indexName = ${indexName};
private final PortfolioMapper portfolioMapper;
public PortfolioSearchService(OpenSearchClient openSearchClient, PortfolioMapper portfolioMapper) {
this.openSearchClient = openSearchClient;
this.portfolioMapper = portfolioMapper;
}
//인덱스 생성
public void createIndex() {
try {
CreateIndexRequest request = CreateIndexRequest.of(builder -> builder.index(indexName));
openSearchClient.indices().create(request);
} catch (Exception e) {
e.printStackTrace();
}
}
//OpenSearch에 인덱싱(저장)
public void indexDocumentUsingDTO(Portfolio portfolio) {
PortfolioSearchDTO.Request request = portfolioMapper.entityToSearchRequest(portfolio);
try {
IndexRequest<PortfolioSearchDTO.Request> indexRequest = IndexRequest.of(builder ->
builder.index(indexName)
.id(String.valueOf(request.getId()))
.document(request)
);
IndexResponse response = openSearchClient.index(indexRequest);
System.out.println("RESPONSE: "+response);
} catch (Exception e) {
e.printStackTrace();
}
}
//OpenSearch에 덮어쓰기(수정/업데이트)
public void updateDocumentUsingDTO(Portfolio portfolio){
PortfolioSearchDTO.Request request = portfolioMapper.entityToSearchRequest(portfolio);
try {
UpdateRequest<PortfolioSearchDTO.Request, Object> updateRequest = UpdateRequest.of(builder ->
builder.index(indexName)
.id(String.valueOf(portfolio.getId()))
.doc(request)
);
UpdateResponse updateResponse = openSearchClient.update(updateRequest, PortfolioSearchDTO.Request.class);
System.out.println("Update Response: " + updateResponse.get());
} catch (Exception e) {
e.printStackTrace();
}
}
//OpenSearch에서 검색
public List<PortfolioSearchDTO.Response> search(String keyword) {
List<PortfolioSearchDTO.Response> resultList = new ArrayList<>();
try {
SearchRequest request = SearchRequest.of(searchRequest ->
searchRequest.index(indexName)
.query(query ->
query.bool(bool ->
bool.should(should ->
should.wildcard(wildcard ->
wildcard.field("{특정 entity 필드 기입}")
.value("*" + keyword + "*")
)
)
.should(should ->
should.wildcard(wildcard ->
wildcard.field("특정 entity 필드 기입")
.value("*" + keyword + "*")
)
)
.should(should ->
should.wildcard(wildcard ->
wildcard.field("특정 entity 필드 기입")
.value("*" + keyword + "*")
)
)
.should(should ->
should.wildcard(wildcard ->
wildcard.field("특정 entity 필드 기입")
.value("*" + keyword + "*")
)
)
)
)
);
SearchResponse<PortfolioSearchDTO.Request> response = openSearchClient.search(request, PortfolioSearchDTO.Request.class);
List<Hit<PortfolioSearchDTO.Request>> hits = response.hits().hits();
System.out.println("HITS: "+hits);
for (Hit<PortfolioSearchDTO.Request> hit : hits) {
resultList.add(portfolioMapper.requestToSearchResponse(hit.source()));
}
} catch (Exception e) {
e.printStackTrace();
}
return resultList;
}
//OpenSearch에서 삭제
public void deleteDocumentById(Long id) {
try {
DeleteRequest deleteRequest = DeleteRequest.of(builder ->
builder.index(indexName)
.id(String.valueOf(id))
);
DeleteResponse response = openSearchClient.delete(deleteRequest);
System.out.println("Delete Response: " + response);
} catch (Exception e) {
e.printStackTrace();
}
}
}
결과
의도하는 포트폴리오가 잘 검색되었다 !
참고
https://opensearch.org/docs/latest/clients/java/
'BackEnd > JAVA\SPRING' 카테고리의 다른 글
[Spring Security] Spring Security 이용하여 헤더로 간단히 User 구분하기 (0) | 2024.07.10 |
---|---|
[S3|AWS] S3와 CloudFront로 이미지 저장소 만들기 (0) | 2024.06.30 |
[Java/Spring] 템플릿 콜백 패턴으로 JDBC 템플릿 구현해보기 (0) | 2024.04.02 |
[SpringSecurity] Koala 프로젝트 간단한 인증처리 (0) | 2024.02.13 |
[Java|Spring] Koala 프로젝트 구현 과정 중 이슈 정리 (0) | 2024.02.01 |