2022년 1월 30일 일요일

[개발] Ktor 서비스 개발 후기

 본 포스트는 내부 공유를 위해 작성했던 내용을 일부 수정하여 작성한 내용이며, 첨부한 소스코드는 제대로 작동하지 않을 수 있습니다.


Ktor(kay-tor)

Ktor는 Jetbrain에서 개발하여 제공하는, Kotlin과, Coroutine을 사용해서 만들어진 경량 서버 프레임워크이다. suspend function을 사용해서 비동기 프로그래밍을 절차적 코드로 작성할 수 있으며 kotlin의 문법을 사용하여 간단하게 이런저런 기능을 구현할 수 있다.



기본적인 세팅

Ktor 버전

버전: 2.0.0-eap-256

2022년 1월 30일 기준 ktor 웹페이지에선 2.0.0-beta-1이 가장 최신이라고 나와있지만 일단 이 영상을 보고 세팅했던거라 버전이 저렇다. maven repo에 따르면 2.0.0-beta-1 이 더 최신이다! 이 버전으로 옮겨가야할 거 같다.

템플릿 엔진

ktor 가이드 문서에서는 여러 템플릿 라이브러리를 소개하는데 그 중 Freemarker를 사용해보았다.

ORM Framework

https://github.com/JetBrains/Exposed

마찬가지로 jetbrain에서 제공하는 Kotlin ORM framework.

간단하게 DB Connection setting이 가능하며 사용도 그렇게 복잡하지는 않다.

Hibernate 기반의 ORM framework를 써보려고 시도해보긴했지만 실패했다. 그 이유는 후술.

EngineMain? EmbeddedServer?

https://ktor.io/docs/eap/engines.html

Ktor는 서버를 실행시키기 위한 두가지 방법을 제공한다. embedded server 방식은 코드 상에서 사용할 서버 엔진(Netty, tomcat, CIO, ...)과 config를 작성하는 식으로 진행되며, engineMain 방식은 외부 configuration 파일에서 config를 작성하게 된다. 우리 서버는 phase 별로 다른 세팅을 해야할 필요가 있을 것으로 보고 이런저런 parameter를 config파일에서도, 또한 program argument로도 넘길 수 있는 engine main 방식을 선택하였다. 이 경우 주의해야할 게, module이라는 property에 서버 기동 시 실행할 메소드를 지정해주어야한다.

// application-base.conf ktor { development = true deployment { port = 8080 port = ${?PORT} shutdown.url = "/shutdown" // 이 값의 의미는 후술 } application { modules = [ com.example.ApplicationKt.configModule, com.example.ApplicationKt.apiModule, com.example.ApplicationKt.staticModule ] } }

Plugin

Ktor에서 되게 자랑스럽게 소개하는 기능 중 하나가 plugin이다. plugin을 사용해서 기능들을 쉽게 추가할 수 있다고 하는데, 이번에 ktor 프로젝트를 진행하면서 총 네가지 정도의 plugin을 작성해보았다.

Plugin 작성법

Ktor 1.x의 플러그인 작성법은 좀 귀찮았는데 2.x에서는 많이 간편화되어 큰 걱정없이 플러그인을 작성할 수 있다. 모든 작성은 createApplicationPlugin method를 호출하는 것으로 시작할 수 있으며, ktor의 데이터 처리 pipeline 중 원하는 부분에 로직을 추가할 수 있다.

기본적으로 plugin 설정에 작성한 모든 코드를 순차적으로 실행한다. 그래서 아래 DB Config 같은 경우도 request/response에 관여하지 않는데도 플러그인으로 작성해두었다.

ktor의 pipeline diagram

https://ktor.io/docs/eap/plugins.html#routing


ktor의 pipeline diagram에 플러그인이 들어가면

https://ktor.io/docs/eap/plugins.html#routing


- onCall

- 모든 호출에 대해서 호출되는 메소드

- onCallReceive

- 들어오는 요청에 대해 처리

- onCallRespond

- 응답을 보내기 전에 처리.

물론, 플러그인 간의 순서도 설정이 가능하다.

자세한 내용: https://ktor.io/docs/eap/custom-plugins.html#first-plugin

Shutdown plugin

프로그램을 종료하기 위해 특정 url을 지정하여 관리할 수 있다. ktor에서 기본적으로 shutdown 플러그인을 제공하고 이에 대한 내용이 공식 도큐먼트에도 나와있지만, 해당 플러그인은 모든 외부에서의 호출을 허용하기 때문에 악용될 가능성이 있을 거라 생각해서 local에서의 호출만 허용하도록 새로 만들어서 사용하였다.

