Article Image
Article Image
read

케이터Ktor젯브레인Jetbrains에서 만든 웹 서버 프레임워크입니다. 그리고 보편적으로 웹 서버는 데이터베이스Database를 이용합니다. 만약 자바Java를 경험해 보셨다면 JPA에 대해 친숙하시거나 적어도 들어보셨을 거라 생각합니다. 혹은 마이바티스MyBatis가 그렇겠지요. 코틀린Kotlin에서도 JPA나 마이바티스처럼 애플리케이션의 도메인 모델과 데이터베이스를 엮어주는 도구가 있습니다.

제가 소개해드릴 ORM 프레임워크Framework익스포즈드Exposed케이텀Ktorm입니다. (Ktorm에 대한 발음을 열심히 찾아봤으나 딱히 소개되지 않아 멋대로 케이텀이라 부르겠습니다.) 선정 기준은 다음과 같습니다.

  • 코틀린으로 짜여졌는가?
  • ORM을 지원하는가?
  • 독립적인 프레임워크인가?
  • 최근까지 관리되고 있는가?

익스포즈드

익스포즈드는 젯브레인에서 제작한 SQL 프레임워크입니다. 먼저 object로 테이블에 상응하는 명세를 작성하고 SQL DSL을 이용해 SQL을 바로 실행해볼 수 있습니다.

// https://github.com/JetBrains/Exposed#sql-dsl-sample 을 참고하여 재구성하였습니다.

import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.Table
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction

object Cities : Table() {
    val id = integer("id").autoIncrement() // Column<Int>
    val name = varchar("name", 50) // Column<String>

    override val primaryKey = PrimaryKey(id)
}

fun main() {
    Database.connect("jdbc:mysql://localhost:3306/test?serverTimezone=UTC", "com.mysql.cj.jdbc.Driver", "root", "fnxm")

    transaction {
        SchemaUtils.create (Cities)
        /*
        CREATE TABLE IF NOT EXISTS Cities (
            id INT AUTO_INCREMENT PRIMARY KEY,
            name VARCHAR(50) NOT NULL
        )
        */

        val id = Cities.insert {
            it[name] = "Elis"
        } get Cities.id
        // INSERT INTO Cities (name) VALUES ('Elis')

        Cities.select { Cities.id eq id }.single().also {
            // SELECT cities.id, cities.`name` FROM cities WHERE cities.id = 1

            println("${it[Cities.id]}: ${it[Cities.name]}")
            // 1: Elis
        }

        for (city in Cities.selectAll()) {
            // SELECT Cities.id, Cities.name FROM Cities

            println("${city[Cities.id]}: ${city[Cities.name]}")
            // 1: Elis
        }

        SchemaUtils.drop (Cities)
        //DROP TABLE Cities
    }
}

또한 ORM을 위해 엔티티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.Database
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction

// IntIdTable을 상속하였으므로 ID 정의를 생략할 수 있습니다.
object Cities: IntIdTable() {
    val name = varchar("name", 50)
}

// 엔티티를 작성합니다.
class City(id: EntityID<Int>) : IntEntity(id) {
    companion object : IntEntityClass<City>(Cities)

    var name by Cities.name
}

fun main() {
    Database.connect("jdbc:mysql://localhost:3306/test?serverTimezone=UTC", "com.mysql.cj.jdbc.Driver", "root", "fnxm")

    transaction {
        SchemaUtils.create(Cities)

        val city = City.new {
            name = "Elis"
        }
        // INSERT INTO cities (`name`) VALUES ('Elis')

        println("${city.id}: ${city.name}")
        // 1: Elis

        City.all().forEach {
            // SELECT cities.id, cities.`name` FROM cities

            println("${it.id}: ${it.name}")
            // 1: Elis
        }

        SchemaUtils.drop(Cities)
    }
}

케이텀

케이텀은 오픈소스Open source ORM 프레임워크입니다. 케이텀 역시 테이블에 상응하는 object를 만들고 SQL DSL을 이용하여 SQL을 실행할 수 있습니다.

