본문 바로가기
안드로이드

[안드로이드] Retrofit 을 이용하는 6가지 방법 (수정 : 22-01-13)

by algosketch 2022. 1. 10.

※ 2023-10-15 Retrofit 관련된 새로운 글을 작성했습니다. 해당 글을 읽어주시길 바라며, 이 글은 더 이상 읽지 않을 것을 권장합니다.

 

주의사항(2023-06-19 코멘트 추가) 이 글은 과거에 작성된 글로 조금 잘못된 지식을 설명하고 있습니다. 곧 수정할 예정이지만 혹시 그 전에 읽는다면 반드시 아래 내용을 유의하여 읽어주세요.

  • viewModel에서는 코루틴 스코프를 직접 만드는 것이 아닌 viewModelScope를 이용해야 합니다. viewModelScope는 Main 스레드를 이용합니다.
  • Retrofit2의 serviece를 만들 때 즉, api interface를 정의할 때 단순히 fun 앞에 suspend 키워드를 붙여주면 별도의 Adapter를 정의하지 않고 코루틴을 이용하여 Retrofit2를 이용할 수 있습니다. 이 경우 Retrofit2 내부적으로 IO스레드를 붙여주기 때문에 명시적으로 withContext(Dispatchers.IO)로 스레드를 변경할 필요가 없습니다.

 

※ 이 글은 상세한 설명보다는 각 코드에 대한 주관적인 생각만 담고 있습니다.

이 글에서 사용된 코드는 이 리포지토리에 작성되어 있다. ServerConfig 싱글톤(kotlin 의 object 키워드 이용) 객체로 baseUrl 만 담고 있고, .gitignore 에 추가되어 깃허브에서는 확인할 수 없다. Retrofit 의 Call 객체와 Coroutine 을 이용하여 5가지 방법으로 Retrofit 라이브러리를 이용한다. RxJava 는 사용하지 않았다.

 

기반 코드

object RetrofitFactory {
    fun <T> createRetrofitService(service: Class<T>): T {
        return Retrofit.Builder()
            .baseUrl(ServerConfig.baseUrl)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(service)
    }
}

Serviece 객체를 만들기 위한 Factory Method 이다. 이 클래스는 service 인터페이스를 받아 service 객체를 반환하는 하나의 메소드만 구현되어 있다.

data class Memo(val id: Long, val content: String, val statue: String)

데이터 모델이다. 서버에서 데이터를 받아올 Entity 로 사용한다. 모델의 필드를 이렇게 정의한 특별한 이유는 없고, 내가 이미 올려두고 지금 돌아가고 있는 서버에서 사용하는 모델이라 채택했다.

interface CallService {
    @GET("memo/{id}")
    fun getMemo(@Path(value = "id") id: Long): Call<Memo>

    @POST(".")
    fun writeMemo(
        @Body requestBody: Memo
    ): Call<Memo>
}
interface CallResponseService {
    @GET("memo/{id}")
    suspend fun getMemo(@Path(value = "id") id: Long): Response<Memo>

    @POST(".")
    suspend fun writeMemo(
        @Body requestBody: Memo
    ): Response<Memo>
}

두 가지 service 를 정의했다. 필요에 따라 둘을 바꿔 끼워가며 사용할 예정이라 위에서 정의한 Factory Method 도 제네릭을 이용했다. 하나는 Call 객체를 반환해 enqueue 할 때 onResponse 와 onFailure 메소드를 오버라이드 하여 사용한다. 다른 하나는 Response 객체만 이용한다. Response 를 이용해 onFailure 와 같은 효과를 내고 싶다면 try catch 로 감싸면 될 것이다.

 

1. 가장 먼저 공부한 Retrofit 사용 방법

fun getMemoWithCallAsync() {
        val service = getRetrofitService(CallService::class.java)
        val call = service.getMemo(1)
        call.enqueue(object : retrofit2.Callback<Memo> {
            override fun onResponse(call: Call<Memo>, response: Response<Memo>) {
                if(response.isSuccessful) {
                    Log.d(tag, "가장 기본적인 Retrofit 사용 방법")
                    Log.d(tag, "${response.body()?.toString()}")
                }
                else {
                    Log.e(tag, "onResponse, status : ${response.code()}, message : ${response.message()}")
                }
            }

            override fun onFailure(call: Call<Memo>, t: Throwable) {
                Log.e(tag, "onFailure")
                t.printStackTrace()
            }
        })
    }

이 코드가 응애시절에 처음 배운 Retrofit 사용법이다. 이런 코드를 이용한다면 투두리스트보다 복잡한 프로젝트 하나만 해 봐도 코드가 매우 더러워진다는 것을 알 수 있다. 로직에 따라서는 response 값을 받은 후 다른 서버 요청을 보내야할 수도 있다. 이럴 경우 콜백 지옥에 빠진다. JavsScript 에서의 콜백지옥보다 몇 배는 복잡하다고 생각한다.