import io.ktor.server.application.plugins.api.* import io.ktor.server.engine.* import io.ktor.server.plugins.* import io.ktor.server.request.* val LocalShutdownPlugin = createApplicationPlugin( name = "LocalShutdownPlugin", createConfiguration = { ShutDownUrl.Configuration() } ) { val shutdownPlugin: ShutDownUrl pluginConfig.apply { shutdownPlugin = ShutDownUrl(shutDownUrl, exitCodeSupplier) } onCallReceive { call -> if (isLocalAddress(call.request.origin.remoteHost) && call.request.uri == shutdownPlugin.url) shutdownPlugin.doShutdown(call) } } const val localIpv4Address = "127.0.0.1" const val localIpv6Address = "0:0:0:0:0:0:0:1" fun isLocalAddress(remoteHost: String) = remoteHost == localIpv4Address || remoteHost == localIpv6Address || remoteHost == "localhost"

DB Configuration

// database 설정하는 부분을 plugin으로 작성한 모습 import com.typesafe.config.ConfigFactory import io.ktor.server.application.plugins.api.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.sql.transactions.transaction val DatabasePlugin = createApplicationPlugin(name = "DatabasePlugin") { val dbConfig = ConfigFactory.load() val host = dbConfig.getString("database.host") val port = dbConfig.getString("database.port") val database = dbConfig.getString("database.database") val usernameProperty = dbConfig.getString("database.username") val passwordProperty = dbConfig.getString("database.password") val dbUrl = "jdbc:mysql://$host:$port/$database" Database.connect(dbUrl, "com.mysql.cj.jdbc.Driver", usernameProperty, passwordProperty) } // https://www.popit.kr/ktor%EB%A1%9C-todo-%EC%84%9C%EB%B9%84%EC%8A%A4-%EB%B9%A0%EB%A5%B4%EA%B2%8C-%EB%A7%8C%EB%93%A4%EA%B8%B0/ suspend fun <T> query(block: () -> T): T = withContext(Dispatchers.IO) { transaction { block() } }

Plugin 적용하기

Plugin을 작성했으면 이걸 실제 서버에 적용하기 위한 과정이 필요하다. Application.install 에 작성한 플러그인을 변수로 넘기면 적용완료이다.(물론 이 install 을 실행하는 configModule 함수를 사용한다고 application.conf의 modules에 명시해놓아야한다)

