본문 바로가기

Android/Tech

Ktor 적용기 (feat.Ktor Generics Response Handler)

Unsplash, Niklas Stumpf.

동기

현재 진행 중인 네이버 부스트캠프 파이널 프로젝트를 처음 시작할 때에, 팀 동료들과 기술 스택에 대해 이야기를 나눴습니다. 사용할만 한 여러 스택들에 대해 의논하였고, 적절한 스택들이 채택되었습니다.

 

그렇게 개발을 별 일 없이 진행하던 중, API 가 하나 둘 씩 나올 때가 되어 이를 연동하기 위해 Retrofit 에 관한 이런 저런 셋팅을 준비하려 했습니다. 의존성을 추가하려던 순간, 문득 Retrofit 을 그냥 적용하는게 맞나? 라는 의문이 들었습니다. 결국엔 Ktor 를 적용하기로 결정했죠. 결정의 이유는 세 가지가 있었는데요.

 

늘 가던 길을 선택하고 싶지 않았던 것이 첫 번째 이유였습니다. 새로운 것을 배워보고 싶었거든요. 이 때 아니면 또 언제 해보겠어라는 생각이 들었습니다.

 

두 번째 이유는 성능이었습니다. 물론 이렇게 자그마한 프로젝트에 다른 기술을 적용한다고 해서 얼마나 큰 성능 개선이 있겠냐만은, 그래도 티끌 모아 태산이니까요. Reflection 을 사용하지 않는 Ktor 가 매력적으로 느껴졌습니다.

 

마지막 세 번째 이유는 사실 조금 개인적인데, 진행 중인 프로젝트와 별개로 작업 예정인 또 다른 프로젝트에서는 KMP 를 통해 멀티 플랫폼 개발에 도전해 볼 예정이기 때문입니다. 위 기술하였듯, Retrofit 은 내부적으로 Reflection 을 사용하기 때문에, JVM 에 의존적입니다. 그래서 KMP 프로젝트에는 Retrofit 을 적용할 수 없기에, 사전 답사 느낌으로 Ktor 적용에 도전하게 되었습니다.

 


의존성 추가

[versions]
ktor = "2.3.6"

[libraries]
ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio", version.ref = "ktor" }
ktor-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
ktor-gson = { group = "io.ktor", name = "ktor-serialization-gson", version.ref = "ktor" }
ktor-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" }

[bundles]
ktor = ["ktor-client-core", "ktor-client-cio", "ktor-negotiation", "ktor-gson", "ktor-logging"]

 

.toml 파일입니다. 

ktor-client-core 는 클라이언트 기능을 제공하는 의존성입니다. 기본적인 Ktor 셋업을 위해 필요하니 꼭 추가해줘야합니다.

 

ktor-client-cio 는 네트워크 요청을 보내는 엔진 의존성입니다. 엔진에는 여러 종류가 있는데, JVM, JVM-Android, Native, CIO(Coroutine Based I/O) 저는 Android 엔진을 추가하는 것보다는 CIO 로 적용하는 것이 KMP 프로젝트에 적용할 때를 대비하기에 좋을 것 같다는 생각이 들어 이와 같이 구성하였습니다.

 

ktor-negotiation 은 Ktor ContentNegotiation 에 관련된 의존성으로, JSON 이나 XML, Poroto-buffer 타입의 데이터를 주고 받기 위해 필요합니다.

 

ktor-gson 은 Retrofit 에서도 사용했던 Gson 입니다. JSON 직렬화 및 역직렬화를 위해 적용했습니다. Ktor 적용이 처음이니, 아는 맛인 Gson 을 활용해 빠르게 진행하는 것이 좋을 것 같았기 때문입니다. 다음엔 성능 면에서 우수한 Kotlinx.serialization 을 사용해보면 좋겠습니다.

 

ktor-logging 은 말그대로 로그를 찍기 위한 의존성입니다.

 


아주 간단하게

val client = HttpClient(CIO) {

}

