Spring Boot와 Kotlin으로 웹 애플리케이션 구축하기

Spring Boot와 Kotlin으로 웹 애플리케이션 구축하기

이 튜토리얼은 Spring BootKotlin을 결합하여 예제 블로그 애플리케이션을 효율적으로 빌드하는 방법을 설명합니다..

만약 Kotlin을 처음 시작한다면 참고 문서를 읽고 온라인 Kotlin Koans 자습서를 따라하며 언어를 배울 수 있습니다.

Spring Kotlin 지원은 Spring FrameworkSpring Boot 참고문서에 설명되어 있습니다. 도움이 필요하면 StackOverflow에서 Spring 및 kotlin 태그로 검색하거나 질문을 할 수 있고, Kotlin Slack#spring 채널에서 토론하십시오.

새 프로젝트 만들기

먼저 Spring Boot 애플리케이션을 만들어야하고, 이는 여러 가지 방법으로 수행 할 수 있습니다. 예를 들어, 우리는 Kotlin 생태계에서 가장 많이 사용하는 Gradle 빌드 시스템을 사용할 것이지만, 만약 원하는 경우 Maven도 자유롭게 사용할 수 있습니다.(예제 블로그 프로젝트의 일부로 Gradle 빌드와 동일한 Maven pom.xml을 사용할 수 있습니다).

Initializr 웹 사이트 사용

https://start.spring.io 로 이동하여 Kotlin 언어를 선택합니다. 또는 Kotlin을 미리 선택하려면 직접 https://start.spring.io/#!language=kotlin 로 방문할 수 있습니다.

그런 다음 Gradle 빌드 시스템, “blog” Artifact, “blog” Package Name(고급 설정)을 선택하고 “Web”, “Mustache”, “JPA”및 “H2” 종속성을 시작점으로 추가한 다음 “Generate Project”를 클릭합니다.

압축을 풀기전에 빈 디렉토리를 만듭니다. 그리고 .zip 파일의 루트 디렉토리에는 표준 Gradle 프로젝트가 포함되어 있습니다.

Command Line 사용

Command Line에서 Initializr HTTP API를 사용할 수 있습니다 (예 : UN*X 시스템의 curl).

1
2
$ mkdir blog && cd blog
$ curl https://start.spring.io/starter.zip -d type=gradle-project -d language=kotlin -d style=web,mustache,jpa,h2 -d packageName=blog -d name=Blog -o blog.zip

IntelliJ IDEA 사용

또한 Spring Initializr은 IntelliJ IDEA Ultimate 에디션에 통합되어 있어 IDE에서 Command Line 또는 웹 UI와 같이 새 프로젝트를 만들고 가져올 수 있습니다.

File | New | Project를 선택하고 Spring Initializr을 선택합니다.

각 단계에 따라 다음 파라미터를 사용합니다.

  • Package name: “blog”
  • Artifact: “blog”
  • Type: Gradle Project
  • Language: Kotlin
  • Name: “Blog”
  • Dependencies: “Web”, “Mustache”, JPA” and “H2”

생성된 프로젝트 이해하기

Gradle build

Plugins

명백한 Kotlin Gradle plugin 외에도, 기본 설정은 스프링 Annotation으로 annotated 또는 meta-annotated가 달린 클래스와 메서드가 자동으로 열리는(Automatically open)kotlin-spring plugin을 선언합니다 (Java와 달리 Kotlin의 기본 한정자(Qualifier)는 final입니다). 예를 들어 CGLIB 프록시에 필요한 open 한정자를 추가하지 않고도 @Configuration 또는 @Transactional Bean을 만들 수있는 경우에 유용합니다.

build.gradle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
buildscript {
ext {
kotlinVersion = '1.2.41'
springBootVersion = '2.0.2.RELEASE'
}
repositories {
mavenCentral()
}
dependencies {
classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVersion}")
classpath("org.jetbrains.kotlin:kotlin-allopen:${kotlinVersion}")
}
}
apply plugin: 'kotlin'
apply plugin: 'kotlin-spring'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

컴파일러 옵션 (Compiler options)