// https://github.com/vincentlauvlwj/Ktorm 문서를 참고하여 재구성하였습니다.

import me.liuwj.ktorm.database.Database
import me.liuwj.ktorm.dsl.from
import me.liuwj.ktorm.dsl.insert
import me.liuwj.ktorm.dsl.select
import me.liuwj.ktorm.schema.Table
import me.liuwj.ktorm.schema.int
import me.liuwj.ktorm.schema.varchar

object Users : Table<Nothing>("user") {
    val id by int("id").primaryKey()
    val name by varchar("name")
}
/* 케이텀은 DDL 유틸리티가 없습니다. 미리 테이블을 생성해야 합니다.
CREATE TABLE `user` (
   `id` int(11) NOT NULL AUTO_INCREMENT,
   `name` varchar(45) CHARACTER SET utf8 NOT NULL,
   PRIMARY KEY (`id`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
 */

fun main() {
    // 익스포즈드와 다르게 데이터베이스 연결 객체를 할당 받아 써야 합니다.
    val database = Database.connect("jdbc:mysql://localhost:3306/test?serverTimezone=UTC", "com.mysql.cj.jdbc.Driver", "root", "fnxm")

    database.useTransaction {
        /* insert() 메서드를 이용하면 INSERT는 가능하지만
        삽입된 레코드를 바로 반환받을 수 없습니다. */
        database.insert(Users) {
            it.name to "Zeus"
        }
        // insert into `user` (name) values (?)
        
        database.from(Users)
            .select(Users.id, Users.name)
            .forEach {
                // select `user`.id as user_id, `user`.name as user_name from `user`

                println("${it[Users.id]}: ${it[Users.name]}")
                // 1: Zeus
            }
    }
}

케이텀도 엔티티를 작성하고 시퀀스Sequence API를 이용하여 객체 관계를 엮을 수 있습니다.

import me.liuwj.ktorm.database.Database
import me.liuwj.ktorm.entity.Entity
import me.liuwj.ktorm.entity.add
import me.liuwj.ktorm.entity.forEach
import me.liuwj.ktorm.entity.sequenceOf
import me.liuwj.ktorm.schema.Table
import me.liuwj.ktorm.schema.int
import me.liuwj.ktorm.schema.varchar

object Users : Table<User>("user") {
    /* bindTo로 객체의 속성과 엮어주어야 합니다.
       여기서 it은 제네릭에 명시한 User입니다. */
    val id by int("id").primaryKey().bindTo { it.id }
    val name by varchar("name").bindTo { it.name }
}

// 엔티티를 작성합니다. interface인 것에 주의하세요.
interface User : Entity<User> {
    companion object : Entity.Factory<User>()
    val id: Int?
    var name: String
}

fun main() {
    val database = Database.connect("jdbc:mysql://localhost:3306/test?serverTimezone=UTC", "com.mysql.cj.jdbc.Driver", "root", "fnxm")

    // 시퀀스를 얻습니다.
    val sequence = database.sequenceOf(Users)

    val user = User {
        name = "Zeus"
    }

    database.useTransaction {
        sequence.add(user)
        // insert into `user` (name) values (?)

        println("${user.id}: ${user.name}")
        // 1: Zeus

        sequence.forEach {
            // select `user`.id as user_id, `user`.name as user_name from `user`

            println("${it.id}: ${it.name}")
            // 1: Zeus
        }
    }
}

익스포즈드 대 케이텀

두 프레임워크는 언뜻 보기에 사용법이 비슷해 보입니다. 그러나 도메인 특화 언어DSL;Domain Specific Language라는 관점에서는 익스포즈드가 좀 더 우아해보입니다. 또한 데이터베이스 연결 객체를 트랜잭션 매니저TransactionManager와 연동하여 사용자가 매번 연결 객체를 유지 관리하지 않아도 됩니다.