viewModelScope.launch {
    val response = client.get("https://jsonplaceholder.typicode.com/posts/1")
    if (response.status == HttpStatusCode.OK) {
        println(response.bodyAsText())
    }
}

// Console
{
    "userId": 1,
    "id": 1,
    "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
    "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"
}

 

정말 너무나 쉽게 결과를 얻어 올 수 있는 것에 크게 놀랐습니다. Retrofit 을 사용했다고 생각하면, Interface 만들고, 메서드 선언하고, 반환 타입 정의해서 지정하고... 기본적인 콜 한 번 해보려고 해도 정말 너무나 많은 과정이 요구됩니다. 하지만 Ktor 는 아주 간단하고 쉽게 요청을 생성할 수 있습니다. 물론, 간단하다는 것은 그만큼 추상화가 잘 된 것을 의미하고, 더욱 자세한 세팅까지 하려고 하면 정말 끝도 없습니다.

타입 지원하기

타입을 지원하기 위해서는 여러 가지 설정이 필요한데요. 일단 기본적으로 Content-Negotiation 의존성 추가가 필요하고, 추가로 serialization 또는 Gson 등의 의존성도 필요합니다. 저는 앞서 기술하였듯 Gson 을 사용하기로 하였습니다.

데이터 타입 선언

data class Post(
    val userId: Int,
    val id: Int,
    val title: String,
    val body: String
)

 

kotlinx.serialization 을 사용한다면 @Serializable 어노테이션을 붙여주어야 합니다. 다만 저는 Gson 을 사용하므로, 별도로 작성할 코드는 없습니다.

클라이언트 재정의

val client = HttpClient(CIO) {
    install(ContentNegotiation) {
        gson()
    }
}

 

HttpClient 를 재정의 해야 합니다. install() 메서드는 HttpClient 에 여러 속성을 추가 정의할 수 있습니다. 저는 JSON 데이터를 받아와야 하므로, ContentNegotiation 을 통해 Gson 을 사용하도록 설정하였습니다. 일반적인 헤더 설정은 물론, 모든 요청에 대한 공통 헤더를 설정하는 등의 다양한 추가 작업을 수행할 수 있으니, Retrofit 에서의 OkHttpClient 와 비슷한 포지션임을 알 수 있습니다.

요청 보내기

public suspend inline fun <reified T> HttpResponse.body(): T = call.bodyNullable(typeInfo<T>()) as T

 

HttpClient 의 주요 메서드 (get(), post(), put(), delet() 등)의 반환값은 HttpResponse 이고, body() 메서드는 페이로드를 넘겨진 타입 파라미터 객체로 받을 수 있도록 합니다.

 

viewModelScope.launch {
    val response: Post = client.get("https://jsonplaceholder.typicode.com/posts/1").body()
    println(response)
}

 

그러므로, 코드를 위와 같이 작성하면 다음의 결과를 얻을 수 있습니다.

 

