안드로이드

[Android] 네트워크 예외 처리 (에러 핸들링)- Retrofit CallAdapter

easyhz 2024. 6. 16. 15:38

Retrofit

이 글은 레트로핏 라이브러리를 사용하고 있다.

레트로핏은 HTTP API에 대해 직접적인 조작 없이 인터페이스를 사용하여 쉽게 요청을 보낼 수 있고 응답 결과를 자바 오브젝트로 변환해 주는 라이브러리이다. 또한 코틀린을 사용한다면 API 호출 시 내부적으로 요청이 이루어지기 때문에 따로 콜백을 정의할 필요 없이 바로 응답 객체를 받을 수 있다.

네트워크 예외 처리

안드로이드 네트워크 예외 처리에는 여러가지 방법이 있을 수 있다.

필자는 retrofit 의 `Response`를 그대로 받아 처리하는 공통 `apiHandler`를 작성해 두어 처리를 했었다.

fun <T : Any> apiHandler(apiFunc: suspend () -> Response<T>): Flow</* 처리 타입 */> =
    flow {
        try {
            val res = apiFunc.invoke()
            if (res.isSuccessful) {
            	emit(/* 처리 타입의 success 처리 */)
            } else {
            	emit(/* 처리 타입의 error 처리 */)
            }
        } catch (e: Exception) {
        	emit(/* 처리 타입의 error 처리 */)
        }
    }.flowOn(Dispatchers.IO)

이렇게 사용하면서 느낀점은 다음과 같다

  • success, error 등 내가 상황에 맞게 원하는 타입으로 조절 가능
  • api를 받을 때 중복 코드 발생

api를 받을 때 중복 코드가 발생하는 것에 대해 살짝 불편함이 있었다. 그래서 찾은 방법은 Retrofit2의 `CallAdpaterFactory`를 추가하는 것이다.

Retrofit - CallAdapter

 

CallAdapter 알아보기 (적용 준비의 준비의 준비의 준비)

`CallAdapter`는 `Call<R>`을 T 타입으로 변환해 주는 인터페이스로, `CallAdapter.Factory`에 의해 인스턴스가 생성된다.

 

CallAdapter

`CallAdapter`는 두 개의 메서드를 가진다.

  • `responseType()` : 어댑터가 HTTP 응답을 자바 오브젝트로 변환할 때 반환값으로 지정할 타입을 리턴하는 메서드. ( `Call<T>`에 대한 responseType의 반환값은 T 타입 )
  • `adapt(Call <R> call)` : 메서드의 파라미터로 받은 call에게 작업을 위임하는 T 타입 인스턴스를 반환하는 메서드

 

CallAdapter.Factory

`CallAdapter`의 인스턴스를 생성하는 팩토리 클래스로, 레트로핏 서비스 메서드의 리턴 타입에 기반한 인스턴스를 생성한다.

서비스 인터페이스 메서드의 기반한 인스턴스의 정확한 의미를 파악하긴 힘들지만, 팩토리의 `get` 메서드에서 파라미터로 받는 returnType에 서비스 메서드의 리턴 타입이 전달된다는 것을 말하는 듯하다.

`CallAdapter.Factory`는 세 개의 메서드를 가진다.

  • `get`: 파라미터로 받은 returnType과 동일한 타입을 반환하는 서비스 메서드에 대한 CallAdapter 인스턴스를 반환한다.
  • `getParameterUpperBound`: type의 index 위치의 제네릭 파라미터에 대한 upper bound type을 반환한다.
    • getParameterUpperBound(1, Map <String,? extends T>)은 타입 T를 반환.
  • `getRawType` : type의 raw type을 반환한다. (raw type: 제네릭 파라미터가 생략된 타입. List <? extends T>의 raw type은 List를 말한다.)

 

응답 타입 작업하기 (적용 준비의 준비의 준비)

일단 필자는 리턴 타입을 Kotlin의 `Result` 로 받을 예정이다. 연습이기도 하고, `onSuccess` , `onFailure` 등의 메서드를 제공해 주기 때문에 `Result` 로 받을 것이다. (커스텀해서 쓰셔도 됩니당)

그리고 Error는 `throwable` 을 상속받은 커스텀 Error를 작업해 준다.

`AppError`는 다음과 같다.

sealed class AppError: Throwable() {
    data object UnexpectedError: AppError() {
        override fun printStackTrace() {
            Log.e("AppError", "예상치 못한 에러가 발생했습니다.")
        }
        override val message: String = "예상치 못한 에러가 발생했습니다."
    }
    data object NetworkError: AppError() {
        override fun printStackTrace() {
            Log.e("AppError", "네트워크 오류가 발생했습니다.")
        }
    }
}
  • `UnexpectedError` : 예상치 못한 에러. 서버의 응답을 받았지만 약속한 errorBody가 오지 않는 등의 경우에 사용
  • `NetworkError`: `IO Exception`에 따른 에러. 서버의 응답을 아예 받을 수 없을 때 사용.

