Kotlin으로 Spring Data Elasticsearch 만들기

개요

Kotlin 언어와 Spring Data Elasticsearch를 이용하여 엘라스틱 서치를 클라이언트 만들어 보겠다.

이 프로젝트는 기본적으로 엘라스틱서치가 설치되어 있어야 실행이 된다. 설치가 되어 있지 않다면 Elastic Search 설치를 먼저 하고 오길 바란다.

프로젝트 생성

아래와 같이 curl 명령어을 사용하여 Spring Boot 초기 프로젝트를 생성한다.

curl https://start.spring.io/starter.tgz \
-d bootVersion=2.5.5 \
-d dependencies=web,data-elasticsearch \
-d baseDir=spring-data-elasticsearch \
-d groupId=com.devkuma \
-d artifactId=spring-data-elasticsearch \
-d packageName=com.devkuma.elasticsearch \
-d applicationName=ElasticsearchApplication \
-d packaging=jar \
-d language=kotlin \
-d javaVersion=11 \
-d type=gradle-project | tar -xzvf -

생성된 프로젝트 확인

프로젝트 파일 구조

제대로 생성이 되었다면 프로젝트의 파일 구조는 아래와 같이 구성된다.

.
├── HELP.md
├── build.gradle.kts
├── gradle
│   └── wrapper
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── settings.gradle.kts
└── src
    ├── main
    │   ├── kotlin
    │   │   └── com
    │   │       └── devkuma
    │   │           └── elasticsearch
    │   │               └── ElasticsearchApplication.kt
    │   └── resources
    │       └── application.properties
    └── test
        └── kotlin
            └── com
                └── devkuma
                    └── elasticsearch
                        └── ElasticsearchApplicationTests.kt

빌드 스크립트

/build.gradle.kts

// ... 생략 ...

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-data-elasticsearch")
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

// ... 생략 ...

의존성 라이브러리에 스프링 데이터 엘라스틱서치 라이브러리(spring-boot-starter-data-elasticsearch)가 포함된 것을 볼 수 있다.

Elasticsearch 설정

Application에 관련된 설정 추가

/src/main/kotlin/com/devkuma/elasticsearch/classElasticsearchApplication.kt

package com.devkuma.elasticsearch

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication

@SpringBootApplication
@ConfigurationPropertiesScan
class ElasticsearchApplication

fun main(args: Array<String>) {
    runApplication<ElasticsearchApplication>(*args)
}

설정 속성을 파일로 받아오기 위해 클래스에 @ConfigurationPropertiesScan을 추가한다.

어플리케이션 설정 추가

어플리케이션 설정 파일(application.yml)에 엘라스틱서치 관련 추가한다.
(초기 프로젝트를 생성하면, application.properties 파일이 있을 것이다. 이 파일에 확장자만 .yml으로 변경하면 된다.)
/src/main/resources/application.yml

elasticsearch:
  host: localhost
  port: 9200

logging:
  level:
    org.springframework: INFO
    org.devkuma: DEBUG

설정명에서 바로 알 수 있듯이 elasticsearch은 엘라스틱 설정이고, logging는 로그 관련 설정이다.

프로퍼티 설정 객체 생성

/src/main/kotlin/com/devkuma/elasticsearch/config/ElasticsearchProperties.kt

package com.devkuma.elasticsearch.config

import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.ConstructorBinding

@ConstructorBinding
@ConfigurationProperties(prefix = "elasticsearch")
class ElasticsearchProperties(
    private val host: String,
    private val port: Integer
) {
    fun getHostAndPort(): String {
        return "$host:$port"
    }
}

속성에외 getHostAndPort 함수를 만들어서 호스트와 포트를 한번에 받아 올 수 있게 하였다.

엘라스티서치 설정 소스 파일 생성

/src/main/kotlin/com/devkuma/elasticsearch/config/ElasticsearchConfig.kt

package com.devkuma.elasticsearch.config

import org.elasticsearch.client.RestHighLevelClient
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.data.elasticsearch.client.ClientConfiguration
import org.springframework.data.elasticsearch.client.RestClients
import org.springframework.data.elasticsearch.config.AbstractElasticsearchConfiguration
import org.springframework.data.elasticsearch.repository.config.EnableElasticsearchRepositories


@Configuration
@EnableElasticsearchRepositories
class ElasticsearchConfig(
    var elasticsearchProperties: ElasticsearchProperties
) : AbstractElasticsearchConfiguration() {

    override fun elasticsearchClient(): RestHighLevelClient {
        val clientConfiguration: ClientConfiguration = ClientConfiguration.builder()
            .connectedTo(elasticsearchProperties.getHostAndPort())
            .build();

        return RestClients.create(clientConfiguration).rest();
    }
}

소스를 보면 AbstractElasticsearchConfiguration 파일을 상속을 받고 있고, 위에서 엘라스틱서치 설정한 ElasticsearchProperties를 받아오고 있다.

Elasticsearch Repository 생성

