본문 바로가기
개발/Android

[Android] Retrofit Call Adapter를 활용해서 효과적인 에러 핸들링 하기

by du.it.ddu 2025. 1. 24.

요즘 앱을 개발하면 API를 사용하지 않는 경우가 거의 없는 듯 합니다.
그렇기 때문에 Retrofit은 굉장히 흔하고 유용한 네트워킹 라이브러리이며 오랫동안 사용되어 오고 있습니다.
최근엔 Ktor를 사용하는 사례도 종종 보입니다만, 아직까지 Retrofit을 사용하는 것이 대부분 일 것입니다.

이와 더불어 앱 권장 아키텍처 또는 MVVM, 클린아키텍처 또한 매우 흔한 기술 스택으로 자리잡고 있습니다.
그러다보니 ViewModel에서 Repository, UseCase를 통해 API로부터 오는 데이터를 얻고 있습니다.

이러한 아키텍처 구성은 레이어간에 느슨한 결합을 만드는 것이 주된 목적 중 하나이기 때문에 인터페이스로 의존하고 있으며, 서로간에 구체적인 구현은 모르게 되는 상황이 된 것이죠.

이러한 상황에서 API를 호출했을 때 발생할 수 있는 에러 상황을 효과적으로 핸들링하기 위한 방법을 소개해보겠습니다.

 

에러? 그냥 try-catch 사용하면 되지 않음?

이전에는 ViewModel에서 Repository, UseCase를 호출할 때 try-catch 또는 runCatching 등을 사용해서 API 호출시 발생하는 예외를 잡아서 처리를 해주었을 것 입니다.
이 방법은 유용하기 때문에 딱히 문제가 느껴지지 않을 것입니다.

이러한 접근을 아키텍처 관점에서 바라보겠습니다.
자, 우리는 MVVM, 클린아키텍처 등을 적용하면서 의존성 주입을 활용하고 있을 것입니다. 그리고 각 레이어의 의존성은 인터페이스로 결합해 구체적인 구현 사항에 얽매이지 않게 만들고 있겠죠.

그럼 ViewModel 입장에서 내가 현재 의존하고 있는 Repository, UseCase의 인터페이스가 어떤 에러를 내려줄지 구체적으로 아는 것은 구체적인 구현 사항에 얽매이지 않게 하는 의도에 위배되는 사항일 것입니다.

또한 API라면 Repository의 구현체가 의존하고 있어 ViewModel과는 더더욱 거리가 먼 관계인데 API에서 발생하는 예외를 알고 있다는 것은 너무나도 구체적으로 알고 있는 것이 됩니다.
이 상황에서 API가 발생시키는 예외가 변한다면 ViewModel도 예외를 처리하기 위해 영향을 받게 됩니다.

서로 느슨한 결합을 만들고, 구체적인 사항을 모르게 만들고, 영향을 서로 덜 받도록 만들기 위해 적용한 아키텍처인데 의도와 다른 결과가 만들어지는 셈이죠.

코드적으로는 어떤 함수의 결과가 반환형 단 하나가 아닌, 예외까지 포함하여 두 가지가 되는 결과를 만들며 예외는 무엇이 발생할 지 모르기 때문에 예측이 어렵다는 것입니다.

종합하면 구체적인 구현과의 의존성, 레이어를 넘나드는 에러 전파, 함수 결과의 모호성과 같은 것들이 문제가 됩니다.

 

그럼 어떻게 해야함?

예외가 레이어를 넘나들면서 전파되지 않고 함수의 결과를 예측할 수 있게 만들어준다면 이 문제는 해결될 수 있습니다.

즉, API는 에러를 예외로써 발생시키는 것이 아닌, 함수의 결과로써 반환할 수 있게 만들고 API에 의존하는 Repository 구현체에서 이를 활용하면 ViewModel은 Repository에서 반환하는 결과만을 보고 성공과 실패의 로직을 수행하면 됩니다.

이를 위해 API 응답 모델을 정의하고 Retrofit에서 반환할 수 있게 만들어 봅시다.

 

API 응답 모델 정의

안드로이드에서 API의 에러는 다양한 상황에서 발생할 수 있습니다.
예를들면 인증 실패, 기기의 네트워크 연결 문제, 서비스의 비즈니스적인 에러 등이 있습니다.
이를 토대로 API 응답을 정의해보겠습니다.

