Spring 공화국에서 Ktor 사용하기

Spring 공화국에서 Ktor 사용하기

요약: 이 블로그 글은 Spring Framework와 Ktor를 비교 분석한 내용을 다룹니다. 베니는 Spring을 사용한 경험을 바탕으로 Ktor의 가벼움, 유연성, Kotlin 호환성 등의 장점을 발견하고 Ktor를 채택했습니다. Ktor와 Spring의 부팅 속도, 리소스 사용량, 성능을 비교한 결과, Ktor가 Spring보다 부팅 속도가 빠르고 리소스 사용량이 적으며 Coroutine을 사용한 블로킹 처리에서 뛰어난 성능을 보였습니다. 베니는 상황에 맞는 프레임워크 선택의 중요성을 강조하며, Ktor의 적용이 적합한 경우와 그렇지 않은 경우를 제시합니다.

시작하며

안녕하세요. 10대들의 성공적인 금융관리 시스템을 개발하고 있는 영세그TF 베니입니다. 저는 실무 초기부터 Spring Framework(이하 Spring)를 사용해 개발을 시작했습니다. 단 몇 줄의 코드만으로 서비스를 쉽게 구성할 수 있고 온갖 필요한 기능은 다 있는 Spring의 매력에 빠졌습니다. 현재는 다른 서버 개발자들처럼 대부분의 프로젝트를 Spring으로 개발하고 서비스에 적용하고 있습니다.

Spring을 사용하면서 개발 효율 면에서 큰 이점을 얻고 있다고 생각하지만 점점 거대해지는 Spring은 Microservice의 작은 인스턴스를 추구하는 현재의 서버개발 환경에서 아쉬운 점이 많았습니다. 그러던 중 우연히 Ktor를 알게 되어 성능 테스트, 낮은 러닝 커브, 개발 편의성 측면에서 도입을 검토했고 업무에 적용하기 적합하다는 판단을 내려 Ktor로 서비스를 구축하게 되었습니다.

이런 경험을 토대로 이번 게시글에서는 Ktor의 성능 측정결과와 분석, 예시코드, Ktor의 장단점, Ktor를 언제 도입하면 좋을지 말씀드리고자 합니다. 이번 게시글은 Ktor와 Spring의 우위를 가리려는 목적은 아닙니다. Ktor를 사용해보고 싶은데 도입에 어려움이 많을 것 같고 안전성, 성능이 걱정되어 사용하기를 망설였던 분들에게 도움이 되기를 바랍니다.

Ktor 소개

Jetbrains에서 크게 3가지 관점으로 Ktor의 장점을 소개합니다.

  1. Kotlin과 Coroutine
  2. 가볍고 유연함
  3. Jetbrains에서 지원

저도 Ktor를 경험해 본 입장으로서 위의 3가지 장점에 크게 공감합니다.

  • Ktor 자체가 Kotlin으로 구축되어 있어 Kotlin으로 개발한다면 언어적으로 궁합이 잘 맞습니다. Spring은 자바로 개발되어 있어 언어적인 차이로 인하여 nullable처리나 호환성 이슈가 있지만 Ktor는 그렇지 않습니다. 또한, Ktor로 들어온 모든 요청은 시작부터 응답까지 Coroutine으로 처리되며 Coroutine을 따로 선언할 필요 없어 로직을 구현함에 있어 편리한 부분이 많았습니다.
  • Ktor는 가볍습니다. 원하는 기능만 선택적으로 사용해서 서버를 구축할 수 있고 Spring과 다르게 사용하지 않은 리소스까지 무분별하게 Bean으로 만들어 인스턴스화하지 않습니다. Ktor는 이미 만들어져 있는 공식 플러그인이나 직접 만든 커스텀 플러그인을 통해 다양한 모듈을 만들어 필요한 기능만 유연하게 추가합니다.
  • JetBrains에서 강력하게 지원해 주는 부분도 좋았습니다. 이 글을 쓰는 현시점에 Ktor 3.0.0 버전까지 출시되며 적극적인 활동을 하고 있습니다. IntelliJ IDEA에서 Ktor를 위한 프로젝트 설정도 지원하고 있습니다.

Ktor VS Spring

Spring을 두고 굳이 Ktor를 써야 할 이유가 있을까요? 먼저, Ktor가 서버 Framework로 얼마나 적합한지를 확인하기 위해, 아래 3가지 관점으로 테스트를 진행했습니다.

  1. 부팅 속도
  2. 리소스 사용량
  3. 성능

