Kotlin HTTP Client: ktor-client 기본적인 사용법

Shinsro·2023년 1월 17일
3

Ktor 는 Jetbrain 의 웹 서버, 클라이언트 프레임워크입니다. 그 중에서도 ktor-client 의 사용법을 중심으로 살펴보겠습니다.

새로운 도구를 살펴보다보면, 여러 트렌드를 살펴볼 수 있어 흥미롭습니다. Ktor 는 그들만의 DSL 을 정의했고, 코루틴을 사용합니다. 물론 벌써 몇 년 전에 대두된 개념이지만, 개념이 내포하는 철학을 담은, 완성도 높은 도구란 점에서 그들의 구현을 읽어보는 것이 유익했습니다.

아래에 사용한 라이브러리의 버전을 명시하였으니, 참고부탁드립니다.
이 링크 에서 본문에 포함된 코드들을 볼 수 있습니다.

kotlin 버전 : 1.7.10
ktor 버전 : 2.2.2

기본적인 사용법

Dependencies

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 를 사용하였으니, 참고 부탁드립니다.

외람된 얘기지만, 포스팅하다보니 개인적으로 흥미로운 점이 종종 생깁니다. 해서 가끔 삼천포로 빠지니 그런 부분들은 적당히 스킵해서 보시면 되겠습니다.

Set Up: Client Configurations

기본적인 클라이언트 선언은 아래와 같습니다.

    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
                }
            }
        }
    }

Requests: 기본적인 사용법

Http Method 명시

아래와 같이 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 인코딩을 지정할 수 있단 차이가 있습니다.

Url 명시

아래와 같이 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", "값") // ...&param2=값

                // 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&param2=값"
    }

Headers 명시

아래와 같이 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()
    }

Body 의 명시

바디는 아래와 같이 명시할 수 있습니다.

    @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"
        
    }

이외 Requests 관련 사항

타입 세이프한 요청, 요청 리트라이 등과 같은 플러그인을 지원하니, 흥미로운 부분입니다. 선택적으로 사용 가능하겠습니다.

Responses: 기본적인 사용법

Raw Body

기본적인 텍스트, 바이트 응답에 대한 처리는 아래와 같습니다.

    @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 Object

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
    }

이외 Responses 관련 사항

ktor 문서 상에서 직렬화는 kotlinx 아티팩트의 @Serializable 어노테이션을 권장하고 있습니다만, 아무래도 jackson 이 일반적이라 jackson 플러그인을 사용했습니다.

이외 공식문서 상에서는 File 리스폰스에 대한 처리도 간략히 소개되어 있으니, 참고할 수 있습니다.

마무리.

이상 ktor-client 의 기본적인 사용법을 개관했습니다. 개인적으로 메소드들과 그 용법이 매력적이었습니다. 2023년 1월 기준 현재, 아무래도 실무에서 적용하고 계신 분들은 들어보진 못했습니다만, 코틀린을 주요하게 사용하는 진영에서 어쩌면 킬러 클라이언트가 될 지도 모르겠단 느낌입니다. 여러분은 어떠셨나요.

감사합니다.

1개의 댓글

comment-user-thumbnail
2023년 1월 17일

좋은 글 감사합니다!

답글 달기