#안드로이드

Ktor 2.3.3 프로젝트에 적용

1jeongg 1jeongg Follow 2024년 01월 14일 · 13 mins read
Share this

🤔 Ktor(케이토)란?

JetBrains에서 개발되었으며 단순한 방법으로 멀티플렛폼 http 클라이언트 애플리케이션을 만드는 프레임워크
비동기 방식으로 클라이언트 및 서버 앱을 작성할 수 있도록 도와준다.

참고: 현재 글은 ktor 2.3.3 버전을 토대로 설명합니다. minsdk는 26, compileSdk와 targetSdk는 34

❓ Ktor를 쓰는 이유?

공식 문서 내용

1️⃣ 코틀린과 코루틴 사용 가능

2️⃣ JetBrain사에서 만듦

3️⃣ 경량성과 유연성

기타

1️⃣ Scope 할당하여 DSL 형식이라 가독성 높음

2️⃣ Serialization 지원

3️⃣ 가독성이 좋고 유지 보수가 쉬움

Retrofit과의 비교

ktor는 retrofit과 달리 jvm에 의존성이 없음

KMM(Kotlin Mutliplatform Mobile) 환경에서 사용하기 적합함

ktor는 Retrofit과 달리 Setup 과정이 간소화되어있음

이번 프로젝트에서 Ktor를 사용한 이유: 그저 KMM으로의 확장성을 고려해서 그렇게 설계했다.

Compose가 ios에서도 사용할 수 있게 변하고, KMM을 통해 데스크탑 애플리케이션을 만들 수 있기 때문에 새로운 프레임워크를 활용해서 프로젝트를 진행하고 싶었다.

새로운 걸 도전하는 건 늘 재밌기에,, 여름 방학동안 공부해보면서 적용해보기로 했다.

🚀 사용 방법

0. gradle 설정

build.gradle.kt(Module :app)

plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("kotlin-kapt")
    id("dagger.hilt.android.plugin")
    id("org.jetbrains.kotlin.plugin.serialization")
}

// ktor dependency
implementation("io.ktor:ktor-client-core:2.3.3")
implementation("io.ktor:ktor-client-cio:2.3.3")
implementation("io.ktor:ktor-client-logging:2.3.3")
implementation("io.ktor:ktor-client-content-negotiation:2.3.3")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.3")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0")
implementation("io.ktor:ktor-client-auth:2.3.3")
implementation("io.ktor:ktor-client-encoding:2.3.3")

// need for test
implementation("com.google.ar:core:1.39.0")
implementation("androidx.test.ext:junit-ktx:1.1.5")
testImplementation("io.ktor:ktor-client-mock:2.3.3")
implementation("org.slf4j:slf4j-simple:1.7.32")

testImplementation("org.mockito.kotlin:mockito-kotlin:4.1.0")
testImplementation("androidx.arch.core:core-testing:2.2.0")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.2")
testImplementation("io.mockk:mockk:1.12.0")
androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.2")

build.gradle.kt(Project)


buildscript{
    repositories {
        google()
        mavenCentral()
    }
    dependencies {
        classpath("com.android.tools.build:gradle:7.2.2")
        classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.21")
        classpath("com.google.dagger:hilt-android-gradle-plugin:2.47")
        classpath("org.jetbrains.kotlin:kotlin-serialization:1.8.10")
    }
}
plugins {
    id("com.android.application") version "8.1.0" apply false
    id("com.android.library") version "8.1.0" apply false
    ...
}

1. DTO 설정

@Serializable
data class ScrapDTO(
    val contentId: Long,
    val contentTitle: String,
    val link: String,
    val pubDate: LocalDateTime,
    val isScrap: Boolean
)

ktor에서는 json serialization을 사용할 것이기에 다음과 같이 Serializable 어노테이션을 달아주어야 한다. 그렇지 않으면 에러남.

2. 간단한 Request 보내고 받기

val customer: Customer = client.get("http://localhost:8080/customer/3").body()

다음 코드는 id 3을 가진 고객에 대한 json 데이터를 받아온다.

