3 minute read

Retrofit

Retrofit을 사용할 때는 각 함수에 annotation을 붙인 인터페이스를 만들고, retrofit.create(MyRestAPI::class.java) 으로 인스턴스를 만들어서 사용한다.

case 1

interface MyRestAPI {
    @GET("/myapp/v1/path_of_api")
    fun getSomething(): Call<GetSomethingResult>
}

case 2

interface MyRestAPI {
    @GET("/myapp/v1/path_of_api")
    suspend fun getSomething(): GetSomethingResult
}


간단하게 동기 함수로 쓰기 위해서는 case 1로 만들고 콜백을 이용해 결과 혹은 에러를 얻는다. 약간의 수고를 더해서 비동기 함수를 쓸 때는 case 2같이 사용할 수 있다. 내부에서 뭔가 엄청 잘 돼있는지 이렇게만 해 줘도 무척 잘 되지만, 에러 응답이 올 경우 이를 별도로 처리하기 애매하다.

먼저 내부에서 어떻게 돌아가는지 간단히 살펴본다.

1. 요청

우리가 retrofit.create(MyRestAPI::class.java)로 <T> 객체를 만들 때 Proxy 객체를 만드는데, 이때 InvocationHandler라는 걸 만든다.

public Object invoke(Object proxy, Method method, Object[] args)

이거 하나 갖는 인터페이스이고, 우리가 create에 올바르게 service를 넣어 줬으면

위 코드에서 가장 마지막 loadServiceMethod이 불리고,

위 함수로 이어진 다음

위 함수로 이어진다.

우리는 아무 Call Adapter도 넣어주지 않았기 때문에, 224라인의 callAdapter.adapt(call); 는 DefaultCallAdapter의 아래 함수로 이어진다.

우리는 아무것도 넣어준게 없기 때문에 executor는 null이다. 그래서 그냥 call이 그대로 돌아온다.

다음으로 위 코드의 227라인에서는 args의 마지막 칸에 들어있는 continuation(우리가 부른 함수의 “다음 할 일” 일 것이다)을 꺼내고, 244라인의 await으로 이어진다.

suspendCancellableCoroutine의 내부 블럭에서는 Call.enqueue 함수가 불린다. 이 enqueue는 어떤 call의 enqueue냐 하면, 아까 저 위에서 new OkHttpCall<>(requestFactory, args, callFactory, responseConverter);로 만든 call이기 때문에 아래의 enqueue가 불린다.

위 함수의 바디에서는 아래의 call.enqueue가 불린다.

이 call은 RealCall이라는 OkHttp의 클래스 인스턴스라서 아래 코드로 이어진다.

비동기 함수를 파고들다보면 어디서 멈춰야 할지 잘 모르겠는데, 보통 그냥 dispatcher 가 등장하면 여기까지만 봐야겠다~ 생각하는 편이다. 이번에도 여기에서, dispatcher에게 잘 넘겼겠거니 하고 멈춘다.

2. 응답

dispatcher에게 callback과 함께 넘긴 우리의 Call에 대한 응답이 오면 callback이 불린다. 이 콜백은 okhttp3.CallBack인데, 얘를 어디에 넣어줬냐 하면 위위 코드의 call.enqueue 안에 구현해서 넣어준 콜백이 있었다. 161라인의 callback.onResponse(OkHttpCall.this, response); 가 불리는데, 이 callback은 어디에서 넣어준 콜백인가 하면 저 위에 suspend fun <T : Any> Call<T>.await(): T에서 구현해서 넣어줬던 Retrofit.CallBack 이다.

위에서 onResponse 함수의 바디만 갖고 와서 보면,

if (response.isSuccessful) {
  val body = response.body()
  if (body == null) {
    val invocation = call.request().tag(Invocation::class.java)!!
    val method = invocation.method()
    val e = KotlinNullPointerException("Response from " +
        method.declaringClass.name +
        '.' +
        method.name +
        " was null but response body type was declared as non-null")
    continuation.resumeWithException(e)
  } else {
    continuation.resume(body)
  }
} else {
  continuation.resumeWithException(HttpException(response))
}

40X나 50X의 경우에는 맨 아래 continuation.resumeWithException(HttpException(response))가 불린다. 우리의 서버가 보내준 에러 메시지는 response.errorBody에 들어있기 때문에, 최상단 try-catch에서 catch하는 HttpException에서 e.response()?.errorBody()?.string()로 에러 메시지를 가져올 수 있다.

