Ktor로 Todo 서비스 빠르게 만들기
개요
최근 가장 성장하고 있는 언어인 코틀린에 대해 관심이 있으시다면 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
이번 예제에선 아래와 같이 build.gradle을 세팅해 주었습니다.
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 43 44 45 46 47
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가지 방식에 대한 차이점은 여기를 눌러 확인해보시기 바랍니다.
resources/application.conf
1 2 3 4 5 6 7 8
ktor { deployment { port = 9999 # 1 } application { modules = [ main.kotlin.MainKt.main ] # 2 } }
application.conf는 Ktor 프로젝트에서 메인 설정 파일입니다. 스프링의 application.yml 또는 application.properties와 동일하다고 볼 수 있습니다. 주목할 점은 application.conf 파일은 HOCON (Human-Optimized Config Object Notation) 표기법을 기본으로 사용하고 있습니다. HOCON외에도 프로퍼티 표기법도 지원합니다.
- port는 9999로 설정하였습니다. 키-밸류를 보면 서버 포트가 9999라는것을 쉽게 예측 가능합니다.
- main.kotlin.MainKt.main은 엔트리 포인트(Application Entry Point)입니다. Ktor는 내부적으로 여기에 지정된 모듈을 사용해 서버를 동작시킵니다.
main/kotlin/Main.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
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 }
- 서버를 동작시키는 main 함수를 정의하였습니다. io.ktor.server.netty.EngineMain.main(args) 코드를 보시면 저희가 만든 서버가 Netty를 사용하고 있다는 사실을 알 수 있습니다. Ktor는 기본적으로 Netty를 사용하지만 선택적으로 Tomcat, Jetty 등과 같은 웹서버도 지원합니다.
- Application.main : 애플리케이션에서 사용될 모듈들을 설정하였습니다. 설정된 모듈들은 이전에 프로젝트 구조 만들기에서 체크했었던 모듈들입니다.
- install(Routing) : todo()라는 함수를 호출하였습니다. Routing 모듈은 서버의 API Endpoint에 대한 설정입니다. todo() 함수의 내부는 다음 코드에서 확인하실 수 있습니다.
- 마지막으로 DatabaseInitializer.init()은 직접 작성한 데이터베이스 설정 초기화 코드입니다. DatabaseInitializer의 내부 코드도 이어서 확인해보겠습니다.
main/kotlin/config/DatabaseInitializer.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
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() } }
- init : 함수 내부에선 Database.connect(HikariDataSource(hikariConfig()))를 사용하여 데이터베이스에 연결 합니다. 커넥션풀은 HikariCP를 사용하였습니다. 데이터베이스에 연결된 이후엔 transaction 내부에서 create(Todos)를 사용하여 Todos라는 테이블을 생성하게 됩니다. create는 Exposed에 포함된 SchemaUtils에 포함되어있으며, 테이블이 존재하지 않으면 자동으로 생성해줍니다.
- hikariConfig : 기본적인 HikariCP 설정입니다. 예제에선 in-memory H2DB를 사용하였습니다.
- query : block이라는 이름의 함수를 인자로 받아서 트랜잭션 범위에서 동작하게 만들었습니다. 이후에 보실 TodoService.kt에서 query 함수를 사용하게됩니다. 주목할 점은 suspend 키워드인데 사전적 의미는 연기하다, 중단하다라는 의미를 가진 단어입니다. suspend는 그 뜻처럼 코루틴 컨텍스트내에서 해당 함수를 일시중지, 재개할 수 있게 하는 일종의 표식(mark)입니다. 그러므로 withContext에 감싸진 block은 기본적으로 코루틴 스코프내에서 동작하게됩니다.
코루틴에 대한 자세한 설명은 이번 예제의 범주를 벗어납니다. 코루틴에 대해 알고싶으다면 이곳에서 확인해 주세요.
main/kotlin/entity/Todo.kt
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
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 }
- DAO 패키지에는 RDB의 테이블 구조와 매핑되는 Table이라는 최상위 클래스가 존재합니다. 이번 예제의 경우 PK인 id가 Int 타입이므로 Todos는 Table의 하위 클래스인 IntIdTable을 상속받았습니다. 이제 Todos 테이블에는 자동으로 id라는 이름의 PK가 생성됩니다.
- Todo 클래스는 Todos 테이블과 매핑되는 엔티티 객체입니다. 마찬가지로 IntEntity를 상속받아서 id를 부모 클래스인 IntEntity에 인자로 넣어줍니다. 그리고 by 키워드를 사용하여 컬럼과 프로퍼티를 매핑해줍니다.
by는 원래 위임(Delegation)이라고 하는게 맞지만 매핑이 더 쉽게 이해할 수 있다고 생각하였습니다.
main/kotlin/model/TodoRequest.kt, TodoResponse.kt
1 2 3 4 5 6
package main.kotlin.model import java.time.LocalDateTime data class TodoRequest(val content: String, val done: Boolean?, val createdAt: LocalDateTime?, val updatedAt: LocalDateTime?)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
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를 정의하였습니다.
main/kotlin/TodoRouter.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 43 44 45 46
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) } } }
- Routing.todo는 Ktor의 Routing 모듈에 대한 사용자 정의 확장 함수입니다. 만약 새로운 라우터를 만들고 싶으시다면 이처럼 Routing에 확장 함수를 제공하고 Main.kt에 정의한 함수를 등록해 주시면 됩니다.
- route("todos")의 "todos"는 URL 접두사입니다. 예를 들어 주소창에 localhost:9999/todos/1234 라는 경로로 호출하게 되면 route("todos") 내부의 get("/{id}") 라우터 함수가 동작합니다. 예제에선 기본 CRUD에 대응하는 get, post, put, delete를 만들어 두었습니다.
main/kotlin/TodoService.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
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 } } 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에 해당하는 메소드들이 정의되어 있습니다.
- getAll() : Todos 테이블내의 전체 Todo를 가져와서 각 요소를 TodoResponse 모델로 매핑 후 리스트로 반환합니다.
- getById(id: Int) : id를 가지고 Todos 테이블에서 조회 후 존재한다면 TodoResponse 모델로 컨버팅하여 반환합니다.
- new(content: String) : id, done, createdAt, updatedAt은 기본값이 존재하므로 content만 인자로 받아서 새로운 Todo를 생성합니다.
- renew(id: Int, req: TodoRequest) : request로 들어온 변경사항으로 Todo를 업데이트합니다.
- delete(id: Int) : id를 가지고 Todos 테이블에서 조회 후 존재한다면 삭제합니다.
동작 확인
IntelliJ에서 Main.kt의 main함수를 run하면 localhost:9999로 서버가 동작하게 됩니다. 정상적으로 빌드되었다면 서버가 동작하면서 기본 설정들과 데이터 베이스 초기화 로그를 확인할 수 있습니다.
1 2 3 4 5 6 7 8 9 10 11
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
Todo 추가
2개의 Todo를 추가해보겠습니다. curl을 사용하여 테스트하겠습니다. 아래의 명령어를 터미널에 입력해보세요.
1 2 3 4 5
curl --location --request POST 'localhost:9999/todos' \ --header 'Content-Type: application/json' \ --data-raw '{ "content" : "첫글" }'
1 2 3 4 5
curl --location --request POST 'localhost:9999/todos' \ --header 'Content-Type: application/json' \ --data-raw '{ "content" : "두번째 글" }'
1건 조회
1
curl --location --request GET 'localhost:9999/todos/1'
1 2 3 4 5 6 7
{ "id": 1, "content": "첫글", "done": false, "createdAt": "2020-03-19 01:39:24", "updatedAt": "2020-03-19 01:39:24" }
전체 조회
1
curl --location --request GET 'localhost:9999/todos'
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
[ { "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" } ]
수정
1 2 3 4 5 6
curl --location --request PUT 'localhost:9999/todos/1' \ --header 'Content-Type: application/json' \ --data-raw '{ "content" : "변경합니다", "done" : true }'
1 2 3 4 5 6 7
{ "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