sealed interface ApiResult<out T> {
    data class Success<T>(val data: T) : ApiResult<T>
    data class Service(val code: String, val message: String?) : ApiResult<Nothing>
    data class Http(val code: String?, val message: String?) : ApiResult<Nothing>
    data class Network(val cause: IOException) : ApiResult<Nothing>
    data class Unexpected(val cause: Throwable) : ApiResult<Nothing>
}

성공했을 때의 응답, 그리고 여러 에러 케이스를 의미하는 응답 모델을 정의했습니다.

Service는 API 호출은 성공이지만 서버의 로직상에서 실패한 경우를 의미합니다.
Http는 200, 204와 같은 성공 응답 외의 Http 에러 코드의 발생을 의미합니다.
Network는 기기의 네트워크 연결 실패, 타임아웃 등을 의미합니다. (타임아웃을 별도로 정의하는 것도 좋은 시도입니다.)
Unexpected는 그 외에 알 수 없는 예외로 발생하는 케이스를 의미합니다.

이제 Retrofit에서 API의 결과를 반환할 때 위 모델을 사용하게 만들어야 합니다.

 

Retrofit CallAdapter 구현

Retrofit Builder에는 addCallAdapterFactory라는 함수가 존재합니다.
이것은 retorift2.CallAdapter 인터페이스 구현체를 전달받을 수 있습니다.

package retrofit2;

public interface CallAdapter<R, T> {
    Type responseType();

    T adapt(Call<R> var1);

    public abstract static class Factory {
        public Factory() {
        }

        @Nullable
        public abstract CallAdapter<?, ?> get(Type var1, Annotation[] var2, Retrofit var3);

        protected static Type getParameterUpperBound(int index, ParameterizedType type) {
            return Utils.getParameterUpperBound(index, type);
        }

        protected static Class<?> getRawType(Type type) {
            return Utils.getRawType(type);
        }
    }
}

 

Type responseType() 함수는 어댑터가 HTTP 응답을 리턴값으로 변환하는 응답 타입을 반환합니다.
만약 Blog 라는 API 모델이 필요하다면 responseType 함수는 Blog의 타입을 반환하게 됩니다.

T adapt(Call<R> var1) 함수는 파라미터로 전달된 call을 T로 변환합니다.
여기서 Call은 Retrofit에서 네트워크 요청을 표현하는 가장 기본 단위로, HTTP 요청의 실행, 취소, 결과 반환 등의 역할을 수행합니다.

CallAdapter를 구현하기 전에 Retrofit에서 앞서 정의한 응답 모델로 동작할 수 있는 Call을 먼저 구현해서 Retrofit에서 네트워크 요청에서 사용할 수 있도록 해야합니다.

class ApiResultCall<T : Any>(
    private val call: Call<T>,
    private val successType: Type
) : Call<ApiResult<T>> {

    override fun execute(): Response<ApiResult<T>> {
        throw UnsupportedOperationException("ApiResultCall doesn't support execute")
    }
    override fun clone(): Call<ApiResult<T>> = ApiResultCall(call.clone(), successType)
    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()

    override fun enqueue(callback: Callback<ApiResult<T>>) = call.enqueue(
        object : Callback<T> {
            override fun onResponse(
                call: Call<T>,
                response: Response<T>
            ) = if (response.isSuccessful) {
                onSuccess(response)
            } else {
                onError(response)
            }

            override fun onFailure(
                call: Call<T>,
                t: Throwable
            ) = onFailure(t)

            private fun onSuccess(
                response: Response<T>
            ) {
                val body = response.body()
                val result = when {
                    body == null && successType == Unit::class.java -> {
                        @Suppress("UNCHECKED_CAST")
                        ApiResult.Success(Unit as T)
                    }
                    body == null -> {
                        ApiResult.Unexpected(cause = IllegalStateException("Body must be not null"))
                    }
                    else -> {
                        ApiResult.Success(body)
                    }
                }
                
                callback.onResponse(this@ApiResultCall, Response.success(result))
            }

            private fun onError(
                response: Response<T>
            ) {
                try {
                    val errorResponse = // 서비스에서 정의된 에러 모델을 파싱하세요.
                    val errorResult = if (errorResponse != null) {
                        ApiResult.Service(
                            code = errorResponse.code ?: "Unknown",
                            message = errorResponse.message
                        )
                    } else {
                        ApiResult.Http(
                            code = response.code().toString(),
                            message = response.message()
                        )
                    }
                    
                    callback.onResponse(
                        this@ApiResultCall,
                        Response.success(errorResult)
                    )
                } catch (e: Exception) {
                    callback.onResponse(
                        this@ApiResultCall,
                        Response.success(ApiResult.Unexpected(e))
                    )
                }
            }

            private fun onFailure(throwable: Throwable) {
                val response = when (throwable) {
                    is IOException -> ApiResult.Network(throwable)
                    else -> ApiResult.Unexpected(throwable)
                }
                
                callback.onResponse(this@ApiResultCall, Response.success(response))
            }
        }
    )
}

