CallAdapter 제대로 알고 사용하기

Hyemdooly·2023년 9월 4일
0

CallAdapter 적용 배경

이번 프로젝트를 진행하면서 에러 핸들링을 편하게 하기 위해 적용했다.

원하는 응답값과 다른 값이 올 경우 Response를 뜯어서 예외처리를 해야했다. 하지만 CallAdapter를 사용하면 내가 만든 일관된 규칙에 따라 원하는 Response 형태로 받을 수 있다.

적용 코드

Custom Response

sealed class ApiResponse<out T : Any> {
    data class Success<T : Any>(val body: T) : ApiResponse<T>() // 성공 시
    data class Failure(val responseCode: Int, val error: String?) : ApiResponse<Nothing>() // 실패 시
    data class NetworkError(val exception: IOException) : ApiResponse<Nothing>() // 네트워크 에러 시
    data class Unexpected(val t: Throwable?) : ApiResponse<Nothing>() // 그 외
}

받고 싶은 Response 형태들을 sealed class로 선언했다. 더 다양하게 나누어도 된다. Custom Call에서 코드만 잘 작성해준다면!

Custom Call

class AuctionCall<T : Any>(private val call: Call<T>, private val responseType: Type) :
    Call<ApiResponse<T>> { // Call<ApiResponse<T>>를 상속받는 Custom Call 클래스
    override fun enqueue(callback: Callback<ApiResponse<T>>) {
        call.enqueue(object : Callback<T> {
            override fun onResponse(call: Call<T>, response: Response<T>) {
                if (response.isSuccessful) { // 200~299 code인 경우 - 성공
                    response.body()?.let { // response body가 null이 아닌 경우
                        callback.onResponse(
                            this@AuctionCall,
                            Response.success(ApiResponse.Success(it)),
                        )
                    } ?: run { // response body가 null인 경우
                        if (responseType == Unit::class.java) { // 원래 body가 없는 응답값인 경우
                            @Suppress("UNCHECKED_CAST")
                            return@run callback.onResponse(
                                this@AuctionCall,
                                Response.success(ApiResponse.Success(Unit as T)),
                            )
                        }
												// body가 있어야하는데 없는 경우 -> 예상치 못한 경우
                        callback.onResponse(
                            this@AuctionCall,
                            Response.success(
                                ApiResponse.Unexpected(
                                    IllegalStateException("Response body가 존재하지 않습니다."),
                                ),
                            ),
                        )
                    }
                } else { // 200~299 이외 code인 경우 - 실패
                    callback.onResponse(
                        this@AuctionCall,
                        Response.success(
                            ApiResponse.Failure(
                                response.code(),
                                response.errorBody()?.string(),
                            ),
                        ),
                    )
                }
            }

            override fun onFailure(call: Call<T>, t: Throwable) {
                val response = when (t) {
                    is IOException -> ApiResponse.NetworkError(t) // IOException인 경우 네트워크 에러
                    else -> ApiResponse.Unexpected(t) // 그 외는 예상치 못한 경우
                }
                callback.onResponse(
                    this@AuctionCall,
                    Response.success(response),
                )
            }
        })
    }

    override fun clone(): Call<ApiResponse<T>> = AuctionCall<T>(call.clone(), responseType)

    override fun execute(): Response<ApiResponse<T>> { // 동기 처리 방식이므로 Coroutine으로 비동기 처리를 할 우리는 사용할 일이 없다.
        throw UnsupportedOperationException("AuctionCall은 execute를 지원하지 않습니다.")
    }

    override fun isExecuted(): Boolean = call.isExecuted

    override fun cancel() = call.cancel()

    override fun isCanceled(): Boolean = call.isCanceled

    override fun request(): Request = call.request()

    override fun timeout(): Timeout = call.timeout()
}

Call를 Call<ApiResponse> 변환하기 위해 작성한다.

