본문 바로가기

Android/Network

[Android] Retrofit 대신 Ktor을 사용하여 통신을 해보자. -1. 기본

728x90

최근 안드로이드 관련 블로그를 보다가 Ktor이라는 것을 알게 되었다.

Retrofit 대신해서 사용할 수 있는 비동기 통신 라이브러리라고 하는데, Retrofit만 사용하던 필자로선 상당히 흥미로운 라이브러리였다.

 

따라서, 아주 간단한 샘플 프로젝트를 만들어서 Ktor을 적용해보고 기본적인 사용 방법에 대해 글을 작성해보고자 한다.


우선,

Ktor이 무엇인가?

Ktor은 JetBrains에서 만든 Framework로
Kotlin을 사용하여 비동기 서버 및 클라이언트를 구축할 때 사용하는 오픈소스.

 

라고 한다.

Kotlin으로 구현되어있기는 하지만, 안드로이드에 제한되어 있지 않고 다양한 플랫폼에서 사용이 가능하다는 특징을 가지고 있다.

 

필자는 클라이언트 입장에서 Ktor을 사용해서 Retrofit 대신 서버와 통신하는 부분을 구현해보고자 하므로,

Module 단위의 Gradle에 디펜던시를 추가해주도록 하자.

 

def ktor_version = "2.0.3"

implementation "io.ktor:ktor-client-core:$ktor_version"
implementation "io.ktor:ktor-client-cio:$ktor_version"

implementation "io.ktor:ktor-client-serialization:$ktor_version"
implementation "io.ktor:ktor-client-logging-jvm:$ktor_version"
implementation "io.ktor:ktor-client-content-negotiation:$ktor_version"
implementation "io.ktor:ktor-serialization-kotlinx-json:$ktor_version"

 

필자가 Ktor을 적용하기 위해서 찾아본 많은 블로그에서는 ktor 버전을 1.6.x로 사용하는 글들만 볼 수 있었는데, 현재 최신 버전은 2.0.3이기 때문에 해당 버전을 사용하였다.

 

2.x.x 버전으로 올라오면서, 변경된 부분들이 존재하여 일정 부분 수정하면서 예제를 작성하였으며, 1.6.x 버전에서 2.x.x 버전으로 올렸을 때 마이그레이션을 해야 하는 부분Ktor 공식 홈페이지에서 확인하면 좋다.

 

디펜던시를 추가해주었다면, Plugin도 추가해주도록 한다.

 

plugins {
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android'
    id 'kotlin-kapt'
    id 'kotlin-parcelize'
    id 'org.jetbrains.kotlin.plugin.serialization' version "1.5.0"
}

 

 

parcelize와 serialization의 경우, 아주 기본으로 결과 값을 string 타입으로 가져오는 경우 사용하지 않아도 상관없지만, 실제 Json 타입의 API 데이터를 받아 사용하기 위해서는 선언해주어야 한다.

 

다음으로, API를 사용할 것이기 때문에 Internet에 대한 메니페스트를 추가해준다.

 

<uses-permission android:name="android.permission.INTERNET" />

 

매니페스트까지 작성해주면, Ktor을 사용하기 위한 기본적인 설정은 끝난다.


필자가 만든 샘플은, 아주 간단하게 기존에 샘플 프로젝트에 사용하던 API를 호출해서, 가져온 데이터를 보여주는 형식으로만 구현해 보았다.

 

 

ViewModel을 사용하며, ApiRequest, ApiResponse, DataEntity는 DataClass로 사용하고, ApiClient에 Ktor에 관련된 코드를 작성하여 사용할 예정이다.

 

ApiClient 부분의 코드를 확인해 보자.

 

 

client는 HttpClient를 사용하여 통신에 사용할 객체를 만들어 준다. Coroutine I/O인 CIO를 엔진으로 사용하도록 하였다.

여기서 CIO가 아닌 okHttp나 Android와 같은 엔진을 사용하도록 할 수 있다.

 

implementation "io.ktor:ktor-client-cio:$ktor_version"
implementation "io.ktor:ktor-client-okhttp:$ktor_version"
implementation "io.ktor:ktor-client-android:$ktor_version"

 

다른 엔진을 사용할 경우, CIO와 마찬가지로 Gradle의 디펜던시에 별도로 라이브러리를 추가해 주어야 한다.

필자는 ktor 공식 페이지에 나와있는 기본 예제처럼 CIO를 엔진으로 사용하도록 하였다.

 

request로 시작하는 함수들은 API 호출을 하여 데이터를 가져오기 위한 함수로, get/post 방식과, return 타입을 나눠서 3개로 선언하여 사용해 보았다.

 

client 부분부터 자세히 확인해보도록 하자.

 