중요한 것은 enqueue 함수입니다. enqueue 함수는 Retrofit에서 요청을 백그라운드로 실행하고 성공, 실패에 대한 응답을 처리합니다.
따라서 우리는 enqueue함수에서 원래 API 응답의 성공, 실패에 따라 적절히 앞서 정의한 모델을 콜백할 수 있도록 구현합니다.

코드를 잘 보면, 응답이 에러인 경우에도 Response.success(response) 와 같이 성공으로 콜백하는 것을 볼 수 있습니다.
실패를 콜백 하면 Retrofit은 예외를 발생시키기 때문에 에러를 반환값으로 받을 수 없게 됩니다.
따라서 이 구현의 의도에 맞게 성공 응답으로 에러를 반환 받는 것 입니다.

이제 Call 객체를 CallAdapter에서 사용할 수 있게 다음과 같이 구현합니다.

class ApiResultCallAdapter<R: Any> @Inject constructor(
    private val successType: Type
) : CallAdapter<R, Call<ApiResult<R>>> {
    override fun responseType(): Type = successType

    override fun adapt(call: Call<R>): Call<ApiResult<R>> = ApiResultCall(call, successType)

    class Factory : CallAdapter.Factory() {
        override fun get(
            returnType: Type,
            annotations: Array<out Annotation>,
            retrofit: Retrofit
        ): CallAdapter<*, *>? {
            if (getRawType(returnType) != Call::class.java) {
                return null
            }

            check(returnType is ParameterizedType) {
                "return type must be parameterized as Call<Result<Foo>> or Call<Result<out Foo>>"
            }

            val responseType = getParameterUpperBound(0, returnType)
            if (getRawType(responseType) != ApiResult::class.java) {
                return null
            }

            check (responseType is ParameterizedType) {
                "Response must be parameterized as ApiResult<Foo> or ApiResult<out Foo>"
            }

            val successType = getParameterUpperBound(0, responseType)

            return ApiResultCallAdapter<Any>(successType)
        }
    }
}

사실 CallAdapter의 코드는 어느정도 규격화되어 있다고 보면 됩니다.
이제 이 CallAdapter를 Retrofit을 생성할 때 추가해주기만 하면 됩니다.

 

Retrofit CallAdapter 추가

Retrofit
    .Builder()
    .baseUrl(BASE_URL)
    .client(...)
    .addConverterFactory(...)
    // CallAdapter를 추가해줍니다.
    .addCallAdapterFactory(ApiResultCallAdapter.Factory())
    .build()
   
// API 인터페이스 예시
interface BlogApi {
    @POST("api/blog")
    suspend fun postBlog(
        @Body request: PostBlogRequest
    ): ApiResult<Blog>
}

Retrofit.Builder의 addCallAdapterFactory 함수에 전달하기만 하면 끝입니다.
그리고 해당 Retrofit에 의해 생성되는 API 인터페이스의 응답에 ApiResult<원하는 타입> 과 같이 응답 타입을 지정해주면 됩니다.

 

마치며

비즈니스가 커지면서 앱은 자연스럽게 복잡하고 다양한 문제에 직면하게 됩니다.
이런 문제 속에서도 앱이 안정적으로 동작하기 위해선 예측 불가능한 요소를 없애고 적절히 처리할 수 있는 구조가 필요합니다.

오늘 소개드린 방법을 통해 API 응답에서 발생할 수 있는 예외를 예측 가능한 요소로 바꾸고 적절한 처리를 할 수 있는 구조를 만듦으로써 더욱 안정적은 앱이 되시기를 희망합니다.

반응형