테이블 정의와 엔티티 작성 측면에서도 차이를 보이고 있습니다. 익스포즈드는 테이블 정의보다 엔티티에 프레임워크 의존도를 두고 있는 반면 케이텀은 테이블 정의 클래스에 의존도를 두고 있습니다. 이는 엔티티가 얼마나 더 프레임워크 독립적인가를 볼 때 큰 차이를 보일 수 있습니다. 익스포즈드는 엔티티의 인스턴스Instance를 만들 때 익스포즈드의 EntityId를 생성자에서 요구합니다. 이는 익스포즈드 없이는 엔티티를 생성할 수 없다는 뜻입니다. 반면 케이텀은 엔티티의 인스턴스를 만드는 데에 아무런 제약이 없습니다. 개인적인 생각으로 익스포즈드의 사용은 프레임워크 독립성을 지키는 데에 큰 제약이 될 것입니다.

위의 몇몇 차이와 살펴보지 못한 다른 차이점이 분명 존재할 것입니다. 반면에 객체간의 관계(조인Joining)나 다중 데이터베이스 연결 사용 등 서로 비슷하게 지원하는 부분 또한 있습니다. 프레임워크를 고르려면 두 프레임워크의 명세를 자세히 살펴보고 엄선해야 할 것 같습니다.

저는 이미 만든 애플리케이션Application이 있고, 기존 코드의 수정을 최대한 피하고 싶기 때문에 케이텀을 이용하도록 하겠습니다.

케이터 시작하기

케이터Ktor와 연동하기 위해 먼저 케이터 프로젝트를 만들어 보겠습니다. 아무래도 코틀린을 사용하기에는 인텔리제이IntelliJ를 사용하는 것이 여러모로 이득입니다. 물론 인텔리제이 역시 젯브레인에서 만들었다는 것이 한몫 하겠죠?

인텔리제이에서 새 프로젝트 창을 띄우면 케이터 프로젝트를 만들 수 있습니다. 이를 통해 쉽게 케이터 프로젝트를 만들고 초기 설정을 건너뛸 수 있습니다.

인텔리제이 케이터 새 프로젝트 만들기

일단 이것 저것 선택하지 않고 만들었습니다. 제가 만진 거라곤 GradleKotlinDsl뿐입니다.

프로젝트가 완성되면 build.gradle에 케이터의 의존성Dependencies이 추가되어 있을 겁니다.

dependencies {
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version")
    implementation("io.ktor:ktor-server-netty:$ktor_version")
    implementation("ch.qos.logback:logback-classic:$logback_version")
    testImplementation("io.ktor:ktor-server-tests:$ktor_version")
}
  • 코틀린 의존성
  • 케이터의 네띠Netty 서버 의존성: 케이터의 웹서버로 네띠를 사용합니다. 다른 서버를 사용할 수도 있습니다.
  • 로그백 의존성: 케이터는 로그 기록을 위해 로그백을 사용합니다.
  • 케이터의 서버 테스트 의존성

추가로 필요한 기능이나 사양이 있다면 의존성을 추가하는 것으로 사용하실 수 있습니다. 또한 애초에 케이터 프로젝트 생성 마법사를 통해 만들지 않은 프로젝트라도 위의 의존성을 추가하면 기존 애플리케이션에 케이터를 연동할 수 있습니다.

저처럼 케이터 프로젝트 생성 마법사를 통해 프로젝트를 만드셨다면 의존성도 추가되어 있고 애플리케이션Application도 만들어져 있을겁니다. 아래는 이미 만들어진 애플리케이션 파일입니다.

// src/Application.kt
import io.ktor.application.Application

fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args)

@Suppress("unused") // Referenced in application.conf
@kotlin.jvm.JvmOverloads
fun Application.module(testing: Boolean = false) {
}

뭐 별거 없는 것 같은데 main함수를 실행하면 서버가 실행됩니다. 서버의 포트 등의 설정은 application.conf파일에 있습니다.