테스트 환경

부팅 속도 관점

부팅 속도가 중요한 이유는 장애 발생으로 인해 인스턴스가 죽었을 때 빠르게 다시 살아나는 것이 필요하기 때문입니다. 인스턴스가 장애에서 빠르게 복구되어야 다른 인스턴스에 트래픽이 몰리는 현상을 방지할 수 있습니다.

Spring과 Ktor를 각각 실행하고 로그상 기록되어 있는 실행시간을 10번 평균을 계산해 봤습니다.

대상측정값(초)
Spring6.694
Ktor1.054

테스트 결과, Ktor는 Spring에 비해 부팅속도가 6배나 빠른 것으로 확인되었습니다. 테스트를 위해 구현한 프로젝트는 Dependency가 겨우 6개만 추가되어 있는 기본 세팅 상태입니다. 더 많은 Dependency를 사용하는 운영 Spring 서비스의 경우 부팅 시간이 20-30초를 초과할 수 있습니다. 그에 반해 제 경험상 Ktor로 구현한 운영 서비스의 경우 여전히 1초대의 부팅시간을 유지하고 있어 Spring에 비해 부팅속도 면에서 이점이 많습니다.

리소스 사용량 관점

리소스를 적게 사용하면 Microservice를 구성할 때 더 유리합니다. 수 백, 수천 개 서비스 인스턴스가 뜨게 되면 Framework에서 기본으로 점유하고 있는 리소스도 무시하기 어렵습니다. Spring이 무거운 Framework라는 것은 체감적으로 느낄 수 있고 리소스를 많이 사용한다는 비교 자료는 구글 검색을 통해 쉽게 찾을 수 있습니다. 실제로 Spring에 비해 Ktor의 리소스 사용량은 얼마나 되는지 테스트를 진행해 보았습니다.

테스트 대상

테스트 대상과 지표를 확인하는 기준은 다음과 같습니다.

  • Max CPU Usage on startup: 프로세스 시작 후 최대 OS CPU 사용률
  • Max CPU Usage on Idle: 아무런 요청 없는 Idle한 상태의 OS CPU 사용률
  • Memory Usage on startup: 서버 시작 후 첫 GC 발생 전까지 JMX 메모리 사용값의 평균
  • Total Loaded Classes on startup: 서버 시작 후 JMX Total Loaded Classes 측정값의 평균

테스트 결과

모든 값은 10회 측정 후 평균값을 계산하였습니다.

테스트 대상SpringKtor
Max CPU Usage on startup93.8%63.5%
Max CPU Usage on Idle1.11%1.01%
Memory Usage on startup195 MB60 MB
Total Loaded Classes on startup15,666개7,716개

환경과 서비스를 어떻게 구성하는지에 따라 결괏값이 많이 바뀔 수 있습니다. 그러나 프로젝트에 매우 간단한 Dependency만 추가했음에도 불구하고 측정된 결과의 차이가 많이 나는 것으로 보아 Spring이 Ktor보다 인스턴스를 띄우기 위해 더 많은 리소스를 사용한다는 것은 확실해 보입니다. 본 테스트는 Gradle 설정에 단 6개의 Dependency만 사용한 기본 세팅 상태이지만 서비스 개발을 하다 보면 Spring에 Dependency를 많이 추가합니다. 결국 서버가 시작할 때 무작위로 생성되는 Bean으로 인해 CPU와 메모리를 더 많이 사용할 것으로 예상됩니다.

성능 관점

성능 관점은 스트레스 테스트를 통해 TPS, 응답시간, 요청 대비 응답 처리량을 확인하고자 합니다. 일반적인 경우라면 서버 Framework에 의해 서비스의 성능이 크게 바뀌기 어렵습니다. 그 이유는 Framework의 내부 처리 성능은 이미 검증된 경우가 많고 전체 성능에 주는 영향이 미미한 경우가 대부분이며 개발자가 어떻게 비즈니스 로직을 구현하는지가 성능에 더 큰 영향을 주기 때문입니다.

그러나 Ktor는 요청의 시작부터 끝까지 Coroutine으로 처리됩니다. 즉, Blocking이 길고 자주 발생할수록 Coroutine을 사용하는 Ktor가 성능면에서 Spring보다 유리하다고 추측해 볼 수 있습니다. 몇 가지 실험을 통해 실제로 그런지 확인해 보겠습니다.

