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
참고자료
'안드로이드' 카테고리의 다른 글
[Android] IndicationNodeFactory 활용해 터치 인터랙션 적용하기 (0) | 2024.08.17 |
---|---|
[Android] 전략 패턴(Strategy Pattern)을 적용하여 소셜 로그인 리팩토링 하기 (0) | 2024.08.12 |
[Android] MVI 패턴을 알아볼까? (1) | 2024.06.07 |
[Android] 메인 화면 성능 개선하기 대작전 (1) | 2024.04.19 |
[Android] Animation Transition 시 이미지 로딩 개선하기 (0) | 2024.04.14 |