Ktor 는 Jetbrain 의 웹 서버, 클라이언트 프레임워크입니다. 그 중에서도 ktor-client 의 사용법을 중심으로 살펴보겠습니다.
새로운 도구를 살펴보다보면, 여러 트렌드를 살펴볼 수 있어 흥미롭습니다. Ktor 는 그들만의 DSL 을 정의했고, 코루틴을 사용합니다. 물론 벌써 몇 년 전에 대두된 개념이지만, 개념이 내포하는 철학을 담은, 완성도 높은 도구란 점에서 그들의 구현을 읽어보는 것이 유익했습니다.
아래에 사용한 라이브러리의 버전을 명시하였으니, 참고부탁드립니다.
이 링크 에서 본문에 포함된 코드들을 볼 수 있습니다.
kotlin 버전 : 1.7.10
ktor 버전 : 2.2.2
Ktor 가 제공하는 아티팩트는 아래와 같습니다.
// build.gradle.kts
// Ktor
// 안드로이드 플랫폼의 경우, 코루틴 라이브러리가 필요할 수 있습니다.
implementation("io.ktor:ktor-client-core:$ktorVersion")
implementation("io.ktor:ktor-client-websockets:$ktorVersion")
// Ktor-Engines
implementation("io.ktor:ktor-client-okhttp:$ktorVersion")
implementation("io.ktor:ktor-client-apache:$ktorVersion")
// > ... implementation("io.ktor:ktor-client-{engine-name}:$ktorVersion")
// Ktor-Serialization
implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
implementation("io.ktor:ktor-serialization-jackson:$ktorVersion")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
// > ... implementation("io.ktor:ktor-serialization-kotlinx-{resource-type}:$ktorVersion")
// Ktor-Other-Plugins
implementation("io.ktor:ktor-client-resources:$ktorVersion")
implementation("io.ktor:ktor-client-auth:$ktorVersion")
implementation("io.ktor:ktor-client-encoding:$ktorVersion")
implementation("io.ktor:ktor-client-logging:$ktorVersion")
// Ktor-For-Tests
testImplementation("io.ktor:ktor-client-mock:$ktorVersion")
이 때, 아티팩트의 suffix 로 플랫폼 명이 올 수 있습니다. ( ex. ktor-client-jvm
, -ktor-client-android
, ktor-client-iosarm64
... ) suffix 없이 implementation 할 경우, -jvm
플랫폼 라이브러리를 가져옵니다. 지원하는 플랫폼은 그들이 잘 명시해놨네요, 참고하실 수 있겠습니다.
사용하시는 아티팩트를 선택하여 가져오시면 되겠습니다. 이 문서에서 사용하는 아티팩트는 아래와 같습니다.
// Ktor
implementation("io.ktor:ktor-client-core:$ktorVersion")
testImplementation("io.ktor:ktor-client-mock:$ktorVersion")
// Ktor Engines
implementation("io.ktor:ktor-client-okhttp:$ktorVersion")
implementation("io.ktor:ktor-client-apache:$ktorVersion")
implementation("io.ktor:ktor-client-cio:$ktorVersion")
// Ktor Serialization
implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
implementation("io.ktor:ktor-serialization-jackson:$ktorVersion")
// Ktor Misc.
implementation("io.ktor:ktor-client-logging:$ktorVersion")
이하 기본 사용법 설명에서 활용하는 테스트 코드 매처는 kotest-core 의 Matcher 를 사용하였으니, 참고 부탁드립니다.
외람된 얘기지만, 포스팅하다보니 개인적으로 흥미로운 점이 종종 생깁니다. 해서 가끔 삼천포로 빠지니 그런 부분들은 적당히 스킵해서 보시면 되겠습니다.
기본적인 클라이언트 선언은 아래와 같습니다.
fun `Ktor Set Up - Default Engines`() {
val client = HttpClient()
// 생성자에 엔진 팩토리를 명시하지 않으면, 빌드 스크립트 아티팩트를 통해 자동 결정합니다.
client.engine should beInstanceOf(OkHttpEngine::class)
// 필자는 OkHttp 를 가져왔습니다.
// 따라서 OkHttpEngineFactory 가 엔진 팩토리이고, clinet.engine 은 OkHttpEngine 입니다.
val explicitClient = HttpClient(OkHttp)
// 여전히, 엔진 팩토리를 명시하여 정의할 수 있습니다.
explicitClient.engine should beInstanceOf(OkHttpEngine::class)
// 언급한 것처럼 엔진 팩토리는 OkHttpEngineFactory 이고, 엔진은 OkHttpEngine 이겠습니다.
}
-스킵가능- '자동' 으로 엔진 팩토리를 결정하는 부분은 흥미롭습니다. 3개의 엔진을 디펜덴시로 가져와 테스트해보겠습니다.
@Test
fun `Ktor Set Up - Engine Factories`() {
// build.gradle.kts 에서는 아래와 같은 순서로 임포트했습니다.
// implementation("io.ktor:ktor-client-okhttp:$ktorVersion")
// implementation("io.ktor:ktor-client-apache:$ktorVersion")
// implementation("io.ktor:ktor-client-cio:$ktorVersion")
val engines = HttpClientEngineContainer::class.java
.let { ServiceLoader.load(it, it.classLoader).toList() }
// 사전에, OkHttp, Apache, CIO 엔진을 의존성으로 가져왔습니다.
// 참고로, ktor 는 위와 동일한 코드로 클래스들을 로딩합니다.
engines.count() shouldBe 3
// 3개를 가져왔으니, 우리가 볼 수 있는 HttpClientEngineContainer 는 총 3개입니다.
with(engines.map(HttpClientEngineContainer::toString)) {
this[0] shouldBe "OkHttp"
this[1] shouldBe "Apache"
this[2] shouldBe "CIO"
}
// build.gradle.kts 에서 임포트한 순서와 같았습니다.
val client = HttpClient()
// 생성자에 엔진 팩토리를 명시하지 않고 결정하게 합니다.
client.engine should beInstanceOf(OkHttpEngine::class)
// build.gradle.kts 상 맨 상단에 적은 의존성을 명시해야 테스트는 통과하였습니다.
}
엔진 로딩 순서의 기준에 대해서 설명하려면, ServiceLoader::class
의 동작 구조를 봐야하고, 이건 너무 삼천포라 이만 하겠습니다.
이제 옵션들을 간략하게 소개합니다. 옵션은 벤더에 따라 다양하니, OkHttp 기준의 일부 옵션들만 적어두었습니다.
@Test
fun `Ktor Set Up - Engine Config, OkHttp`() {
HttpClient(OkHttp) {
engine {
// common 프로퍼티
threadsCount = 8 // default : Int = 4
pipelining = true // default : Boolean = false
proxy = ProxyBuilder.http("") // default : ProxyConfig? = null
// OkHttp https://ktor.io/docs/http-client-engines.html#okhttp
// OkHttpConfig https://api.ktor.io/ktor-client/ktor-client-okhttp/io.ktor.client.engine.okhttp/-ok-http-config/index.html?_ga=2.250086022.1082379539.1673918991-752697833.1673561447&_gl=1*9p2cne*_ga*NzUyNjk3ODMzLjE2NzM1NjE0NDc.*_ga_9J976DJZ68*MTY3MzkyNjE3OC44LjEuMTY3MzkyNjc5My42MC4wLjA.
clientCacheSize = 5 // default : Int = 10
preconfigured // default : OkHttpClient? = null
webSocketFactory // default : WebSocket.Factory? = null
// 인터셉터 추가
addInterceptor { chain ->
chain.proceed(chain.request())
}
// 네트워크 인터셉터 추가
addNetworkInterceptor { chain ->
chain.proceed(chain.request())
}
// OkHttpClient.Builder 를 이용한 설정
config {
// this: OkHttpClient.Builder
}
}
}
}
아래와 같이 Http Method 를 명시할 수 있습니다. 사용한 MockEngine 클래스는 ktor 의존성 그룹 내 ktor-client-mock 아티팩트에서 찾아볼 수 있습니다.
@Test
fun `Ktor Requests - Http 메소드의 명시`(): Unit = runBlocking {
// 편의 상 요청의 HttpMethod 를 String 으로 반환하는 서버응답을 Mocking 했습니다.
val client = HttpClient(MockEngine { req ->
respondOk(req.method.value)
})
// HttpClient.request
client.request {
method = HttpMethod.Get
}.body() as String shouldBe "GET"
// HttpCleint.get [.post | .option | .head | .delete ... ]
client.get("...").body() as String shouldBe "GET"
client.post("...").body() as String shouldBe "POST"
client.delete("...").bodyAsText() shouldBe "DELETE"
// 이미 구현된 extension 을 사용하는 것이 좀 더 읽기 쉬워보입니다.
}
사소한 얘기지만 body() 를 String 으로 읽을 때 as String 을 쓰셔도 되고, bodyAsText() 를 쓰셔도 좋습니다. bodyAsText() 는 인자에 fallback 인코딩을 지정할 수 있단 차이가 있습니다.
아래와 같이 Uri 팩터들을 명시할 수 있습니다.
@Test
fun `Ktor Requests - Url 의 명시`(): Unit = runBlocking {
// 편의 상 요청의 uri 를 String 으로 반환하는 서버응답을 Mocking 했습니다.
val client = HttpClient(MockEngine { req ->
respondOk("${req.url.protocol.name}://${req.url.host}${req.url.fullPath}")
})
val response = client.request {
url { // this: URLBuilder
protocol = URLProtocol.HTTPS
host = "example.host.com"
// path 정의
// URLBuilder.path
path("/path1/path2") // .../path1/path2
appendPathSegments("path3", "패스3") // .../path3/%ED%8C%A8%EC%8A%A43
appendEncodedPathSegments("패스4") // .../패스4
// vararg 로 path 를 나누어 전달할 수 있습니다.
// appendPathSegments 는 기본적으로 Url 인코딩합니다.
// appendEncodedPathSegments 를 사용하여 이미 인코딩된 ( 인코딩하지 않을 ) path 를 명시할 수 있습니다.
// Query String 정의
// URLBuilder.parameters
parameters.append("param1", "value") // ...?param1=value
encodedParameters.append("param2", "값") // ...¶m2=값
// Fragment 정의
fragment = "fragment1" // #fragment1
encodedFragment = "프래그먼트2" // #프래그먼트2
}
}
response.bodyAsText() shouldBe "https://example.host.com/path1/path2/path3/%ED%8C%A8%EC%8A%A43/패스4?param1=value¶m2=값"
}
아래와 같이 header 들을 명시할 수 있습니다.
@Test
fun `Ktor Requests - Headers 의 명시`(): Unit = runBlocking {
// 편의 상 요청의 헤더를 출력하는 응답을 목킹합니다.
val client = HttpClient(MockEngine { req ->
respondOk(req.headers.entries().joinToString("\n"))
})
client.get("test.host") {
header("Single-Header", "값") // single header
headers {
append(HttpHeaders.Accept, "text/plain")
append(HttpHeaders.Authorization, "auth")
append(HttpHeaders.UserAgent, "ktor client")
append(HttpHeaders.AcceptCharset, "EUC-KR") // Default 값 : [UTF-8]
} // 여러 헤더 값 추가
// 여러 extension 메소드들도 존재합니다.
accept(ContentType.Text.Html)
userAgent("ktor client 2")
cookie("email", "ShinsRo@email.com", expires = GMTDate())
}.body() as String shouldBe """
Single-Header=[값]
Accept=[text/plain, text/html]
Authorization=[auth]
User-Agent=[ktor client 2]
Accept-Charset=[EUC-KR]
Cookie=[email=ShinsRo%40email.com]
""".trimIndent().trim()
}
바디는 아래와 같이 명시할 수 있습니다.
@Test
fun `Ktor Requests - Body 의 명시`(): Unit = runBlocking {
// 편의 상 요청의 바디를 출력하는 응답을 목킹합니다.
val client = HttpClient(MockEngine { req ->
respondOk(req.body.toByteReadPacket().readText())
})
// Plain Text
client.post("...") {
setBody("컨텐츠")
}.bodyAsText() shouldBe "컨텐츠"
// Objects - ContentNegotiation 플러그인이 필요합니다.
// ktor-client-content-negotiation 아티팩트에서 찾아볼 수 있습니다.
// 위 플러그인은 Accept 와 Content-Type 헤더를 통한 요청 / 응답 사이 [역]직렬화를 지원합니다.
val jacksonSupportedClient = HttpClient(MockEngine { req ->
respondOk(req.body.toByteReadPacket().readText())
}) {
// 아래처럼, 플러그인을 설치하여 유틸리티를 사용할 수 있습니다.
install(ContentNegotiation) {
jackson()
}
}
data class Customer(val id: Int, val first: String, val second: String)
jacksonSupportedClient.post("...") {
contentType(ContentType.Application.Json)
setBody(Customer(3, "Shins", "Ro"))
}.bodyAsText() shouldBe """{"id":3,"first":"Shins","second":"Ro"}"""
// Form
client.submitForm(
url = "...",
formParameters = Parameters.build {
append("username", "Shins")
append("password", "snihS")
},
encodeInQuery = false // default : false
).bodyAsText() shouldBe "username=Shins&password=snihS"
}
타입 세이프한 요청, 요청 리트라이 등과 같은 플러그인을 지원하니, 흥미로운 부분입니다. 선택적으로 사용 가능하겠습니다.
기본적인 텍스트, 바이트 응답에 대한 처리는 아래와 같습니다.
@Test
fun `Ktor Responses - Raw Body`(): Unit = runBlocking {
// 편의 상 서버응답을 Mocking 했습니다.
val client = HttpClient(MockEngine { req ->
respondOk("OK")
})
// Raw Body
val response: HttpResponse = client.get("...")
val stringBody: String = response.body()
val byteArrayBody: ByteArray = response.body()
// .body() 를 통한 참조는 autoCast 된 결과입니다.
stringBody shouldBe "OK"
byteArrayBody shouldBe "OK".toByteArray()
}
Json 오브젝트에 대한 처리는 제공하는 ContentNegotiation 플러그인을 사용합니다. 아래와 같이 쓸 수 있습니다.
@Test
fun `Ktor Responses - Json Object`(): Unit = runBlocking {
// POJO 로 Json 바디를 읽으려면 ContentNegotiation 플러그인이 필요합니다.
// ktor-client-content-negotiation 아티팩트에서 찾아볼 수 있습니다.
// 위 플러그인은 Accept 와 Content-Type 헤더를 통한 요청 / 응답 사이 [역]직렬화를 지원합니다.
data class Customer(val id: Int, val first: String, val second: String)
val customer = Customer(2, "Shins", "Ro")
val customerJson = ObjectMapper().writeValueAsString(customer)
// 편의 상 서버응답을 Mocking 했습니다.
val jacksonSupportedClient = HttpClient(MockEngine { req ->
respond(
content = customerJson,
headers = headersOf("Content-Type" to listOf("application/json"))
)
}) {
// 아래처럼, 플러그인을 설치하여 유틸리티를 사용할 수 있습니다.
install(ContentNegotiation) {
jackson()
}
}
val respondCustomer: Customer = jacksonSupportedClient.get("...") {
accept(ContentType.Application.Json)
}.body()
respondCustomer shouldBe customer
}
ktor 문서 상에서 직렬화는 kotlinx 아티팩트의 @Serializable 어노테이션을 권장하고 있습니다만, 아무래도 jackson 이 일반적이라 jackson 플러그인을 사용했습니다.
이외 공식문서 상에서는 File 리스폰스에 대한 처리도 간략히 소개되어 있으니, 참고할 수 있습니다.
이상 ktor-client 의 기본적인 사용법을 개관했습니다. 개인적으로 메소드들과 그 용법이 매력적이었습니다. 2023년 1월 기준 현재, 아무래도 실무에서 적용하고 계신 분들은 들어보진 못했습니다만, 코틀린을 주요하게 사용하는 진영에서 어쩌면 킬러 클라이언트가 될 지도 모르겠단 느낌입니다. 여러분은 어떠셨나요.
감사합니다.
좋은 글 감사합니다!