이런 식으로 get, post, delete 등 다양한 메서드를 적용할 수 있다. 이외에도 form data 등을 사용할 수 있고 다양한 parameter, query 등을 추가할 수 있다.

여기에 대해 모든 것을 다루기는 사실 힘들 것 같아서 실제 내가 적용한 프로젝트의 스크랩 기능을 토대로 설명해보겠다.

3. HttpClient와 연결되는 ScrapApi

class ScrapApi(
    private val client: HttpClient
): ScrapRepository {

    override suspend fun getScrapList(subscribeId: Long?, page: Int?): HttpResponse {
        val path = if (subscribeId == null) "" else "/$subscribeId"
        return client.get(HttpRoutes.GET_SCRAPS + path){
            parameter("page", page)
        }
    }

    override suspend fun addScrap(contentId: Long): HttpResponse {
        return client.post(HttpRoutes.ADD_SCRAP + "/$contentId"){}
    }

    override suspend fun deleteScrap(contentId: Long): HttpResponse {
        return client.post(HttpRoutes.DELETE_SCRAP + "/$contentId"){}
    }

}

HttpClient를 이용해서 request를 보내는 코드이다.

getScrapList는 구독한 subscribe_id에 따라 스크랩한 리스트를 페이지별로 불러오는 코드다. 이때 page와 subscribe_id는 비어있을 수 있다. 다음 코드에서 요청하는 url은 URL/{subscribe_id}?page=2 이다.

addScrap은 해당 content을 스크랩한 부분에 추가하고 deleteScrap은 삭제한다. url은 URL/{content_id} 이다.

4. ScrapApi와 ScrapRepositoryImpl를 이어주는 Interface

interface ScrapRepository {
    suspend fun getScrapList(subscribeId: Long?, page: Int?): HttpResponse
    suspend fun addScrap(contentId: Long): HttpResponse
    suspend fun deleteScrap(contentId: Long):HttpResponse
}

5. ScrapRepository의 내용을 implement하는 ScrapRepositoryImpl

@Singleton
class ScrapRepositoryImpl @Inject constructor(
    private val scrapApi: ScrapApi
): ScrapRepository{

    override suspend fun getScrapList(subscribeId: Long?, page: Int?): HttpResponse {
        return scrapApi.getScrapList(subscribeId, page)
    }

    override suspend fun addScrap(contentId: Long): HttpResponse {
        return scrapApi.addScrap(contentId)
    }

    override suspend fun deleteScrap(contentId: Long): HttpResponse {
        return scrapApi.deleteScrap(contentId)
    }
}

6. 각 method를 수행하는 Usecase

class AddScrap @Inject constructor(
    private val scrapRepository: ScrapRepository
){
    operator fun invoke(contentId: Long): Flow<Resource<String>> = flow {
        try {
            emit(Resource.Loading())
            val response = scrapRepository.addScrap(contentId)
            val body = response.body<ApiUtils.ApiResult<String>>()
            val successMessage = body.response ?: "성공적으로 스크랩 완료했습니다."
            val errorMessage = body.error?.message ?: "스크랩에 실패하였습니다."

            if (response.status == HttpStatusCode.OK && body.success) {
                emit(Resource.Success(successMessage))
            }
            else {
                emit(Resource.Error(errorMessage))
            }
        } catch(error: Exception){
            emit(Resource.Error(getErrorMessage(error)))
        }
    }
}

서버 response에서 한 번더 성공 여부를 알려주기에 이를 검증하는 로직을 추가하였다.

7. 통신을 관리하는 네트워크 모듈

