많은 Java + Spring 기반으로 웹 백엔드 개발을 하던 회사들이 하나둘 언어를 Kotlin으로 전환하고 있습니다.
저 역시 예외는 아니라서, 새로 합류한 팀에서는 Kotlin + Spring으로 서비스를 구성하고 있네요.
예전에는 개인 공부의 성격으로만 Java + Spring 5를 공부했다면, 이젠 생존형(?)으로 Kotlin + Spring 5를 공부하게 되었습니다.
간단하면서도 하나의 flow를 따라서 Kotlin + Spring 개발을 해 보고자, Spring 공식 튜토리얼을 따라해보게 되었습니다.
다만 공식 문서가 작성된 시점은 Spring 2.3.0이 최신 버전이던 시점이라... Gradle 등 여러 환경이 조금 오래 됐습니다.
따라서 2023년 1월 기준으로 진행한 내용을 공유합니다. 다만 이 내용은 튜토리얼을 한국어로 번역한다거나 하는 목적은 아니기 때문에, 일부 내용이 생략되었습니다. 원 글과 함께 읽으시는 것을 추천합니다.
목차
1. 프로젝트 생성
2. build.gradle.kts
3. JUnit 5 테스트
4. Kotlin Extension 만들기
5. JPA
6. 나머지 테스트, 폼 만들기
7. HTTP API 작성, mock 테스트
8. Application Properties 설정
9. 마무리
1. 프로젝트 생성
Spring Initializr 웹사이트를 사용하셔도 되고, IntelliJ IDEA 내에서 Spring Initializr를 통해 만드셔도 됩니다.
저는 IntelliJ 내에서 간편하게 만들어 보도록 하겠습니다.
공식 문서에 있는 이미지를 보시면, Project: Gradle Project를 고르라고 되어 있습니다.
하지만 이후 글 내용을 보시면 build.gradle 파일이 아닌 build.gradle.kts 파일을 통해 빌드 정보를 관리합니다.
최신 Spring Inltializr에서는 build type을 Gradle - Groovy와 Gradle - Kotlin으로 구분하여 제공하고 있습니다.
저희는 Gradle-Kotlin을 선택하여 진행하겠습니다.
한 가지 주의하실 점은, 만약 Java를 17 미만으로 선택하신다면, Spring Boot 3.0 이상을 선택하실 수 없습니다. Spring Boot 3.0이 기반으로 하는 Spring 6은 JDK 17을 기본으로 요구하기 때문인데요.
혹시라도 Spring Boot 3.0 이상을 선택하실 분들이라면 참고해 주세요.
저는 JDK 11을 주로 사용하고 있고, 이번에도 Spring Boot 2.7.7로 생성하였습니다.
그 외에 dependencies는 공식 문서에서 언급한 5가지를 추가해 주시면 됩니다.
2. build.gradle.kts
성공적으로 프로젝트를 생성하시고 나면, 아래와 같이 Gradle build 정보가 담긴 파일이 생성됩니다.
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
id("org.springframework.boot") version "2.7.7"
id("io.spring.dependency-management") version "1.0.15.RELEASE"
kotlin("jvm") version "1.6.21"
kotlin("plugin.spring") version "1.6.21"
kotlin("plugin.jpa") version "1.6.21"
}
group = "com.tistory.test"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_11
repositories {
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
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")
runtimeOnly("com.h2database:h2")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
tasks.withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs = listOf("-Xjsr305=strict")
jvmTarget = "11"
}
}
tasks.withType<Test> {
useJUnitPlatform()
}
생성된 파일을 읽고 의존 관계 내용을 확인해 주시면 됩니다.
이에 대한 자세한 설명은 공식 문서의 'Understanding the Gradle Build'를 참고해 주시면 됩니다.
추가로, 아래 경로에 있는 properties 파일에 다음과 같이 작성해 줍니다.
#src/main/resources/application.yml
spring:
jpa:
properties:
hibernate:
globally_quoted_identifiers: true
globally_quoted_identifiers_skip_column_definitions: true
이는 H2에서 user와 같은 예약어를 정상적으로 처리하기 위한 설정입니다.
저는 .properties 파일보다는 .yml 파일을 통해 설정을 작성하는 것을 좋아합니다. 따라서 파일 확장자를 .yml로 변경하고, 위와 같이 작성하였습니다. 혹시 properties 파일로 설정하고 싶으시다면 위 내용을 이어 붙여서 두 줄을 작성해 주시면 됩니다.
3. JUnit 5 테스트
먼저 웹 페이지를 표시하기 위한 컨트롤러를 하나 등록해 줍니다.
// src/main/kotlin/com/tistory/katfun/blog/HtmlController.kt
@Controller
class HtmlController {
@GetMapping("/")
fun blog(model: Model): String {
model["title"] = "Blog"
return "blog"
}
}
"/"라는 url로 오는 GET 요청을 받고, model 내의 "title"이라는 필드에 "Blog"라는 내용을 담은 후, "blog"를 리턴하는 controller입니다.
이에 대응할 Mustache 템플릿을 작성해 주시고 (원 글 참조), 애플리케이션을 실행해 보시면 http://localhost:8080/에 접속하셨을 때, "Blog"라는 문자열이 나타나는 것을 확인하실 수 있습니다.
이제 이 내용을 테스트 코드를 통해 검증해 보겠습니다.
// src/test/kotlin/com/tistory/katfun/blog/IntegrationTests.kt
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class IntegrationTests(@Autowired val restTemplate: TestRestTemplate) {
@BeforeAll
fun setup() {
println(">> Setup")
}
@Test
fun `Assert blog page title, content and status code`() {
println(">> Assert blog page title, content and status code")
val entity = restTemplate.getForEntity<String>("/")
assertThat(entity.statusCode).isEqualTo(HttpStatus.OK)
assertThat(entity.body).contains("<h1>Blog</h1>")
}
@Test
fun `Assert article page title, content and status code`() {
println(">> TODO")
}
@AfterAll
fun teardown() {
println(">> Tear down")
}
}
여기서 @BeforeAll, @AfterAll 어노테이션을 정상적으로 사용하기 위해서는 약간의 설정이 필요합니다.
BeforeAll은 클래스 내의 모든 메소드 실행 전에, AfterAll은 실행 후에 실행되어야 하는 메소드인데요. JUnit5에서는 기본적으로 테스트 클래스는 테스트마다 각각 인스턴스화 (instantiazed)되기 때문에, 메소드가 static이기를 요구합니다 (Kotlin 내에서는 companion object).
이러한 행동을, 테스트마다 인스턴스화되는 것에서 클래스마다 한번씩 인스턴스화되도록 수정할 수 있습니다. 그 중 한 방법으로, properties 파일을 추가해 보겠습니다.
# src/test/resources/junit-platform.properties
junit.jupiter.testinstance.lifecycle.default = per_class
설정 내용이 꽤 직관적이죠? 테스트 인스턴스의 기본 라이프사이클을 '매 클래스별로'로 설정하는 내용입니다.
위 내용을 추가하신 후에 작성하신 테스트 코드를 실행해 보시면 됩니다.
4. Kotlin Extension 만들기
Java에서는 abstract methods를 사용하여 util class를 이용합니다. 반면 Kotlin에서는, 이러한 기능을 Kotlin extensions를 통해 제공하는 편입니다. 여기서는 LocalDateTime 타입을 영문 날짜 형식으로 변환하도록 format() 함수를 추가합니다.
// src/main/kotlin/com/tistory/katfun/bog/Extensions.kt
fun LocalDateTime.format(): String = this.format(englishDateFormatter)
private val daysLookup = (1..31).associate { it.toLong() to getOrdinal(it) }
private val englishDateFormatter = DateTimeFormatterBuilder()
.appendPattern("yyyy-MM-dd")
.appendLiteral(" ")
.appendText(ChronoField.DAY_OF_MONTH, daysLookup)
.appendLiteral(" ")
.appendPattern("yyyy")
.toFormatter(Locale.ENGLISH)
private fun getOrdinal(n: Int) = when {
n in 11..13 -> "${n}th"
n % 10 == 1 -> "${n}st"
n % 10 == 2 -> "${n}nd"
n % 10 == 3 -> "${n}rd"
else -> "${n}th"
}
fun String.toSlug() = lowercase(Locale.getDefault())
.replace("\n", " ")
.replace("[^a-z\\d\\s]".toRegex(), " ")
.split(" ")
.joinToString("-")
.replace("-+".toRegex(), "-")
이 확장 기능은 이후에 다루게 됩니다.
5. JPA
lazy fetching을 사용하기 위해, 엔티티는 'open'이어야 합니다 (참고). 이를 위해서 Kotlin의 allopen 플러그인을 사용합니다.
build.gradle.kts에 아래와 같은 내용을 추가합니다.
plugins {
...
kotlin("plugin.allopen") version "1.6.21"
}
allOpen {
annotation("javax.persistence.Entity")
annotation("javax.persistence.Embeddable")
annotation("javax.persistence.MappedSuperclass")
}
다음으로 엔티티를 정의합니다. Kotlin에서는 primary constrctor에서 바로 엔티티를 정의하게 됩니다 (참고).
// src/main/kotlin/com/tistory/katfun/blog/Entities.kt
@Entity
class Article(
var title: String,
var headline: String,
var content: String,
@ManyToOne var author: User,
var slug: String = title.toSlug(),
var addedAt: LocalDateTime = LocalDateTime.now(),
@Id @GeneratedValue var id: Long? = null)
@Entity
class User(
var login: String,
var firstname: String,
var lastname: String,
var description: String? = null,
@Id @GeneratedValue var id: Long? = null)
위 코드를 보시면, var slug: String = title.toSlug() 라는 내용이 보이시나요?
앞서 정의한 Kotlin Extensions 내에 있던 함수입니다. 이를 사용하여 Article의 생성자 내에 있는 slug를 초기화하는데 사용합니다. 이렇게 기본값을 따로 지정한 인자는, 생성자에 별도의 이름 없이 (named arguments) 정의하지 않는 경우를 고려하여 제일 뒷순서에 두게 됩니다 (positional arguments).
또, Kotlin에서는 위와 같이 같은 파일 내에 간결한 클래스를 묶어서 정의할 수도 있습니다.
위 엔티티에서 사용할 repository를 작성합니다.
// src/main/kotlin/com/tistory/katfun/blog/Repositories.kt
interface ArticleRepository : CrudRepository<Article, Long> {
fun findBySlug(slug: String): Article?
fun findAllByOrderByAddedAtDesc(): Iterable<Article>
}
interface UserRepository : CrudRepository<User, Long> {
fun findByLogin(login: String): User?
}
6. 나머지 테스트, 폼 만들기
먼저 JPA 테스트를 추가해 봅시다.
// src/test/kotlin/com/tistory/katfun/blog/RepositoriesTests.kt
@DataJpaTest
class RepositoriesTests @Autowired constructor(
val entityManager: TestEntityManager,
val userRepository: UserRepository,
val articleRepository: ArticleRepository) {
@Test
fun `When findByIdOrNull then return Article`() {
val juergen = User("springjuergen", "Juergen", "Hoeller")
entityManager.persist(juergen)
val article = Article("Spring Framework 5.0 goes GA", "Dear Spring community ...", "Lorem ipsum", juergen)
entityManager.persist(article)
entityManager.flush()
val found = articleRepository.findByIdOrNull(article.id!!)
assertThat(found).isEqualTo(article)
}
@Test
fun `When findByLogin then return User`() {
val juergen = User("springjuergen", "Juergen", "Hoeller")
entityManager.persist(juergen)
entityManager.flush()
val user = userRepository.findByLogin(juergen.login)
assertThat(user).isEqualTo(juergen)
}
}
이어서, mustache 템플릿을 추가합니다. (마찬가지로 공식 문서를 참고해 주세요...)
다음으로, 앞서 작성했던 HtmlController.kt에 내용을 추가해 줍시다.
src/main/kotlin/com/tistory/katfun/blog/HtmlController.kt
@Controller
class HtmlController(private val repository: ArticleRepository) {
@GetMapping("/")
fun blog(model: Model): String {
model["title"] = "Blog"
model["articles"] = repository.findAllByOrderByAddedAtDesc().map { it.render() }
return "blog"
}
@GetMapping("/article/{slug}")
fun article(@PathVariable slug: String, model: Model): String {
val article = repository
.findBySlug(slug)
?.render()
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "This article does not exist")
model["title"] = article.title
model["article"] = article
return "article"
}
fun Article.render() = RenderedArticle(
slug,
title,
headline,
content,
author,
addedAt.format()
)
data class RenderedArticle(
val slug: String,
val title: String,
val headline: String,
val content: String,
val author: User,
val addedAt: String)
}
이어서, 데이터 초기화를 위한 BlogConfiguration 클래스를 작성합니다.
// src/main/kotlin/com/tistory/katfun/blog/BlogConfiguration.kt
@Configuration
class BlogConfiguration {
@Bean
fun databaseInitializer(userRepository: UserRepository,
articleRepository: ArticleRepository) = ApplicationRunner {
val smaldini = userRepository.save(User("smaldini", "Stéphane", "Maldini"))
articleRepository.save(Article(
title = "Reactor Bismuth is out",
headline = "Lorem ipsum",
content = "dolor sit amet",
author = smaldini
))
articleRepository.save(Article(
title = "Reactor Aluminium has landed",
headline = "Lorem ipsum",
content = "dolor sit amet",
author = smaldini
))
}
}
마지막으로, BlogConfiguration을 위한 테스트까지 작성해 줍니다.
// src/test/kotlin/com/tistory/katfun/blog/IntegrationTests.kt
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class IntegrationTests(@Autowired val restTemplate: TestRestTemplate) {
@BeforeAll
fun setup() {
println(">> Setup")
}
@Test
fun `Assert blog page title, content and status code`() {
println(">> Assert blog page title, content and status code")
val entity = restTemplate.getForEntity<String>("/")
assertThat(entity.statusCode).isEqualTo(HttpStatus.OK)
assertThat(entity.body).contains("<h1>Blog</h1>", "Reactor")
}
@Test
fun `Assert article page title, content and status code`() {
println(">> Assert article page title, content and status code")
val title = "Reactor Aluminium has landed"
val entity = restTemplate.getForEntity<String>("/article/${title.toSlug()}")
assertThat(entity.statusCode).isEqualTo(HttpStatus.OK)
assertThat(entity.body).contains(title, "Lorem ipsum", "dolor sit amet")
}
@AfterAll
fun teardown() {
println(">> Tear down")
}
}
이제 애플리케이션을 실행하면, 위에서 작성한 글 내용이 보입니다.
7. HTTP API 작성, mock 테스트
이제 @RestController 어노테이션을 사용하여 HTTP API를 구현해 봅니다.
// src/main/kotlin/com/tistory/katfun/blog/HttpControllers.kt
@RestController
@RequestMapping("/api/article")
class ArticleController(private val repository: ArticleRepository) {
@GetMapping("/")
fun findAll() = repository.findAllByOrderByAddedAtDesc()
@GetMapping("/{slug}")
fun findOne(@PathVariable slug: String) =
repository.findBySlug(slug) ?:
throw ResponseStatusException(HttpStatus.NOT_FOUND, "This article does not exist")
}
@RestController
@RequestMapping("/api/user")
class UserController(private val repository: UserRepository) {
@GetMapping("/")
fun findAll() = repository.findAll()
@GetMapping("/{login}")
fun findOne(@PathVariable login: String) =
repository.findByLogin(login) ?:
throw ResponseStatusException(HttpStatus.NOT_FOUND, "This user does not exist")
}
간단한 튜토리얼이므로 Service 게층 없이 Controller에서 바로 repository를 호출하도록 하였습니다.
이제 Controller에 대한 unit test를 해 보려면, repository에는 의존하면 안 되는데요. 이를 위해 @WebMvcTest와 Mockk를 사용하여 repository를 mocking하여 테스트해 보겠습니다.
우선 build.gradle.kts에 아래 내용을 추가해 주시구요.
testImplementation("org.springframework.boot:spring-boot-starter-test") {
exclude(module = "junit")
exclude(module = "mockito-core")
}
testImplementation("org.junit.jupiter:junit-jupiter-api")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
testImplementation("com.ninja-squad:springmockk:3.0.1")
테스트를 작성해 줍니다. (SpringBootTest가 아닌 이유는, SpringBootTest를 붙이면 모든 Bean을 다 등록시키기 때문에 Unit Test로써의 역할에 미흡해집니다!)
// src/test/kotlin/com/tistory/katfun/blog/HttpControllersTests.kt
@WebMvcTest
class HttpControllersTests(@Autowired val mockMvc: MockMvc) {
@MockkBean
private lateinit var userRepository: UserRepository
@MockkBean
private lateinit var articleRepository: ArticleRepository
@Test
fun `List articles`() {
val juergen = User("springjuergen", "Juergen", "Hoeller")
val spring5Article = Article(
"Spring Framework 5.0 goes GA", "Dear Spring community ...", "Lorem ipsum", juergen)
val spring43Article = Article(
"Spring Framework 4.3 goes GA", "Dear Spring community ...", "Lorem ipsum", juergen)
every { articleRepository.findAllByOrderByAddedAtDesc() }
returns listOf(spring5Article, spring43Article)
mockMvc.perform(get("/api/article/").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("\$.[0].author.login").value(juergen.login))
.andExpect(jsonPath("\$.[0].slug").value(spring5Article.slug))
.andExpect(jsonPath("\$.[1].author.login").value(juergen.login))
.andExpect(jsonPath("\$.[1].slug").value(spring43Article.slug))
}
@Test
fun `List users`() {
val juergen = User("springjuergen", "Juergen", "Hoeller")
val smaldini = User("smaldini", "Stéphane", "Maldini")
every { userRepository.findAll() } returns listOf(juergen, smaldini)
mockMvc.perform(get("/api/user/").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("\$.[0].login").value(juergen.login))
.andExpect(jsonPath("\$.[1].login").value(smaldini.login))
}
}
테스트 통과까지 확인하시면 됩니다.
8. Application Properties 설정
앞서 HtmlController 내에서 값을 직접 설정하는 방식을 코드를 작성했는데, 이 방법은 사실 바람직하지 않습니다.
Kotlin에서는 application properties를 @ConfigurationProperties와 @ConstructorBinding을 사용하여 read-only로 관리합니다. 이 내용을 적용해 보겠습니다.
먼저 BlogProperties.kt를 작성합니다.
// src/main/kotlin/com/tistory/katfun/blog/BlogProperties.kt
@ConstructorBinding
@ConfigurationProperties("blog")
data class BlogProperties(var title: String, val banner: Banner) {
data class Banner(val title: String? = null, val content: String)
}
그리고 이것을 적용합니다.
// src/main/kotlin/com/tistory/katfun/blog/BlogApplication.kt
@SpringBootApplication
@EnableConfigurationProperties(BlogProperties::class)
class BlogApplication {
// ...
}
(작성하신 프로젝트명에 따라 Application.kt의 이름이 다를 수 있습니다!)
다음으로, IDE가 직접 작성한 메타데이터를 인식하려면, kapt가 설정되어야 합니다.
build.gradle.kts 파일을 열고, 아래 내용을 추가해 주세요.
plugins {
...
kotlin("kapt") version "1.6.21"
}
dependencies {
...
kapt("org.springframework.boot:spring-boot-configuration-processor")
}
이어서, application.yml (properties)에 내용을 추가해 줍시다.
blog:
title: Blog
banner:
title: Warning
content: The blog will be down tomorrow.
이에 맞춰서 mustache 템플릿도 수정해 주시구요.
이후 애플리케이션을 시작하면, 위 properties 내용이 인식되어 나타나는 것을 확인할 수 있습니다.
9. 마무리
두서없이 튜토리얼을 따라 하며 글을 작성해 보았는데요.
어떻게 하면 코드를 조금 더 '코틀린 스럽게' 작성할 수 있는지 맛만 봤다고 생각합니다.
저도 이어서 Spring 및 Kotlin 각각에 대해 열심히 공부할 예정입니다.
참고한 글:
- https://stackoverflow.com/questions/61308052/kotlin-1-3-with-gradle-kotlin-not-configured-error-after-marking-the-source
- https://stackoverflow.com/questions/30474767/no-tests-found-for-given-includes-error-when-running-parameterized-unit-test-in
- https://stackoverflow.com/questions/50128728/how-do-i-use-the-native-junit-5-support-in-gradle-with-the-kotlin-dsl
- https://basketdeveloper.tistory.com/73
'개발 > 스프링' 카테고리의 다른 글
H2 Database를 이용하여 간단하게 개발 시작하기 (0) | 2022.12.30 |
---|---|
[HTTP] HTTP 메소드와 사용 예 (0) | 2022.07.19 |
[Spring] Dependency Injection을 수행하는 세 가지 방법 (0) | 2022.07.11 |
[JPA] 조회 메소드에 파라미터 추가하기 (0) | 2022.07.04 |
@Transactional 어노테이션 (0) | 2022.07.03 |
댓글