Ktor 찍먹하기 - 간단 HTTP API 작성 | Trying Out Ktor - Writing a Simple HTTP API

ktor 공식문서 따라해 보는 중 : https://ktor.io/docs/gradle.html#create-entry-point
Adding Ktor to an existing Gradle project | Ktor
ktor.io
나의 소스코드 : https://github.com/jyami-kim/ktor-sample
1. 서버 실행을 위한 기본 골격
- ktor embeddedServer로 실행 : https://ktor.io/docs/gradle.html#create-embedded-server
- ktor engineMain으로 실행 : https://ktor.io/docs/gradle.html#create-engine-main
a. embeddedServer
package com.example
import io.ktor.application.*
import io.ktor.response.*
import io.ktor.routing.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
fun main() {
embeddedServer(Netty, port = 8080) {
routing {
get("/") {
call.respondText("Hello, world!")
}
}
}.start(wait = true)
}
기본 서버 실행을 위한 골격. routing에 해당하는 부분을 주로 아래와 같이 configureXXX 와 같은 형태의 플러그인 형식으로 빼서, 분리하는 패턴을 갖고있음. (코틀린의 확장함수 사용)
// com.jyami.Application
fun main() {
embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
configureRouting()
}.start(wait = true)
}
// com.jyami.plugins.Routing
fun Application.configureRouting() {
routing {
get("/") {
call.respondText("Hello World!")
}
}
}
위와같이 간단한 코드만 짜서 서버 RUN을 시키면?
서버 실행 속도가 이렇게까지 빨라도 되는건가.. spring안쓰고 간단하게 서버 만들고 싶을 때 쓰면 좋을 것 같다.

