사이드프로젝트인 DMC2(동양미래대학교 신입생 도우미 앱)를 요새 리팩터링 중이었습니다.
아무래도 데드라인을 지키려고 노력하다보니 통신관련해서는 해피케이스(데이터가 잘 받아와 지는 경우)에만 집중하여 개발이 된 상태였습니다.
그래서 기술 부채로 남아있던 오류 처리를 하면서 고생한 흔적들을 미래의 저와 그리고 답답한 마음으로 이 글에서 도움을 받길 바라는 여러분들 을 위해 정리해보았습니다.
제가 결과적으로 이 오류 처리를 하면서 얻고 싶은 결과는 다음과 같습니다.
Clean Architecture인 만큼 Domain은 오류에 대한 자세한 내용은 모를 것이 모든 것을 고려해 보았을 때 Retrofit의 CallAdapter 기능과 Result를 활용하면 좋겠다는 판단이 들어서 적용해려 합니다.
본격적으로 적용하기에 앞서서 클린 아키텍쳐에서는 Repository의 인터페이스가 도메인 레이어 내에 존재합니다.
그러나 Repository의 구현체는 데이터 레이어에 존재합니다.
만약 Result를 CustomResult로 구현을 한다면 어떻게 될까요?
sealed class CustomResult<out T : Any> {
data class Success<T : Any>(val data: T?) : CustomResult<T>()
data class ApiError(val responseCode: Int, val error: String?) : CustomResult<Nothing>()
data class NetworkError(val exception: IOException) : CustomResult<Nothing>()
data class UnexpectedError(val exception: Throwable) : CustomResult<Nothing>()
}
이 CustomResult를 사용하는 Domain Repository interface는 어떨까요?
interface CardNewsRepository {
suspend fun getAllCardNews(): CustomResult<List<CardNews>>
}
또한 이러한 CustomResult를 위하여 도메인 레이어에서 다른 레이어에 접근할 수는 없으니 CustomResult를 도메인에 위치시켜야합니다.
하지만 그렇게 하게 된다면 도메인레이어는 다음코드를 보고 생각할 겁니다.
data class Success<T : Any>(val data: T?) : CustomResult<T>()
data class ApiError(val responseCode: Int, val error: String?) : CustomResult<Nothing>()
data class NetworkError(val exception: IOException) : CustomResult<Nothing>()
data class UnexpectedError(val exception: Throwable) : CustomResult<Nothing>()
도메인 레이어: 아 에러에는 Api에러도 있고 우리 Repository는 Network로 통신하겠구나!
저는 도메인레이어에서는 Repository를 통해 어떤 데이터를 보내고 받을 수 있다 정도로만 관심사가 있었음 하였습니다.
위에 정리해둔 것과 함께 다시 저의 목표를 정리해보겠습니다.
저는 사용자가 어떤 오류인지를 알아야하고, 때에 따라 오류시 화면이동 등을 만들어야 할 수 있으니 API Response 코드 조차도 분기할 수 있다면 좋겠다 생각이 들었습니다.
때문에 오류에 대한 상세한 내용을 Result가 포함하였음 합니다.
하지만 위에 말했듯이 Repository의 interface를 도메인이 들고 있는 만큼 해결 방법을 강구하다 결국 Kotlin의 Result를 사용하였습니다.
Kotlin Result는 Success(성공)와 Failure(실패)만 존재합니다.
이렇게만 보면 단순하게 실패는 실패한다는 한 가지 사실밖에 모르는 것 아닌 것인가 할 수 있어서 다양한 실패 상황에 대한 대처를 하지 못해보이지만, Failure는 다음과 같이 예외를 들고있을 수 있습니다.
@Suppress("INAPPLICABLE_JVM_NAME")
@InlineOnly
@JvmName("failure")
public inline fun <T> failure(exception: Throwable): Result<T> =
Result(createFailure(exception))
바로 이 exception을 활용하는 겁니다.
sealed class RemoteError : Exception() {
abstract fun toStringForUser(): String
abstract fun toStringForDeveloper(): String
}
data class ApiError(val responseCode: Int, val description: String? = null) : RemoteError() {
override fun toStringForUser(): String {
return "오류코드: $responseCode 서버에 일시적인 문제가 있습니다."
}
override fun toStringForDeveloper(): String {
return "responseCode: $responseCode - $description"
}
}
data class NetworkError(val exception: IOException, val description: String? = null) :
RemoteError() {
override fun toStringForUser(): String {
return "인터넷 연결을 확인해주세요."
}
override fun toStringForDeveloper(): String {
return "$exception"
}
}
data class UnexpectedError(val exception: Throwable, val description: String? = null) :
RemoteError() {
override fun toStringForUser(): String {
return "예기치 못한 오류가 발생하였습니다. \n잠시후 다시 시도해주세요."
}
override fun toStringForDeveloper(): String {
return "$exception $description"
}
}
이렇게
Exception을 상속한RemoteError를 정의해주었습니다.
또한 abstract fun으로 사용자 또는 개발자가 오류의 의미를 파악할 수 있도록 공통적으로 정의해주었습니다.
자 이제 사용자에게 오류에 대한 피드백을 주려합니다.
// ViewModel
fun fetchFoodRecommends(categoryIds: List<Int>, showMessage: (String) -> Unit) {
viewModelScope.launch {
foodRecommendRepository.getFoodRecommends(categoryIds)
.onSuccess {
_foodRecommends.value = it.map { it.toUiModel() }
}
.onFailure {
Log.d(
"${FoodRecommendCardsViewModel::class.simpleName}",
(it as RemoteError).toStringForDeveloper()
)
showMessage(it.toStringForUser())
}
}
}
// Activity
foodRecommendCardsViewModel.fetchFoodRecommends(categoryIds.toList(), ::onRemoteError)
private fun onRemoteError(message: String) {
showDefaultToast(this, message)
finish()
}
이렇게 제 목표는 충족 되었습니다.
CallAdapter를 따로 적용하는 것은 검색하면 비슷한 코드가 많이 나오지만 그래도 보시는 분들의 편의를 위해 남겨두겠습니다.
class DMC2CallAdapter<R : Any> private constructor(private val responseType: Type) :
CallAdapter<R, Call<Result<R>>> {
override fun responseType(): Type = responseType
override fun adapt(call: Call<R>): Call<Result<R>> = DMC2Call(call, responseType)
companion object CallAdapterFactory : CallAdapter.Factory() {
private const val RETURN_TYPE_IS_NOT_PARAMETERIZED_TYPE =
"Return type must be parameterized as Call<Result<Foo>> or Call<Result<out Foo>>"
private const val RESPONSE_MUST_BE_PARAMETERIZED = "" +
"Response must be parameterized as Result<Foo> or Result<out Foo>"
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_IS_NOT_PARAMETERIZED_TYPE
}
val responseType: Type = getParameterUpperBound(0, returnType)
if (getRawType(responseType) != Result::class.java) {
return null
}
check(responseType is ParameterizedType) {
RESPONSE_MUST_BE_PARAMETERIZED
}
val successBodyType = getParameterUpperBound(0, responseType)
return DMC2CallAdapter<Any>(successBodyType)
}
}
}
class DMC2Call<T : Any>(private val call: Call<T>, private val responseType: Type) :
Call<Result<T>> {
override fun clone(): Call<Result<T>> = DMC2Call(call.clone(), responseType)
override fun execute(): Response<Result<T>> {
throw UnsupportedOperationException(NOT_SUPPORT_EXECUTE)
}
override fun enqueue(callback: Callback<Result<T>>) {
call.enqueue(object : Callback<T> {
override fun onResponse(call: Call<T>, response: Response<T>) {
val networkResponse = when {
responseType == Unit::class -> Result.success(Unit as T)
!response.isSuccessful ->
Result.failure(
ApiError(
response.code(),
response.errorBody()?.toString()
)
)
response.body() == null ->
Result.failure(UnexpectedError(NullPointerException(RESPONSE_BODY_IS_NULL)))
else -> Result.success(response.body()!!)
}
callback.onResponse(this@DMC2Call, Response.success(networkResponse))
}
override fun onFailure(call: Call<T>, t: Throwable) {
val networkResponse: Result<T> = when (t) {
is IOException ->
Result.failure(
NetworkError(t, FAILED_TO_CONNECT_TO_SERVER)
)
else -> Result.failure(UnexpectedError(t, UNEXPECTED_ERROR))
}
callback.onResponse(this@DMC2Call, Response.success(networkResponse))
}
})
}
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()
companion object {
private const val NOT_SUPPORT_EXECUTE = "DMC2Call은 execute를 지원하지 않습니다."
private const val RESPONSE_BODY_IS_NULL = "응답이 비어있습니다."
private const val FAILED_TO_CONNECT_TO_SERVER = "인터넷 연결을 확인해주세요. :)"
private const val UNEXPECTED_ERROR = "에상치 못한 오류가 발생하였습니다."
}
}