object Callback : retrofit2.Callback<Memo> {
        override fun onResponse(call: Call<Memo>, response: Response<Memo>) {
            if(response.isSuccessful) {
                Log.d("tag", "가장 기본적인 Retrofit 사용 방법")
                Log.d("tag", "${response.body()?.toString()}")
            }
            else {
                Log.e("tag", "onResponse, status : ${response.code()}, message : ${response.message()}")
            }
        }

        override fun onFailure(call: Call<Memo>, t: Throwable) {
            Log.e("tag", "onFailure")
            t.printStackTrace()
        }
    }

물론 대부분의 예제에서 사용하고 있는 onResponse 와 onFailure 의 구현은 위처럼 별도로 분리할 수 있다. kotlin 을 처음 사용한다면 조금 당황스러울 수 있지만, Callback<Memo> 인터페이스를 구현한 익명 싱글톤 객체일 뿐이다. 위 코드는 그 객체에 이름만 붙여줬을 뿐이다.

비록 무지성 코드를 작성해왔어도, JavaScript 6개월 프로젝트와 Dart 외주 프로젝트로 단련해온 나는 그렇게 호락호락하지 않다. 이 두 언어는 JVM 계열 언어보다 스레드는 부족하지만 비동기는 매우 잘 처리했다고 생각한다. 둘 다 async & await 를 지원한다. 이것과 비슷한 개념을 kotlin 에서도 쓸 수 있을 거라 생각했던 게 계기가 되었다. 찾아보면 Coroutine 을 이용하는 방법과 RxJava 를 이용하는 방법이 있고 지금은 Coroutine 을 먼저 공부하고 있다.

동기와 비동기 처리 혹은 그와 비슷해보이게 동작하는 코드는 코틀린 코드로도 생각보다 많이 찾을 수 있었다. JS 를 사용하는 것보다는 조금 복잡하긴 하다.

 

2. Call 객체와 서브 스레드를 이용하는 방법

fun getMemoWithCallSync() {
    val service = getRetrofitService(CallService::class.java)
    val call = service.getMemo(1)

    /**
     * Main Thread 에서 사용하면 Exception 발생.
     */
    Thread {
        val result = call.execute()

        if(result.isSuccessful) {
            Log.d(tag, "스레드 + 동기를 이용한 Retrofit 사용 방법")
            Log.d(tag, "${result.body()?.toString()}")
        }
        else {
            Log.e(tag, "onResponse, status : ${result.code()}, message : ${result.message()}")
        }
    }.start()
}

DB 접근과 통신은 느리다. 언어에서도 기본적으로 메인 스레드에서 통신할 수 없게 되어 있다. 하지만 서브 스레드를 이용하면 통신도 동기적으로 처리할 수 있다. 물론 서브 스레드 안에서만. 만약 메인 스레드에서 5초 이상 기다리는 일이 발생하면 ANR 에러가 발생한다. 스레드 객체를 만드는 과정에 의문이 생긴다면 람다를 찾아보면 된다.

Call 객체를 만드는 메소드에서 통신하는 게 아니라, call.execute 할 때 통신하게 된다. 따라서 이 부분부터 스레드로 감싸주면 된다.

 

3. Coroutine 과 Response 객체를 이용하는 방법

fun getMemoWithResponse() {
    val service = getRetrofitService(CallResponseService::class.java)

    CoroutineScope(Dispatchers.IO).launch {
        val response = service.getMemo(1)

        if(response.isSuccessful) {
            Log.d(tag, "suspend function 과 코루틴 이용")
            Log.d(tag, "${response.body()?.toString()}")
        }
        else {
            Log.e(tag, "onResponse, status : ${response.code()}, message : ${response.message()}")
        }
    }
}

이전 코드와는 다른 service 를 사용하고 있음에 유의하라.

코루틴은 세 가지 Dispatcher 를 제공한다. ( 참고자료 ) 코루틴 스코프 안에서 실행되는 로직은 비동기 작업은 만나면 잠시 중단 됐다가 비동기 작업이 끝나면 다시 실행된다. 이때 어떤 스레드에서 실행될 것인지 Dispatcher 를 통해 지정할 수 있다. UI 작업을 하는 게 아니라면 굳이 Main 스레드를 사용할 필요는 없는 것 같다.

Response 객체를 만드는 시점에서 통신이 이루어진다. service interface 에서 getMemo 메소드를 suspend 로 만들었다. suspend 메소드는 반드시 다른 suspend 메소드 안에서 호출되거나 코루틴 스코프 안에서 호출되어야 한다.

 

4. Coroutine 과 async & await 를 이용한 구현

suspend fun getMemoWithAwait() {
    val service = getRetrofitService(CallResponseService::class.java)
    val deferred = CoroutineScope(Dispatchers.IO).async {
        service.getMemo(1)
    }

    val result = deferred.await()

    Log.d(tag, result.body().toString())
}
// 사용하는 곳
CoroutineScope(Dispatchers.IO).launch {
    getMemoWithAwait()
}