fun main(args: Array<String>) = EngineMain.main(args) fun Application.configModule() { install(DatabasePlugin) install(LocalShutdownPlugin) { shutDownUrl = environment.config.property("ktor.deployment.shutdown.url").getString() exitCodeSupplier = { 0 } } configureMonitoring() // request 로깅용 설정(ktor 기본) configureSerialization() // data (de)serialize 설정(ktor 기본) configureTemplating() // freemarker template 사용하기 위한 설정(ktor 기본) }

Kotlin-jdsl?

https://github.com/line/kotlin-jdsl

kotlin-jdsl은 line에서 개발한 hibernate 기반의 kotlin dsl 라이브러리이다. Entity만 잘 선언해두면 별도의 repository 설정이나, queryDsl같은 복잡한 설정없이 SQL query를 작성할 수 있다는 장점이 있지만 이번 ktor 프로젝트에는 적용할 수 없었다.

이유 → Hibernate!

ktor에서는 아직 공식적으로 auto DI를 지원하지 않는다. 서비스 레이어 등을 작성하고 그걸 controller 레이어에서 사용하고자 하면 처음 생성 시에 다 일일히 생성해서 넣어주어야한다.

hibernate는 DI에 좀 많이 의존한다.

hibernate를 사용하는 대표적인 라이브러리인 sprint-data에서는 conf파일을 기반으로 설정값을 다 넣어주고 queryFactory나 EntityManagerFactory → EntityManager 등을 Bean 기반으로 전부 생성해준다. 하지만 이러한 과정을? 전부? ktor를 사용하면서 새로 구현한다? 오....

그래서 결국 kotlin-jdsl은 사용하지 않는 것으로 결정.

Auto Reload

ktor 실행 중에 파일 변경 발생시 서버 재실행 없이 auto-reload를 통해 빠르게 적용할 수 있다.(개발 단계에서만 가능)

- watch

- auto reload를 적용하기 위해 파일 변경을 감지할 package를 설정할 수 있다.

- ktor 실행 중에 rebuild하면 알아서 적용

- Freemarker로 프론트 작업 시에 유용했다.

배포 및 실행하기

배포할 파일 만들기

배포를 어떻게 할까 보다가, fat jar를 만들어서 jar 파일을 통째로 배포하는 것으로 결정했다. ktor 공홈에서 소개한 shadow plugin을 사용하였다.

// build.gradle.kts plugins { application kotlin("jvm") version "1.6.0" id("org.jetbrains.kotlin.plugin.serialization") version "1.6.0" id("com.github.johnrengelman.shadow") version "7.0.0" } ./gradlew shadowJar → build/libs/ 에 jar 파일 떨어짐.

이후 phase env variable을 어떻게 적용할 것인가가 문제가 되었다. spring framework를 사용할 때는 phase variable을 사용해서 local-beta-real 의 서로 다른 config를 상황에 맞게 사용가능했지만, ktor에서는 그게 불가능했다! ktor의 phase variable 도입은 문의가 몇번 있던 듯 하나(https://github.com/ktorio/ktor/issues/1277) 별 대응이 없다.

다른 서비스에서 어떻게 이 문제를 해결하나 봤더니, docker 기반 배포로, 컨테이너 생성 시 환경변수로 값을 넣어주고 있었기에 참고가 불가능한 경우가 많았다. ktor 공식 도큐에도 컨테이너 배포를 기본으로 상정하는 듯한 느낌이 있었다,

이를 해결하기 위해 program argument에 사용할 config 파일을 직접 지정하는 방식을 사용하기로 했다. 여기서의 결정 때문에 embeddedEngine을 안쓰고 EngineMain 방식을 사용하게 되었다. embeddedEngine을 사용하면 program argument를 넘기는게 제대로 작동을 안하는 듯.

java -jar hts-admin.jar -config=paht/to/conf/application-{phase}.conf -port=8081 .. // ktor에서 사용할 argument가 -jar 보다 앞으로 오면 안되므로 주의해야 한다.

config 파일이 공유하는 부분은 HOCON의 include 기능을 사용하였다.

// application-dev.conf // 값이 개발 phase 별로 달라질 nclavis 설정값 등은 분리하고, 공통부분을 include로 해결한 모습 include "application-base.conf" database { host = {database url} port = {port} database = {database} username = {username} password = {password} }

단 -config arg를 사용할 경우, config 파일의 file path를 잘 세팅해야한다. program argument로 넘겨주는 conf file의 진짜 raw path를 지정해주어야한다. fat jar의 압축을 풀어보면 분명 안에 conf 파일이 들어있는데 이 jar 파일 내의 conf를 참조를 할 수가 없었다. 이렇다보니 또 다른 문제가 발생했다.

- build 이후 conf 파일이 있는 곳 : build/resources/main

- build 이후 jar 파일이 있는 곳 : build/libs

처음엔 그냥 build 폴더 전체를 배포해버릴까 했지만, 역시 이건 좀 너무 부담스러웠다. 그래서 build 이후에 conf 파일을 배포 대상 폴더 안으로 복사해주는 step을 추가하였다. shadowJar 이후에 파일 복사과정이 바로 진행되도록 gradle 파일을 수정했다.

// build.gradle.kts // 파일을 복사할 task copyResource를 정의 val copyResource by tasks.registering(Copy::class) { from("build/resources/main") into("build/libs") include("*.conf") doLast { println("Copy Resource file to libs folder.") } } // finalizedBy를 통해 shadowJar 완료 이후 무조건 실행되도록 설정했다. tasks.shadowJar { finalizedBy(copyResource) }

실행하고 모니터링하기.

healthcheck

healthcheck도 shutdown plugin 과 비슷하게 plugin으로 개발하여 붙였다.

startup/shutdown script

phase argument에 따라 ktor conf파일을 선택할 수 있도록하고, shutdown.sh 실행 시 shutdown url을 호출할 수 있도록 작성했다.

#!/bin/bash PROJECT=$1 PHASE=$2 PORT="${3:-8080}" PROJECT_HOME=/User/users/Document/${PROJECT} KTOR_OPTS=( # Config file -config="$PROJECT_HOME"/application-"$PHASE".conf # port -port="$PORT" ) # Fat jar JAR_NAME=`find -L $PROJECT_HOME -maxdepth 1 \( -name '*-all.jar' -or -name '*.war' \) -and -not -name '*javadoc.jar' -and -not -name '*sources.jar'` exec java -jar "$JAR_NAME" "${KTOR_OPTS[@]}" &> /dev/null &
#!/bin/bash PROJECT=$1 PORT=$2 L7_DOMAIN="http://127.0.0.1" SHUTDOWN_PATH="/shutdown" echo ">> Ktor shutdown." curl ${L7_DOMAIN}:${PORT}${SHUTDOWN_PATH} sleep 5 echo ">> Ktor shutdown was finished."