private val client = HttpClient(CIO) {
        install(Logging) {
            logger = object : Logger {
                override fun log(message: String) {
                    Log.d("ktorLogger", "message : $message")
                }
            }
//            logger = Logger.DEFAULT
            
            level = LogLevel.ALL
        }

        // JsonFeature > ContentNegotiation
        install(ContentNegotiation) {
            json(Json {
                ignoreUnknownKeys = true
                isLenient = true
                encodeDefaults = true
            })
        }

        install(HttpTimeout) {
            connectTimeoutMillis = 6000
            requestTimeoutMillis = 6000
            socketTimeoutMillis = 6000
        }

        defaultRequest {
            contentType(ContentType.Application.Json)

            headers {
                append("X-Naver-Client-Id", "33chRuAiqlSn5hn8tIme")
                append("X-Naver-Client-Secret", "fyfwt9PCUN")
            }
        }
    }

 

우선, 선택적으로 필요한 플러그인을 설정해주기 위해 install을 사용하였으며, 기본적인 매개변수들을 설정해주기 위하여 defaultRequest를 사용하여 type과 header를 설정해 주었다.

 

각 세부적인 코드를 확인해보자.

 

install(Logging) {
            logger = object : Logger {
                override fun log(message: String) {
                    Log.d("ktorLogger", "message : $message")
                }
            }
//            logger = Logger.DEFAULT

            level = LogLevel.ALL
        }

 

Logger를 세팅하여 통신에서 발생하는 로그 정보를 확인할 수 있도록 도와주는 플러그인이다.

gradle에 추가한 logging-jvm 라이브러리를 사용하는 것으로, 주석처리 한 부분처럼 Default, Android, Simple 등 기본으로 설정된 방식으로 로그를 찍을 수도 있으며 Logger 객체를 override 하여 사용자가 원하는 로그를 찍을 수 있다.

 

 

Log의 레벨 또한 이처럼 필요한 부분을 찾아서 찍도록 설정해줄 수 있다.

 

// JsonFeature > ContentNegotiation
install(ContentNegotiation) {
    json(Json {
        ignoreUnknownKeys = true
        isLenient = true
        encodeDefaults = true
    })

    // implementation "io.ktor:ktor-serialization-gson:$ktor_version"
    gson {  }
    // implementation "io.ktor:ktor-serialization-jackson:$ktor_version"
    jackson {  }
}

 

 

다음은, Json형태의 파일을 직렬화 하기 위한 플러그인을 설정해 준다.

1.6.x 버전의 Ktor에서는 JsonFeature를 사용하여 Json에 대한 데이터를 직렬화 해주었다.

하지만, 2.x.x 버전 부터는 JsonFeature를 사용할 수 없고 ContentNegotiation을 사용하여 데이터를 직렬화하도록 변경되었다.

 

json, gson, jackson을 모두 사용이 가능하며, gson과 jackson을 사용하기 위해서는 주석에 추가한 것처럼 디펜던시에 해당 라이브러리를 추가해주면 된다.

json에 추가되어있는 설정들은 다음과 같은 효과를 가지고 있다.

  • ignoreUnknownKeys : 받아오는 데이터 모델에 없는 Key 값은 무시한다.
  • isLenient : 따옴표(" ")가 잘못 설정되어있는 부분은 무시한다.
  • encodeDefaults : NULL 값도 정상적으로 처리한다.

이 3가지 옵션 외에도 다양한 옵션이 있으므로, 해당 라이브러리를 한번 확인해보는 것도 좋은 경험이 될 것이다.

 

install(HttpTimeout) {
    connectTimeoutMillis = 6000
    requestTimeoutMillis = 6000
    socketTimeoutMillis = 6000
}

 

Http 통신에 사용되는 TimeOut 설정이다.

이름부터 알 수 있듯이, 해당 통신에 사용되는 제한 시간을 ms로 설정한다.

 

connectTimeout(60, TimeUnit.SECONDS)
readTimeout(60, TimeUnit.SECONDS)
writeTimeout(60, TimeUnit.SECONDS)

 

이전에 Retrofit을 사용할 때 사용했던 위와 같은 설정과 동일한 역할을 한다고 보면 된다.

 

defaultRequest {
    contentType(ContentType.Application.Json)

    headers {
        append("X-Naver-Client-Id", "33chRuAiqlSn5hn8tIme")
        append("X-Naver-Client-Secret", "fyfwt9PCUN")
    }
}

 

마지막으로 기본 매개변수를 설정해 주었다.

Json Type의 값을 가져올 것이며, Header에 필요한 값들을 설정해주었다.

 

 

Client에 대한 설정 값을 확인해 보았으니,

실제 API를 호출하는 부분을 확인해보도록 하자.

 

suspend fun requestMoveSearch(query: String, start: Int = 1, display: Int = 15): String =
    withContext(Dispatchers.IO) {
        val response: HttpResponse =
            client.get {
                url(BASE_URL + "v1/search/movie.json")
                method = HttpMethod.Get

                parameter("query", query)
                parameter("start", start)
                parameter("display", display)
            }

        val responseStatus = response.status

        if (responseStatus == HttpStatusCode.OK) {
            response.bodyAsText()
        } else {
            "Error :: $responseStatus"
        }
    }

 

우선, 해당 함수의 return 값은 전달받은 resonse의 body 부분을 전부 반환하도록 해두었기 때문에 String 타입으로 반환하도록 하였다.