엘라스틱서치에 직업 연결하는 레파지토리를 만들어 보자.

도큐먼트 객체 생성

먼저 데이터를 받아오는 도큐먼트 객체를 만들다.

/src/main/kotlin/com/devkuma/elasticsearch/document/Phone.kt

package com.devkuma.elasticsearch.document

import org.springframework.data.annotation.Id
import org.springframework.data.elasticsearch.annotations.Document

@Document(indexName = "phone")
class Phone(
    @Id
    var id: Int,
    val number: String,
    val author: String
)

여기서는 예제로 전화번호 정보 만들었다. @Document을 설정하여 인덱스 네임을 설정하였다.

Repository 객체 생성

레파지토리 객체를 생성한다.

/src/main/kotlin/com/devkuma/elasticsearch/repository/ElasticsearchRepository.kt

package com.devkuma.elasticsearch.repository

import com.devkuma.elasticsearch.document.Phone
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository

interface ElasticsearchRepository: ElasticsearchRepository<Phone, Int>

소스 파일을 보면 ElasticsearchRepository를 상속받을 것을 확인할 수 있다.

Repository에 대한 Test 코드 생성

레파지토리 객체를 테스트 할 수 있는 코드를 작성한다.

/src/test/kotlin/com/devkuma/elasticsearch/repository/ElasticsearchRepositoryTests.kt

package com.devkuma.elasticsearch.repository

import com.devkuma.elasticsearch.document.Phone
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest


@SpringBootTest
class ElasticsearchRepositoryTests() {

    @Autowired
    private lateinit var elasticsearchRepository: ElasticsearchRepository

    @Test
    @DisplayName("저장")
    fun save() {
        val phone: Phone = Phone(1, "010-0000-0000", "devkuma")
        elasticsearchRepository.save(phone)
    }

    @Test
    @DisplayName("조회")
    fun findById() {
        // 저장
        val savedPhone: Phone = Phone(1, "010-0000-0000", "devkuma")
        elasticsearchRepository.save(savedPhone)

        // 조회
        val searchedPhone = elasticsearchRepository.findById(1).orElseGet(null)

        // 테스트
        assertNotNull(searchedPhone)
        assertEquals(savedPhone.id, searchedPhone.id)
        assertEquals(savedPhone.number, searchedPhone.number)
        assertEquals(savedPhone.author, searchedPhone.author)
    }
}

실행을 하고 실패없이 모두 통과하는지 확인하자.

Elasticsearch Service 생성

이번에는 레파지토리에서 읽어 올 수 있게 서비스를 생성해 보겠다.

Service 객체 생성

서비스 객체를 생성한다.

/src/main/kotlin/com/devkuma/elasticsearch/service/ElasticsearchService.kt

package com.devkuma.elasticsearch.service

import com.devkuma.elasticsearch.document.Phone
import com.devkuma.elasticsearch.repository.ElasticsearchRepository
import org.springframework.stereotype.Service

@Service
class ElasticsearchService(
    private val elasticsearchRepository: ElasticsearchRepository
) {

    fun save(phone: Phone): Phone {
        return elasticsearchRepository.save(phone)
    }

    fun findById(id: Int): Phone {
        return elasticsearchRepository.findById(id).orElseThrow()
    }

    fun delete(id: Int) {
        val phone =  elasticsearchRepository.findById(id).orElseThrow()
        elasticsearchRepository.delete(phone)
    }
}

Service에 대한 Test 코드 생성

서비스 객체를 테스트 할 수 있는 코드를 작성한다.

/src/test/kotlin/com/devkuma/elasticsearch/service/ElasticsearchServiceTests.kt

package com.devkuma.elasticsearch.service

import com.devkuma.elasticsearch.document.Phone
import com.devkuma.elasticsearch.repository.ElasticsearchRepository
import org.junit.jupiter.api.*
import org.mockito.ArgumentMatchers.any
import org.mockito.BDDMockito.given
import org.mockito.InjectMocks
import org.mockito.Mock
import org.springframework.boot.test.context.SpringBootTest
import java.util.*


@SpringBootTest
class ElasticsearchServiceTests {

    @Mock
    private lateinit var elasticsearchRepository: ElasticsearchRepository
    @InjectMocks
    private lateinit var elasticsearchService: ElasticsearchService

    @BeforeEach
    fun setUp() {
        elasticsearchService = ElasticsearchService(elasticsearchRepository)
    }

    @Test
    fun save() {
        // given
        val phone = Phone(1, "010-0000-0000", "devkuma")
        given(elasticsearchRepository.save(any())).willReturn(phone)

        // when
        val savedPhone = elasticsearchService.save(phone)

        // then
        Assertions.assertAll(
            { Assertions.assertNotNull(savedPhone) },
            { Assertions.assertEquals(savedPhone.id, phone.id) },
            { Assertions.assertEquals(savedPhone.number, phone.number) },
            { Assertions.assertEquals(savedPhone.author, phone.author) }
        )
    }

