이번 프로젝트를 진행하면서 에러 핸들링을 편하게 하기 위해 적용했다.
원하는 응답값과 다른 값이 올 경우 Response를 뜯어서 예외처리를 해야했다. 하지만 CallAdapter를 사용하면 내가 만든 일관된 규칙에 따라 원하는 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에서 코드만 잘 작성해준다면!
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 함수를 실행시키는 이유는, 앱을 종료시키지 않고 그에 맞게 앱을 작동시키기 위함이다.
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로 변환하는 것이다.
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를 생성하여 반환한다.
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 -> { //... }
}
}
}
멧돼지가 기깔나게 설명해두었다. 혹시 이 글로 이해가 되지 않는 사람이 있다면 이 블로그 글을 참고하기 바람.