API 함수를 부르는 곳에서 try-catch를 해서 catch된 exception의 errorBody를 파싱해서 써도 되지만, 기왕이면 더 하단에서 모두 파싱을 끝낸 후에 우리의 커스텀 Exception 객체로 exception을 발생시키고 싶다. 이유가 꼭 이것뿐만은 아니고, 에러 원인이 40X나 50X가 아니라 Http Fail이나 와야 하는 body가 오지 않은 경우에는 HttpException이 아니라 KotlinNullPointerException이나 UnknownHostException가 오는데 이를 상단 호출부에서 분기를 치는 게 무척 어색하기 때문이기도 하다.

Custom Call Adapter

내부 동작을 간단히 알아봤는데, 이제 커스텀 CallAdapter를 만들어야 할 필요성을 알게 되었다. Retrofit의 빌더에 커스텀 CallAdapter를 넣어 준다.

return Retrofit.Builder()
    .baseUrl("http://~~~~~~.com")
    .client(okHttpClient)
    .addConverterFactory(MoshiConverterFactory.create(moshi)) 
    .addCallAdapterFactory(MyCallAdapterFactory()) // 여기


MyCallAdapterFactory와 MyCallAdapter는 아래와 같이 만들 수 있다.

class MyCallAdapterFactory: CallAdapter.Factory() {
    override fun get(
        returnType: Type,
        annotations: Array<out Annotation>,
        retrofit: Retrofit
    ): CallAdapter<*, *> {
        return MyCallAdapter<Any>(returnType)
    }
}
class MyCallAdapter<R>(
    private val bodyType: Type,
): CallAdapter<R, Any> {

    override fun responseType(): Type {
        return (bodyType as? ParameterizedType)?.let {
            it.actualTypeArguments[0]
        } ?: Unit::class.java
    }

    override fun adapt(call: Call<R>): Any {
        return call.let {
            object: Call<R> by it {
                override fun enqueue(callback: Callback<R>) {
                    it.enqueue(object: Callback<R> {
                        override fun onResponse(call: Call<R>, response: Response<R>) {
                            if (response.isSuccessful) {
                                callback.onResponse(call, response)
                            } else {
                                callback.onFailure(call, parseErrorBody(response))
                            }
                        }

                        override fun onFailure(call: Call<R>, t: Throwable) {
                            callback.onFailure(call, t)
                        }
                    })
                }
            }
        }
    }
}

뭔가 굉장히 복잡한데, 뭘 해야되는지만 이해하면 된다. Retrofit 라이브러리 코드 내에서 Call 인스턴스를 만드는데, 우리는 이 Call의 함수 중 enqueue를 수정할 것이다. 정확히는 enqueue 안에 들어가는 CallBack에 손을 댈 건데, 사이에 한 계층을 추가한다.

커스텀 Call Adapter를 넣지 않았다면, 에러일 때 아래 주석으로 표시한 라인이 불리게 된다.

override fun onResponse(call: Call<T>, response: Response<T>) {
  if (response.isSuccessful) {
    // ...
  } else {
    continuation.resumeWithException(HttpException(response)) // 여기가 불리는 것이 문제
  }
}

override fun onFailure(call: Call<T>, t: Throwable) {
  continuation.resumeWithException(t) // 커스텀 Error 객체를 여기로 쏴 주는 것이 목표
}

거기로 가지 않고 resumeWithException(t)으로 가도록 아래와 같이 가로챈다.

it.enqueue(object: Callback<R> {
    override fun onResponse(call: Call<R>, response: Response<R>) {
        if (response.isSuccessful) {
            callback.onResponse(call, response)
        } else {
            callback.onFailure(call, parseErrorBody(response)) // 여기
        }
    }

    override fun onFailure(call: Call<R>, t: Throwable) {
        callback.onFailure(call, t)
    }
})

위 코드의 else문(주석 //여기)에서 parse한 후 callback.onFailure를 불러서 우리의 커스텀 Exception으로 resumeWithException할 수 있도록 유도한다.

마지막으 parseErrorBody는 각자 앱마다의 구현을 해 주면 된다. 스누티티 안드로이드의 경우 serializer를 이용해 response.errorBody()?.string()를 ErrorDTO의 형태로 deserialize한 후 response와 함께 ErrorParsedHttpException라는 클래스의 형태로 throw한다. (코드)