// resources/application.conf
ktor {
    deployment {
        port = 8080
        port = ${?PORT}
    }
    application {
        modules = [ ApplicationKt.module ]
    }
}

자 이제 웹서버에 HTTP 종단점Endpoint을 하나 추가해보겠습니다.

import io.ktor.application.Application
import io.ktor.application.call
import io.ktor.http.ContentType
import io.ktor.request.receiveText
import io.ktor.response.respondText
import io.ktor.routing.post
import io.ktor.routing.routing

fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args)

@Suppress("unused") // Referenced in application.conf
@kotlin.jvm.JvmOverloads
fun Application.module(testing: Boolean = false) {
    routing { // HTTP 요청 분기 설정 추가됨
        post("/cities") { // POST 방식의 /cities 종단점 추가
            // 요청값을 receiveText()로 받아서 다시 응답으로 보내기 위해 respondText()를 호출합니다.
            call.respondText(call.receiveText(), ContentType.Text.Plain)
        }
    }
}

이제 다시 웹서버를 실행하고 HTTP 요청을 보내봅시다.

D:\Workspace\ktor-orm>curl http://localhost:8080/cities -d "Elis"
Elis

응답 잘 받으셨나요? 이 HTTP 종단점으로 도시 이름을 보내서 도시를 만들어 볼 생각입니다.

케이텀 시작하기

언제나 그렇듯 의존성부터 추가해봅시다. 저는 애플리케이션 데이터 저장소로 마이시퀄MySQL을 사용할 예정이므로 마이시퀄의 제이디비씨JDBC 드라이버를 함께 추가해 주겠습니다. (마이시퀄 서버를 설치하는 단계는 생략하겠습니다.)

// build.gradle.kts
...
dependencies {
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version")
    implementation("io.ktor:ktor-server-netty:$ktor_version")
    implementation("ch.qos.logback:logback-classic:$logback_version")
    implementation("me.liuwj.ktorm:ktorm-core:${ktorm_version}") // 추가됨
    implementation("mysql:mysql-connector-java:8.0.19") // 추가됨
    testImplementation("io.ktor:ktor-server-tests:$ktor_version")
}

이제 테이블 정의 객체와 엔티티를 만들어 봅시다.

import me.liuwj.ktorm.entity.Entity
import me.liuwj.ktorm.schema.Table
import me.liuwj.ktorm.schema.int
import me.liuwj.ktorm.schema.varchar

object Cities : Table<City>("city") {
    val id by int("id").primaryKey().bindTo { it.id }
    val name by varchar("name").bindTo { it.name }
}

interface City : Entity<City> {
    companion object : Entity.Factory<City>()
    val id: Int?
    var name: String
}

그리고 종단점에서 데이터베이스 연결 객체를 획득한 다음 데이터베이스에 저장하고 반환해보겠습니다.

import io.ktor.application.Application
import io.ktor.application.call
import io.ktor.http.ContentType
import io.ktor.request.receiveText
import io.ktor.response.respondText
import io.ktor.routing.post
import io.ktor.routing.routing
import me.liuwj.ktorm.database.Database
import me.liuwj.ktorm.entity.add
import me.liuwj.ktorm.entity.sequenceOf

fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args)

@Suppress("unused") // Referenced in application.conf
@kotlin.jvm.JvmOverloads
fun Application.module(testing: Boolean = false) {
    routing {
        post("/cities") {
            // 데이터베이스 연결 획득
            val database = Database.connect("jdbc:mysql://localhost:3306/test?serverTimezone=UTC", "com.mysql.cj.jdbc.Driver", "root", "fnxm")
            // 시퀀스 획득
            val sequence = database.sequenceOf(Cities)
            // 엔티티 생성
            val city = City {
                name = call.receiveText()
            }
            // 엔티티 저장
            sequence.add(city)
            // 엔티티 응답
            call.respondText("${city.id}:${city.name}", ContentType.Text.Plain)
        }
    }
}

다시 API를 호출해 봅시다.