    @Test
    fun findById() {
        // given
        val phone = Phone(1, "010-0000-0000", "devkuma")
        given(elasticsearchRepository.findById(any())).willReturn(Optional.of(phone))

        // 조회
        val searchedPhone = elasticsearchService.findById(1)

        // then
        Assertions.assertAll(
            { Assertions.assertNotNull(searchedPhone) },
            { Assertions.assertEquals(phone.id, searchedPhone.id) },
            { Assertions.assertEquals(phone.number, searchedPhone.number) },
            { Assertions.assertEquals(phone.author, searchedPhone.author) }
        )
    }

    @Test
    fun delete() {
        // given
        val phone = Phone(1, "010-0000-0000", "devkuma")
        given(elasticsearchRepository.findById(any())).willReturn(Optional.of(phone))

        // given
        assertDoesNotThrow { elasticsearchService.delete(1) }
    }
}

실행을 하고 실패없이 모두 통과하는지 확인하자.

Elasticsearch Controller 생성

마지막으로 서비스를 호출하는 컨트롤러를 생성해 보겠다.

Controller 파일을 생성한다.

/src/main/kotlin/com/devkuma/elasticsearch/controller/ElasticsearchController.kt

package com.devkuma.elasticsearch.controller

import com.devkuma.elasticsearch.document.Phone
import com.devkuma.elasticsearch.service.ElasticsearchService
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import java.net.URI


@RestController
@RequestMapping("/phones")
class ElasticsearchController(
    private val elasticsearchService: ElasticsearchService
) {

    @GetMapping("/{id}")
    fun findById(@PathVariable id: Int): ResponseEntity<Phone> {
        return ResponseEntity.ok().body(elasticsearchService.findById(id))
    }

    @PostMapping
    fun add(@RequestBody phone: Phone): ResponseEntity<Phone> {
        elasticsearchService.add(phone);
        return ResponseEntity.created(URI.create("/phones/${phone.id}")).build()
    }

    @DeleteMapping("/{id}")
    fun delete(@PathVariable id: Int): ResponseEntity<Object> {
        elasticsearchService.delete(id)
        return ResponseEntity.noContent().build()
    }
}

Controller에 대한 Test 코드 생성한다.

/src/test/kotlin/com/devkuma/elasticsearch/controller/ElasticsearchControllerTests.kt

package com.devkuma.elasticsearch.controller

import com.devkuma.elasticsearch.document.Phone
import com.devkuma.elasticsearch.service.ElasticsearchService
import com.fasterxml.jackson.databind.ObjectMapper
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.*
import org.mockito.BDDMockito.given
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.http.MediaType.APPLICATION_JSON
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*
import org.springframework.test.web.servlet.result.MockMvcResultHandlers.print
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*

@WebMvcTest(ElasticsearchController::class)
class ElasticsearchControllerTests {

    @Autowired
    private lateinit var mockMvc: MockMvc
    @MockBean
    private lateinit var elasticsearchService: ElasticsearchService

    val phone = Phone(1, "010-0000-0000", "devkuma")

    @Test
    fun findById() {
        // given
        given(elasticsearchService.findById(anyInt())).willReturn(phone)

        // when
        var resultActions = mockMvc.perform(get("/phones/{id}", phone.id))
            .andDo(print())

        // then
        resultActions
            .andExpect(status().is2xxSuccessful)
            .andExpect(jsonPath("$.id").value(phone.id))
            .andExpect(jsonPath("$.number").value(phone.number))
            .andExpect(jsonPath("$.author").value(phone.author))
            .andDo(print())
    }

    @Test
    fun add() {
        // given ma")
        given(elasticsearchService.add(phone)).willReturn(phone)

        // when
        var resultActions = mockMvc.perform(post("/phones")
            .contentType(APPLICATION_JSON)
            .content(ObjectMapper().writeValueAsString(phone)))
            .andDo(print())

        // then
        resultActions
            .andExpect(status().is2xxSuccessful)
            .andExpect(redirectedUrlTemplate("/phones/{id}", phone.id))
            .andDo(print())
    }

    @Test
    fun delete() {
        // when
        var resultActions = mockMvc.perform(delete("/phones/{id}", phone.id))
            .andDo(print())

        // then
        resultActions
            .andExpect(status().is2xxSuccessful)
            .andDo(print())
    }
}

여기서도 실행을 해서 실패없이 모두 통과하는지 확인하자.

curl 명령어로 실제 테스트해 본다.

프로젝트를 생성하여 실제 동작을 하는지 curl 명령어로 테스트해 보자.

추가

% curl -X POST http://localhost:8080/phones \
   -H "Content-Type: application/json" \
   -d '{"id":1,"number":"010-0000-0000","author":"devkuma"}'

삭제

% curl -XGET http://localhost:8080/phones/1
{"id":1,"number":"010-0000-0000","author":"devkuma"}%

조회

% curl -XDELETE http://localhost:8080/phones/1

그밖에