코루틴을 사용하여 비동기로 처리되기 때문에 suspend 키워드를 사용하였고, API 통신은 메인 스레드가 아닌 IO 스레드에서 이루어져야 하므로 IO 스레드에서 통신을 하도록 해주었다.

 

get 형태로 통신을 하기 때문에 client.get을 사용하였고, 중괄호 안에 필요한 데이터를 추가해 주었다.

위의 client를 확인해 보면, 결과적으로 다음과 같은 url이 완성되게 된다.

 

https://openapi.naver.com/v1/search/movie.json?query=red&start=1&display=15

 

결과 값은 response에 저장되게 되며, status가 OK인 경우. 즉, 정상적으로 성공했을 경우에만 반환된 값의 body에 들어있는 데이터를 결과로 반환하도록 하였다.

 

suspend fun requestMoveSearchData(
    query: String,
    start: Int = 1,
    display: Int = 15,
): List<DataEntity> =
    client.get(BASE_URL + "v1/search/movie.json") {
        parameter("query", query)
        parameter("start", start)
        parameter("display", display)
    }.body<ApiResponse>().items

 

다음은, 실제 Item List를 반환하는 형태로 구현해 보았다.

아까와 다른 부분은, 중괄호 내에서 url을 설정한 것이 아닌 매개변수로 url을 넣어보았으며, bodyAsText()를 통해 body의 모든 값을 반환한 것이 아닌 필요한 데이터만 골라서 반환시키기 위해 body<수신 객체>().객체 를 사용하였다.

 

여기서, body<수신 객체>에 사용되는 객체는 response 데이터로,

 

@Serializable
data class ApiResponse(
    val display: Int,
    val items: List<DataEntity>,
    val lastBuildDate: String,
    val start: Int,
    val total: Int,
)

 

실제 API를 통해 받는 데이터 타입을 의미한다.

Body에 들어가 있는 해당 수신 객체에서, items를 반환하고자 하기 때문에 해당 함수의 return 타입이 items의 타입과 동일한 List<DataEntity>가 된다.

 

@Serializable
data class DataEntity(
    val actor: String,
    val director: String,
    val image: String,
    val link: String,
    val pubDate: String,
    val subtitle: String,
    val title: String,
    val userRating: String,
)

 

Ktor에 사용되는 Data Class는 모두 Serializable 어노테이션 사용해주어야 정상적으로 인식을 하여 사용할 수 있다.

 

마지막으로 ,

get이 아닌 post를 사용하여 통신하는 방법이다.

 

suspend fun requestMoveSearchPost(apiRequest: ApiRequest) =
    client.post(BASE_URL + "v1/search/movie.json") {
        setBody(apiRequest)
    }.bodyAsText()

 

post는 url 뒤에 데이터를 붙이는 형식이 아니기 때문에, setBody를 통해 body에 필요한 데이터를 넣어주는 형식으로 사용해야 한다.

이때, ApiRequest라는 data class를 만들어서  body에 넣어주도록 하였으며, 해당 객체는 다음과 같다.

 

@Serializable
data class ApiRequest(
    val query: String,
    val start: Int = 1,
    val display: Int = 15,
)

 

get 방식에서 url 뒤에 붙였던 데이터를 객체로 만들어 넣어주는 형태가 되는 것이다.

 

 

값을 넣어 던지게 되면, 이처럼 body 안에 데이터가 들어가게 된다.

 

하지만, 필자가 샘플로 만들어 둔 프로젝트에서 사용되는 API는 Post 방식을 지원하지 않기 때문에 결과는 Error로 나오게 되는 것을 참고하고 확인하길 바란다.

 

이처럼 선언한 함수들은, ViewModel에서 호출해서 사용하면 된다,


이번 샘플에는 DI도 사용하지 않고, API로 받아온 데이터를 컨트롤하지도 않고 아주 간단하게 Ktor을 통해 API를 호출하고, 값을 가져와서 그냥 보여주도록만 하였다.

 

기존에 사용하던 Retrofit과 다른 부분이 많아서 아주 기본적으로 사용할 수 있는 코드를 구현하는 것에도 시간이 많이 걸렸던 것 같다.

Kotlin 기반으로 멀티 플랫폼으로 사용할 수 있다는 면은 Ktor의 확실한 장점은 될 것이지만, 아주 기본적인 사용 방법만 구현해 보았을 때, Retrofit과 Ktor 중 뭐가 좋고 나쁘고는 판단할 수 없었다.

Ktor이 사용되는 플러그인 부분은 조금 더 직관적으로 보이는 것 같다고 생각되었지만, API를 호출하는 부분이 익숙지 않아 불편하다고 느껴졌다.

 

다음에는 DI를 적용하고, 다른 샘플 프로젝트에 적용시키면서 조금 더 다양한 방면으로 확인해 볼 생각이다.

 

해당 게시글에 사용된 예제 코드는 Github에 올려두었다.

https://github.com/HeeGyeong/KtorSample

 

GitHub - HeeGyeong/KtorSample

Contribute to HeeGyeong/KtorSample development by creating an account on GitHub.

github.com

 

728x90