Kotlin의 핵심 기능 중 하나는 런타임시 유명한 NullPointerException에 부딪히지 않고 컴파일 타임에 null값을 깔끔하게 처리하는 null-safety입니다. 따라서 애플리케이션이 null 허용 선언을 통해 안전하고, Optional(“값 또는 값 없음” 의미를 표현)과 같은 래퍼의 비용을 지불하지 않아도됩니다. Kotlin은 nullable 값을 가진 함수 생성자를 사용할 수 있습니다. Kotlin null-safety에 대한 포괄적 가이드를 확인하십시오.

Java는 type-system에서 null-safety를 허용하지 않지만 Spring Framework는 org.springframework.lang 패키지에 선언된 도구 친화적인(Tooling-friendly) Annotation을 통해 전체 Spring Framework API의 null-safety를 제공합니다. 기본적으로 Kotlin에서 사용되는 Java API의 타입은 null 체크가 완화된 플랫폼 타입으로 인식됩니다. JSR 305 annotations + Spring Nullability Annotation에 대한 Kotlin 지원은 컴파일 타임에 null 관련 문제를 처리 할 수 있다는 이점을 가지고 Kotlin 개발자에게 전체 Spring Framework API에 대한 null-safety를 제공합니다.

이 기능은 -Xjsr305 컴파일러 플래그에 strict 옵션을 추가하여 활성화 할 수 있습니다.

아래 코드의 Kotlin 컴파일러는 Java 8 바이트 코드 (Java 6이 기본값)를 생성하도록 구성되어 있습니다.

build.gradle

1
2
3
4
5
6
7
8
9
10
11
12
13
sourceCompatibility = 1.8
compileKotlin {
kotlinOptions {
freeCompilerArgs = ["-Xjsr305=strict"]
jvmTarget = "1.8"
}
}
compileTestKotlin {
kotlinOptions {
freeCompilerArgs = ["-Xjsr305=strict"]
jvmTarget = "1.8"
}
}

종속성(Dependencies)

Kotlin 특정 라이브러리 3개는 이러한 Spring Boot 웹 애플리케이션에 필요하며 기본 구성에 필요합니다.

  • kotlin-stdlib-jdk8은 Kotlin 표준 라이브러리의 Java 8 변형입니다.
  • kotlin-reflect은 Kotlin 리플렉션 라이브러리입니다 (Spring Framework 5에서 필수).
  • jackson-module-kotlin은 Kotlin 클래스 및 데이터 클래스의 직렬화 / 비 직렬화에 대한 지원을 추가합니다 (단일 생성자 클래스는 자동으로 사용할 수 있고 보조 생성자 또는 정적 팩토리가 있는 클래스도 지원됩니다)

build.gradle

