최근 가장 성장하고 있는 언어인 코틀린에 대해 관심이 있으시다면 Ktor에 대해서도 들어보셨을 거라 생각합니다. 오늘 소개해드릴Ktor (Kay-tor로 발음)는 코틀린과 마찬가지로 JetBrains에서 개발된 프레임워크 로써 멀티 플랫폼에 대한 지원을 목적으로 개발되었습니다. Ktor를 사용하면 코루틴 기반의 비동기 서버와 HTTP 클라이언트 모두 개발이 가능합니다.
오늘은 Ktor를 이용하여 간단한 Todo 웹 서비스를 만들어보고 다른 프레임워크들과는 어떤 차이점이 있는지 알아보도록 하겠습니다.
수동으로 프로젝트를 만드는 방법도 있지만, Ktor 퀵 스타트에선 더 쉽게 프로젝트를 구성하기 위한 제너레이터인 start.ktor.io와 IntelliJ 플러그인을 제공하고 있습니다. 스프링에 익숙하신 분이라면 start.ktor.io는 start.spring.io와 똑같다고 보시면 됩니다. 저는 IntelliJ 플러그인을 사용해 프로젝트 구조를 생성하겠습니다.
첫번째로 IntelliJ Ktor plugin을 사용해 프로젝트를 생성합니다.
저는 CallLogging, DefaultHeaders, Jackson을 선택하였습니다.
두번째로 프로젝트의 GroupId와 ArtifactId를 입력해주세요.
입력 후 Next를 누르시면 아래와 같은 형태의 프로젝트 구조가 만들어집니다.
이번 예제에선 아래와 같이 build.gradle을 세팅해 주었습니다.
group 'com.digimon'
version '1.0-SNAPSHOT'
buildscript {
ext.kotlin_version = '1.3.61'
ext.ktor_version = '1.3.1'
ext.exposed_version = '0.21.+'
ext.h2_version = '1.4.200'
ext.jackson_version = '2.10.2'
repositories {
mavenCentral()
}
dependencies {
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
apply plugin: 'java'
apply plugin: 'kotlin'
apply plugin: 'application'
sourceSets {
main.kotlin.srcDirs = main.java.srcDirs = ['src']
test.kotlin.srcDirs = test.java.srcDirs = ['test']
main.resources.srcDirs = ['resources']
test.resources.srcDirs = ['testresources']
}
sourceCompatibility = 1.8
compileKotlin { kotlinOptions.jvmTarget = "1.8" }
compileTestKotlin { kotlinOptions.jvmTarget = "1.8" }
repositories {
mavenLocal()
mavenCentral()
jcenter()
}
dependencies {
compile "io.ktor:ktor-server-netty:$ktor_version"
compile "io.ktor:ktor-jackson:$ktor_version"
compile "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
compile "org.jetbrains.exposed:exposed-core:$exposed_version"
compile "org.jetbrains.exposed:exposed-jdbc:$exposed_version"
compile "org.jetbrains.exposed:exposed-dao:$exposed_version"
compile "org.jetbrains.exposed:exposed-java-time:$exposed_version"
compile "com.h2database:h2:$h2_version"
compile "com.zaxxer:HikariCP:3.4.2"
compile "com.fasterxml.jackson.core:jackson-databind:$jackson_version"
compile "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:$jackson_version"
compile "ch.qos.logback:logback-classic:1.2.3"
testImplementation "io.ktor:ktor-server-tests:$ktor_version"
}
위 코드에서 중요한 부분은 io.ktor로 시작하는 설정들과 org.jetbrains.exposed로 시작하는 설정들 입니다. 이번 예제에선 Jetbrains에서 만든 경량 SQL 프레임워크인 Exposed도 함께 사용하겠습니다. Exposed는 데이터 엑세스를 위한 방식으로 SQL DSL과 DAO 방식을 지원합니다. 이번 예제에선 DAO 방식을 사용하여 CRUD를 구현해보겠습니다. 2가지 방식에 대한 차이점은여기를 눌러 확인해보시기 바랍니다.
ktor {
deployment {
port = 9999
}
application {
modules = [ main.kotlin.MainKt.main ]
}
}
application.conf는 Ktor 프로젝트에서 메인 설정 파일입니다. 스프링의 application.yml 또는 application.properties와 동일하다고 볼 수 있습니다. 주목할 점은 application.conf 파일은 HOCON (Human-Optimized Config Object Notation) 표기법을 기본으로 사용하고 있습니다. HOCON외에도 프로퍼티 표기법도 지원합니다.
package main.kotlin
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer
import io.ktor.application.Application
import io.ktor.application.install
import io.ktor.features.CallLogging
import io.ktor.features.ContentNegotiation
import io.ktor.features.DefaultHeaders
import io.ktor.jackson.jackson
import io.ktor.routing.Routing
import main.kotlin.config.DatabaseInitializer
import main.kotlin.service.TodoService
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args) // 1
const val DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss"
fun Application.main(testing: Boolean = false) { // 2
install(DefaultHeaders)
install(CallLogging)
install(ContentNegotiation) {
jackson {
enable(SerializationFeature.INDENT_OUTPUT)
registerModule(JavaTimeModule().apply {
addSerializer(LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DATE_TIME_FORMAT)))
addDeserializer(LocalDateTime::class.java, LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DATE_TIME_FORMAT)))
})
}
}
install(Routing) { // 3
todo(TodoService())
}
DatabaseInitializer.init() // 4
}
package main.kotlin.config
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import main.kotlin.entity.Todos
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils.create
import org.jetbrains.exposed.sql.transactions.transaction
object DatabaseInitializer {
fun init() {
Database.connect(HikariDataSource(hikariConfig()))
transaction {
create(Todos)
}
}
}
private fun hikariConfig() =
HikariConfig().apply {
driverClassName = "org.h2.Driver"
jdbcUrl = "jdbc:h2:mem:test"
maximumPoolSize = 3
isAutoCommit = false
username = "sa"
password = "sa"
transactionIsolation = "TRANSACTION_REPEATABLE_READ"
validate()
}
suspend fun <T> query(block: () -> T): T = withContext(Dispatchers.IO) {
transaction {
block()
}
}
package main.kotlin.entity
import org.jetbrains.exposed.dao.IntEntity
import org.jetbrains.exposed.dao.IntEntityClass
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.sql.`java-time`.datetime
import java.time.LocalDateTime
// Table scheme
object Todos : IntIdTable() { // 1
val content = text("content").default("")
val done = bool("done").default(false)
val createdAt = datetime("created_at").index().default(LocalDateTime.now())
val updatedAt = datetime("updated_at").default(LocalDateTime.now())
}
// Entity
class Todo(id: EntityID<Int>) : IntEntity(id) { // 2
companion object : IntEntityClass<Todo>(Todos)
var content by Todos.content
var done by Todos.done
var createdAt by Todos.createdAt
var updatedAt by Todos.updatedAt
}
package main.kotlin.model
import java.time.LocalDateTime
data class TodoRequest(val content: String,
val done: Boolean?,
val createdAt: LocalDateTime?,
val updatedAt: LocalDateTime?)
package main.kotlin.model
import main.kotlin.entity.Todo
import java.time.LocalDateTime
data class TodoResponse(val id: Int,
val content: String,
val done: Boolean,
val createdAt: LocalDateTime,
val updatedAt: LocalDateTime) {
companion object {
fun of(todo: Todo) =
TodoResponse(
id = todo.id.value,
content = todo.content,
done = todo.done,
createdAt = todo.createdAt,
updatedAt = todo.updatedAt
)
}
}
request, response에 대응하는 DTO를 정의하였습니다.
package main.kotlin
import io.ktor.application.call
import io.ktor.features.BadRequestException
import io.ktor.http.HttpStatusCode
import io.ktor.request.receive
import io.ktor.response.respond
import io.ktor.routing.Routing
import io.ktor.routing.delete
import io.ktor.routing.get
import io.ktor.routing.post
import io.ktor.routing.put
import io.ktor.routing.route
import io.ktor.util.KtorExperimentalAPI
import main.kotlin.model.TodoRequest
import main.kotlin.service.TodoService
@KtorExperimentalAPI
fun Routing.todo(service: TodoService) { // 1
route("todos") { // 2
get {
call.respond(service.getAll())
}
get("/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
?: throw BadRequestException("Parameter id is null")
call.respond(service.getById(id))
}
post {
val body = call.receive<TodoRequest>()
service.new(body.content)
call.response.status(HttpStatusCode.Created)
}
put("/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
?: throw BadRequestException("Parameter id is null")
val body = call.receive<TodoRequest>()
service.renew(id, body)
call.response.status(HttpStatusCode.NoContent)
}
delete("/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
?: throw BadRequestException("Parameter id is null")
service.delete(id)
call.response.status(HttpStatusCode.NoContent)
}
}
}
package main.kotlin.service
import io.ktor.features.NotFoundException
import io.ktor.util.KtorExperimentalAPI
import main.kotlin.config.query
import main.kotlin.entity.Todo
import main.kotlin.entity.Todos
import main.kotlin.model.TodoRequest
import main.kotlin.model.TodoResponse
import org.jetbrains.exposed.sql.SortOrder
import java.time.LocalDateTime
@KtorExperimentalAPI
class TodoService {
suspend fun getAll() = query {
Todo.all()
.orderBy(Todos.id to SortOrder.DESC)
.map(TodoResponse.Companion::of)
.toList()
}
suspend fun getById(id: Int) = query {
Todo.findById(id)?.run(TodoResponse.Companion::of) ?: throw NotFoundException()
}
suspend fun new(content: String) = query {
Todo.new {
this.content = content
this.createdAt = LocalDateTime.now()
this.updatedAt = this.createdAt
}
}
suspend fun renew(id: Int, req: TodoRequest) = query {
val todo = Todo.findById(id) ?: throw NotFoundException()
todo.apply {
content = req.content
done = req.done ?: false
updatedAt = LocalDateTime.now()
}
}
suspend fun delete(id: Int) = query {
Todo.findById(id)?.delete() ?: throw NotFoundException()
}
}
TodoService 클래스에는 TodoRouter에서 정의한 get, post, put, delete에 해당하는 메소드들이 정의되어 있습니다.
IntelliJ에서 Main.kt의 main함수를 run하면 localhost:9999로 서버가 동작하게 됩니다. 정상적으로 빌드되었다면 서버가 동작하면서 기본 설정들과 데이터 베이스 초기화 로그를 확인할 수 있습니다.
2020-03-19 01:14:10.944 [main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting...
2020-03-19 01:14:11.123 [main] DEBUG com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Added connection conn0: url=jdbc:h2:mem:test user=SA
2020-03-19 01:14:11.125 [main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Start completed.
2020-03-19 01:14:11.229 [HikariPool-1 housekeeper] DEBUG com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Pool stats (total=1, active=0, idle=1, waiting=0)
2020-03-19 01:14:11.232 [HikariPool-1 connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Added connection conn1: url=jdbc:h2:mem:test user=SA
2020-03-19 01:14:11.233 [HikariPool-1 connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Added connection conn2: url=jdbc:h2:mem:test user=SA
2020-03-19 01:14:11.233 [HikariPool-1 connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - After adding stats (total=3, active=0, idle=3, waiting=0)
2020-03-19 01:14:11.441 [main] DEBUG Exposed - CREATE TABLE IF NOT EXISTS TODOS (ID INT AUTO_INCREMENT PRIMARY KEY, CONTENT TEXT DEFAULT '' NOT NULL, DONE BOOLEAN DEFAULT false NOT NULL, CREATED_AT DATETIME DEFAULT '2020-03-19T01:14:11.379841' NOT NULL, UPDATED_AT DATETIME DEFAULT '2020-03-19T01:14:11.379918' NOT NULL)
2020-03-19 01:14:11.443 [main] DEBUG Exposed - CREATE INDEX TODOS_CREATED_AT ON TODOS (CREATED_AT)
2020-03-19 01:14:11.458 [main] INFO Application - Responding at http://0.0.0.0:9999
2020-03-19 01:14:11.458 [main] TRACE Application - Application started: io.ktor.application.Application@2a8a4e0c
2개의 Todo를 추가해보겠습니다. curl을 사용하여 테스트하겠습니다. 아래의 명령어를 터미널에 입력해보세요.
curl --location --request POST 'localhost:9999/todos' \
--header 'Content-Type: application/json' \
--data-raw '{
"content" : "첫글"
}'
curl --location --request POST 'localhost:9999/todos' \
--header 'Content-Type: application/json' \
--data-raw '{
"content" : "두번째 글"
}'
curl --location --request GET 'localhost:9999/todos/1'
{
"id": 1,
"content": "첫글",
"done": false,
"createdAt": "2020-03-19 01:39:24",
"updatedAt": "2020-03-19 01:39:24"
}
curl --location --request GET 'localhost:9999/todos'
[
{
"id": 2,
"content": "두번째 글",
"done": false,
"createdAt": "2020-03-19 01:39:27",
"updatedAt": "2020-03-19 01:39:27"
},
{
"id": 1,
"content": "첫글",
"done": false,
"createdAt": "2020-03-19 01:39:24",
"updatedAt": "2020-03-19 01:39:24"
}
]
curl --location --request PUT 'localhost:9999/todos/1' \
--header 'Content-Type: application/json' \
--data-raw '{
"content" : "변경합니다",
"done" : true
}'
{
"id": 1,
"content": "변경합니다",
"done": true,
"createdAt": "2020-03-19 01:39:24",
"updatedAt": "2020-03-19 01:40:22"
}
수고하셨습니다. Ktor를 사용하여 Todo 서비스를 간단히 만들어 봤는데요 어떠신가요? 저는 Ktor를 공부하면서 이렇게 쉽게 서버를 개발할 수 있다는 것이 축복이라고 느껴질 정도였습니다. 특히 설정이 간편하고, 경량 서버이기 때문에 정말 빠르게 구동할 수 있었습니다. 이러한 장점들은 빠르게 배포하고 유연하게 확장하는 현대적 MSA 구조에도 아주 잘 맞는다고 할 수 있습니다.
또한, 코틀린을 좀 더 깊이 있게 사용해 볼 수 있는 기회가 됩니다. 이번 예제에서도 여러 가지 코틀린의 특징들을 간단하게나마 사용해 볼 수 있었습니다. 이런 특징들은 개발자로 하여금 좀 더 나은 코틀린 개발자로 성장하게 합니다.
사실 Ktor 외에도 정말 많은 경량 서버 프레임워크들이 출시되어 널리 사용되고 있습니다. 하지만 언어, 프레임워크, IDEA를 같은 회사에서 만들고 있는 경우는 흔치 않기 때문에 많은 개발자들이 Ktor의 성장 가능성을 높게 보고 있는 이유 중 하나입니다.
마지막으로 이번 예제는 최대한 심플하게 만들기 위해 핵심적인 부분만 구성하여 개발해봤습니다. 실무에서 사용하게 된다면 좀 더 유지보수를 고려한 구조로 개선할 필요가 있을겁니다. 개선사항은 직접 고민해보시기 바랍니다. 감사합니다.
오늘 만든 예제 코드는 https://github.com/digimon1740/todo-ktor 에서 확인 가능합니다.
https://www.raywenderlich.com/7265034-ktor-rest-api-for-mobile
https://movile.blog/quickly-building-a-kotlin-rest-api-server-using-ktor
https://github.com/JetBrains/Exposed/wiki/Transactions