우리는 enqueue 함수만 보면 된다. 나머지는 다 인자로 받은 Call에게 시킬 것이기 때문이다.

복잡해보이지만 뜯어보면 생각보다 간단한 코드이다. Callback의 onResponse 함수에서 Response를 Wrapping 해주는 것을 확인할 수 있다. response가 어떤 것인지에 따라서 ApiResponse에서 정의한 것들 중 하나로 감싸고, callback.onResponse 함수를 실행한다.

에러의 경우에도 onFailure가 아니라 모두 onResponse 함수를 실행시키는 이유는, 앱을 종료시키지 않고 그에 맞게 앱을 작동시키기 위함이다.

Custom CallAdapter

class AuctionCallAdapter<T : Any>(private val responseType: Type) : CallAdapter<T, Call<ApiResponse<T>>> {
    override fun responseType(): Type = responseType // 바꿔줄 객체 타입

    override fun adapt(call: Call<T>): Call<ApiResponse<T>> = AuctionCall(call, responseType) // 기존 call을 내가 만든 Call로 변환 처리
}

우리가 사용할 CallAdapter를 작성한다. responseType 함수는 바꿔줄 객체 타입을 리턴한다. adapt 함수를 보면, Call를 받아서 AuctionCall로 만들어 내보내는 것을 확인할 수 있다. 기존 Call을 내가 만든 Custom Call로 변환하는 것이다.

Custom CallAdapterFactory

class CallAdapterFactory : CallAdapter.Factory() {
    override fun get(
        returnType: Type,
        annotations: Array<out Annotation>,
        retrofit: Retrofit,
    ): CallAdapter<*, *>? {
				// getRawType : type의 raw type 반환, 즉 제네릭을 제외한 타입을 반환
				// Call로 감싸져 있는가?
        if (getRawType(returnType) != Call::class.java) return null // false : 여기서는 처리를 못한다! -> null 반환
        check(returnType is ParameterizedType) { // reflection 사용, returnType이 제네릭 인자를 가지는가?
            "Call 반환 타입은 제네릭을 포함해야 합니다."
        }
				// getParameterUpperBound : type의 index 위치의 제네릭 파라미터에 대한 upper bound type 반환
				// 즉 제네릭 자료형이 뭔지 열어보는 것
        val responseType = getParameterUpperBound(0, returnType)
				// ApiResponse로 감싸져 있는가?
        if (getRawType(responseType) != ApiResponse::class.java) return null
        check(responseType is ParameterizedType) { // reflection 사용, responseType이 제네릭 인자를 가지는가?
            "Api Response는 제네릭을 포함해야 합니다."
        }

        val genericType = getParameterUpperBound(0, responseType) // 변환할 타입 받기
        return AuctionCallAdapter<Any>(genericType) // 변환할 타입을 담아 CallAdapter 생성
    }
}

Custom CallAdapterFactory를 작성해주어야한다. 말그대로, CallAdapter를 생성하는 친구다.

이 Factory에서 처리할 수 없다면 null, 있다면 Custom CallAdapter를 생성하여 반환한다.

Create Retrofit

Retrofit.Builder()
        // ...
        .addCallAdapterFactory(CallAdapterFactory())
        // ...

마지막으로 Retrofit을 생성할 때 CallAdapterFactory를 넣어주면 끝.

결과

그럼 다음과 같이 ViewModel에서 예외처리가 가능하다.

fun loadAuctions() {
    viewModelScope.launch {
        when (val response = repository.getAuctionPreviews(lastAuctionId.value, SIZE_AUCTION_LOAD)) {
            is ApiResponse.Success -> { // ... }
            is ApiResponse.Failure -> { //... }
            is ApiResponse.NetworkError -> { //... }
            is ApiResponse.Unexpected -> { //... }
        }
    }
}

멧돼지가 기깔나게 설명해두었다. 혹시 이 글로 이해가 되지 않는 사람이 있다면 이 블로그 글을 참고하기 바람.

0개의 댓글