검색어 자동완성을 구현하는 가장 쉬운 방법은 RDB에서 매번 특정 칼럼에 Like문을 때리는 것이다. 그런데 이렇게 하면 문제가 있다. 첫번째, 검색어 자동완성 자체가 이벤트가 일어날 때 마다 지속적으로 api 요청을 보내는데, 거기에 Full Scan으로 검색하는 RDB Like 문으로 검색하면 DB 부하가 장난 아닐 것이다. 두번째, 모든 데이터가 영어로 보장되있다면 그나마 괜찮은데 (인덱스를 활용하면 되기 때문), 한글 데이터가 들어가는 순간, Like 문 검색은 반드시 피해야된다. 왜냐하면, 한글 특성상 DB 인덱스를 사용하기 매우 어렵기 때문이다. 한글은 자모음 분리가 가능하고, 초성 검색 등의 특수한 경우를 고려해야 하기에, 기본적인 Like 문으로는 성능이 급격히 떨어진다.
따라서, ElasticSearch(OpenSearch)같은 검색 엔진을 사용하여 효율적으로 검색이 가능하도록 구성해야 된다.
실제로, 내가 라프텔에 문의를 넣어서 받은 답변이다. 여기서도 말하길, DB만을 기반으로 검색 기능을 제공하는 콘텐츠 서비스는 거의 없다고 한다. 그만큼, ElasticSearch가 강력한 검색엔진이라는 것이다. 특히, 자동 검색어 완성에서 빛을 본다. 특유의 역색인 구조 덕분에 훨씬 빠른 검색 속도를 자랑한다. 다만, 단점은 운용할 Cost가 높고, Learning Curve가 좀 높고 (개인적으로 쿠버네티스를 처음 접했을 때의 벽을 느꼈다), RDB는 그냥 Indexing이랑 쿼리만 잘 작성하면 되는데, ElasticSearch는 형태소 분석 등등.... 검색어 튜닝을 지속적으로 해야되가지고 어렵다.
그래서, 해당 글에서는 내가 현재 진행하고 있는 프로젝트에서 Spring + OpenSearch를 이용하여 자동 검색어 완성 기능을 구현하는 법을 제시해보고자 한다. 생각보다 자료가 많이 없어서 많이 힘들었다.
참고로, 내가 OpenSearch를 사용한 이유는, RDS처럼 ElasticSearch도 AWS 클라우드 자체 관리형을 쓸 수 있는데 (AWS OpenSearch) ELK 제단이랑 AWS랑 대판 싸워서 AWS는 ElasticSearch를 7.x 버전까지만 제공해준다. 하지만, Spring Boot 3.x 버전의 호완성 때문에 ElasticSearch 8.x 버전을 사용해야되는데, 이를 제공해주지 않기 때문에 AWS가 7.x 버전에서 분기하여 독자적으로 발전시켜온 OpenSearch를 사용하는 것이다.
사용처
내가 현재 진행하고 있는 프로젝트는 2가지이다. 하나는 개발자들의 프로젝트를 공유할 수 있는 사이트이고, 나머지 하나는 애니를 검색하고, 리뷰를 남기고, 줄거리를 입력하면 해당 애니를 찾아주는 사이트이다.
그래서 왼쪽 사진처럼(프로그래머스) 기술 스택을 검색하면 자동 검색 완성이 되도록 하려고 하고, 오른쪽 사진처럼(라프텔) 애니 이름이 자동 검색 완성이 되게 하려고 한다. 후자는 한글 검색이라 추가 과정이 필요해서 추후 다뤄보고, 오늘은 모든 데이터가 영어로 되있는 전자를 구현해보고자 한다.
시작하기 전에 아래 글을 참고해서 OpenSearch를 띄워두자.
참고로 아래 전체 코드는 아래에서 볼 수 있다. 현재 진행하고 있는 프로젝트의 코드다 보니 이것 저것 섞여있으니 양해바란다.
dependencies
dependencies {
implementation("org.opensearch.client:spring-data-opensearch-starter:1.5.3") {
exclude("org.opensearch.client", "opensearch-rest-high-level-client")
}
implementation("org.opensearch.client:opensearch-java:2.11.1")
implementation("com.opencsv:opencsv:5.6")
implementation("jakarta.json:jakarta.json-api")
}
의존성은 위의 표를 참고하여 자신에게 맞는 버전을 골라서 추가하면 된다. opencsv는 초기 데이터를 넣기 위해 csv 파일에서 읽어와서 넣은 의존성이다. 필요시에만 넣으면 되겠다. 이때, 중요한 것이 exclude ~~ 이 부분을 해주지 않으면 bean 충돌이 난다.
TechStackAutoComplete
데이터는 위 사진처럼 생겼다. 해당 데이터는 내가 stackshare라는 사이트를 크롤링해서 만든 데이터이다. 이 중에서, 이름만 자동완성 검색만 되면 되므로 이에 대한 Document를 만들어준다.
import jakarta.persistence.Id;
import lombok.*;
import org.springframework.data.elasticsearch.annotations.*;
import org.springframework.data.elasticsearch.core.suggest.Completion;
@Builder
@ToString
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Document(indexName = "stack-auto-complete", writeTypeHint = WriteTypeHint.FALSE)
@Mapping(mappingPath = "elastic/tech-mapping.json")
@Setting(settingPath = "elastic/tech-setting.json")
public class TechStackAutoComplete {
@Id
private String id;
@CompletionField(maxInputLength = 100)
private Completion suggest;
private String name;
}
사용할 index 이름을 지정해주고, 밑에 필드들도 작성해준다. 그런데 사실 밑에 필드의 타입들은 대충 설정해도 된다. 우리는 보다 세부적인 설정을 위해서 @Mapping과 @Setting 어노테이션을 활용해서 mapping.json과 setting.json을 지정해줄 것이다. ElasticSearch에서 Index 설정할 때의 setting과 mapping 부분이 맞다.
{
"number_of_shards": 1,
"number_of_replicas": 0
}
위 코드가 tech-stack-setting.json이다. 사실 한글 데이터였다면 한글 형태소 분석기를 추가한 Tokenizer 및 Analyzer를 Setting에 정의를 해주어야 한다.(ex: nori 분석기) 우리는 그런데 모두 영어라는 보장이 있기 때문에 일단은 shard 및 복제에 대한 설정만 한다. 원래는 이렇게 하면 안되지만, 우리는 돈이 없기 때문에 다중 노드로 구성된 ElasticSearch 클러스터 구성이 어렵기에 복제본은 없고, shard는 1개로 해둔다.
{
"properties": {
"suggest": {
"type": "completion",
"preserve_separators": true,
"preserve_position_increments": true,
"max_input_length": 100
},
"name": {
"type": "text"
}
}
}
위 코드는 tech-stack-mapping.json이다. 필드는 원본 데이터인 name 필드와, 추천 검색어들이 들어갈 suggest 필드로 나눈다. 여기서 제일 중요한 것이 suggest 필드는 completion type으로 지정해줘야 한다. 이렇게 해야 FST 구조로 들어가고, Completion Suggest를 사용할 수 있다.
preserve_position_increments : 입력된 단어 사이의 위치 정보를 유지할지 여부를 결정하는 옵션. "java spring"이라는 검색어로 "java boot spring"이나 "spring java" 등의 결과가 포함될 수 있도록 위치 정보를 무시할 때 해당 옵션을 false로 해주면 된다. (Default는 true)
preserve_separators : 텍스트에서 단어 사이의 구분자(예: 공백, 대시 -, 언더스코어 _ 등)를 유지할지 여부를 결정하는 옵션이다. 예를 들어, "java-spring"이라는 입력값이 있다고 할 때, preserve_separators가 true이면 "java-spring"으로 구분자를 포함한 상태로 유지된다. preserve_separators가 false이면 "java-spring"이 "javaspring"처럼 구분자 없이 처리된다. (Default: true)
이렇게 만들어두면 JPA처럼 자동으로 Index를 만들어준다. (Config에서 추가 설정 해야됨)
※ 참고
만약에 한글 데이터로 구축하고 싶다고 가정을 해보자. 한글 자동 완성은 좀 더 심오한데... 예를 들어 내가 "국"이라는 단어를 입력했다고 해보자. 그러면 "구글" 또는 "국밥" 이런식으로 검색될 수 있다. ㄱ이 종성이 될 수도 있고 초성이 될 수도 있기 때문이다. 따라서, 한글 형태소 분석이 따로 필요하다.
PUT /autocomplete
{
"settings": {
"analysis": {
"analyzer": {
"nori_analyzer": {
"type": "custom",
"tokenizer": "nori_tokenizer",
"filter": ["lowercase"]
}
}
}
},
"mappings": {
"properties": {
"suggest": {
"type": "completion",
"search_analyzer": "nori_analyzer"
}
}
}
}
그러면 인덱스 설정을 위처럼 해줘야 한다. nori 형태소 분석기를 사용하여 한글 형태소 분석기를 적용해주고, 이는 search_analyzer를 이용하여 사용자가 검색시, 해당 analyzer가 적용될 수 있도록 하는 것이다. 이렇게 하면 사용자가 국을 입력하면 suggest 필드의 국밥, 구글 이렇게 검색해서 알려줄 수 있다. 해당 내용은 깊게 들어가면 너무 많아져서 다음에 따로 다뤄보고자 한다. 우선 해당 글에서는 영어만 있다는 전제하에 간단하게 구현해볼 것이다.
TechStackAutoCompleteRepository
import com.pofo.elasticsearch.document.TechStackAutoComplete;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface TechStackAutoCompleteRepository extends ElasticsearchRepository<TechStackAutoComplete, String> {
}
JPA처럼 자동으로 Index를 만들어주기 위해서 Repository를 하나 만들어주고, Config에 연결시켜주어야 한다. ElasticsearchRepository를 상속받아 하나 만들어주자. 참고로 이를 이용해서 JPA처럼 findby~~ 이런식으로도 활용이 가능하나, 우리는 High Level REST Client를 사용할 것 이므로 쓰지 않는다. 단지, Index를 코드로 관리하기 위함으로 만들었다.
참고로,
List<TechStackAutoComplete> findByName(String name);
Repository에서는 이렇게 만들 수 있다. 이는, Spring data OpenSearch 라이브러리에서 제공해주는 추상화 방식의 메서드를 가져다가 자동 쿼리를 날려주는 방식(JPA Repository 생각하시면됩니다)이다. 그래서, 복잡한 조건이 없는 검색일 경우 이를 활용하면 코드가 간결해진다. 하지만, 오타 보정을 해주는 fuzzy, 집계를 해주는 aggregations, 검색어 추천을 해주는 suggest 등 복잡한 쿼리 이용이 불가능하므로 되도록 High Level REST Client 방식을 이용하도록 하자.
OpenSearchConfig
import org.apache.http.conn.ssl.TrustSelfSignedStrategy
import org.apache.http.impl.nio.client.HttpAsyncClientBuilder
import org.apache.http.ssl.SSLContextBuilder
import org.opensearch.client.RestClientBuilder
import org.opensearch.spring.boot.autoconfigure.RestClientBuilderCustomizer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.ComponentScan
import org.springframework.context.annotation.Configuration
import org.springframework.data.elasticsearch.repository.config.EnableElasticsearchRepositories
import java.security.KeyManagementException
import java.security.KeyStoreException
import java.security.NoSuchAlgorithmException
@Configuration
@EnableElasticsearchRepositories(basePackageClasses = [TechStackAutoCompleteRepository::class])
@ComponentScan(basePackageClasses = [OpenSearchConfig::class])
class OpenSearchConfig {
@Bean
fun customizer(): RestClientBuilderCustomizer =
object : RestClientBuilderCustomizer {
override fun customize(builder: HttpAsyncClientBuilder) {
try {
builder.setSSLContext(
SSLContextBuilder()
.loadTrustMaterial(null, TrustSelfSignedStrategy())
.build(),
)
} catch (ex: KeyManagementException) {
throw RuntimeException("Failed to initialize SSL Context instance", ex)
} catch (ex: NoSuchAlgorithmException) {
throw RuntimeException("Failed to initialize SSL Context instance", ex)
} catch (ex: KeyStoreException) {
throw RuntimeException("Failed to initialize SSL Context instance", ex)
}
}
override fun customize(builder: RestClientBuilder) {
// nothing
}
}
}
이제 Docker로 띄운 OpenSearch랑 연결시켜볼 것이다. 여기서 중요한 것이 EnableElasticsearchRepositories 이 부분이다. 이 부분을 명시해주어야 자동으로 Index가 생성되고 관리된다. basePackageClasses는 저렇게 하나만 등록해도 같은 폴더에 있는 Repository들이 다 등록이 된다.
opensearch:
uris: ${OPENSEARCH_URI}
username: ${OPENSEARCH_USERNAME}
password: ${OPENSEARCH_PASSWORD}
그리고 application.yml 파일에 우리의 Opensearch uri 및 인증 정보를 써두자.
@SpringBootApplication(
exclude = [ElasticsearchDataAutoConfiguration::class, ElasticsearchRestClientAutoConfiguration::class],
)
class ApiApplication
fun main(args: Array<String>) {
runApplication<ApiApplication>(*args)
}
그리고 Main Application 파일에 ElasticsearchDataAutoConfiguration, RestClient~~ 이 것들을 exclude 해줘야 한다.
OpenSearchController
@RequestMapping("/tech-stack")
@RestController
class AutocompleteController(
private val openSearchService: OpenSearchService,
) {
@PostMapping("/upload-csv")
fun uploadCSV(
@RequestParam("file") file: MultipartFile,
): ResponseEntity<String> =
try {
openSearchService.bulkInsertFromCSV(file)
ResponseEntity.ok("CSV 데이터가 성공적으로 업로드되었습니다.")
} catch (e: Exception) {
e.printStackTrace()
ResponseEntity.status(400).body("CSV 업로드에 실패했습니다: ${e.message}")
}
@GetMapping("/autocomplete")
fun autoComplete(
@RequestParam query: String,
): ResponseEntity<List<String>> = ResponseEntity.ok(openSearchService.getSuggestions(query))
}
이제 Controller를 하나 만들어주자. /upload-csv는 Bulk API를 이용해 Batch로 초기 데이터를 넣을 Endpoint이다. /autocomplete이 우리가 구현하고자하는 자동완성 endpoint이다.
OpenSearchService
package org.pofo.api.service
import com.opencsv.CSVReader
import com.pofo.elasticsearch.document.TechStackAutoComplete
import org.opensearch.client.opensearch.OpenSearchClient
import org.opensearch.client.opensearch._types.FieldValue
import org.opensearch.client.opensearch.core.BulkRequest
import org.opensearch.client.opensearch.core.IndexRequest
import org.opensearch.client.opensearch.core.IndexResponse
import org.opensearch.client.opensearch.core.SearchRequest
import org.opensearch.client.opensearch.core.SearchResponse
import org.opensearch.client.opensearch.core.bulk.CreateOperation
import org.opensearch.client.opensearch.core.search.FieldSuggester
import org.opensearch.client.opensearch.core.search.SourceConfig
import org.opensearch.client.opensearch.core.search.Suggester
import org.springframework.data.elasticsearch.core.suggest.Completion
import org.springframework.stereotype.Service
import org.springframework.web.multipart.MultipartFile
import java.io.InputStreamReader
@Service
class OpenSearchService(
private val openSearchClient: OpenSearchClient,
) {
fun bulkInsertFromCSV(file: MultipartFile) {
val indexName = "stack-auto-complete"
val bulkRequest = BulkRequest.Builder()
CSVReader(InputStreamReader(file.inputStream)).use { csvReader ->
val rows = csvReader.readAll().drop(1)
rows.forEach { row ->
val stackName = row[0]
val docId = stackName.replace(" ", "_").lowercase()
val suggestInputs = generateAllCombinations(stackName)
val techStack =
TechStackAutoComplete
.builder()
.id(docId)
.suggest(Completion(suggestInputs))
.name(stackName)
.build()
bulkRequest.operations { operation ->
operation.create { co: CreateOperation.Builder<TechStackAutoComplete> ->
co.index(indexName).id(docId).document(techStack)
}
}
}
}
openSearchClient.bulk(bulkRequest.build())
}
private fun generateAllCombinations(input: String): List<String> {
val cleanedInput = input.replace(Regex("[^A-Za-z0-9 ]"), "")
val words = cleanedInput.split(" ")
val combinations = mutableSetOf<String>()
for (i in words.indices) {
for (j in i until words.size) {
combinations.add(words.slice(i..j).joinToString(" ").lowercase())
}
}
return combinations.toList()
}
fun getSuggestions(query: String): List<String> {
val indexName = "stack-auto-complete"
val sanitizedQuery = query.lowercase().replace(" ", "")
return try {
val map = HashMap<String, FieldSuggester>()
map["tech-suggestion"] =
FieldSuggester.of { fs -> fs.completion { cs -> cs.skipDuplicates(true).size(20).field("suggest") } }
val suggester = Suggester.of { s -> s.suggesters(map).text(sanitizedQuery) }
val searchRequest =
SearchRequest.of { search ->
search
.index(indexName)
.suggest(suggester)
.source(SourceConfig.of { s -> s.filter { f -> f.includes(listOf("name")) } })
}
val response = openSearchClient.search(searchRequest, TechStackAutoComplete::class.java)
response.suggest()["tech-suggestion"]?.flatMap { suggestion ->
suggestion.completion().options().map {
it.source().name
}
} ?: emptyList()
} catch (e: Exception) {
e.printStackTrace()
emptyList()
}
}
}
이제 이게 본격적으로 돌아갈 비즈니스 로직이다. 일단 초기 데이터를 삽입하는 Bulk API부터 봐보자.
fun bulkInsertFromCSV(file: MultipartFile) {
val indexName = "stack-auto-complete"
val bulkRequest = BulkRequest.Builder()
CSVReader(InputStreamReader(file.inputStream)).use { csvReader ->
val rows = csvReader.readAll().drop(1)
rows.forEach { row ->
val stackName = row[0]
val docId = stackName.replace(" ", "_").lowercase()
val suggestInputs = generateAllCombinations(stackName)
val techStack =
TechStackAutoComplete
.builder()
.id(docId)
.suggest(Completion(suggestInputs))
.name(stackName)
.build()
bulkRequest.operations { operation ->
operation.create { co: CreateOperation.Builder<TechStackAutoComplete> ->
co.index(indexName).id(docId).document(techStack)
}
}
}
}
openSearchClient.bulk(bulkRequest.build())
}
private fun generateAllCombinations(input: String): List<String> {
val cleanedInput = input.replace(Regex("[^A-Za-z0-9 ]"), "")
val words = cleanedInput.split(" ")
val combinations = mutableSetOf<String>()
for (i in words.indices) {
for (j in i until words.size) {
combinations.add(words.slice(i..j).joinToString(" ").lowercase())
}
}
return combinations.toList()
}
csv파일로부터 값을 읽어서 stack_name에 있는 칼럼만 따온다. 그리고 이걸 이제 suggest 필드에 넣을 값을 쪼갠다. 그런데, Suggester API는 중간 키워드 검색을 지원하지 않기 때문에 원하는 대상을 배열로 지정하는 것이 필요하다. 예시로 "AWS Elastic LoadBalancer"라고 치면 [AWS Elastic LoadBalancer, Elastic LoadBalancer, Loadblancer, AWS Elastic, AWS] 이런식으로 끊어서 저장을 해주어야 한다. 그래서, generateAllCombinations를 통해서 특수문자를 제거하고, 소문자로 전처리해서 suggest Field에 넣고, 이를 Bulk로 Batch해서 한번에 올리도록 구성했다. 이때, 필요하다면 weight을 주어서 더 대중적인 기술에는 상위 결과로 출력되도록도 할 수 있다.
※ 참고
completion 필드가 아닌 search-as-you-type 필드를 이용하는 방법도 있다. 이렇게 하면 저렇게 하나하나 넣어줄 필요 없이 자동으로 중간 검색도 가능하게 해준다. (prefix가 아닌 infix 방식) 그런데 해당 방법은 결국 Edge-Ngram을 이용하여 작동하고, 이는 FST 기반이 아닌 HardDisk 기반이기 때문에 상대적으로 빠른 검색을 추구해야 되는 경우 성능이 떨어질 수 있는 것 같다(?) 공식 문서를 읽어봤을 때의 내 의견이고.... 100% 정확한 것은 아님을 유의하자!
fun getSuggestions(query: String): List<String> {
val indexName = "stack-auto-complete"
val sanitizedQuery = query.lowercase().replace(" ", "")
return try {
val map = HashMap<String, FieldSuggester>()
map["tech-suggestion"] =
FieldSuggester.of { fs -> fs.completion { cs -> cs.skipDuplicates(true).size(20).field("suggest") } }
val suggester = Suggester.of { s -> s.suggesters(map).text(sanitizedQuery) }
val searchRequest =
SearchRequest.of { search ->
search
.index(indexName)
.suggest(suggester)
.source(SourceConfig.of { s -> s.filter { f -> f.includes(listOf("name")) } })
}
val response = openSearchClient.search(searchRequest, TechStackAutoComplete::class.java)
response.suggest()["tech-suggestion"]?.flatMap { suggestion ->
suggestion.completion().options().map {
it.source().name
}
} ?: emptyList()
} catch (e: Exception) {
e.printStackTrace()
emptyList()
}
}
원래는 search_analyzer를 써서 필터링해도 괜찮지만, 나는 들어오는 검색어에 대해서 그냥 Spring 내에서 필터링해도 된다고 생각했다. (그렇게 복잡한게 아니기 때문) 따라서, 들어오는 쿼리를 필터링하고, Suggest를 날리기 위해 SearchRequest를 구성했다. 이때, skipDuplicates 옵션을 통해 중복을 없에고, size를 20으로 설정해서 최대 20개 추천되도록 구성했다. 또한, source는 원본 기술스택 이름인 name만 나오면 되기 때문에 source config로 "name"을 설정해두고 Search API를 날렸다. 그리고, 이걸 이제 map을 활용해 추천 목록만 뽑아오는 것이다.
실제로 Postman을 통해 요청을 날리면 이렇게 AW만 입력했을때 관련 기술 스택들 검색어가 추천되는 것을 볼 수 있다!!
해당 글에서 쓴 것은 우리 프로젝트 상황에 맞춘 검색 구성일 뿐, 각각 비즈니스 목적에 따라서 검색 엔진 튜닝이 필요할 수 있다. 조금씩 바꿔가며 지속적으로 테스트해서 검색을 개선할 필요가 있다. 나 또한, 현재 내가 크롤링한 데이터의 품질이 살짝 떨어져서 (ex : AWS라고 되있는 것도 있고 Amazon이라고 되있는 것도 있음. 크롤링을 해서 얻다보니 긴 데이터는 ...으로 축약되있는 데이터도 있음) 추가적인 데이터 전처리도 필요한 상황이다.
이외에도 앞서 언급한 것 처럼 한글 자동완성 검색을 한다던지 (Nori 분석기와 조합해서 구현) 아니면 오타 검색어 보정을 허용한다던지 (Fuzzy 쿼리와 조합해서 구현) 아니면 특정 필터를 거쳐서 자동 검색 결과를 리턴하고 싶다던지 (ex: 2010년에 개봉한 영화만 자동완성이 되게 하고 싶음) 등 상황이 발생할 수 있다. 이는 다음 구현에서 알아보도록 하자.