Blocking이 없는 상황

테스트 조건

테스트에서 사용한 API는 길이가 100,000인 문자열을 랜덤 생성하여 반환합니다. 외부 호출과 같은 Blocking 로직이 일절 없으며 오직 CPU 연산을 통해 긴 문자열을 랜덤 생성합니다. 응답 시간은 약 20ms 정도입니다.

타임라인TPS
0분 ~ 1분TPS 5 -> TPS 500 선형적으로 상승
1분 ~ 3분TPS 500 유지
3분 ~ 4분TPS 500 -> TPS 5 선형적으로 하락
테스트 결과

Spring과 Ktor의 테스트 결과는 구분하기 어려울 정도로 일치합니다.

Blocking이 매우 긴 상황

테스트 조건

테스트에서 사용한 API는 SQL In절로 데이터 1000개를 조회하고 반환합니다.
In절 조회로 Blocking 시간이 응답 시간의 대부분을 차지합니다. 응답 시간은 약 40ms입니다.

타임라인TPS
0분 ~ 1분TPS 5 -> TPS 500 선형적으로 상승
1분 ~ 3분TPS 500 유지
3분 ~ 4분TPS 500 -> TPS 5 선형적으로 하락
테스트 결과

Spring 테스트 결과를 보면 요청에 비해 응답이 뒤로 밀리는 것을 볼 수 있습니다. Spring의 최대 처리량은 약 초당 230개 밖에 되지 않습니다. TPS가 계속 증가하자 Spring에서 소화할 수 있는 양을 넘어 요청을 받아들이고 있습니다.(서버 커넥션의 제한은 없습니다.) 요청을 빨리 처리하지 못해 Active 유저의 숫자는 계속 증가하고 나중에 들어온 요청까지 영향을 주고 결국 서버가 제 기능을 하지 못하고 있습니다. 테스트툴(gatling)의 요청 timeout은 60초이기 때문에 처리하지 못한 모든 요청들을 결국 실패로 처리되었습니다. Ktor는 초당 430개 정도를 처리하며 조금씩 응답이 밀리고 있지만 모든 요청을 실패 없이 처리하였습니다.

성능 테스트 결론

CPU 사용률과 응답시간을 보면 Blocking 여부와 상관없이 두 테스트 모두 서버 처리능력의 한계에 도달한 상태입니다. 그러나 두 테스트의 결과는 매우 다른 모습을 보여줍니다.

Blocking이 없는 상황
Blocking이 없는 테스트의 경우 Ktor와 Spring 테스트 결과가 동일한 이미지라고 볼 수 있을 정도로 일치합니다. 100,000자의 랜덤 문자열을 생성해야 하는 로직은 CPU 연산에 의존적인 로직입니다. 그 어떤 서버 프레임워크를 사용해도 이것은 CPU의 처리능력에 응답속도가 결정된다는 것은 변함이 없으며 테스트를 통해 그 결과를 확인할 수 있습니다.

Blocking이 매우 긴 상황
Blocking이 매우 긴 테스트에서는 매우 다른 결과를 볼 수 있습니다. TPS 230이 한계인 Spring과 다르게 Ktor는 TPS 430이 한계인 모습을 보여줍니다. 쿼리 자체가 동일하게 느리기 때문에 Ktor라고 해서 Spring보다 쿼리를 더 빠르게 처리할 수 없습니다.

그럼에도 불구하고 처리량에 차이가 나는 이유는 Ktor의 Coroutine이 Blocking 처리에서 더 강점을 보이기 때문입니다. Ktor의 Coroutine이 Blocking 로직에서 더 효율적으로 CPU를 사용한다고 볼 수 있습니다.

Ktor의 장단점

Ktor의 장단점을 정리하면 아래와 같습니다.

Ktor의 장점

  1. Jetbrains 공식 프로젝트로 신뢰도가 있다.
  2. 따로 설정하지 않아도 Coroutine이 적용되어 간편하게 성능을 올릴 수 있다.
  3. 리소스 사용량이 적고 부팅속도가 빠르다
  4. Framework가 직관적이고 단순하여 러닝커브가 낮다.

Ktor의 단점

  1. Ktor에서 공식적으로 사용할 수 있는 기능들이 Spring에 비해 적다.
  2. Ktor를 사용할 수 있는 영역은 API 서버에 국한되며 Spring Cloud 생태계를 대체할 수는 없다.
  3. 개발과 운영을 위한 참고 자료와 예시 코드가 적어 문제 해결에 도움 받기 어려울 수 있다.