1
2
3
4
5
6
7
8
9
dependencies {
compile('org.springframework.boot:spring-boot-starter-data-jpa')
compile('org.springframework.boot:spring-boot-starter-web')
compile('org.springframework.boot:spring-boot-starter-mustache')
compile('com.fasterxml.jackson.module:jackson-module-kotlin')
compile("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
compile("org.jetbrains.kotlin:kotlin-reflect")
testCompile('org.springframework.boot:spring-boot-starter-test')
}

Spring Boot Gradle 플러그인은 Kotlin Gradle 플러그인에 선언된 Kotlin 버전을 자동으로 사용합니다.

Application

src/main/kotlin/blog/BlogApplication.kt

1
2
3
4
5
6
7
8
9
10
11
package blog
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class BlogApplication
fun main(args: Array<String>) {
runApplication<BlogApplication>(*args)
}

Java와 비교하면, 세미콜론이 줄어들었고, 빈 클래스에 괄호가 없으며 (@Bean Annotation을 통해 Bean을 선언해야하는 경우 추가 할 수 있습니다), 최상위 수준 함수 runApplication을 사용할 수 있다는 것을 알수 있습니다. runApplication<BlogApplication>(*args)SpringApplication.run(BlogApplication::class.java, *args)에 대한 Kotlin의 대안적인 표현이며, 다음 구문을 사용하여 사용자 정의 애플리케이션을 사용할 수 있습니다.

src/main/kotlin/blog/BlogApplication.kt

1
2
3
4
5
fun main(args: Array<String>) {
runApplication<BlogApplication>(*args) {
setBannerMode(Banner.Mode.OFF)
}
}

첫 번째 Kotlin 컨트롤러 작성하기

간단한 웹 페이지를 표시하는 간단 컨트롤러를 만들어 보겠습니다.

src/main/kotlin/blog/HtmlController.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package blog
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.ui.set
import org.springframework.web.bind.annotation.GetMapping
@Controller
class HtmlController {
@GetMapping("/")
fun blog(model: Model): String {
model["title"] = "Blog"
return "blog"
}
}

Kotlin extension을 사용하여 기존의 Spring 타입에 Kotlin 함수나 연산자를 추가 할 수 있습니다. 여기에서는 model.addAttribute("title", "Blog") 대신 model["title"] = "Blog"를 사용할 수 있도록 org.springframework.ui.set 확장 함수를 가져옵니다.

그리고 관련된 Mustache 템플릿을 만들어야합니다.

src/main/resources/templates/header.mustache

1
2
3
4
5
<html>
<head>
<title>{{title}}</title>
</head>
<body>

src/main/resources/templates/footer.mustache

1
2
</body>
</html>

src/main/resources/templates/blog.mustache

1
2
3
4
5
{{> header}}
<h1>{{title}}</h1>
{{> footer}}

BlogApplication.ktmain 함수를 실행하여 응용프로그램을 시작하고, http://localhost:8080/으로 이동하면 “Blog” 제목이 있는 단순한 웹 페이지가 나타납니다.

JUnit 5로 테스트하기

JUnit 4는 여전히 Spring Boot와 함께 제공되는 기본 테스트 프레임 워크이지만, JUnit 5는 null이 허용되지 않는 val 프로퍼티를 사용할 수 있는 생성자/메서드 파라미터의 Autowiring을 포함하여 Kotlin에서 매우 편리한 다양한 기능을 제공합니다. 그리고 @BeforeAll/@AfterAll을 일반적인 비 정적 메서드에서 사용할 수 있습니다.

JUnit 4에서 JUnit 5로 전환

먼저 네이티브 JUnit 5 지원을 활용할 수 있도록 ./gradlew -version을 실행하여 Gradle 4.6 이상을 사용하고 있는지 확인하십시오. 이전 버전을 사용하는 경우 ./gradlew wrapper --gradle-version 4.7을 실행하여 최신 버전의 Gradle을 업데이트 할 수 있습니다.

build.gradle 파일에 다음 줄을 추가하여 JUnit 5 지원을 활성화합니다.

build.gradle

1
2
3
test {
useJUnitPlatform()
}

그런 다음 junitspring-boot-starter-test 전이(transitive) 의존성에서 제외하고 junit-jupiter-apijunit-jupiter-engine을 추가합니다.

build.gradle

1
2
3
4
5
6
7
dependencies {
testCompile('org.springframework.boot:spring-boot-starter-test') {
exclude module: 'junit'
}
testImplementation('org.junit.jupiter:junit-jupiter-api')
testRuntimeOnly('org.junit.jupiter:junit-jupiter-engine')
}

Gradle 설정을 새로 고치고 BlogApplicationTests를 열어 @RunWith(SpringRunner::class)@ExtendWith(SpringExtension::class)로 대체합니다.

src/test/kotlin/blog/BlogApplicationTests.kt

1
2
3
4
5
6
7
8
9
@ExtendWith(SpringExtension::class)
@SpringBootTest
class BlogApplicationTests {
@Test
fun contextLoads() {
}
}

테스트는 Command Line과 IDE에서 잘 실행되어야합니다.

Kotlin에서 JUnit 5 테스트 작성하기

이 예제에서는 다양한 기능을 보여주기 위해 통합 테스트를 작성해 보겠습니다.

  • 표현형 테스트 함수 이름을 제공하기 위해 camel-case 대신 백틱 사이에 실제 문장을 사용합니다.
  • JUnit 5는 생성자와 메서드 파라미터를 삽입(Inject) 할 수 있습니다.이 파라미터는 Kotlin 불변(Immutable) 및 Nullable 속성과 잘 어울립니다.
  • 이 코드는 getForObjectgetForEntity Kotlin extension을 사용합니다 (import 할 필요가 있음).

src/test/kotlin/blog/IntegrationTests.kt

1
2
3
4
5
6
7
8
9
10
11
12
@ExtendWith(SpringExtension::class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class IntegrationTests(@Autowired val restTemplate: TestRestTemplate) {
@Test
fun `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>")
}
}

테스트 인스턴스 생명주기(Lifecycle)

때로는 주어진 클래스의 모든 테스트 전후에 메서드를 실행해야 할 때가 있습니다. Junit 4와 마찬가지로 JUnit 5는 테스트 클래스가 테스트마다 한 번 인스턴스화되기 때문에 기본적으로 이 메서드가 정적 (이것은 Kotlin의 companion object로 변환되며, 이는 매우 장황하고 직설적이지 않습니다.) 이어야합니다.

그러나 Junit 5에서는 이 기본 동작을 변경하고 클래스당 한 번 테스트 클래스를 인스턴스화 할 수 있습니다. 이 작업은 다양한 방법으로 수행 할 수 있습니다. 여기서는 프로퍼티 파일을 사용하여 전체 프로젝트의 기본 동작을 변경합니다.

src/test/resources/junit-platform.properties

1
junit.jupiter.testinstance.lifecycle.default = per_class

이 구성(Configuration)을 사용하면 위의 IntegrationTest의 업데이트된 버전과 같은 일반 메서드에서 @BeforeAll@AfterAll Annotation을 사용할 수 있습니다.

src/test/kotlin/blog/IntegrationTests.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@ExtendWith(SpringExtension::class)
@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")
}
}

JPA를 이용한 Persistence

Kotlin 불변 클래스(Immutable class)를 사용하려면 Kotlin JPA 플러그인을 활성화해야합니다. @Entity, @MappedSuperclass 또는 @Embeddable로 Annotation된 모든 클래스에 대해 파라미터가 없는 생성자를 생성합니다.

build.gradle

1
2
3
4
5
6
buildscript {
dependencies {
classpath("org.jetbrains.kotlin:kotlin-noarg:${kotlinVersion}")
}
}
apply plugin: 'kotlin-jpa'

그런 다음 데이터를 보유하고 equals(), hashCode(), toString(), componentN() 함수 및 copy()를 자동으로 제공하도록 설계된 Kotlin 데이터 클래스(Data class)를 사용하여 모델을 만듭니다.

src/main/kotlin/blog/Model.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Entity
data class Article(
val title: String,
val headline: String,
val content: String,
@ManyToOne @JoinColumn val author: User,
@Id @GeneratedValue val id: Long? = null,
val addedAt: LocalDateTime = LocalDateTime.now())
@Entity
data class User(
@Id val login: String,
val firstname: String,
val lastname: String,
val description: String? = null)

기본 값이 있는 선택적 파라미터는 위치가 있는 파라미터를 사용할 때 생략 할 수 있도록 마지막 위치에 정의됩니다 (Kotlin은 이름이 있는 파라미터도 지원함). Kotlin에서 동일한 파일에서 간결한 클래스 선언을 그룹화하는 것은 이례적인 일이 아닙니다.

Spring Data JPA Repository를 다음과 같이 선언합니다.

src/main/kotlin/blog/Repositories.kt

1
2
3
4
5
interface ArticleRepository : CrudRepository<Article, Long> {
fun findAllByOrderByAddedAtDesc(): Iterable<Article>
}
interface UserRepository : CrudRepository<User, String>

그리고 JPA 테스트를 작성하여 기본 유스 케이스가 예상대로 작동하는지 확인합니다.

src/test/kotlin/blog/RepositoriesTests.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@ExtendWith(SpringExtension::class)
@DataJpaTest
class RepositoriesTests(@Autowired val entityManager: TestEntityManager,
@Autowired val userRepository: UserRepository,
@Autowired val articleRepository: ArticleRepository) {
@Test
fun `When findById 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.findById(article.id!!)
assertThat(found.get()).isEqualTo(article)
}
@Test
fun `When findById then return User`() {
val juergen = User("springjuergen", "Juergen", "Hoeller")
entityManager.persist(juergen)
entityManager.flush()
val found = userRepository.findById(juergen.login)
assertThat(found.get()).isEqualTo(juergen)
}
}

나만의 확장 기능 만들기

Java에서 추상 메서드로 util 클래스를 사용하지만, Kotlin에서는 Kotlin 확장을 통해 제공하는 것이 일반적입니다. 여기서 영어 날짜 형식으로 텍스트를 생성하기 위해 기존 LocalDateTime 타입에 format() 함수를 추가 할 것입니다.

src/main/kotlin/blog/Extensions.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
fun LocalDateTime.format() = this.format(englishDateFormatter)
private val daysLookup = (1..31).associate { it.toLong() to getOrdinal(it) }
private val englishDateFormatter = DateTimeFormatterBuilder()
.appendPattern("MMMM")
.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"
}