b. engineMain (HOCON 포맷)
위 예제는 embeddedServer로 짜는 방법을 적었는데, 만약 EngineMain 방식으로 ktor 서버를 짜고 싶다면 조금 다른 방식이다.
// main.kotlin.com.jyami
fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args)
fun Application.module() {
configureRouting()
}
// main.kotlin.com.jyami.plugins
fun Application.configureRouting() {
routing {
get("/") {
call.respondText("Hello World!")
}
}
}
// resource
ktor {
deployment {
port = 8080
}
application {
modules = [ com.jyami.ApplicationKt.module ]
}
}
HOCON 포맷이라고한다. resources 폴더안에 application.conf 파일을 이용해서 실행시킬 포트나, entry point에대한 애플리케이션 설정을 작성한다. application.conf 관련 여러 설정들 : https://ktor.io/docs/configurations.html
2. gradle 설정 확인
gradle application 플러그인 : https://docs.gradle.org/current/userguide/application_plugin.html
The Application Plugin
This plugin also adds some convention properties to the project, which you can use to configure its behavior. These are deprecated and superseded by the extension described above. See the Project DSL documentation for information on them. Unlike the extens
docs.gradle.org
실행가능한 JVM 애플리케이션을 만들 수 있는 기능을 제공함.
개발도중에 쉽게 애플리케이션을 시작할 수 있도록 해주며, TAR, AIP과 같이 애플리케이션 패키지도 지원해줌
application 플러그인 안에 java / distribution 플러그인이 모두 내장되어있다. 각각 main에 대한 소스 셋, 배포를 위한 패키징 기능을 가진 플러그인 인듯 하다.
application 플러그인을 사용할 때 configuration은 main class (i.e. entry point)를 지정하는 건 꼭 필요하다.
plugins {
application
kotlin("jvm") version "1.6.0"
}
application {
mainClass.set("com.kakao.ApplicationKt")
}
실행시 intellij runner사용도 되고, ./gradlew run을 사용해도 된다.
3. 튜토리얼 1: HTTP API 만들기
build.gradle 분석
dependencies {
implementation("io.ktor:ktor-server-core:$ktor_version")
implementation("io.ktor:ktor-server-netty:$ktor_version")
implementation("ch.qos.logback:logback-classic:$logback_version")
implementation("io.ktor:ktor-serialization:$ktor_version")
testImplementation("io.ktor:ktor-server-tests:$ktor_version")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
}
- ktor-server-core : ktor가 제공하는 핵심 컴포넌트 제공
- ktor-server-netty : 네티엔진을 사용하여 서버 기능을 사용할 수 있게한다. 외부 애플리케이션 컨테이너에 의존하지 않고도 가능.
- logback-classic : SLF4J 구현
- ktor-serialization : 코틀린 객체를 JSON과 같은 직렬화 형태로 변환해준다.
- ktor-server-test-host : http 스택을 사용하지 않고도 서버 테스트가 가능함. unit test로 사용가능함
ConentNegotiation
fun Application.module() {
install(ContentNegotiation){
json()
}
}
- 클라이언트와 서버간의 미디어타입을 중계해준다. : Accept, Content-Type Header
- kotlinx.serialization 라이브러리 혹은 그외의 것 (Gson, Jackson)을 사용하여 컨텐츠를 특정 포맷으로 serializing/deserializing한다. (json으로 하겠지)
ContentNegotiation 플러그인을 사용하기 위하여 애플리케이션 초기화 코드에 install 함수를 사용하여 해당하는 ConetentNegotiation 플러그인을 넘겨준다.
ktor에서는 Content-Type으로 다양한 내장 함수를 제공하고 있는데
- kotlinx.serializtion : JSON, Protobuf, CBOR
- Gson : JSON
- Jackson : JSON
그이외에 커스텀한 Content-Type을 제공하고 싶다면 아래와 같이 ContentNegotiation의 register 메서드를 사용하여 등록하면 된다
install(ContentNegotiation) {
register(ContentType.Application.Json, CustomJsonConverter())
register(ContentType.Application.Xml, CustomXmlConverter())
}
request, response 파라미터 제어
1. path parameter
get("{id}") {
val id = call.parameters["id"] ?: return@get call.respondText(
"Missing or malformed id",
status = HttpStatusCode.BadRequest
)
val customer = customerStorage.find {it.id == id} ?: return@get call.respondText (
"No customer with id $id",
status = HttpStatusCode.NotFound
)
call.respond(customer)
}
인덱스 접근자 함수 : call.parameters["myParamName"]
요청 Path를 기본적으로 string으로 가져오게 된다.
2. request body
post {
val customer = call.receive<Customer>()
customerStorage.add(customer)
call.respondText ("Customer stored correctly", status = HttpStatusCode.Created)
}
call.recevie<T> : 제네릭 변수를 사용하여 호출한 request body를 자동으로 코틀린 객체로 역직렬화 한다.
동시에 여러 요청이 접근될 때 문제는 이 실습의 범위를 벗어난다 : 동시에 요청/스레드에서 액세스 할 수 있는 데이터 구조 혹은 코드를 작성하면 될 것
3. response body
get {
if (customerStorage.isNotEmpty()) {
call.respond(customerStorage)
} else {
call.respondText("No customers found", status = HttpStatusCode.NotFound)
}
}
- call.respond() : kotlin 객체를 가져와 해당 객체가 지정된 형식으로 직렬화하여 http 응답을 반환한다.
- call.respondText() : Text를 응답으로 보낼 때의 respond를 간단하게 구현해둔 함수이다. 문자열 응답을 반환한다.
Routing
Route 확장함수를 이용하여 경로를 정의하였다. 이렇게 할 경우 아무래도 라우팅만 역할을 빼서 코드를 관리할 수 있어서 좋아보인다.
Application.module에 있는 라우팅 블록 내부에 각 경로를 직접 추가할 수 있긴하지만, 파일 경로들을 그룹화하여 관리하는 것이 유지보수에 더 좋다.
Route 확장함수만을 사용하여 경로를 정의할 경우 실제 Ktor 서버에는 적용되지 않는데, 맨 처음 ktor 시작하기에 했던 것처럼, route를 Application에 등록해주는 과정이 필요하다. 즉, Route.module에 등록된 경로들을 Application.module에 등록하는 방식으로 관리하자.
Application의 확장함수를 사용함으로써 application에 등록할 path를 route dsl을 이용하여 등록할 수 있으며, 이 dsl에는 사실상 Route 모듈에 등록된 경로를 Application에 경로를 등록하도록 도와주는 것으로 보인다.
fun Route.customerRouting() {
route("/customer") {
get{}
}
}
fun Application.registerCustomerRoutes() {
routing {
customerRouting()
}
}
fun main() {
embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
registerCustomerRoutes()
install(ContentNegotiation){
json()
}
}.start(wait = true)
}
4. Ktor 테스트
ktor-server-test-host : netty를 시작하지 않고도 endpoint 테스트가 가능하다.
테스트 request를 실행하기 위한 몇가지 헬퍼 메서드가 프레임워크에서 제공되며, 그중 하나가 TestApplication 이다.
HOCON방식 : 한번에 모든 configuration이 등록가능한 구조
internal class OrderRoutingKtTest {
@Test
fun testGetOrder() {
withTestApplication({ module() }) {
handleRequest(HttpMethod.Get, "/order/2020-04-06-01").apply {
assertEquals(
"""{"number":"2020-04-06-01","contents":[{"item":"Ham Sandwich","amount":2,"price":5.5},{"item":"Water","amount":1,"price":1.5},{"item":"Beer","amount":3,"price":2.3},{"item":"Cheesecake","amount":1,"price":3.75}]}""",
response.content
)
assertEquals(HttpStatusCode.OK, response.status())
}
}
}
}
HOCON 포맷인 경우에 사용이 가능한 것으로 보인다. (embeddedServer 방식으로 짜는 경우에는 httpClient를 사용한 직접 호출 방식을 이용하는 것으로 보인다 : https://ktor.io/docs/testing.html#end-to-end)
핵심은 withTestApplication인데, 테스트로 실행하려는 애플리케이션을 주입한다. (Application.module() 형태로된 메서드면 무엇이든 주입이 가능한 것으로 보인다.)
HOCON 포맷의 경우에는, main에 넣기전 main밖에 있는 Application 확장함수에 정의되어있어, 해당 애플리케이션에 대한 모든 설정이 등록된채 테스트가 가능하고, 따라서 위와 같이 module() 을 넣고 테스트를 해도 무관하다.
embeddedServer 방식 : 필요모듈만 configuration에 등록하여 테스트할 수 있는 구조
fun Application.testModule(){
registerOrderRoute()
install(ContentNegotiation){
json()
}
}
@Test
fun testGetOrder() {
withTestApplication({ testModule() }) {
handleRequest(HttpMethod.Get, "/order/2020-04-06-01").apply {
assertEquals(
"""{"number":"2020-04-06-01","contents":[{"item":"Ham Sandwich","amount":2,"price":5.5},{"item":"Water","amount":1,"price":1.5},{"item":"Beer","amount":3,"price":2.3},{"item":"Cheesecake","amount":1,"price":3.75}]}""",
response.content
)
assertEquals(HttpStatusCode.OK, response.status())
}
}
}
다만 embeddedServer 방식으로 withTestApplication을 작성하는 경우에는, main() 함수안에 직접 선언이 되어있어 애플리케이션에 대한 모든 테스트는 불가능하고 위에서 내가 정의한 configureRouting(), registerCustomRouting(), registerOrderRouting()과 같이 Application 확장함수 단위로 테스트가 가능하다.
다만 여기서 주의해야할 점은 configureRouting은 단일하게 넣어도 테스트가 가능하나, registerOrderRouting, registerCustomRouting의 경우에는 불가능하다 왜냐하면 이것들을 직접 넣고 돌리게되면, Application에 등록해둔 ContentNegotiation이 빠지게 될것이고, serialize과정에 문제가 생겨서 테스트가 실패하게 될 것이다. 따라서 테스트시 두개의 configuration을 함께 등록하여 테스트해야한다.
위와같이 테스트가 가능하게 되면서, ktor을 사용할 경우에는 모듈별로, 설정별로 테스트가 손쉽게 될 것으로 보인다.
사실, embeddedServer나 HOCON이나 하나의 Application 확장함수를 만들어서 테스트하는건 같아서 입맛대로 사용하도록하자.

Following along with the ktor official docs: https://ktor.io/docs/gradle.html#create-entry-point
Adding Ktor to an existing Gradle project | Ktor
ktor.io
My source code: https://github.com/jyami-kim/ktor-sample
1. Basic Skeleton for Running a Server
- Running with ktor embeddedServer: https://ktor.io/docs/gradle.html#create-embedded-server
- Running with ktor engineMain: https://ktor.io/docs/gradle.html#create-engine-main
a. embeddedServer
package com.example
import io.ktor.application.*
import io.ktor.response.*
import io.ktor.routing.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
fun main() {
embeddedServer(Netty, port = 8080) {
routing {
get("/") {
call.respondText("Hello, world!")
}
}
}.start(wait = true)
}
This is the basic skeleton for running a server. The routing part is typically extracted into a plugin-style pattern like configureXXX as shown below, separating concerns. (Using Kotlin's extension functions)
// com.jyami.Application
fun main() {
embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
configureRouting()
}.start(wait = true)
}
// com.jyami.plugins.Routing
fun Application.configureRouting() {
routing {
get("/") {
call.respondText("Hello World!")
}
}
}
If you write just this simple code and RUN the server?
Is it even okay for the server startup to be this fast.. Seems like it would be great for quickly spinning up a server without using Spring.

b. engineMain (HOCON Format)
The example above showed how to write it using embeddedServer. If you want to build a ktor server using the EngineMain approach, it's a slightly different method.
// main.kotlin.com.jyami
fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args)
fun Application.module() {
configureRouting()
}
// main.kotlin.com.jyami.plugins
fun Application.configureRouting() {
routing {
get("/") {
call.respondText("Hello World!")
}
}
}
// resource
ktor {
deployment {
port = 8080
}
application {
modules = [ com.jyami.ApplicationKt.module ]
}
}
It's called the HOCON format. You write the application configuration — such as the port to run on and the entry point — in an application.conf file inside the resources folder. Various settings for application.conf: https://ktor.io/docs/configurations.html
2. Checking the Gradle Configuration
Gradle application plugin: https://docs.gradle.org/current/userguide/application_plugin.html
The Application Plugin
This plugin also adds some convention properties to the project, which you can use to configure its behavior. These are deprecated and superseded by the extension described above. See the Project DSL documentation for information on them. Unlike the extens
docs.gradle.org
It provides the ability to create executable JVM applications.
It makes it easy to start the application during development and also supports application packaging like TAR and ZIP.
The application plugin has both the java and distribution plugins built in. Each seems to be a plugin that provides source sets for main and packaging functionality for distribution, respectively.
When using the application plugin, specifying the main class (i.e. entry point) in the configuration is mandatory.
plugins {
application
kotlin("jvm") version "1.6.0"
}
application {
mainClass.set("com.kakao.ApplicationKt")
}
You can run it using the IntelliJ runner or by using ./gradlew run.
3. Tutorial 1: Building an HTTP API
Analyzing build.gradle
dependencies {
implementation("io.ktor:ktor-server-core:$ktor_version")
implementation("io.ktor:ktor-server-netty:$ktor_version")
implementation("ch.qos.logback:logback-classic:$logback_version")
implementation("io.ktor:ktor-serialization:$ktor_version")
testImplementation("io.ktor:ktor-server-tests:$ktor_version")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
}
- ktor-server-core: Provides the core components offered by ktor
- ktor-server-netty: Enables server functionality using the Netty engine, without depending on an external application container.
- logback-classic: SLF4J implementation
- ktor-serialization: Converts Kotlin objects into serialized formats like JSON.
- ktor-server-test-host: Allows server testing without using the HTTP stack. Can be used as unit tests.
ContentNegotiation
fun Application.module() {
install(ContentNegotiation){
json()
}
}
- Mediates media types between the client and server: Accept, Content-Type Header
- Uses the kotlinx.serialization library or others (Gson, Jackson) for serializing/deserializing content into a specific format (JSON in our case).
To use the ContentNegotiation plugin, you pass the ContentNegotiation plugin using the install function in the application initialization code.
Ktor provides various built-in functions for Content-Type:
- kotlinx.serialization: JSON, Protobuf, CBOR
- Gson: JSON
- Jackson: JSON
If you want to provide a custom Content-Type beyond those, you can register it using ContentNegotiation's register method like this:
install(ContentNegotiation) {
register(ContentType.Application.Json, CustomJsonConverter())
register(ContentType.Application.Xml, CustomXmlConverter())
}
Controlling Request and Response Parameters
1. path parameter
get("{id}") {
val id = call.parameters["id"] ?: return@get call.respondText(
"Missing or malformed id",
status = HttpStatusCode.BadRequest
)
val customer = customerStorage.find {it.id == id} ?: return@get call.respondText (
"No customer with id $id",
status = HttpStatusCode.NotFound
)
call.respond(customer)
}
Index accessor function: call.parameters["myParamName"]
It retrieves the request path as a string by default.
2. request body
post {
val customer = call.receive<Customer>()
customerStorage.add(customer)
call.respondText ("Customer stored correctly", status = HttpStatusCode.Created)
}
call.receive<T>: Uses a generic type parameter to automatically deserialize the request body into a Kotlin object.
Handling concurrent request access is beyond the scope of this tutorial — you'd just need to write data structures or code that can handle simultaneous requests/threads.
3. response body
get {
if (customerStorage.isNotEmpty()) {
call.respond(customerStorage)
} else {
call.respondText("No customers found", status = HttpStatusCode.NotFound)
}
}
- call.respond(): Takes a Kotlin object, serializes it into the specified format, and returns it as an HTTP response.
- call.respondText(): A convenience function for sending text as a response. Returns a string response.
Routing
Routes are defined using Route extension functions. This approach is nice because it lets you separate and manage just the routing logic on its own.
While you could add each route directly inside the routing block in Application.module, grouping file paths together is better for maintainability.
If you only define routes using Route extension functions, they won't actually be applied to the Ktor server. As we did in the initial ktor setup, you need to register the routes with the Application. In other words, manage it by registering the routes defined in Route.module into Application.module.
By using Application extension functions, you can register paths to the application using the route DSL. This DSL essentially helps register the routes defined in the Route module into the Application.
fun Route.customerRouting() {
route("/customer") {
get{}
}
}
fun Application.registerCustomerRoutes() {
routing {
customerRouting()
}
}
fun main() {
embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
registerCustomerRoutes()
install(ContentNegotiation){
json()
}
}.start(wait = true)
}
4. Testing in Ktor
ktor-server-test-host: Allows endpoint testing without starting Netty.
The framework provides several helper methods for executing test requests, and one of them is TestApplication.
HOCON approach: A structure where all configurations can be registered at once
internal class OrderRoutingKtTest {
@Test
fun testGetOrder() {
withTestApplication({ module() }) {
handleRequest(HttpMethod.Get, "/order/2020-04-06-01").apply {
assertEquals(
"""{"number":"2020-04-06-01","contents":[{"item":"Ham Sandwich","amount":2,"price":5.5},{"item":"Water","amount":1,"price":1.5},{"item":"Beer","amount":3,"price":2.3},{"item":"Cheesecake","amount":1,"price":3.75}]}""",
response.content
)
assertEquals(HttpStatusCode.OK, response.status())
}
}
}
}
This appears to be usable with the HOCON format. (For the embeddedServer approach, it seems you'd use a direct call method with httpClient instead: https://ktor.io/docs/testing.html#end-to-end)
The key part is withTestApplication, which injects the application you want to run for testing. (It looks like any method in the form of Application.module() can be injected.)
With the HOCON format, since the Application extension function is defined outside of main before being put into main, all configurations for that application are registered and can be tested. So testing with module() as shown above works just fine.
embeddedServer approach: A structure where you can register only the needed modules in the configuration for testing
fun Application.testModule(){
registerOrderRoute()
install(ContentNegotiation){
json()
}
}
@Test
fun testGetOrder() {
withTestApplication({ testModule() }) {
handleRequest(HttpMethod.Get, "/order/2020-04-06-01").apply {
assertEquals(
"""{"number":"2020-04-06-01","contents":[{"item":"Ham Sandwich","amount":2,"price":5.5},{"item":"Water","amount":1,"price":1.5},{"item":"Beer","amount":3,"price":2.3},{"item":"Cheesecake","amount":1,"price":3.75}]}""",
response.content
)
assertEquals(HttpStatusCode.OK, response.status())
}
}
}
However, when writing withTestApplication with the embeddedServer approach, since everything is declared directly inside the main() function, you can't test the entire application at once. Instead, you can test at the Application extension function level — like configureRouting(), registerCustomRouting(), and registerOrderRouting() that I defined above.
One thing to watch out for here: configureRouting can be tested on its own, but registerOrderRouting and registerCustomRouting cannot. That's because if you plug them in directly and run them, the ContentNegotiation registered in the Application will be missing, causing issues with the serialization process and making the tests fail. So you need to register both configurations together when testing.
With testing set up like this, using ktor makes it look easy to test by module and by configuration.
Honestly, whether you use embeddedServer or HOCON, it's the same in that you create one Application extension function and test with it, so just use whichever you prefer.
'Develop > Kotlin' 카테고리의 다른 글
| backingField와 recursive call | Backing Field and Recursive Call (1) | 2022.05.04 |
|---|---|
| Kotlin Void vs Unit vs Nothing | Kotlin Void vs Unit vs Nothing (0) | 2022.05.02 |
댓글
Comments