잡동사니

Springboot기반 코틀린 입문 본문

IT/Kotlin

Springboot기반 코틀린 입문

yeTi 2023. 1. 30. 14:41

안녕하세요. 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 IDEAAndroid 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")
}

코틀린에서 Entitylazy 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")
}

코틀린에서는 @WebMvcTestMockito 보다 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 입문자 분들께 추천합니다.

Comments