다음 섹션에서 이 확장을 활용할 것입니다.

블로그 엔진 구현하기

우리가 구현중인 블로그 엔진은 Markdown을 HTML로 렌더링해야하며, 이를 위해 commonmark 라이브러리를 사용할 것입니다.

build.gradle

1
2
3
4
dependencies {
compile("com.atlassian.commonmark:commonmark:0.11.0")
compile("com.atlassian.commonmark:commonmark-ext-autolink:0.11.0")
}

Kotlin 함수 타입을 활용하는 MarkdownConverter Bean을 소개합니다.

src/main/kotlin/blog/MarkdownConverter.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
class MarkdownConverter : (String?) -> String {
private val parser = Parser.builder().extensions(listOf(AutolinkExtension.create())).build()
private val renderer = HtmlRenderer.builder().build()
override fun invoke(input: String?): String {
if (input == null || input == "") {
return ""
}
return renderer.render(parser.parse(input))
}
}

그리고 HTML을 렌더링 할 수 있는 커스텀 Mustache.Compiler Bean을 제공합니다.

src/main/kotlin/blog/BlogApplication.kt

1
2
3
4
5
6
7
@SpringBootApplication
class BlogApplication {
@Bean
fun mustacheCompiler(loader: Mustache.TemplateLoader?) =
Mustache.compiler().escapeHTML(false).withLoader(loader)
}