그리고 위에서 정의한 `AppError` 를 상속한 `HttpError` 에러를 따로 정의한다.

sealed class HttpError : AppError() {
    /**
     * 400 Bad Request
     */
    data class BadRequestError(override val message: String) : HttpError()

    /**
     * 401 Unauthorized
     */
    data class UnauthorizedError(override val message: String) : HttpError()

    /**
     * 403 Forbidden
     */
    data class ForbiddenError(override val message: String) : HttpError()

    /**
     * 404 Not Found
     */
    data class NotFoundError(override val message: String) : HttpError()

    /**
     * 500 Internal Server Error
     */
    data class InternalServerError(override val message: String) : HttpError()
}

fun getErrorByStatusCode(statusCode: Int, message: String): AppError {
    return when (statusCode) {
        400 -> HttpError.BadRequestError(message = message)
        401 -> HttpError.UnauthorizedError(message = message)
        403 -> HttpError.ForbiddenError(message = message)
        404 -> HttpError.NotFoundError(message = message)
        500 -> HttpError.InternalServerError(message = message)
        else -> AppError.UnexpectedError
    }
}

http status 코드에 따른 Error를 만들어주었다.

이로써 적용 준비의 준비의 준비는 끝났다!

CallAdapter, CallAdapterFactory 작성하기 (적용 준비의 준비)

먼저 `CallAdapter` 를 상속받은 `ResultCallAdapter` 를 작성한다. (필자는 Result~ 로 네이밍 했다.)

그리고 `CallAdapter`의 타입에 응답 타입인 `Result <Type>` 으로 래핑 해주었다.

위에서 알아본 것처럼,

`responseType()` 은 `ResultCallAdapter`의 파라미터로 받은 타입을 반환값으로 지정해 주도록 작성하고,

`adapt`는 메서드의 파라미터로 받은 `call` 에게 작업을 위임하는 T 타입 인스턴스를 반환하도록 해준다.

class ResultCallAdapter(
    private val responseType: Type
): CallAdapter<Type, Call<Result<Type>>> {
    override fun responseType(): Type = responseType

    override fun adapt(call: Call<Type>): Call<Result<Type>> = ResultCall(call)
}

 

 

그리고 `Call` 을 상속받은 `ResultCall` 클래스를 작성한다.

`ResultCall`에 신경을 기울여야 할 곳은 `enqueue` 메서드이다. 나머지 메서드는 파라미터로 받은 기존의 `Call <T>` 인스턴스에게 작업을 위임한다.

private class ResultCall<T>(
    private val delegate: Call<T>
): Call<Result<T>> {
    override fun clone(): Call<Result<T>> = ResultCall(delegate.clone())

    override fun execute(): Response<Result<T>> = throw UnsupportedOperationException()

    override fun enqueue(callback: Callback<Result<T>>) { /* 아래에서 계속 */}

    override fun isExecuted(): Boolean = delegate.isExecuted

    override fun cancel() = delegate.cancel()

    override fun isCanceled(): Boolean = delegate.isCanceled

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

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

 

 

`enqueue` 메서드를 다시 보자

`callback`의 `response`의 결과로 내가 원하는 결과를 보내주면 된다.

모든 콜백에 `Response.success`를 호출하였다. 반환된 `Result` 객체에서 한 번 필터링을 거쳐 `failure` 로 보내주기 때문에 `success` 로 호출하였다.

override fun enqueue(callback: Callback<Result<T>>) {
        delegate.enqueue(object : Callback<T> {
            override fun onResponse(call: Call<T>, response: Response<T>) {
                callback.onResponse(this@ResultCall, Response.success(response.toResult()))
            }

            override fun onFailure(call: Call<T>, throwable: Throwable) {
                val failure = when(throwable) {
                    is IOException -> AppError.NetworkError
                    else -> AppError.UnexpectedError
                }

                callback.onResponse(this@ResultCall, Response.success(Result.failure(failure)))
            }

            private fun Response<T>.toResult(): Result<T> {
                val body = this.body()
                val errorBody = this.errorBody()?.string()
                if (this.isSuccessful) {
                    return if (body != null) {
                        Result.success(body)
                    } else {
                        Result.failure(AppError.UnexpectedError)
                    }
                }
                if (errorBody == null) {
                    return Result.failure(AppError.UnexpectedError)
                }
                try {
                    val errorResponse = fromJsonToErrorResponse(errorBody)

                    val httpError = getErrorByStatusCode(
                        statusCode = errorResponse.status.toInt(),
                        message = errorResponse.message
                    )

                    return Result.failure(httpError)
                } catch (e: Exception) {
                    return  Result.failure(AppError.UnexpectedError)
                }
            }

        })
    }

 

`Response <T>. toResult` 에 대해 자세히 보면,

`isSuccessful` 이면서 `body != null` 이면 `Result.success`에 결과 값을 보낸다.

`isSuccessful` 이면서 `body == null` 이면 `Result.failure` 에 예상치 못한 에러를 보낸다.

그리고 실패했을 때,

`errorBody == null` 이면 `Result.failure` 에 예상치 못한 에러를 보낸다.

그리고 `errorBody` 는 있지만, 약속한 에러 코드의 Json으로 받지 못했을 때는 catch로 잡아 `Result.failure(AppError.UnexpectedError)` 를 보낸다

예제에서 사용한 api는 에러 코드가 다음과 같다.

data class ErrorResponse(
    val status: String,
    val title: String,
    val type: String,
    val detail: String,
    val message: String
)

fun fromJsonToErrorResponse(json: String): ErrorResponse {
    return Gson().fromJson(json, ErrorResponse::class.java)
}

그리고 ResultCallAdapterFactory를 작성해 준다.

responseType로 추출한 Type 인스턴스로 CallAdapter를 생성하도록 작성하였다.

class ResultCallAdapterFactory: CallAdapter.Factory() {

    companion object {
        fun create() = ResultCallAdapterFactory()
    }

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

        val wrapperType = getParameterUpperBound(0, type as ParameterizedType)

        if (getRawType(wrapperType) != Result::class.java) return null

        val responseType = getParameterUpperBound(0, wrapperType as ParameterizedType)
        return ResultCallAdapter(responseType)
    }
}

