Elasticsearch 튜토리얼 2

Published on

spring에서 elasticsearch client를 통해 API를 호출해 봅니다.

ElasticsearchClient

공식 문서에 따르면, 7.15.0 버전 기점으로 Java High Level REST Client(HLRC)는 deprecated 되었습니다. 따라서 low level client인 co.elastic.clients:elasticsearch-java dependency를 사용합니다.

implementation("co.elastic.clients:elasticsearch-java")

일부 빈만 생성해서 auto configuration(ElasticsearchClientAutoConfiguration)에 맡겨도 되지만, RestClient의 세부적인 옵션 추가를 대비하여 ElasticsearchProperties만 사용해 ElasticsearchClient를 빈으로 생성했습니다.

# ElasticsearchProperties
spring:
  elasticsearch:
    uris:
      - http(s)://<url>:<port>
import co.elastic.clients.elasticsearch.ElasticsearchClient
import co.elastic.clients.json.jackson.JacksonJsonpMapper
import co.elastic.clients.transport.rest_client.RestClientTransport
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import org.apache.http.HttpHost
import org.elasticsearch.client.RestClient
import org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class ElasticsearchConfig(
    private val elasticsearchProperties: ElasticsearchProperties,
) {
    @Bean
    fun esClient(): ElasticsearchClient {
        val hosts = elasticsearchProperties.uris.map { HttpHost.create(it) }.toTypedArray()
        val restClient = RestClient.builder(*hosts).build()
        val transport = RestClientTransport(restClient, JacksonJsonpMapper(ObjectMapper().registerKotlinModule()))

        return ElasticsearchClient(transport)
    }
}

이제 빈으로 등록된 해당 클라이언트를 통해 Elasticsearch API를 호출할 수 있습니다.

인덱스 관리

그 전에 튜토리얼 1#alias에서 언급한 alias와 daily index를 관리하기 쉽도록 코드를 작성해 봅니다.

/**
 * e.g.
 * alias: message
 * index: message_v0-20250226
 */
enum class ESDocumentIndexType(val alias: String, val version: String) {
    MESSAGE(alias = "message", version = "v0"),
    ;

    fun dailyIndex(date: Instant): String {
        val dateString = dateFormatter.format(date)
        return "${alias}_${version}-${dateString}"
    }

    companion object {
        private val dateFormatter = DateTimeFormatter.ofPattern("yyyyMMdd").withZone(ZoneId.of("UTC"))
    }
}

Elasticsearch API

다양한 API를 제공하지만 튜토리얼답게 CRUD에 해당하는 [색인, 검색, 수정, 삭제]만 다룰 것이며, 인덱스 대상은 아래와 같이 정의했습니다.

interface ESDocument {
    @get:JsonIgnore
    val id: String

    @get:JsonIgnore
    val index: String
}

data class MessageESDocument(
    val messageId: Long,
    val chatId: Long,
    val userId: Long,
    val content: String,
    val createdAt: Instant,
) : ESDocument {
    override val id: String
        get() = messageId.toString()
    override val index: String
        get() = ESDocumentIndexType.MESSAGE.dailyIndex(createdAt)
}

색인

fun <T : ESDocument> index(document: T) {
    esClient.index {
        it.index(document.index)
            .id(document.id)
            .document(document)
    }
}

Function을 함수 파라미터로 받기 때문에 DSL 스타일의 람다로 표현 가능합니다. document는 위의 JacksonJsonMapper에 의해 json으로 직렬화됩니다.

index template을 정의했다면 존재하지 않는 인덱스에 대한 색인 시 해당 인덱스가 생성됩니다.

검색

final inline fun <reified T : ESDocument> search(
    indexType: ESDocumentIndexType,
    size: Int = DEFAULT_QUERY_SIZE,
    noinline query: (Query.Builder) -> ObjectBuilder<Query>,
    noinline sort: ((SortOptions.Builder) -> ObjectBuilder<SortOptions>)? = null,
): List<T> {
    return esClient.search({ search ->
        search.index(indexType.alias)
            .size(size)
            .query(query)
            .apply { sort?.let { sort(it) } }
    }, T::class.java)
        .hits()
        .hits()
        .mapNotNull { it.source() }
}

val result: List<MessageESDocument> = sut.search(
    indexType = ESDocumentIndexType.MESSAGE,
    query = { query ->
        query.bool { bool ->
            bool
                .filter { filter -> filter.term { it.field("chatId").value(100L) } }
                .must { must -> must.match { it.field("content").query("검색 분석").operator(Operator.And) } }
        }
    },
    sort = { sort ->
        sort.field { it.field("messageId").order(SortOrder.Desc) }
    }
)

여러 인덱스 타입에 활용 가능하도록 추상화하였으며, 아래 코드와 같이 사용할 수 있습니다.

수정

interface PartialESDocument

data class MessageContentPartialESDocument(
    val content: String,
) : PartialESDocument

fun update(message: Message, partialESDocument: PartialESDocument) {
    esClient.update<ESDocument, PartialESDocument>({
        it.index(ESDocumentIndexType.MESSAGE.dailyIndex(message.createdAt))
            .id(message.id.toString())
            .doc(partialESDocument)
    }, ESDocument::class.java)
}

tutorial 1#문서 분석에서 언급한 내용과 같이 _id 기반의 수정 및 삭제는 문서 검색 가능 여부와 관계없이 즉각 수행됩니다. 또한, 조회 및 수정이 아닌 원하는 필드만 바로 수정이 가능하므로, PartialESDocument라는 인터페이스로 추상화하였습니다.

삭제

fun delete(message: Message) {
    esClient.delete {
        it.index(ESDocumentIndexType.MESSAGE.dailyIndex(message.createdAt))
            .id(message.id.toString())
    }
}

수정과 마찬가지로 _id 기반의 삭제입니다.

수정 및 삭제 작업에서는 어느 인덱스에서 작업해야 하는지 알아내기 위해 createdAt 값이 필요합니다. 하지만 ID가 Snowflake처럼 날짜 정보를 포함하는 고유한 ID라면, ID만으로도 날짜를 추출해 데일리 인덱스를 생성할 수 있기 때문에 더 간결하고 효율적인 방식이 될 것입니다.

이밖에도 aggregation, bulk, complex query, ES cluster와 관련된 API도 호출할 수 있습니다. 공식 문서에 정리가 잘 되어 있으니 참고하면 좋을 것 같습니다.