Ktor는 언제 적용하는 게 좋을까?

도입해 봐도 좋은 분들

  1. 새로운 Framework에 대한 시도가 필요한 분들
  2. Ktor의 직관적이고 가벼운 동작 방식을 좋아하는 분들
  3. Blocking 로직이 많아 성능에 고민이신 분들
  4. Blocking처리를 위해 RxJava 혹은 Reactor 등의 사용으로 코드가 복잡해지는 것이 싫은 분들
  5. 간단한 코드로 Coroutine을 도입하고 싶은 분들

도입할 필요 없는 분들

  1. JDK21 Virtual Thread를 기다릴 수 있는 분들
  2. 이미 Spring Webflux 또는 Spring에 Coroutine을 사용하고 있고 만족하는 분들
  3. API서버가 Spring Clound 생태계에 강하게 결합되어 있는 분들

Ktor 사용법

Ktor Engine

Ktor는 어떤 엔진을 선택해서 인스턴스를 띄울지 선택할 수 있습니다.

fun main(args: Array<String>) {
    io.ktor.server.netty.EngineMain.main(args) // netty사용
//    io.ktor.server.tomcat.EngineMain.main(args) // tomcat사용
}

Ktor Route

Spring의 Controller와 비슷한 기능을 하는 것이 Ktor의 Route입니다.

fun Application.randomRouting() {
    routing {
        route("/api/random") {
            get("/string/{length}") {
                val length = call.parameters["length"]!!.toInt()
                call.respond(getRandomString(length))
            }
        }
    }
}

Ktor Plugin

Ktor는 거의 모든 기능이 플러그인으로 이루어있고 원하는 플러그인을 선택해서 사용할 수 있습니다. Spring에서 지원하는 많은 기능을 전부 대체할 수는 없지만 세기 힘들 정도로 많은 기능이 충분히 구현되어 있습니다.
설정방법은 간단합니다.

install(플러그인) {
    // 플러그인 Config 설정
}

주요한 몇 개의 플러그인만 소개하겠습니다.

공식 플러그인역할
CallLoggingCall이 발생할 때 이벤트를 받아서 로그로 남깁니다.
StatusPagesException에 따라 응답을 다르게 처리할 수 있습니다.
CORSCORS 설정을 합니다.
ContentNegotiationContentType, Accept에 따라 필요한 Serialize, Deserialize 정책을 설정합니다.
MicrometerMetrics매트릭을 설정합니다. 프로메테우스와 연동하여 사용할 수 있습니다.
Sessions세션을 생성합니다.
Authenticationbasic, bearer, session 등 다양한 Auth에 대한 기능을 지원합니다.
Velocity, FreeMarker, Thymeleaf다양한 FE 리소스를 Route 할 수 있도록 지원합니다.
DefaultHeaders응답 시 기본 헤더값을 추가합니다.
Compression이미지, 비디오 타입 등 압축이 필요한 데이터를 압축합니다.
HttpsRedirectHTTP 요청을 HTTPS요청으로 redirect 합니다.

공식 플러그인뿐만 아니라 직접 플러그인을 구현할 수 있습니다. ‘Call 요청 전’, ‘요청 후’, ‘응답 전’, ‘응답 후’ 같은 곳에 필요한 코드를 Interceptor처럼 삽입할 수 있습니다.

CallLogging

CallLogging 플러그인을 사용하면 Call이 발생할 때 아래와 같은 로그가 남게 됩니다.

implementation("io.ktor:ktor-server-call-logging:2.3.6")

fun Application.callLoggingConfiguration() {
    // Application내부의 install함수를 사용합니다.
    install(CallLogging) {
        level = Level.INFO
        format { call ->
            val status = call.response.status()
            val httpMethod = call.request.httpMethod.value
            val path = call.request.path()
            val processingTime = call.processingTimeMillis()
            "$status $httpMethod - $path in ${processingTime}ms"
        }
    }
}
200 OK GET - /api/random/string/100000 in 49ms

StatusPages

implementation("io.ktor:ktor-server-status-pages:2.3.6")

fun Application.exceptionConfiguration() {
    install(StatusPages) {
        exception<BadRequestException> { call, cause ->
            logger.error(cause.stackTraceToString())
            call.respond(HttpStatusCode.BadRequest, "잘못된 요청입니다.")
        }
    }
}

ContentNegotiation