D:\Workspace\ktor-orm>curl http://localhost:8080/cities -d "Elis"
1:Elis

어떤가요? 도시 이름이 잘 저장되었나요? 다른 도시 이름으로 한 번 더 호출해 봅시다.

D:\Workspace\ktor-orm>curl http://localhost:8080/cities -d "Egypt"
2:Egypt

데이터베이스에 저장됨에 따라 자동 증가Auto Increment ID가 함께 출력됩니다.

이번에는 데이터베이스를 조회해서 저장된 도시를 가져와 보도록 하겠습니다.

// ...
import io.ktor.http.HttpStatusCode
import io.ktor.response.respond
import io.ktor.routing.get
import me.liuwj.ktorm.dsl.eq
import me.liuwj.ktorm.entity.find

fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args)

@Suppress("unused") // Referenced in application.conf
@kotlin.jvm.JvmOverloads
fun Application.module(testing: Boolean = false) {
    routing {
        post("/cities") {
            // ...
        }
        get("/cities/{id}") { // GET 방식의 /cities/{id} 종단점 추가
            val database = Database.connect("jdbc:mysql://localhost:3306/test?serverTimezone=UTC", "com.mysql.cj.jdbc.Driver", "root", "fnxm")
            val sequence = database.sequenceOf(Cities)
            // 경로로 전달된 인자를 숫자로 변환
            val id = call.parameters["id"]?.toInt() ?: -1
            // ID로 도시 조회
            val city = sequence.find { it.id eq id } ?: run {
                call.respond(HttpStatusCode.NotFound)
                return@get
            }
            // 조회된 도시를 응답으로 출력
            call.respondText("${city.id}:${city.name}", ContentType.Text.Plain)
        }
    }
}

특정 ID의 도시를 얻어오는 API 종단점을 추가하였습니다. 이 API는 ID가 숫자가 아니거나, 숫자라 할지라도 데이터베이스에서 찾지 못하면 404 Not Found를 응답합니다. 실행해 볼까요?

D:\Workspace\ktor-orm>curl http://localhost:8080/cities/1
1:Elis

D:\Workspace\ktor-orm>curl http://localhost:8080/cities/3 -I
HTTP/1.1 404 Not Found
Content-Length: 0

1번 도시를 조회했을 때에는 아까 추가한 Elis가 올바르게 출력되었습니다. 3번 도시는 아직 없기 때문에 조회하면 404 Not Found가 출력됩니다.

마치며

간단하게 케이터에서 객체 관계 매핑ORM; Object Relational Mapping 프레임워크를 사용해보았습니다. 위에 작성된 예제들만 보면 쉽고 간단해 보이지만 꼭 그렇지만도 않습니다. 추상화와 캡슐화를 통해서 실제 케이텀을 사용하고 있다는 것을 숨기고 애플리케이션은 그저 데이터를 저장(영속)하고 불러오는 행위만 알도록 해야 합니다. 그러기 위해서는 많은 고민이 필요하겠죠. 쉽게는 JPA의 개념을 빌려와 저장소Repository 형태로 만들 수도 있을 겁니다. 또는 마틴 파울러의 엔터프라이즈 애플리케이션 아키텍처 패턴PoEAA; Patterns of Enterprise Application Architecture의 데이터 원본 아키텍처 패턴 중 하나를 차용할 수도 있을 겁니다.

가장 중요한 것은 이번에 살펴 본 프레임워크들이 객체와 데이터의 매핑을 도와준다는 것이며 이것이 케이터와 연동되어 애플리케이션의 데이터를 영속할 수 있다는 점입니다.

다음에는 위에 나열한 고민들을 같이 풀어보면서 적절한 데이터 전송 방식까지 연동한 기초적인 애플리케이션을 만들어보겠습니다.

읽어주셔서 고맙습니다.

Blog Logo

Lifeclue


Published

Image

Lifeclue

Developer / Backend / Java / Spring / Kotlin / Ktor

Back to Overview