[DroidKnights] Retrofit -> Ktor 마이그레이션
오늘은 필자의 "첫 컨트리뷰션 도전기"에 대해 적어보고자 한다.
항상 오픈소스에 기여를 하고 싶다는 생각이 있어서 여러 오픈소스의 이슈들을 주의깊게 보고 있었다.
그러다 마침 '국내 최대 규모 안드로이드 컨퍼런스 드로이드나이츠 앱'에서 KMP로 마이그레이션하면서 여러 이슈들이 올라온 것을 보았다.
이미 UI를 다루는 이슈들은 대부분 다른 분들이 하겠다고 하셔서 남아 있는 이슈들 중에 골랐다.
Koin으로 마이그레이션 하는 이슈와, Ktor로 마이그레이션 하는 이슈가 중에 고민이 되었다. Koin은 저번에 마이그레이션을 한 경험이 있었기 때문에 이미 알고 있는 이슈보다는 새로운 기술에 도전해보면서 내 스스로도 성장을 하고 싶어 Ktor 마이그레이션 이슈를 고르게 되었다!
💡해당 이슈는 아래에서 확인이 가능합니다.
Retrofit -> Ktor 마이그레이션 (core:network 구현) · Issue #409 · droidknights/DroidKnightsApp
Overview (Required) 기존에 Retrofit으로 구현해둔 것을 Ktor를 이용하여 구현. 구현 후, assets/sessions.json을 연결하여 세션 목록에 실제 데이터가 노출되도록 한다.
github.com
Ktor
먼저 들어가기 전에 Ktor에 대해 알아보도록 하자.
Ktor는 Kotlin으로 작성된 경량 웹 프레임워크이다. 이 프레임워크는 비동기적인 네트워크 요청 처리를 위해 코루틴을 사용할 수 있다.
즉, KMP는 안드로이드 라이브러리인 Retrofit 사용이 불가능하기 때문에, Kotlin으로 사용할 수 있는 Ktor로 마이그레이션을 해 주는 것이다.
Ktor는 Server와 Client를 모두 지원한다. 백엔드 서버도 만들 수 있고, 클라이언트용 HTTP 요청도 보낼 수 있는 것이다.
필자는 클라이언트 입장에서 작성하는 것이기 때문에 클라이언트용을 사용하였다.
이제 본격적으로 Ktor를 구현하는 방법에 대해 알아보자.
KMP 구조
우선, Ktor를 작성해줄 :core:network모듈을 추가해 준다.
이때 필자는 모듈을 추가할 때 안드로이드에 종속이 되면 안 될 것 같아서 순수한 자바 코틀린으로 이루어져 있는 모듈로 추가해 줬다.
그리고 사전에 빌드로직으로 정의되어 있었던 KMP 플러그인을 추가해 준다.
plugins {
id("droidknights.kotlin.multiplatform")
}
그럼 KMP 작성을 위한 모듈 준비가 끝난 것이다!
이제 KMP에 맞는 구조로 바꿔보자.
KMP 파일 구조는 commonMain > kotlin 파일 하위에 작성해줘야 한다.
이에 따라 build.gradle.kts 파일에서 의존성을 추가해줄 때에도 sourceSets의 commonMain dependencies 아래에 넣어준다.
plugins {
alias(libs.plugins.androidLibrary)
id("droidknights.kotlin.multiplatform")
}
kotlin {
sourceSets {
commonMain.dependencies {
// 여기에 의존성 추가
}
}
}
android.namespace = "com.droidknights.app.core.network"
Ktor 구현
Ktor를 구현하기 위해 필요한 의존성은 아래와 같다.
[versions]
ktor = "3.1.3"
[libraries]
# Ktor
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-logging = {module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
추가로, DI를 위한 Koin 의존성도 추가해 준다.
[versions]
koin = "4.1.0-RC1"
[libraries]
# Koin
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
이제 제일 중요한 Ktor 네트워크 코드를 작성해 보자.
class DroidKnightsNetwork {
val httpClient = httpClient {
// 1번 코드
install(ContentNegotiation) {
val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
register(ContentType.Application.Json, KotlinxSerializationConverter(json))
register(ContentType.Text.Plain, KotlinxSerializationConverter(json))
}
// 2번 코드
install(Logging) {
level = LogLevel.ALL
}
// 3번 코드
install(HttpTimeout) {
connectTimeoutMillis = TIMEOUT_MILLIS
requestTimeoutMillis = TIMEOUT_MILLIS
socketTimeoutMillis = TIMEOUT_MILLIS
}
// 4번 코드
defaultRequest {
contentType(ContentType.Application.Json)
url {
protocol = URLProtocol.HTTPS
host = BASE_HOST
}
}
}
companion object {
private const val TIMEOUT_MILLIS = 6_000L
private const val BASE_HOST = "raw.githubusercontent.com"
}
}
- 1번 코드: ContentNegotiation
- 서버 응답을 자동으로 직렬화, 역직렬화하기 위한 코드이다.
- 2번 코드: Logging
- 응답 관련 코드를 로깅할 수 있다.
- 3번 코드: HttpTimeout
- 연결/요청/소켓 각각 6초 초과 시 타임아웃 발생한다.
- 4번 코드: defaultRequest
- 요청의 기본 속성을 작성한다.
- 기본 host 문자열을 지정해 준다.
val coreNetworkModule = module {
single { DroidKnightsNetwork() }
}
그리고 이 네트워크 클래스를 싱글톤으로 만들어준다.
아직 koin-annotation이 적용되지 않은 상태라 koin 형태로만 작성을 해 주었다.
이렇게 등록한 모듈을 app 파일에 등록해주면 된다.
internal val appModule = module {
// 여기에 추가!
includes(
coreNetworkModule,
)
}
internal fun knightsAppDeclaration(
additionalDeclaration: KoinApplication.() -> Unit = {},
): KoinAppDeclaration = {
modules(appModule)
additionalDeclaration()
}
마지막으로 데이터를 불러오고 싶은 모듈에서 이를 연결해주면 된다!
이번에 필자는 Session 데이터를 불러와야 하기 때문에 data-session 모듈에 아래처럼 SessionApi 클래스를 만들어 HttpClient에서 제공하는 get() 함수를 사용했다.
class SessionApi(private val client: HttpClient) {
suspend fun getSessions(): List<SessionResponse> = client
.get("/droidknights/DroidKnightsApp/main/core/data/src/main/assets/sessions.json")
.body()
}
그럼 드디어 서버통신을 한 데이터가 잘 뜨는 것을 확인할 수 있다!
Data 모듈과의 의존성 분리
그런데 한 가지 요청사항이 더 있었다.
바로 SessionApi를 작성한 모듈에 Ktor 의존성이 전이되지 않도록 하는 것이다.
현재 SessionApi는 다음과 같이 Ktor 라이브러리를 사용하고 있었다.
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
그래서 :core:network 모듈에 Network 인터페이스를 만들어 별도로 구현하는 방식을 사용하고자 했다.
드로이드나이츠의 코드 스타일을 보았을 때 추상화 계층 모듈을 api로 네이밍하고 사용하는 것 같아 해당 방식을 고안했다.
기존에 있던 DroidKnightsNetwork 인터페이스는 아래와 같았다.
다만, 기존처럼 Class<T>를 이용해 refied T의 기능을 활용하려고 하였으나, KMP의 추상화 계층(interfcae)에서는 inline과 refied T를 사용할 수 없기 때문에 get() 함수를 직접 구현하는 데에 한계가 있었다.
이에 필자는 Httpclient의 body() 함수의 동작 방식을 참고하고자 했다.
body를 직접 들어가보면 typeInfo를 활용하고 있는 것을 확인할 수 있다.
따라서 필자가 생각한 코드는 아래와 같았다.
interface DroidKnightsNetwork {
suspend fun <T : Any> get(path: String, typeInfo: TypeInfo): T
}
suspend inline fun <reified T : Any> DroidKnightsNetwork.get(path: String): T =
this.get(path, typeInfo<T>())
class DroidKnightsNetworkImpl(
private val client: HttpClient,
) : DroidKnightsNetwork {
override suspend fun <T : Any> get(path: String, typeInfo: TypeInfo): T =
client.get(path).body(typeInfo)
}
다만, 코드리뷰를 해 주시는 분께서 get() 함수가 두 개 사용되는 것이 혼란을 줄 수도 있을 것 같아 하나로 합치는 것을 제안해 주셨고, 필자도 그렇게 하는 방향이 편리하고 좋을 것 같아서 최종 코드는 DroidKnightsNetwork 클래스에 아래처럼 사용해주긴 했다.
class DroidKnightsNetwork {
// 생략 ..
suspend inline fun <reified T : Any> get(path: String): T = httpClient.get(path).body()
}
플랫폼 별 엔진 분류
아직 끝나지 않았다.
네트워크를 각 플랫폼 별로 분류를 해줘야 한다!
처음에 필자는 이를 분리하지 않아서 Koin Verify Test에서 CI 오류가 발생했었다.
org.koin.test.verify.MissingKoinDefinitionException: Missing definition for '[field:'engine' - type:'io.ktor.client.engine.HttpClientEngine']' in definition '[Singleton: 'io.ktor.client.HttpClient']'.
각 플랫폼 별로 엔진을 분류하는 방법은 아래 공식문서에 잘 나와 있다.
Client engines | Ktor
ktor.io
이를 위해 build.gradle.kts 파일에 각 플랫폼 별 의존성을 추가해 준다.
[libraries]
ktor-client-darwin = { group = "io.ktor", name = "ktor-client-darwin", version.ref = "ktor" } # ios
ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" } # android, jvm
ktor-client-js = { group = "io.ktor", name = "ktor-client-js", version.ref = "ktor" } # js
kotlin {
sourceSets {
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
commonMain.dependencies {
// 생략
}
desktopMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
wasmJsMain.dependencies {
implementation(libs.ktor.client.js)
}
}
}
그리고 expect와 actual을 활용하여 함수를 정의해 주면 된다!
- commonMain 모듈
internal expect fun httpClient(config: HttpClientConfig<*>.() -> Unit = {}): HttpClient
- androidMain 모듈
internal actual fun httpClient(config: HttpClientConfig<*>.() -> Unit) = HttpClient(OkHttp) {
config(this)
engine {
config {
retryOnConnectionFailure(true)
connectTimeout(0, TimeUnit.SECONDS)
}
}
}
- iosMain 모듈
internal actual fun httpClient(config: HttpClientConfig<*>.() -> Unit) = HttpClient(Darwin) {
config(this)
engine {
configureRequest {
setAllowsCellularAccess(true)
}
}
}
* wasmJsMain 모듈의 경우 CORS 이슈가 있어 생략하였습니다.
마치며
이렇게 길고 길었던 Ktor 마이그레이션 과정이 끝났다..!
(내 프로필이 컨트리뷰션 목록에 있다니🥹🥹)
아직 익숙하지 않은 KMP 플랫폼인 데다가 처음 써보는 기술인 Ktor를 적용하는 것이 어느정도 부담이 되었던 건 사실이다. (게다가 오픈소스 기여라니!) 생각치 못한 오류들도 많이 마주치면서 문제를 어떻게 풀어야 할지 스스로 고민도 많이 해 본 것 같다.
그래도 코드리뷰 해 주시는 분께서 좋은 방향으로 조언을 많이 해 주셨고, 그 과정에 있어서 필자도 크게 성장할 수 있었던 경험이었다!
좋은 기회를 주셔서 감사하다고 전해드리고 싶다.
필자가 작성한 PR은 아래와 같다.
➡️ https://github.com/droidknights/DroidKnightsApp/pull/479
[Feature/#409] Retrofit -> Ktor 마이그레이션 (core:network 구현) by leeeyubin · Pull Request #479 · droidknights/Droi
Issue close Retrofit -> Ktor 마이그레이션 (core:network 구현) #409 Overview (Required) 기존 Retrofit을 사용하던 부분을 Ktor로 마이그레이션 하였습니다. Ktor에 대한 의존성이 SessionApi가 있는 모듈로 전이되지
github.com
그리고 필자의 PR 이후 조금 더 나은 방향으로 코드가 수정이 되어 최종 코드를 보기 위해선 아래 PR도 함께 봐주면 좋을 것 같다!
[#503] HttpClient를 di를 통해 주입받는 것이 아닌, DroidKnightsNetwork 내 초기화 by workspace · Pull Request #50
Issue close Ktor HttpClient + Koin verify 테스트 실패 문제 해결 #503 Overview (Required) HttpClient를 di를 통해 주입받는 것이 아닌, DroidKnightsNetwork 내 초기화 httpClient에 internal 추가
github.com