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
그밖에
- 이 포스트는 아래 사이트를 참고하여 작성이 되었다.
- 위에 모든 소스 코드는 GitHub에서 확인해 볼 수 있다.