@Module
@InstallIn(SingletonComponent::class)
class NetworkModule {
    @Provides
    @Singleton
    fun provideHttpClient(
        @ApplicationContext context: Context
    ): HttpClient {
        return HttpClient(CIO) {
            install(Logging){
                logger = object: Logger {
                    override fun log(message: String){
                        Log.d("ppap_api", message)
                    }
                }
                level = LogLevel.ALL
            }
            install(ContentNegotiation) {
                json(Json{
                    prettyPrint = true
                    isLenient = true
                    encodeDefaults = true
                })
            }
            install(HttpTimeout) {
                connectTimeoutMillis = 5000
                requestTimeoutMillis = 5000
                socketTimeoutMillis = 5000
            }
            install(Auth){
                bearer {
                    refreshTokens {
                        ...
                    }
                }
            }
            install(ContentEncoding) {
                deflate(1.0F)
                gzip(0.9F)
            }
            defaultRequest{
                val accessToken = PDataStore(context).getData(ACCESS_TOKEN_KEY)
                contentType(ContentType.Application.Json)
                if (accessToken.isNotEmpty())
                    headers.appendIfNameAbsent(HttpHeaders.Authorization, accessToken)
                url(HttpRoutes.BASE_URL)
            }
        }
    }

    @Provides
    @Singleton
    fun provideScrapApi(client: HttpClient): ScrapRepository {
        return ScrapApi(client)
    }
    @Provides
    @Singleton
    fun provideScrapRepositoryImpl(scrapApi: ScrapApi): ScrapRepositoryImpl {
        return ScrapRepositoryImpl(scrapApi)
    }
}

네트워크 모듈이다. dependency injection을 해주며 ktor 을 통한 비동기 통신을 위한 내용들을 제공한다. 사실 ContentNegotiation과 Logging을 제외하면 필요하지 않다면 안 적어도 된다.

a. Logging

해당 request에 대한 로그를 찍어준다. 나는 일반 로그는 ppap_log로, api 로그는 ppap_api로 설정하여 필터링할 때 ppap_ 를 써서 확인하였다. 로깅 레벨은 ALL, HEADERS 등으로 설정할 수 있고 그 외에도 filter를 통해 ~한 패턴을 가진 url을 호출하는 request의 로그만 볼 수도 있다.

b. ContentNegotiation 이번 프로젝트에서는 JSON을 사용하기에 JSON serializer를 사용하였지만 XML, CBOR, ProtoBuf도 지원한다.

c. HttpTimeout http 통신을 하는데 한참 걸리면 사용자가 불편할 것이기에 5초의 timeout을 걸어두었다.

d. Auth refreshToken과 asccessToken을 관리할 수 있는 부분이다. accessToken은 아래 defaultRequest에서 보내기 때문에 해당 부분에선 생략하였다. 반면 서버의 token 유효기간은 30분으로 설정되었기 때문에 403 에러가 나면 자동으로 refresh 하고 response로 accessToken과 refreshToken을 받아서 이를 다시 저장하는 로직이 필요했다. 따라서 다음과 같은 코드를 작성하였다. (… 안에 들어있는 내용)

refreshTokens {
    val refreshToken = PDataStore(context).getData(REFRESH_TOKEN_KEY)
    val token = client.post(HttpRoutes.KAKAO_REISSUE){
        setBody(RefreshTokenDTO(refreshToken))
        markAsRefreshTokenRequest()
    }.body<ApiUtils.ApiResult<KakaoLoginDTO>>()

    if (token.success){
        PDataStore(context).setData(ACCESS_TOKEN_KEY, token.response?.accessToken ?: "")
        PDataStore(context).setData(REFRESH_TOKEN_KEY, token.response?.refreshToken ?: "")
    }
    BearerTokens(
        accessToken = token.response?.accessToken ?: "",
        refreshToken = token.response?.refreshToken ?: ""
    )
}

e. ContentEncoding 특정 압축 알고리즘(deflate, gzip)를 사용할 수 있다. 또한 server에서 response의 raw body를 준다면 custom Encoder function을 사용하여 이를 디코딩할 수 있다.

f. defaultRequest request를 보낼 때 항상 설정되었으면 하는 걸 header에 추가하거나 base url을 설정할 수 있다. 이번 경우엔 contentType을 JSON으로, accessToken(있을 경우에만), base url을 설정해주었다.

8. Usecase와 ViewModel과의 연결