API의 요청과 응답에서 Accept와 ContentType에 따라 Json, XML, CBOR와 같은 데이터를 Serialize, Deserialize하는 방식을 설정할 수 있습니다.

implementation("io.ktor:ktor-server-content-negotiation:2.3.6")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.6")
implementation("io.ktor:ktor-serialization-kotlinx-xml:2.3.6")

fun Application.contentNegotiationConfiguration() {
    install(ContentNegotiation) {
        json(
            Json {
                namingStrategy = JsonNamingStrategy.SnakeCase
                ignoreUnknownKeys = true
                explicitNulls = false
            }
        )
        xml(
            format = XML {
                xmlDeclMode = XmlDeclMode.Charset
            }
        )
    }
}

MicrometerMetrics

implementation("io.ktor:ktor-server-metrics-micrometer:2.3.6")
implementation("io.micrometer:micrometer-registry-prometheus:1.11.1")

val appMicrometerRegistry = PrometheusMeterRegistry(PrometheusConfig.DEFAULT)

fun Application.prometheusConfiguration() {
    // MicrometerMetrics 설정
    install(MicrometerMetrics) {
        registry = appMicrometerRegistry
        meterBinders = listOf( // 추가할 Metrics를 기재합니다.
            UptimeMetrics(),
            LogbackMetrics(),
            ClassLoaderMetrics(),
            JvmMemoryMetrics(),
            JvmGcMetrics(),
            JvmInfoMetrics(),
            ProcessorMetrics(),
            JvmThreadMetrics(),
            FileDescriptorMetrics()
        )
        metricName = "http.server.requests" // 매트릭 이름을 spring 방식으로 바꿉니다.
    }
}

Exposed Database

// Exposed + HikariCP
object DatabaseSingleton {
    fun init() {
        // Exposed Database를 HikariCP로 생성
        Database.connect(
            HikariDataSource(
                HikariConfig().apply {
                    driverClassName = "org.h2.Driver"
                    jdbcUrl = "jdbc:h2:file:./build/db"
                    username = "username"
                    password = "password"
                    maximumPoolSize = 10
                    minimumIdle = 10
                    poolName = "ktor-performance"
                    validate()
                }
            )
        )
    }

    // Suspended Block에서 사용하는 Transaction 생성
    suspend fun <T> dbQuery(block: suspend () -> T): T =
        newSuspendedTransaction(Dispatchers.IO) { block() }
}

override suspend fun article(id: Int): Article? = dbQuery {
    // Exposed DSL
    // select * from article where id = :id
    Articles
        .select { Articles.id eq id }
        .map(::resultRowToArticle)
        .singleOrNull()
}

Route Test

@Test
fun testPostCustomer() = testApplication {
    val client = createClient {
        install(ContentNegotiation) {
            json()
        }
    }
    val response = client.post("/customer") {
        contentType(ContentType.Application.Json)
        setBody(Customer(3, "Jet", "Brains"))
    }
    assertEquals("Customer stored correctly", response.bodyAsText())
    assertEquals(HttpStatusCode.Created, response.status)
}

마치며

이번 게시글을 통해 Ktor를 언제 어떻게 사용하면 좋을지 소개해드리고자 하였습니다. 그 과정에서 굴러온 돌 Ktor를 소개하기 위해 박힌 돌 Spring과 많은 부분을 비교해 보았습니다. 저는 수년간 Spring으로 개발해 왔고 업무의 모든 요구사항을 Spring으로도 충분히 해결할 수 있다고 생각합니다. 그럼에도 Ktor를 적용해보고자 했던 이유는 Ktor에는 좋은 성능, 충분한 안정성, 낮은 러닝커브, 직관적인 코드라는 장점이 있었기 때문입니다.

좋은 결과물을 만들기 위해서는 상황에 맞는 적절한 도구를 선택해야 합니다. 마찬가지로 Spring만 고집할 필요 없이 상황에 맞는 Framework를 선택할 수 있는 것도 서버 개발자에게 필요한 부분이라고 생각합니다. 이 글을 통해 자신의 업무에 Ktor가 적합한지 검토해 보시고 올바른 판단을 내리는데 도움이 되길 바랍니다.

benny.ahn
benny.ahn

카카오페이 허브클랜 서버 개발자 베니입니다. 빠르고 안정적인 서비스 구축에 관심이 많습니다. 항상 바른 개발, 꼼꼼한 개발을 하기 위해 노력합니다.

태그