Springboot기반 코틀린 입문
안녕하세요. yeTi입니다.
오늘은 Building web applications with Spring Boot and Kotlin을 참고하여 spring기반
에서 코틀린을 사용
하기 위한 기본 정보
를 얻고자 합니다.
학습 목표는 Html 페이지
구성 부터 RestAPI
, JPA
, Properties
에 이르기까지 웹 어플리케이션을 만들기 위한 기본 환경 및 통합 테스트
, API 테스트
, JPA 테스트
에 이르기까지 개발함에 있어서 필수적인 테스트 환경에 대해서 익히는 것입니다.
샘플 코드 Spring guide 깃헙에서 확인할 수 있습니다.
계기
Kotlin에 대해 관심을 가지게 된 계기는 백엔드 개발자들 사이에서 코프링(Kotlin + Spring)
이라는 용어가 생길정도로 코틀린에 대한 관심도가 증가하고 있다는 느낌을 받았습니다.
추가적으로 간단한 사내 프로젝트를 진행할 기회를 새로운 언어에 대한 학습 기회로 삼을 수 있겠다는 판단에 팀원들과 코틀린으로 프로젝트를 진행해보고자 제안했습니다.
심심해서 찾아본 구글 트랜드를 보면 코틀린이 생각보다 널리 사용되고 있지 않다는 생각도 듭니다.
빌드 환경
코틀린을 사용할 때 gradle
을 사용하길 추천합니다. 왜냐하면 Kotlin DSL
을 기본적으로 제공하기 때문입니다. 예시 하지만 취향에 따라 maven
을 사용하더라도 무방합니다.
Plugins
build.gradle.kts
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
id("org.springframework.boot") version "3.0.1"
id("io.spring.dependency-management") version "1.1.0"
kotlin("jvm") version "1.8.0" // Kotlin Gradle plugin
kotlin("plugin.spring") version "1.8.0" // kotlin-spring plugin
kotlin("plugin.jpa") version "1.8.0" // Kotlin JPA plugin
}
코틀린 사용시 기본적으로 2개의 플러그인을 사용하고 JPA를 위해 플러그인을 추가할 수 있습니다.
kotlin-spring plugin
은 스프링 어노테이션 사용시 자동으로 open(?) 해주는 기능을 지원하고 Kotlin JPA plugin
은 JPA 클래스에 대해서 기본 생성자를 생성해주고 non-nullable
프로퍼티를 사용할 수 있도록 지원을 합니다.
Compiler options
tasks.withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs += "-Xjsr305=strict"
}
}
코틀린의 좋은 기능 중 하나는 컴파일시 null-safety
를 보장받을 수 있다는 것입니다. 따라서 spring 에서는 프레임워크 차원에서 모든 Spring Framework API
가 null-safe한지 검사하도록 지원하는데요. 위 옵션이 이를 엄격하게 감시하도록 하겠다는 옵션입니다.
Dependencies
dependencies {
...
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
...
}
Springboot
사용시 spring-boot-starter-web
dependency에 jackson
라이브러리가 포함되어 있습니다. 어떠한 이유에서인지는 모르겠지만 코틀린을 위한 jackson 라이브러리를 추가해줘야 합니다.
Reflection
의 경우 스프링에서 편의성을 위해 사용하고 있습니다. 코틀린의 경우 별도의 reflection을 위한 라이브러리를 추가 해줘야 하는거 같습니다.
src/main/resources/application.properties
spring.jpa.properties.hibernate.globally_quoted_identifiers=true
spring.jpa.properties.hibernate.globally_quoted_identifiers_skip_column_definitions = true
H2
dependency 추가시 최신 버전의 경우 예약어 이슈가 있어 이를 피하기 위해 위 옵션을 추가해줘야 합니다.
Application 구성
package com.ho.practice.kotlin.practicekotlin
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class PracticeKotlinApplication
fun main(args: Array<String>) {
runApplication<PracticeKotlinApplication>(*args)
/* inline 함수로 정의됨
inline fun <reified T : Any> runApplication(vararg args: String): ConfigurableApplicationContext =
SpringApplication.run(T::class.java, *args)
/*
}
코틀린의 문법적 특성을 확인할 수 있습니다.
- 세미콜론(;) 부재
- 클래스에 대해 중괄호({) 부재
간단한 Page 생성
HtmlController.kt
@Controller
class HtmlController {
@GetMapping("/")
fun blog(model: Model): String {
model["title"] = "Blog"
return "blog"
}
}
위 코드를 보면 기존 spring의 스타일을 유지하고 있습니다. 이러한 스타일 자체가 코틀린 extension
을 사용하고 있는 부분을 확인할 수 있습니다. model["title"] = "Blog"
문법은 org.springframework.ui.set
extension에서 지원하는 스타일입니다. 보다 자세한 내용은 Spring Framework KDoc API를 통해 확인할 수 있습니다.
header.mustache
<html>
<head>
<title>{{title}}</title>
</head>
<body>
footer.mustache
</body>
</html>
blog.mustache
{{> header}}
<h1>{{title}}</h1>
{{> footer}}
위와 같이 구성 후 결과를 확인하면 Blog
타이틀을 가진 페이지를 확인할 수 있습니다. (실행 환경 및 디버깅 환경에 대한 정보는 아래 내용을 참고하세요.)
실행환경 구성 (feat. 디버깅)
VScode
자바와 코틀린을 컴파일하고 실행할 수있도록 환경을 구성합니다.
- 자바
- Kotlin compiler
Gradle을 활용하여 간단하게 구동할 수 있습니다. (Gradle bootRun)
참고자료: Spring + Kotlin 환경 세팅 (Ubuntu 20.04 + VScode)
하지만 디버깅 환경을 구성하기 위해서는 IDE에서 컴파일하고 실행 환경까지 구성해줘야하는데 VSCode에서는 공식적으로 Kotlin을 지원하지 않습니다. Programming Languages
추가적으로 플러그인을 활용하여 디버깅 환경을 구성해보려고 시도했지만 디버깅 모드 진입시 kotlin source를 인식하지 못하는 문제를 해결하지 못했습니다.
STS (Spring Tools 4)
이후 STS를 활용하여 디버깅 환경을 구성하려 했지만 VSCode와 동일한 문제로 인하여 실패했습니다.
Intellij
코틀린 공식 홈페이지에서 제공하는 IDE support를 보면 공식 지원 IDE는 IntelliJ IDEA
와 Android Studio
인것을 확인할 수 있었습니다. Get started with Kotlin/JVM에도 IntelliJ IDEA
를 사용하도록 가이드하고 있습니다.
결국 IntelliJ IDEA
를 사용해서 간편하게 디버깅 모드로 잘 동작하는것을 확인했습니다.
JUnit 5를 사용하여 Testing 환경 구성하기
IntegrationTests.kt
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
// @TestInstance(TestInstance.Lifecycle.PER_CLASS)
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")
}
}
Springboot
에서는 JUnit5
를 기본 테스트 라이브러리
로 지원합니다. Dependency injection
시 자바에서 final
과 동일한 효과를 코틀린의 val
를 활용해서 가질 수 있습니다.
또한 backticks(
)`를 활용하면 문장 형태로 함수의 네이밍을 할 수 있는 특징도 확인할 수 있습니다.
Test 인스턴스의 lifecycle
src/test/resources/junit-platform.properties
junit.jupiter.testinstance.lifecycle.default = per_class
기본적으로 테스트 클래스들은 매 테스트마다 초기화 됩니다. 하지만 간혹 클래스의 테스트 전/후로 액션 정의가 필요한 경우가 있는데요. 이를 위해 java
에서는 static method
를 활용했습니다. (코틀린에서는 companion object를 사용할 수 있습니다.)
하지만 JUnit5
부터는 테스트 인스턴스의 기본 라이프사이클에 대해 정의할 수 있고, per_class
옵션과 함께 @BeforeAll
, @AfterAll
어노테이션을 사용할 수 있습니다.
Extension 만들기
흔히 java
에서 유틸성 기능들을 사용하기 위해 static method
를 활용합니다. 이를 코틀린에서는 extensions
로 지원합니다.
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(), "-")
JPA 사용하기
build.gradle
plugins {
...
kotlin("plugin.allopen") version "1.8.0"
}
allOpen {
annotation("jakarta.persistence.Entity")
annotation("jakarta.persistence.Embeddable")
annotation("jakarta.persistence.MappedSuperclass")
}
코틀린에서 Entity
의 lazy fetch
를 활용하기 위해서는 allopen
플러그인을 사용해야 합니다.
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)
위 코드를 보면 코틀린
에서 제공하는 primary constructor
를 사용하여 변수 선언
및 all args constructor
를 정의한 것을 확인할 수 있습니다.
Article
클래스를 자바 코드로 변경하면 다음과 같습니다.
@Entity
public class Article {
private final String title;
private final String headline;
private final String content;
@ManyToOne
private final User author;
private final String slug;
private final LocalDateTime addedAt;
@Id @GeneratedValue
private final Long id;
public Article(String title, String headline, String content, User author, String slug, LocalDateTime addedAt, Long id) {
this.title = title;
this.headline = headline;
this.content = content;
this.author = author;
this.slug = title.toSlug();
this.addedAt = LocalDateTime.now();
this.id = id;
}
}
또한, Artlcle
클래서의 var slug: String = title.toSlug(),
구문을 통하여 이전에 만든 extension
을 사용하는 것도 확인할 수 있습니다.
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?
}
참고적으로 Iterable
은 인터페이스로 탐색할 수 있는 자료구조로 반환하겠다는 의도로 해석하면 될거 같습니다.
RepositoriesTests.kt
@DataJpaTest
class RepositoriesTests @Autowired constructor(
val entityManager: TestEntityManager,
val userRepository: UserRepository,
val articleRepository: ArticleRepository) {
@Test
fun `When findByIdOrNull then return Article`() {
val johnDoe = User("johnDoe", "John", "Doe")
entityManager.persist(johnDoe)
val article = Article("Lorem", "Lorem", "dolor sit amet", johnDoe)
entityManager.persist(article)
entityManager.flush()
val found = articleRepository.findByIdOrNull(article.id!!)
assertThat(found).isEqualTo(article)
}
@Test
fun `When findByLogin then return User`() {
val johnDoe = User("johnDoe", "John", "Doe")
entityManager.persist(johnDoe)
entityManager.flush()
val user = userRepository.findByLogin(johnDoe.login)
assertThat(user).isEqualTo(johnDoe)
}
}
참고적으로 class RepositoriesTests @Autowired constructor
구문은 자바에서 생성자 @Autowired
로 인식하면 됩니다.
blog 고도화
blog.mustache
{{> header}}
<h1>{{title}}</h1>
<div class="articles">
{{#articles}}
<section>
<header class="article-header">
<h2 class="article-title"><a href="/article/{{slug}}">{{title}}</a></h2>
<div class="article-meta">By <strong>{{author.firstname}}</strong>, on <strong>{{addedAt}}</strong></div>
</header>
<div class="article-description">
{{headline}}
</div>
</section>
{{/articles}}
</div>
{{> footer}}
article.mustache
{{> header}}
<section class="article">
<header class="article-header">
<h1 class="article-title">{{article.title}}</h1>
<p class="article-meta">By <strong>{{article.author.firstname}}</strong>, on <strong>{{article.addedAt}}</strong></p>
</header>
<div class="article-description">
{{article.headline}}
{{article.content}}
</div>
</section>
{{> footer}}
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)
}
참고적으로 repository.findAllByOrderByAddedAtDesc().map { it.render() }
구문은 조회한
Article엔티티들을 각각
reder()하라
라는 의미입니다. 여기서 render()
는 조회 결과를 위한 spec으로 정의했습니다.
repository.findBySlug(slug)?.render()?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "This article does not exist")
구문은 조회한 Article
엔티티가 null
일 수 있는데, null 이 아니면 render()
하고 null 이면 Exception()
하라고 해석할 수 있습니다.
BlogConfiguration.kt
@Configuration
class BlogConfiguration {
@Bean
fun databaseInitializer(userRepository: UserRepository,
articleRepository: ArticleRepository) = ApplicationRunner {
val jdoe = userRepository.save(User("jdoe", "John", "Doe"))
articleRepository.save(Article(
title = "Lorem ipsum 1",
headline = "Lorem ipsum 1",
content = "dolor sit amet",
author = jdoe
))
articleRepository.save(Article(
title = "Lorem ipsum 2",
headline = "Lorem ipsum 2",
content = "dolor sit amet",
author = jdoe
))
}
}
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>", "Lorem")
}
@Test
fun `Assert article page title, content and status code`() {
println(">> Assert article page title, content and status code")
val title = "Lorem ipsum 1"
val entity = restTemplate.getForEntity<String>("/article/${title.toSlug()}")
assertThat(entity.statusCode).isEqualTo(HttpStatus.OK)
assertThat(entity.body).contains(title, "Lorem", "dolor sit amet")
}
@AfterAll
fun teardown() {
println(">> Tear down")
}
}
RestAPI
HttpApiControllers.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")
}
코틀린에서는 @WebMvcTest
시Mockito 보다 Mockk가 더 적합합니다. 따라서 기존에 사용하던 @MockBean
,@SpyBean
대신 SpringMockK를 사용하게 됩니다.
build.gradle
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.junit.jupiter:junit-jupiter-api")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
testImplementation("com.ninja-squad:springmockk:4.0.0")
HttpApiControllersTests.kt
@WebMvcTest
class HttpControllersTests(@Autowired val mockMvc: MockMvc) {
@MockkBean
lateinit var userRepository: UserRepository
@MockkBean
lateinit var articleRepository: ArticleRepository
@Test
fun `List articles`() {
val johnDoe = User("johnDoe", "John", "Doe")
val lorem5Article = Article("Lorem", "Lorem", "dolor sit amet", johnDoe)
val ipsumArticle = Article("Ipsum", "Ipsum", "dolor sit amet", johnDoe)
every { articleRepository.findAllByOrderByAddedAtDesc() } returns listOf(lorem5Article, ipsumArticle)
mockMvc.perform(get("/api/article/").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("\$.[0].author.login").value(johnDoe.login))
.andExpect(jsonPath("\$.[0].slug").value(lorem5Article.slug))
.andExpect(jsonPath("\$.[1].author.login").value(johnDoe.login))
.andExpect(jsonPath("\$.[1].slug").value(ipsumArticle.slug))
}
@Test
fun `List users`() {
val johnDoe = User("johnDoe", "John", "Doe")
val janeDoe = User("janeDoe", "Jane", "Doe")
every { userRepository.findAll() } returns listOf(johnDoe, janeDoe)
mockMvc.perform(get("/api/user/").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("\$.[0].login").value(johnDoe.login))
.andExpect(jsonPath("\$.[1].login").value(janeDoe.login))
}
}
Properties 설정
BlogProperties.kt
@ConfigurationProperties("blog")
data class BlogProperties(var title: String, val banner: Banner) {
data class Banner(val title: String? = null, val content: String)
}
위 예제에서는 표현되지 않았지만 코틀린에서 applicaiton properties
를 관리하는 방법으로 read-only
속성으로 관리하는 것을 추천합니다.
BlogApplication.kt
@SpringBootApplication
@EnableConfigurationProperties(BlogProperties::class)
class BlogApplication {
// ...
}
build.gradle
plugins {
...
kotlin("kapt") version "1.8.0"
}
dependencies {
...
kapt("org.springframework.boot:spring-boot-configuration-processor")
}
IDE
에서 custom properties
가 있다는 것을 인식
하기 위해서 kapt
플러그인이 필요합니다. 또한 kapt 플러그인은 spring-boot-configuration-processor
dependency를 필요로 합니다.
application.properties
blog.title=Blog
blog.banner.title=Warning
blog.banner.content=The blog will be down tomorrow.
blog-banner.mustache
{{> header}}
<h1>{{title}}</h1>
<div class="articles">
{{#banner.title}}
<section>
<header class="banner">
<h2 class="banner-title">{{banner.title}}</h2>
</header>
<div class="banner-content">
{{banner.content}}
</div>
</section>
{{/banner.title}}
</div>
{{> footer}}
HtmlController.kt
@GetMapping("/banner")
fun blogBanner(model: Model): String {
model["title"] = properties.title
model["banner"] = properties.banner
model["articles"] = repository.findAllByOrderByAddedAtDesc().map { it.render() }
return "blog-banner"
}
결론
매우 간단하게 코틀린으로 springboot를 사용하여 웹 서비스를 만들기 위한 환경을 구성해봤습니다.
Spring의 장점이 예제 코드를 기반으로 간단하고 빠르게 웹 어플리케이션을 만들 수 있는 것처럼 해당 예제를 기반으로 코틀린에 대한 간단한 이해만 있다면 빠르게 웹 어플리케이션을 만들 수 있다고 생각합니다.
해당 내용은 코틀린에 대한 간단한 이해를 가진 springboot 입문자 분들께 추천합니다.