@HiltViewModel
class ScrapViewModel @Inject constructor(
    private val getScrapListUseCase: GetScrapList,
    private val getSubscribesUseCase: GetSubscribes,
    private val addScrapUseCase: AddScrap,
    private val deleteScrapUseCase: DeleteScrap,
): ViewModel(){

    ...

    private val _eventFlow = MutableSharedFlow<PEvent>()
    val eventFlow = _eventFlow

    private fun addScrap(contentId: Long){
        viewModelScope.launch{
            addScrapUseCase(contentId).collect { response ->
                when(response){
                    is Resource.Loading -> _eventFlow.emit(PEvent.LOADING)
                    is Resource.Success -> _eventFlow.emit(PEvent.ADD)
                    is Resource.Error -> _eventFlow.emit(PEvent.ERROR(response.message))
                }
            }
        }
    }
}

이제 viewModel에서 이런 식으로 쓰면 된다! 끝! usecase에서 받아온 response 값에 따라 다른 event를 호출한다.

9. 테스트코드 작성

@Test
fun addScrap_success(){
    runBlocking {
        // given
        val content = """{"success": true, "response": null, "error": null}"""
        val contentId = 1L

        // when
        val mock = getExceptionHttpClient(content)
        val data = ScrapApi(mock).addScrap(contentId).body<ApiUtils.ApiResult<String>>()

        // then
        Assert.assertEquals(true, data.success)
        Assert.assertEquals(null, data.response)
        Assert.assertEquals(null, data.error)
    }
}

mock api를 만들고 더미 데이터를 억지로 만들어서 테스트 할 수 있다.

🚨문제 발생과 해결

  1. jvmTarget 문제 retrofit 쓸 땐 1.8로 써도문제없었는데 jvm을 지원해주지 않아서 17로 바꿔야 한다… 하지만 버전을 바꾸는 건 쉽다.

  2. ContentNegotiation 문제 진짜 문제는 얘였다. 처음엔 다른 블로그 글들을 참고하며 작성했는데 그러다 보니 구버전들 (1.xx) 밖에 없었다. 구 버전에선 지원하던 JsonFeature가 2.xx로 바뀌면서 deprecated되고 ContentNegotiation으로 바뀌었던 것이다.. 누가봐도 비슷한 점이 없으니까 어떤 부분이 잘못됐는지 몰라서 한참 헤맸다.

    새로 만들어진지 얼마 안된 프레임워크라 그런 것 같다.

    이 블로그 글을 보는 누군가 ktor를 적용할 계획이라면 다른 블로그들은 참고 정도로 보고 공식 홈페이지 ktor.io/docs를 보고 작성하길 추천한다.. 진심으로..

  3. Auth 문제 토큰이 만료되면 403 에러가 나게 된다. 이때 Refresh를 시켜줘야하는데 ktor에서는 다음과 같은 코드를 작성하면 자동으로 토큰을 갱신시켜준다.

    token은 refresh 했을 때 response data이다. 성공하면 datastore에 해당 내용을 저장한다.

    이렇게만 보면 정말 좋은 plugin같다… 그런데 어떤 요청엔 kakao를 header로, 어떤 요청엔 Authorization을 header로 넣다 보니 header가 겹쳐서 에러가 났다. 이렇게 겹치는 상황을 방지하기 위해선 appendIfNameAbsent을 적용해줘야한다.

❤️ 느낀점

단순한 프로젝트를 하기 위해서 도전! 또는 KMM을 적용하고 싶은 분들에게는 추천한다.

하지만 retrofit과 비교해봤을 때 retrofit이 아직까지는 reference도 많고 에러에 대한 다양한 해결방법이 존재한다는 점에서 더 좋은 것 같다.

다음 프로젝트를 하게 되면 다시 retrofit2로 돌아가야겠다.. 그리워 😥

🔗 참고 자료

Retrofit2 대신 Ktor는 어떠신가요?

유튜브

ktor 공식 사이트



1jeongg
Written by 1jeongg Follow

I'm studying Android development by Kotlin and Spring by Java