Post(userId=1, id=1, title=sunt aut facere repellat provident occaecati excepturi optio reprehenderit, body=quia et suscipit

핸들러 만들기

네트워크 통신에 꼭 핸들러가 필요한 것은 아니지만, 추상화하면 수정이나 변경에 대응하기 용이하니 핸들러를 만들어주는 것이 좋습니다.

 

다만, Ktor 자체가 Retrofit 만큼 대중화된 라이브러리는 아니기 때문에 적절한 핸들러 예제를 찾을 수가 없었습니다. 그러므로 직접 구현할 수 밖에 없었는데, 이 역시 정확하지 않을 수 있으므로 피드백 주신다면 적극적으로 수용하려 합니다. 피드백 주실만한 부분이 있다면 댓글로 부탁드리겠습니다.


저희 팀의 경우, 다음과 같은 형태로 응답 데이터를 일반화하고 있습니다. 성공과 실패를 나누어 일반화하고 있으며, 차이점은 data 프로퍼티입니다.

Success

val statusCode: Int?,
val message: String?,
val data: T?

Failure

val statusCode: Int?,
val message: String?

 

현재 프로젝트에는 Presentation, Domain, Data 레이어의 CleanArchitecture 가 적용되어 있습니다. 통신을 수행하는 것은 Data 레이어이기 때문에, Data 레이어에서 성공 및 실패에 대한 처리를 수행한 뒤 이것이 Presentation 레이어까지 전달 되도록 구현하였습니다. 그러므로, 우리는 Presentation 에서 이와 관련하여 UI 에 대한 업데이트가 가능합니다.

 

data class ApiResponse<T>(
    val statusCode: Int?,
    val message: String?,
    val data: T?
)

 

이는 앞서 서술 하였듯이, 응답을 일반화하는 클래스입니다. 즉, 성공 시에는 모든 프로퍼티에 값이 할당되고, 실패하는 경우에는 data 를 제외한 다른 프로퍼티에만 값이 할당됩니다.

 

이에 대한 분기 처리를 수행하기 위해, 또 다른 클래스가 필요했습니다.

 

sealed class DataState<out T> {
    data class Success<T>(val data: T) : DataState<T>()
    data class Failure(val networkError: NetworkError) : DataState<Nothing>()
}

 

이를 활용한 분기 처리는 다음과 같습니다.

 

return flow {
    networkHandler.request<VideosRandomResponse>(
        method = HttpMethod.Get,
        url = {
            path("videos", "random")
        }
    ).collect { response ->
        response.data?.let {
            emit(DataState.Success(it.toDomainModel()))
        } ?: run {
            emit(DataState.Failure(NetworkError(response.statusCode, response.message)))
        }
    }
}

 

통신의 결과로 전달된 response 의 data 프로퍼티를 확인하고, 적절한 값이 있는 경우에는 그대로 Success 를 반환, 값이 없는 경우에는 Domain 레이어에 생성해둔 NetworkError 클래스에 값을 할당하여 emit 합니다.

Ktor HttpClient

먼저, HttpClient 전체 소스입니다.

 

val client: HttpClient
    get() = HttpClient(CIO) {
        install(ContentNegotiation) {
            gson {
                setPrettyPrinting().create()
            }
        }

        install(HttpTimeout) {
            requestTimeoutMillis = REQUEST_TIMEOUT
            connectTimeoutMillis = CONNECT_TIMEOUT
        }

        install(Logging) {
            logger = object : Logger {
                override fun log(message: String) {
                    Log.d(LOG_TAG, message)
                }
            }
            level = LogLevel.ALL
        }

        install(DefaultRequest) {
            host = BASE_URL
        }
    }

 

HttpClient 생성 코드가 꽤나 긴데요, 이에 관해 설명하도록 하겠습니다.

 

val client: HttpClient
    get() = HttpClient(CIO) {
        ...
    }

 

저는 HttpClient 의 파라미터로 CIO 를 넘겨주고 있는데, 적용하고자 하는 프로젝트의 특성에 맞춰 적절한 값을 넘겨주면 됩니다.

Client install

저는 JSON 파싱을 위해 Gson 을 사용하고 있기 때문에, ContentNegotiation 으로 Gson 에 관한 부분을 구현해줍니다. 

 

install(ContentNegotiation) {
    gson {
        setPrettyPrinting().create()
    }
}

 

이후, 타임아웃 정책을 설정해줍니다. 저는 너무 길지 않게 5초 정도로 설정하였습니다.

 

install(HttpTimeout) {
    requestTimeoutMillis = REQUEST_TIMEOUT
    connectTimeoutMillis = CONNECT_TIMEOUT
}

...

companion object {
    private const val REQUEST_TIMEOUT = 5000L
    private const val CONNECT_TIMEOUT = 5000L
}

 

다음은 로깅인데요. 개발 시 편의를 위해 작성하였습니다. OkHttpClient 에서 로깅을 구현할 때와 같이, 여러 수준의 로깅을 구현할 수 있습니다. 저는 전체를 모두 보는게 편해서 LogLevel.All 값을 할당했습니다.

 

install(Logging) {
    logger = object : Logger {
        override fun log(message: String) {
            Log.d(LOG_TAG, message)
        }
    }
    level = LogLevel.ALL
}

 

Client 마지막 설정인 DefaultRequest 입니다. 모든 요청에 적용되는 값이므로, 토큰 헤더 또는 host, 외에 각종 헤더를 설정할 수 있습니다. 일단 저는 host 정도만 설정해줬습니다.

 

install(DefaultRequest) {
    host = BASE_URL
}

request()

@OptIn(InternalAPI::class)
inline fun <reified T> request(
    method: HttpMethod,
    crossinline url: URLBuilder.() -> Unit,
    noinline content: (ParametersBuilder.() -> Unit)? = null
): Flow<ApiResponse<T>> = flow {
    client.use { client ->
        val response: ApiResponse<T>? = client.request {
            this.headers {
                append(
                    "Authorization",
                    "Bearer $newAccessToken"
                )
            }

            this.method = method

            url {
                url()
            }

            content?.let {
                body = FormDataContent(Parameters.build {
                    content()
                })
            }
        }.body()

        response?.let {
            emit(response)
        } ?: run {
            // 응답 값 없을 때 에러 처리
        }
    }
}

 

Generics 를 활용한 요청 메서드입니다. 반환값이 BaseResponse<T> 이기 때문에, 런타임에는 타입 파라미터 T 에 대한 값이 삭제됩니다. 그러므로, 어떠한 타입이 들어오게 될지 모르니 reified 키워드를 활용하였습니다. reified 키워드에 관해서는 다음 포스트에 자세히 작성해 두었습니다.

 

 

Generics 와 Reified 키워드

본 포스팅은 CustomResponseHandler 를 구현하다 알게 된 사실에 대해 기록한 것입니다. API 통신을 통해 받아 온 결과가 성공일 수도 있고 실패일 수도 있는데, 기존의 프로젝트 구조에서는 총 두 번의

blothhundr.tistory.com

 

파라미터로는 HttpMethod, URL 의 구성에 필요한 path, query 를 호출 사이트에서 받을 수 있도록 구현한 함수 타입 파라미터, url, 그리고 x-www-form-urlencoded 형식의 바디를 포함하기 위한 함수 타입 파라미터를 작성해 두었습니다.

 

각 파라미터의 순서대로 메서드가 진행되기 때문에, 파라미터 작성의 순서를 따라 메서드에 대한 설명을 하고자 합니다.

 

HttpMethod 의 경우, 말 그대로 Http 통신의 Method 입니다. 원하는 값을 넘겨줍니다. 보통은 Get, Post, Put, Delete 가 되겠지요.

 

client.use { client ->
    val response: BaseResponse.Success<T> = client.request {
        this.method = method
        ...
    }
}.body()

 

use { } 를 사용하는 이유는 간단한데, 통신 작업을 수행한 뒤 이를 즉각적으로 해제하기 위함입니다. 공식 문서에서도 낭비되는 리소스를 최소화하기 위해 통신 작업이 끝나면 이를 해제하도록 권고하고 있습니다. Custom Getter 를 통해 변수를 참조하면 새로운 Client 인스턴스를 받아오기 때문에, 매 호출마다 이를 해제합니다. 다만, 이를 '어떠한 시점에 해제해주어야 한다' 라는 설명이 없어서 조금 헷갈리는 부분이긴 합니다.

 

url 파라미터는 URL 생성에 필요한 path 와 query 를 호출 사이트에서 입력 받을 수 있도록 구현하였습니다.

 

url = {
    path("auth", "refresh")
},

 

URLBuilder 를 외부에서 받아 작성할 수 있도록 한건데요. 이렇게 구현하지 않으면 요청 코드를 작성할 때마다 더욱 긴 코드를 작성하도록 강요 받을 수 밖에 없습니다. 자연스럽게 코드가 길어지고, 그에 따라 가독성이 저하 될 겁니다. 원래는 빌더 패턴을 적용한 클래스를 통해 간단하게 파라미터를 적용할 수 있었으나, 그렇게 하는 경우 path 와 path 사이에 query 가 필요한 경우에 대한 대응이 불가능하기에, 수정을 거쳤습니다.

 

content?.let {
    body = FormDataContent(Parameters.build {
        content()
    })
}

 

마지막 함수 타입 파라미터는 바디값을 동적으로 추가할 수 있도록 합니다. noinline 키워드가 붙은 것은, Http 메서드에 따라 바디가 있을 수도, 없을 수도 있기 때문에 단순히 NotNull 로 두기엔 리소스의 낭비가 있을 것이라 생각했습니다. 그러므로, 해당 파라미터를 Nullable 로 작성하고 crossinline 대신(애초에 작성하지도 못하지만) noinline 을 작성해줬습니다.(작성하지 않으면 컴파일이 안 되지만)

 

content 값이 null 인지 체크하고, null 이 아닌 경우에만 body 에 값을 할당해주도록 구현하였습니다. null 인 경우에는 let { } 스코프 내의 코드가 실행되지 않으므로 리소스를 낭비하지 않을 수 있습니다.

 

response?.let {
    emit(response)
} ?: run {
    // todo: 응답 없을 때 에러 처리
}

 

response 는 통신을 수행하고 얻어 온 body 입니다.

호출 예시

리프레시 토큰을 통해 액세스 토큰을 재발급 받는 코드를 작성해보았습니다.

 

viewModelScope.launch {
    NetworkHandler().request<ID>(
        method = HttpMethod.Post,
        url = {
            path("auth", "refresh")
        },
        content = {
            append("refreshToken", refreshToken)
        }
    ).collect {
        println(it)
    }
}

 

타입 파라미터 T 에 대해서는, ApiResponse<T> 에 대한 작성 없이 반환 받고 싶은 값에 대해서만 타입을 넘겨주면 됩니다. HttpMethod 는 각 API 에 맞는 값을 넘겨주고, url 의 경우, 함수 타입 파라미터를 통해 URLBuilder 를 넘겨주고 있으므로, 해당 클래스의 명세대로 적절한 값을 작성해주면 됩니다. 이후 content 도 같은 방식으로 작성해 줍니다.

 

통신 결과는 다음과 같습니다.

 

 

지정한 타입으로 데이터를 받아 올 수 있음이 확인됩니다.


적용 후기

일단 직관적입니다. 구구절절 작성해야 하는 코드가 꽤 많았던 Retrofit 에 비해 확실히 간편했습니다. 간단한 데이터 통신을 구현해야 한다면, 많은 코드 필요없이 정말 빠르게 구현할 수 있습니다.

 

또한, Retrofit 은 통신 메서드를 직접 물려줘야한다는 단점이 존재합니다. 즉, 로직이 단순, 명료해지는 것은 맞지만, 구현 자체가 사이트마다 달라져 통신을 수행하는 저수준까지 가야 확실한 로직의 이해를 얻을 수 있습니다. (물론 DynamicUrl 을 사용한다면 해결은 되겠지만...) 그에 반해 Ktor 의 경우 구현에 대한 자유도가 훨씬 높다는 느낌을 받았습니다.

 

다만, 아직 낯설어서 그런지 코드를 작성하는 데에 꽤 긴 시간이 들긴 했습니다. 특히, 한국어로 된 레퍼런스를 찾는 것이 거의 불가능한 수준이었습니다. 특히, Generics 를 활용한 핸들러 관련 코드도 거의 없어서 꽤 해맸던 것 같네요.


라고 징징댔지만, 직접 구현해 볼 수 있어 좋았던 점도 분명히 존재합니다. Generics 에 대한 이해도 깊어지고, 딱히 참고할만한 레퍼런스가 없던 부분을 자력으로 구현해냈다는 것에 대한 뿌듯함과 자신감 상승도 기분 좋네요.

 

조만간 시작하게 될 KMP 프로젝트에서도 요긴하게 사용할 것 같습니다.

 

혹시나 Ktor 를 위한 Generics 핸들러를 찾고 계시다면, 사용해보셔도 좋겠습니다.