적용 준비의 준비가 끝났다!

CallAdapter 적용하기 (적용 준비)

이제 `Retrofit`의 빌더에 적용하면 된다. 필자는 연습 예제로 https://dummyjson.com의 Mock HTTP Responses를 적용했다.

Retrofit.Builder()
        .client(client)
        .baseUrl("https://dummyjson.com")
        .addConverterFactory(GsonConverterFactory.create())
        .addConverterFactory(MoshiConverterFactory.create())
        .addCallAdapterFactory(ResultCallAdapterFactory.create()) // 이 부분
        .build()

처리 부분

`callAdapter`를 적용해서 `Result`로 래핑했기 때문에 `Result`처럼 처리해 주면 된다.

fun getNotFound() = viewModelScope.launch {
      mainRepository.fetchNotFound()
          .onSuccess {
              _resultState.value = it
          }
          .onFailure {
              _errorState.value = it.handleError()
              _resultState.value = null
          }
  }

  private fun Throwable.handleError(): String {
      return when(this) {
          is AppError.UnexpectedError -> "예상치 못한 에러에요.."
          is AppError.NetworkError -> "네트워크 에러가 떴어요.."
          is HttpError.BadRequestError -> "bad request"
          is HttpError.UnauthorizedError -> "unauthorized"
          is HttpError.ForbiddenError -> "Forbidden"
          is HttpError.NotFoundError -> "Not Found"
          is HttpError.InternalServerError -> "Internal Server Error"
          else -> "과연 .."
      }
  }

결과

간단한 화면으로 준비했다. 원하는 대로 잘 작동하는 것을 알 수 있다.

결과

 

이로써 네트워크 예외처리를 보다 효율적으로 관리할 수 있었다. 중복 코드를 줄이고, `try-catch `의 지옥에서 탈출할 수 있었다.

 

해당 코드는 깃허브에서 볼 수 있다.

https://github.com/EASYhz/NetworkErrorHandling

 

EASYhz/NetworkErrorHandling

NetworkErrorHandling Practice. Contribute to EASYhz/NetworkErrorHandling development by creating an account on GitHub.

github.com

 

 

 

참고자료

https://github.com/square/retrofit/blob/trunk/samples/src/main/java/com/example/retrofit/ErrorHandlingAdapter.java

 

retrofit/samples/src/main/java/com/example/retrofit/ErrorHandlingAdapter.java at trunk · square/retrofit

A type-safe HTTP client for Android and the JVM. Contribute to square/retrofit development by creating an account on GitHub.

github.com

https://blog.canopas.com/retrofit-effective-error-handling-with-kotlin-coroutine-and-result-api-405217e9a73d

 

Retrofit — Effective error handling with Kotlin Coroutine and Result API

Centralised and effective error handling with Retrofit, Kotlin Coroutine and Kotlin result API.

blog.canopas.com