Null을 허용하는 Mustache.TemplateLoader?는 선택적인 Bean이라는 것을 의미합니다(JPA 전용 테스트를 실행할 때 실패를 피하기 위해서).

“Blog” Mustache 템플릿을 업데이트합니다.

src/main/resources/templates/blog.mustache

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{{> header}}
<h1>{{title}}</h1>
<div class="articles">
{{#articles}}
<section>
<header class="article-header">
<h2 class="article-title"><a href="/article/{{id}}">{{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”를 만듭니다.

src/main/resources/templates/article.mustache

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{{> 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를 업데이트합니다. ArticleRepositoryMarkdownConverter 생성자 파라미터는 HtmlController가 단일 생성자 (암시적 @Autowired)를 가지고 있기 때문에 자동으로 Autowired 됩니다.

src/main/kotlin/blog/HtmlController.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@Controller
class HtmlController(private val repository: ArticleRepository,
private val markdownConverter: MarkdownConverter) {
@GetMapping("/")
fun blog(model: Model): String {
model["title"] = "Blog"
model["articles"] = repository.findAllByOrderByAddedAtDesc().map { it.render() }
return "blog"
}
@GetMapping("/article/{id}")
fun article(@PathVariable id: Long, model: Model): String {
val article = repository
.findById(id)
.orElseThrow { IllegalArgumentException("Wrong article id provided") }
.render()
model["title"] = article.title
model["article"] = article
return "article"
}
fun Article.render() = RenderedArticle(
title,
markdownConverter.invoke(headline),
markdownConverter.invoke(content),
author,
id,
addedAt.format()
)
data class RenderedArticle(
val title: String,
val headline: String,
val content: String,
val author: User,
val id: Long?,
val addedAt: String)
}

BlogApplication에 데이터 초기화를 추가합니다.

src/main/kotlin/blog/BlogApplication.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Bean
fun databaseInitializer(userRepository: UserRepository,
articleRepository: ArticleRepository) = CommandLineRunner {
val smaldini = User("smaldini", "Stéphane", "Maldini")
userRepository.save(smaldini)
articleRepository.save(Article(
"Reactor Bismuth is out",
"Lorem ipsum",
"dolor **sit** amet https://projectreactor.io/",
smaldini,
1
))
articleRepository.save(Article(
"Reactor Aluminium has landed",
"Lorem ipsum",
"dolor **sit** amet https://projectreactor.io/",
smaldini,
2
))
}

그리고 통합 테스트(Integration test)도 업데이트합니다.

src/test/kotlin/blog/IntegrationTests.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@ExtendWith(SpringExtension::class)
@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 entity = restTemplate.getForEntity<String>("/article/2")
assertThat(entity.statusCode).isEqualTo(HttpStatus.OK)
assertThat(entity.body).contains("Reactor Aluminium has landed",,
"<a href=\"https://projectreactor.io/\">https://projectreactor.io/</a>")
}
@AfterAll
fun teardown() {
println(">> Tear down")
}
}

웹 애플리케이션을 시작(또는 재시작)하고 http://localhost:8080/으로 이동하면 클릭 가능한 링크가있는 기사 목록이 나타나 특정 기사를 볼 수 있습니다.

HTTP API 노출

@RestController Annotation이 달린 컨트롤러를 통해 HTTP API를 구현합니다.

src/main/kotlin/blog/HttpApi.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@RestController
@RequestMapping("/api/article")
class ArticleController(private val repository: ArticleRepository,
private val markdownConverter: MarkdownConverter) {
@GetMapping("/")
fun findAll() = repository.findAllByOrderByAddedAtDesc()
@GetMapping("/{id}")
fun findOne(@PathVariable id: Long, @RequestParam converter: String?) = when (converter) {
"markdown" -> repository.findById(id).map { it.copy(
headline = markdownConverter.invoke(it.headline),
content = markdownConverter.invoke(it.content)) }
null -> repository.findById(id)
else -> throw IllegalArgumentException("Only markdown converter is supported")
}
}
@RestController
@RequestMapping("/api/user")
class UserController(private val repository: UserRepository) {
@GetMapping("/")
fun findAll() = repository.findAll()
@GetMapping("/{login}")
fun findOne(@PathVariable login: String) = repository.findById(login)
}

테스트의 경우 통합 테스트 대신 @WebMvcTest@MockBean을 사용하여 웹 레이어만 테스트합니다.

src/test/kotlin/blog/HttpApiTests.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@ExtendWith(SpringExtension::class)
@WebMvcTest
class HttpApiTests(@Autowired val mockMvc: MockMvc) {
@MockBean
private lateinit var userRepository: UserRepository
@MockBean
private lateinit var articleRepository: ArticleRepository
@MockBean
private lateinit var markdownConverter: MarkdownConverter
@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, 1)
val spring43Article = Article("Spring Framework 4.3 goes GA", "Dear Spring community ...", "Lorem ipsum", juergen, 2)
whenever(articleRepository.findAllByOrderByAddedAtDesc()).thenReturn(listOf(spring5Article, spring43Article))
whenever(markdownConverter.invoke(any())).thenAnswer { it.arguments[0] }
mockMvc.perform(get("/api/article/").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(jsonPath("\$.[0].author.login").value(juergen.login))
.andExpect(jsonPath("\$.[0].id").value(spring5Article.id!!))
.andExpect(jsonPath("\$.[1].author.login").value(juergen.login))
.andExpect(jsonPath("\$.[1].id").value(spring43Article.id!!))
}
@Test
fun `List users`() {
val juergen = User("springjuergen", "Juergen", "Hoeller")
val smaldini = User("smaldini", "Stéphane", "Maldini")
whenever(userRepository.findAll()).thenReturn(listOf(juergen, smaldini))
mockMvc.perform(get("/api/user/").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(jsonPath("\$.[0].login").value(juergen.login))
.andExpect(jsonPath("\$.[1].login").value(smaldini.login))
}
}

when은 예약된 Kotlin 키워드이므로, whenever 별칭을 제공하는 mockito-kotlin 라이브러리 (이스케이프된 `when` 도 사용 가능)를 사용하기로 결정한 이유입니다. 이를 사용하려면 다음 종속성을 추가하십시오.

build.gradle

1
2
3
dependencies {
testCompile("com.nhaarman:mockito-kotlin:1.5.0")
}

$는 문자열 보간(Interpolation)에 사용되기 때문에 문자열에서 이스케이프 해야합니다. @MockBean JUnit 5 파라미터 리졸버(Parameter resolver)가 아직 없으므로 지금은 lateinit var를 사용해야합니다.

Configuration properties

애플리케이션 프로퍼티를 관리하는 가장 좋은 방법은 @ConfigurationProperties를 활용하는 것입니다. 변경할 수 없는 프로퍼티는 아직 지원되지 않지만 Null이 허용되지 않는 프로퍼티를 처리해야하는 경우 lateinit var을 사용할 수 있습니다.

src/main/kotlin/blog/BlogProperties.kt

1
2
3
4
5
6
7
8
9
10
11
12
@ConfigurationProperties("blog")
class BlogProperties {
lateinit var title: String
val banner = Banner()
class Banner {
var title: String? = null
lateinit var content: String
}
}

그런 다음 BlogApplication 수준에서 사용하도록 설정합니다.

src/main/kotlin/blog/BlogApplication.kt

1
2
3
4
5
@SpringBootApplication
@EnableConfigurationProperties(BlogProperties::class)
class BlogApplication {
// ...
}

IDE에서 이러한 사용자 정의 프로퍼티를 인식하기 위해 자신의 메타 데이터를 생성하려면 spring-boot-configuration-processor 종속성을 사용하여 다음과 같이 Kapt가 설정되어야합니다.

build.gradle

1
2
3
4
apply plugin: 'kotlin-kapt'
dependencies {
kapt("org.springframework.boot:spring-boot-configuration-processor")
}

IntelliJ IDEA에서 :

  • 메뉴 File | Settings | Plugins | Spring Boot 에서 Spring Boot plugin이 활성화되어 있는지 확인하십시오.
  • Annotation 처리를 위해 메뉴의 File | Settings | Build, Execution, Deployement | Compiler | Annotation Processors | Enable annotation processing 을 활성화 합니다.
  • Kapt가 아직 IDEA에 통합되지 않았으므로 메타 데이터를 생성하려면 ./gradlew kaptKotlin 명령을 수동으로 실행해야합니다.

application.properties(Autocomplete, Validation 등)를 편집 할 때 사용자 정의 프로퍼티를 인식해야합니다.

src/main/resources/application.properties

1
2
3
blog.title=Blog
blog.banner.title=Warning
blog.banner.content=The blog will be down tomorrow.

그에 따라 템플릿과 컨트롤러를 수정합니다.

src/main/resources/templates/blog.mustache

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{{> header}}
<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}}

src/main/kotlin/blog/HtmlController.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Controller
class HtmlController(private val repository: ArticleRepository,
private val markdownConverter: MarkdownConverter,
private val properties: BlogProperties) {
@GetMapping("/")
fun blog(model: Model): String {
model["title"] = properties.title
model["banner"] = properties.banner
model["articles"] = repository.findAllByOrderByAddedAtDesc().map { it.render() }
return "blog"
}
// ...
}

웹 애플리케이션을 다시 시작하고 http://localhost:8080/을 새로 고치면 블로그 홈페이지에 배너가 나타납니다.

결론

이제 이 예제 Kotlin 블로그 애플리케이션을 빌드했습니다. 소스 코드는 Github에서 사용할 수 있습니다. 특정 기능에 대한 자세한 내용은 Spring FrameworkSpring Boot 참고 문서를 읽어 보십시오.


이 내용은 나중에 참고하기 위해 Spring Boot 가이드 문서를 공부하며 정리한 내용입니다.
의역, 오역, 직역이 있을 수 있음을 알려드립니다.
This post is a translation of this original article Building web applications with Spring Boot and Kotlin

참고

공유하기