이 코드가 다른 언어와 비슷하면서도 다른 언어보다 사용하기 어렵다고 생각하는 부분이다. suspend 키워드가 다른 언어에서의 async 와 비슷하다. async + await 가 다른 언어에서의 await 와 비슷한 것 같다. 이렇게 사용하지 않고 Deferred 객체를 반환하거나 deferred.await() 를 반환할 수도 있을 것 같다. 그런데 이 경우 그냥 Response 객체를 반환하는 것에 비해 이점이 없다. 만약 이 방법을 사용한다면 예외 처리 코드를 await 를 호출하는 쪽에서 작성하고 response.body() 를 반환하는 게 사용하기에 좋아보인다.

여기서 Deferred 객체가 JavaScript 의 Promise 객체와 비슷해 보인다. 다만 Promise 의 경우 .then 을 이용해 쉽게 사용할 수 있지만 Deferred 는 스코프로 감싸야 하는 번거로움이 있다.

 

5. 4번 방법을 조금 더 간단히

suspend fun fetchMemo() = withContext(Dispatchers.IO) {
    val service = getRetrofitService(CallResponseService::class.java)

    service.getMemo(1)
}
// 사용하는 곳
CoroutineScope(Dispatchers.Main).launch {
    val result = fetchMemo()
    if(result.isSuccessful) {
        Log.d(tag, result.body().toString())
    }
}

여기부터 01-13 에 글을 수정하면서 가장 많이 달라진 부분이다.

withContext 는 두 개의 파라미터를 받는다. 첫 번째는 코루틴 컨텍스트이고 두 번째는 수신 객체 타입의 코루틴 스코프이다. 수신 객체 타입은 확장 함수처럼 동작한다. 여기선 코루틴 컨텍스트로 Dispatchers.Main 을 넘겨 줬는데, Dispatchers.Main 의 상속 구조를 타고 올라가면 코루틴 컨텍스트를 구현하고 있다. 수신 객체 타입으로 CoroutineScope. 를 받고 있으므로 이 메소드는 반드시 코루틴 스코프 안에서 사용되어야 한다. (혹은 suspend) 실제로는 CoroutineScope 라는 클래스 안에서 this.withContext(Dispatchers.Main) 을 호출한다고 생각하면 된다.

 

6. withContext 를 이용하는 또 다른 방법

suspend fun fetchAndDisplay() {
    val service = getRetrofitService(CallResponseService::class.java)
    
    coroutineScope { 
        // UI 와 관련된 데이터를 얻는다.
        val memo = async(Dispatchers.IO) { service.getMemo(1) }
        
        withContext(Dispatchers.Main) {
            // doSomeWork
            val result = memo.await()
            // 얻은 data 를 이용해 UI 에 뿌려준다.
        }
    }
}

coroutineScope 는 코루틴 스코프를 만들고 람다를 실행한다. 마찬가지로 수신 객체 타입을 파라미터로 받는다.

위 코드처럼 작성하면, doSomeWork 부분과 네트워크를 통해 데이터를 가져오는 부분을 병렬로 실행할 수 있다. 또한 네트워크에서 가져오는 부분은 IO 스레드에서, soSomeWork 부분은 Main 스레드에서 실행한다.

 

기반 코드에서 추가된 부분 (22-01-13)

object OkHttp3Util {
    private val client = OkHttpClient()

    fun createClientWithLogInterceptor() : OkHttpClient {
        return OkHttpClient.Builder().addInterceptor(LogInterceptor.createLogInterceptor()).build()
    }
}
object LogInterceptor {
    fun createLogInterceptor() : HttpLoggingInterceptor {
        val interceptor = HttpLoggingInterceptor()
        interceptor.level = HttpLoggingInterceptor.Level.BODY

        return interceptor
    }
}
object RetrofitFactory {
    fun <T> createRetrofitService(service: Class<T>): T {
        return Retrofit.Builder()
            .baseUrl(ServerConfig.baseUrl)
            .client(OkHttp3Util.createClientWithLogInterceptor()) // 이 부분 추가
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(service)
    }
}

로그 인터셉터를 추가했다. 로그 인터셉터를 추가하면 헤더, 바디, 응답 코드 등의 로그를 쉽게 관리할 수 있다.

 

참고 자료

https://developer.android.com/kotlin/coroutines-adv?hl=ko (번역에 주의)

 

Kotlin 코루틴으로 앱 성능 향상  |  Android 개발자  |  Android Developers

Kotlin 코루틴으로 앱 성능 향상 Kotlin 코루틴을 사용하면 네트워크 호출이나 디스크 작업과 같은 장기 실행 작업을 관리하면서 앱의 응답성을 유지하는 깔끔하고 간소화된 비동기 코드를 작성할

developer.android.com

https://developer88.tistory.com/67

 

OKHttp 의 Application Interceptor 와 Network Interceptor

안드로이드 앱을 만들면서 네트워크 라이브러리로 무엇을 쓰시나요? 물론, 구글 개발자사이트에서 코드까지 제공해주는 Volley Library도 있긴하지만, 실제로 구글도 많이는 않쓴다고 하지요. 저와

developer88.tistory.com