[Android] 예외 처리 구조 설계 (Custom Exception, Call Adapter)

Thirfir·2025년 4월 29일
post-thumbnail

에러 핸들링

앱을 사용함에 있어서 항상 모든 동작을 성공시킬 수는 없다.
따라서 에러를 적절히 처리하는 것은 당연히 중요하다.

이 중요성을 깨달은 이후부터는 항상 에러를 놓치지 않도록 노력하고 있으며, 이 게시글은 그러한 노력 끝에 설계해보고 프로젝트에 실제 적용해 본 에러 핸들링 아키텍처에 대한 구성 내용이다.

이전엔...

이전 프로젝트에서는 예외 처리가 잘 이루어지지 않았다.
그 이유는 단순하다: 초보였기 때문이었다.

당시에는 기능 구현만으로도 벅찼기 때문에, 예외 처리까지 신경 쓸 여유가 없었다.
결국 예외 처리는 자연스럽게 소홀해졌고, 그로 인해 앱의 안정성도 떨어지게 되었다.

이런 경험을 하면서 한 가지를 깨달았다.
예외 처리가 누구나 쉽게 사용할 수 있는 구조라면, 초보여도 적은 이해로 자연스럽게 활용할 수 있을 것이라는 점이다.

초보자에게는 예외 처리가 체감되지 않는 작업이다.
그렇기 때문에 실수로 빠뜨리기도 쉽고, 작업 우선순위에서도 밀리게 된다.
결국 점점 더 소홀해지는 악순환이 반복된다.

그래서 이번 프로젝트에서는
예외 처리를 쉽게 사용할 수 있도록 아키텍처를 설계하는 것을 목표로 삼았다.
구조를 쉽게 만들어줌으로써 팀원들이 예외 처리에 자연스럽게 익숙해질 수 있도록 하고,
결과적으로 앱을 안정적으로 운영할 수 있도록 하고자 했다.

핵심 목표

결국 가장 중요하게 생각한 것은 사용할 때 편해야 하는 것이었다.
편하게 사용하도록 하기 위해서 중요한 것이 무엇이 있을까?

우선 첫 번째는, 기본 틀을 벗어나지 말자였다.
코틀린은 정말 쉽고 간편한 언어라고 생각한다. 지금껏 코틀린을 사용함에 있어서 불편함을 느낀 경험은 거의 전무하다.
이렇게 간단한 언어를 그대로 활용하지 않고 굳이 크게 바꿀 필요가 있을까?
이러한 생각을 바탕으로, 코틀린의 기본 기능을 최대한 활용하는 것첫 번째 목표였다.

두 번째 목표는, 분기 처리다.
Jetpack Compose를 사용하면서, 상태에 따른 UI 분기 처리 방식에 상당한 매력을 느껴왔다.
이 것처럼, 예외도 어떤 예외인지 쉽게 분기할 수 있도록 하는 것두 번째 목표였다.

목표1️⃣. 코틀린 활용

코틀린은 Result라는 강력한 클래스를 기본적으로 제공한다.
Result는 성공/실패를 하나의 타입으로 Wrapping 할 수 있게 해 주며, 성공 값과 실패 예외를 안전하게 다루는 다양한 유틸 함수들을 함께 제공한다. 이를 통해 개발자는 쉽고 간결하게 결과를 핸들링할 수 있다.

val result = try {
    Result.success(someReturn())
} catch(e: Exception) {
    Result.failure(e)
}

result.onSuccess {
    // 성공
}.onFailure {
    // 실패
}

특히, runCatching 함수를 이용해 try-catch를 간결화할 수 있는 것이 특징이다.

val result = runCatching {
    someReturn()
}

result.onSuccess {
    // 성공
}.onFailure {
    // 실패
}
public inline fun <R> runCatching(block: () -> R): Result<R> {
    return try {
        Result.success(block())
    } catch (e: Throwable) {
        Result.failure(e)
    }
}
runCatching 내부 구현

다만 runCatching을 그대로 사용할 경우 CancellableException을 다시 던지지 않고 그대로 잡아내므로 suspend 함수 사용 시에는 주의해야 한다.

Result 클래스만으로도 에러를 핸들링하는 것이 가능할 것으로 예상되었기에 이를 그대로 활용해보고자 하였다.

목표2️⃣. 분기 처리

어떤 에러냐에 따라서 다르게 피드백을 제시(다른 UI, 다른 에러 메시지 등)해주어야 할 수 있다. 그렇기에 에러를 분기하는 것은 중요한 요소라고 생각할 수 있다.

어떤 방식을 사용하면 에러를 쉽게 분기할 수 있을까?
이를 위한 방안으로 Custom Exception을 정의하는 것을 금방 떠올려볼 수 있었다.

간단한 예시로 로그인을 한다고 생각해보자.
로그인을 할 때 발생할 수 있는 에러는 간단하게 다음과 같은 것들을 떠올려 볼 수 있다.

  • 유효하지 않은 아이디 포맷
  • 유효하지 않은 비밀번호 포맷
  • 이미 존재하는 아이디
  • ...

이것들을 쉽게 분기할 수 있게 만든다면 각 상황마다 서로 다른 UI 피드백을 제공해주는 것도 어려움은 아닐 것이다.
다음과 같은 방식으로 말이다.

class InvalidIdFormat(): Throwable()
class InvalidPasswordFormat(): Throwable()
class AlreadyExistId(): Throawable()
...
최상위 예외 클래스인 Throwable을 상속하는 커스텀 예외들

특히 이것들을 위에서 언급한 Result와 묶는다면? 아래와 같이 간단한 구조를 만들어낼 수 있다.

fun login(id: String, password: String): Result<Unit> {
    return runCatching {
        server.login(id, password)
    }
}
val id = "thirfir"
val password = "123123"

val result = login(id, password)
result.onSuccess {
    // 로그인 성공
}.onFailure { e ->
    when(e) {
        is InvalidIdFormat -> ...
        is InvalidPasswordFormat -> ...
        is AlreadyExistId -> ...
    }
}

위 2가지 목표를 이루는 것으로 기본적인 틀은 마련이 되었다.
그렇지만 이것만 가지고는 에러는 해결되지 않는다. 에러가 어떻게 발생할 것인지는 아직 구체적으로 정의하지 않았기 때문이다.
(커스텀 예외 클래스는 어떤 에러가 발생할 것인지만을 정의한 것이다)

따라서 서버와의 통신 상황에서 발생한 에러를 넘겨주는 과정을 구현할 필요가 있다.

서버와의 통신 - OkHttp3, Retrofit2

서버 - 클라이언트간 HTTP 통신에는 통신 결과 코드를 나타내는 HTTP Status Code가 수반된다.
일반적으로 이 코드를 바탕으로 결과를 처리하게 되는데, HTTP 통신에서 2xx번대 에러는 응답 성공을, 4xx, 5xx번대 에러는 응답 실패를 나타낸다.

Retrofit Service

레트로핏을 통한 API 응답의 형태는 레트로핏 서비스에서 반환 타입을 어떻게 명시하느냐에 따라 다르다.

  • Call Wrapping
  • Response Wrapping
  • Wrapping X

나는 레트로핏 서비스 인터페이스에서 코루틴을 사용하는 것과 반환 타입을 그대로 명시하는 것을 선호한다. 다음과 같이 말이다.

interface SpotApi {	// 레트로핏 서비스
    @GET(.../.../...)
    suspend fun fetchSpotList(): SpotListResponse	// Wrapping 하지 않음
}

참고로 SpotResponse는 아래와 같은 형태이다. Json 파싱 라이브러리로는 Kotlin Serialization을 사용하였다.

@Serializable
data class SpotListResponse(
    @SerialName("spotList") val spotList: List<SpotResponse>
)

응답이 성공해서 2xx번대 Status code를 얻는다면, 레트로핏 서비스에서 명시한 반환 타입을 그대로 얻을 수 있다.
하지만 2xx번대 Status code가 아니라면, Response는 실패로 처리되게 되는데, 실패일 경우에는 서비스에서 명시한 반환 타입을 그대로 이용할 수 없다.

응답 실패 객체

Response 객체는 응답의 성공 실패와 관계없이 body를 갖게 되는데:

  • 성공일 경우: 원본 응답 Json
  • 실패일 경우: 에러 응답 Json

을 갖는다.

이때 수신하는 에러 응답의 형태는 당연하겠지만 서버가 정의하여 보내는 에러 객체의 형태를 따른다.

예를 들어, 우리 프로젝트에서는 아래와 같은 Json 형태로 에러 응답을 수신하였다.

{
  "code": 123,	// 에러 코드. Status Code와는 다름
  "message": "에러 메시지"
}

특히 중요한 것이 이 code라는 값이라고 생각한다.
Status Code만으로는 정확히 어떤 에러인지 파악하는 것에 한계가 있다.
그러므로 별개의 code를 또 협의하여 정하는 것으로 에러를 확실히 할 수 있다.
예를 들어 중복 아이디 에러는 40010번으로 정의한다던지 말이다.


그리고 이는 Kotlin Serialization으로 파싱하여 사용하였다.

@Serializable
data class NetworkErrorResponse(
    @SerialName("code") val code: Int,
    @SerialName("message") val message: String
)

그럼 이제 Response로 응답의 성공/실패와 관계없이 Json을 얻을 수 있는 걸 알았는데, Response는 결국 어디서 얻어서 사용할 수 있는 것일까?

Okhttp 인터셉터

이런 경우 활용할 수단으로 생각할 수 있었던 것이 바로 인터셉터다.
인터셉터에선 요청(Request)이나 응답(Response)을 가로채 중간에서 가공하거나 검사할 수 있다.

예를 들어, 요청에 공통 헤더를 추가하거나 응답에 추가적인 작업을 수행할 수 있고, 응답에 추가적인 작업을 수행할 때 Response 객체를 활용할 수 있다.

Interceptor {
    return Interceptor { chain: Interceptor.Chain ->
        val response = chain.proceed(chain.request())

        if (response.isSuccessful.not()) {
            val errorBody = response.body?.string()
            val errorResponse = try {
                errorBody?.let {
                    Json.decodeFromString<NetworkErrorResponse>(it)	// Kotlin Serialization을 이용한 파싱
                }
            } catch (e: Exception) {
                null
            }
             ... 실패 시 추가 작업
        }
        response
    }

그리고 레트로핏 객체를 생성할 때 인터셉터를 붙여준다.

val retrofit = Retrofit.Builder()
        ...
        .addInterceptor(...)	// 여기에 인터셉터 추가
        .build()

인터셉터를 활용하면 응답을 가공해 원하는 형태의 예외로도 던질 수 있다.

아래와 같이 말이다.

Interceptor {
    return Interceptor { chain: Interceptor.Chain ->
        val response = chain.proceed(chain.request())

        if (response.isSuccessful.not()) {
            val errorBody = response.body?.string()
            val errorResponse = try {
                errorBody?.let {
                    Json.decodeFromString<NetworkErrorResponse>(it)
                }
            } catch (e: Exception) {
                null
            }
             
             throw RemoteError(		// 에러를 던짐
                response = response,
                errorCode = errResp?.code ?: 0,
                message = errResp?.message ?: response.message(),
             )
                 
        }
        response
    }
data class RemoteError(
    val response: Response<*>,
    val errorCode: Int,
    override val message: String,
) : IOException(response) {

    val statusCode: Int = response.code()
    val httpErrorMessage: String = mapHttpError(statusCode)
}

private fun mapHttpError(code: Int) = when (code) {
    400 -> "Bad Request: 잘못된 요청입니다."
    401 -> "Unauthorized: 인증되지 않은 사용자입니다."
    403 -> "Forbidden: 접근 권한이 없습니다."
    404 -> "Not Found: 요청한 리소스를 찾을 수 없습니다."
    in 500 until 600 -> "Internal Server Error: 서버 내부 오류입니다."
    else -> "Unknown Error: 알 수 없는 오류입니다."
}

여기서 중요한 것은 errorCode를 던지는 것이었다.
errorCode를 바탕으로 어떤 에러인지 확실히 파악할 수 있기 때문이다.

그래서 이 errorCode를 바탕으로 어떤 에러인지 파악하고, 그에 일치하는 커스텀 에러를 다시 던지는 것이 주목적이었다.

다만 인터셉터에서 예외를 던질 경우, 던지는 예외는 반드시 IOException 타입이어야 한다(그렇지 않으면 앱이 죽어버린다). 이에 대한 개선책: CallAdapter 사용을 마지막에 정리하였다. Interceptor에 비해 사용이 복잡하지만, 더 자유롭게 결과를 다룰 수 있다는 큰 장점이 존재한다.

에러 코드 비교

커스텀 예외 클래스

커스텀 예외 클래스를 다시 살펴보자.
글의 서두에서 아래와 같은 간단한 형태의 커스텀 예외 클래스를 정의했다.

class InvalidIdFormat(): Throwable()
class InvalidPasswordFormat(): Throwable()
class AlreadyExistId(): Throawable()
...

그리고 이를 지금까지의 형태와 조합한다면 아래처럼 사용할 수 있다.
errorCode에 따라서 결과를 Result.failure로 Wrapping 해주는 것이다.

suspend fun login(id: String, password: String): Result<Unit> {
    return try {
        Result.success(server.login(id, password))
    } catch (e: RemoteError) {
        when(e.errorCode) {
            40008 -> Result.failure(InvalidIdFormat())
            40009 -> Result.failure(InvalidPasswordFormat())
            40010 -> Result.failure(AlreadyExistId())
            ...
        }
    } catch (e: Throwable) {
        Result.failure(e)
    }
}
val id = "thirfir"
val password = "123123"

val result = login(id, password)
result.onSuccess {
    // 로그인 성공
}.onFailure { e ->
    when(e) {
        is InvalidIdFormat -> ...
        is InvalidPasswordFormat -> ...
        is AlreadyExistId -> ...
    }
}

위와 같이 구현한다면 함수가 login 하나라면 괜찮겠지만... 그럴 일은 없다.
즉, 공통적인 부분인 try ~ catch 부분을 하나의 함수로 묶을 필요성을 느낄 수 있다.
특히, try~catch를 사용하는 곳에서 코드와 예외를 matching 시키는 것 (ex. 40008 -> InvalidIdFormat)이 형태가 불안정적이라는 생각을 할 수 있었다.

즉, 커스텀 예외를 선언하면서 그에 맞는 에러 코드를 같이 명시하는 것이 안정적이라고 판단하였다.
따라서 컴파일 타임에 커스텀 예외 정의와 에러 코드 명시를 강제할 필요가 있다고 생각하였고, 추상 클래스를 확장하는 아래와 같은 형태를 적용했다.

abstract class RootError: Throwable() {	// 커스텀 예외 클래스들의 부모가 될 추상클래스: RootError
    open val code: Int = 0
}

sealed class FetchSpotListError : RootError() {	// 예시 커스텀 예외 클래스

    class InvalidSpotType : FetchSpotListError() {
        override val code: Int = 40018
    }
    class InvalidCategory : FetchSpotListError() {
        override val code: Int = 40019
    }
    class InvalidOption : FetchSpotListError() {
        override val code: Int = 40020
    }
    class NonMatchingSpotTypeAndCategory : FetchSpotListError() {
        override val code: Int = 40021
    }
    class NonMatchingCategoryAndOption : FetchSpotListError() {
        override val code: Int = 40022
    }
    class OutOfServiceAreaError : FetchSpotListError() {
        override val code: Int = 40405
    }
}
enum을 사용할 수 있다면 override하지 않아도 되므로 더 편하겠지만, enum은 (인터페이스가 아닌) 클래스를 확장하지 못하도록 설계되어 있어 불가능했다.

이와 같은 방식으로 예외 정의와 코드 명시를 동시에 이루어내, 관리 용이성과 안정성을 높일 수 있었다.

Custom runCatching

그리고 try-catch를 커스텀하여 runCatchingWith라는 이름으로 함수화하였다.

internal inline fun <R> runCatchingWith(
    vararg definedErrors: RootError,	// 1. 커스텀 예외 객체들
    block: () -> R
): Result<R> {
    return try {
        Result.success(block())
    } catch (e: RemoteError) {	    // 2. Response를 가공하여 던지는 에러
        definedErrors.find { definedError ->
            e.errorCode == definedError.code	// 코드가 일치하면 코드에 해당하는 커스텀 예외 객체를 Result로 Wrapping.
        }?.let { Result.failure(it) } ?: Result.failure(e)
    } catch (e: CancellationException) {
        throw e
    } catch (e: Throwable) {
        Result.failure(e)
    }
}

흐름을 요약하면 다음과 같다.
1. 매개변수로 커스텀 예외 객체들을 전달받는다.
2. RemoteError를 catch한다.
3. RemoteError와 RootError의 에러 코드를 비교한다.
4. 에러 코드가 일치하는 예외 객체를 Result로 매핑하여 반환한다.
5. CancellationException은 다시 던진다. (코루틴)

다음과 같이 사용한다.

suspend fun fetchSpotList(...) : Result<List<Spot>> {
    return runCatchingWith(
      FetchSpotListError.InvalidSpotType(),
      FetchSpotListError.InvalidCategory(),
      ...
    ) {
    	// API 호출
    }
}

다만 보다시피 사용할 때 일일이 예외 객체들을 생성해주어야 하는데, 이는 실수를 유발할 가능성이 있으므로 조정할 필요가 있었다.
이를 위한 방식으로 팩토리 메서드를 구현하도록 커스텀 예외 클래스를 추가 설계하였다.

abstract class RootError: Throwable() {	// 커스텀 예외 클래스들의 부모가 될 추상클래스: RootError
    open val code: Int = 0

    companion object : ErrorFactory {
        override fun createErrorInstances(): Array<RootError> {
            throw NotImplementedError("createErrorInstances() 메서드가 재정의 되지 않았습니다.")
        }
    }
}

sealed class FetchSpotListError : RootError() {

    class InvalidSpotType : FetchSpotListError() {
        override val code: Int = 40018
    }
    class InvalidCategory : FetchSpotListError() {
        override val code: Int = 40019
    }
    class InvalidOption : FetchSpotListError() {
        override val code: Int = 40020
    }
    class NonMatchingSpotTypeAndCategory : FetchSpotListError() {
        override val code: Int = 40021
    }
    class NonMatchingCategoryAndOption : FetchSpotListError() {
        override val code: Int = 40022
    }
    class OutOfServiceAreaError : FetchSpotListError() {
        override val code: Int = 40405
    }

    companion object : ErrorFactory {
        override fun createErrorInstances(): Array<RootError> {
            return arrayOf(
                InvalidSpotType(),
                InvalidCategory(),
                InvalidOption(),
                NonMatchingSpotTypeAndCategory(),
                NonMatchingCategoryAndOption(),
                OutOfServiceAreaError()
            )
        }
    }
}

그러면 아래처럼 사용할 수 있다.

suspend fun fetchSpotList(
    latitude: Double,
    longitude: Double,
    condition: Condition,
): Result<List<Spot>> {
    return runCatchingWith(*FetchSpotListError.createErrorInstances()) {
        // API 호출
    }
}

리플렉션을 이용해 sealed로 묶인 예외 클래스들을 런타임에 생성하는 것도 시도해보았지만, 성능 이슈로 실제 적용에는 무리가 있었다(결과 반환에 1초 이상 소요).

실제 사용

이제 모든 게 끝났다. 이젠 에러를 적절히 사용하기만 하면 된다. Result의 onFailure 구문에서 에러를 분기하고, 필요에 따라 사용한다.

// ViewModel
suspend fun fetchSpotList(...) {
    spotRepository.fetchSpotList(...).onSuccess {
        ...
    }.onFailure { e ->
        when(e) {
            is FetchSpotListError.InvalidSpotType -> ...
            is FetchSpotListError.InvalidCategory -> ...
            ...
            else -> ...
        }
    }
}

우리 프로젝트는 클린 아키텍처 구조를 따랐고, ViewModel에서 Repository의 메서드를 호출하는 방식이 주를 이뤘다.

Call Adapter

인터셉터를 사용하는 것은 구현이 편리하다는 장점이 있었지만, 던지는 예외가 반드시 IOException이어야 한다는 문제가 있었다.
이는 활용에 있어서 큰 문제는 없지만, 예외를 원래 의도와 다르게 해석할 여지가 있었다.

Call Adapter란?

Retrofit에서 서버 응답을 원하는 타입으로 변환해주는 역할을 하는 컴포넌트다.
순서 상으로 응답 인터셉터 이후에 Call Adapting이 수행된다.
즉, API 응답 처리에 있어서 최종적인 위치에 있다.

응답을 단순히 가공하는 것에는 인터셉터와 큰 차이가 없지만, 예외를 던지는 것에서는 다르다.
인터셉터는 IOException만을 던질 수 있는 반면, Call Adapter는 예외에 제한을 두지 않는다.
따라서 원하는 타입의 예외를 던질 수 있다.

HttpException

인터셉터로 예외를 던질 때의 RemoteError의 타입은 IOException이었다.
이제 Call Adapter를 사용하는 것으로 RemoteError를 더 적합한 의도의 Exception으로 만들 수 있다.

data class RemoteError(
    val response: Response<*>,
    val errorCode: Int,
    override val message: String,
) : HttpException(response) {	// IOException -> HttpException으로 변경

    val statusCode: Int = response.code()
    val httpErrorMessage: String = mapHttpError(statusCode)
}

Call Adapter 구현

Call Adapter를 활용하는 것으로, Retrofit 객체에서 인터셉터를 제거하고 대신 아래의 CallAdapter를 연결한다.

import kotlinx.serialization.json.Json
import okhttp3.Request
import okio.Timeout
import retrofit2.Call
import retrofit2.CallAdapter
import retrofit2.Callback
import retrofit2.Response
import retrofit2.Retrofit
import java.lang.reflect.ParameterizedType
import java.lang.reflect.Type

class RemoteErrorCallAdapterFactory(
    private val json: Json = Json
) : CallAdapter.Factory() {

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

        val responseType = (returnType as ParameterizedType).actualTypeArguments[0]
        return RemoteErrorCallAdapter(responseType, json)
    }

    private class RemoteErrorCallAdapter<R>(
        private val responseType: Type, private val json: Json
    ) : CallAdapter<R, Call<R>> {

        override fun responseType(): Type = responseType

        override fun adapt(call: Call<R>): Call<R> {
            return object : Call<R> {
                override fun enqueue(callback: Callback<R>) {
                    call.enqueue(object : Callback<R> {
                        override fun onResponse(call: Call<R>, response: Response<R>) {
                            if (response.isSuccessful) {
                                if (response.body() != null)
                                    callback.onResponse(call, response)
                                else {
                                    callback.onFailure(
                                        call, RemoteError(
                                            response = response,
                                            errorCode = 0,
                                            message = "Empty body",
                                        )
                                    )
                                }
                            } else {
                                val errJson = response.errorBody()?.string()
                                val errResp = try {
                                    errJson?.let { json.decodeFromString<NetworkErrorResponse>(it) }
                                } catch (_: Exception) {
                                    null
                                }
                                callback.onFailure(
                                    call, RemoteError(
                                        response = response,
                                        errorCode = errResp?.code ?: 0,
                                        message = errResp?.message ?: response.message(),
                                    )
                                )
                            }
                        }

                        override fun onFailure(call: Call<R>, t: Throwable) {
                            callback.onFailure(call, t)
                        }
                    })
                }

                override fun execute(): Response<R> {
                    val response = call.execute()
                    if (response.isSuccessful) return response
                    val errJson = response.errorBody()?.string()
                    val errResp = try {
                        errJson?.let { json.decodeFromString<NetworkErrorResponse>(it) }
                    } catch (_: Exception) {
                        null
                    }
                    throw RemoteError(
                        response = response,
                        errorCode = errResp?.code ?: 0,
                        message = errResp?.message ?: response.message(),
                    )
                }

                override fun clone(): Call<R> = adapt(call.clone())
                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()
            }
        }
    }
}

여기서 중요하게 봐야할 부분은 adapt 내부의 enqueue 메서드 재정의이다.

기존 Retrofit의 Call은 enqueue 호출 시 서버로부터 응답을 받아 그대로 Callback으로 넘겨준다.
하지만 이 구조에서는 응답을 받았을 때 성공 여부를 판단하고, 실패라면 RemoteError로 가공해 Callback에 넘긴다.

enqueue에는 Callback에 대한 익명 클래스를 전달하게 되는데, 동작을 정리하면 다음과 같다.

  1. 응답이 성공(isSuccessful) + body가 존재하면 → 정상적으로 onResponse 호출
  2. 응답이 성공이지만 body가 없음 → RemoteError를 만들어 onFailure 호출
  3. 응답이 실패(4xx, 5xx) → 에러 응답 JSON을 파싱하여 RemoteError로 감싸 onFailure 호출

그리고 Throwable을 전달받은 onFailure는 그 예외를 그대로 던지도록 구현되어 있다. (항상 예외 객체를 원본 그대로 던지는 것은 아니지만, 적어도 이 글에서의 구현에서는 그대로 던진다)

즉,
"성공 응답만 통과시키고, 나머지는 전부 RemoteError로 변환해서 넘긴다."
이게 adapt 재정의의 핵심이다.

execute() 또한 재정의해두었으나, Retrofit의 suspend 함수 경로에서는 enqueue 기반 비동기 호출만 사용하기 때문에 실제로는 호출되지 않는다.


그리하여 이렇게 구현된 Call Adapter를 레트로핏 객체에 추가한다.

val retrofit = Retrofit.Builder()
			...
            .addCallAdapterFactory(RemoteErrorCallAdapterFactory(json))
            .build()

🔥 아키텍처 요약

아키텍처를 구현의 순서에 따라 요약하자면 다음과 같다.

1. 서버에서 보내주는 응답 실패 구조를 파악한다.

@Serializable
data class NetworkErrorResponse(
    @SerialName("code") val code: Int,
    @SerialName("message") val message: String
)

2. 정상 응답 실패 시 던질 에러를 정의한다.

data class RemoteError(
    val response: Response<*>,
    val errorCode: Int,
    override val message: String,
) : HttpException(response) {

    val statusCode: Int = response.code()
    val httpErrorMessage: String = mapHttpError(statusCode)
}

private fun mapHttpError(code: Int) = when (code) {
    400 -> "Bad Request: 잘못된 요청입니다."
    401 -> "Unauthorized: 인증되지 않은 사용자입니다."
    403 -> "Forbidden: 접근 권한이 없습니다."
    404 -> "Not Found: 요청한 리소스를 찾을 수 없습니다."
    in 500 until 600 -> "Internal Server Error: 서버 내부 오류입니다."
    else -> "Unknown Error: 알 수 없는 오류입니다."
}

3. Call Adapter를 구성한다.

Call Adapter에서는 응답 실패(400, 500번대 에러) 발생 시 커스텀한 에러를 던진다.

import kotlinx.serialization.json.Json
import okhttp3.Request
import okio.Timeout
import retrofit2.Call
import retrofit2.CallAdapter
import retrofit2.Callback
import retrofit2.Response
import retrofit2.Retrofit
import java.lang.reflect.ParameterizedType
import java.lang.reflect.Type

class RemoteErrorCallAdapterFactory(
    private val json: Json = Json
) : CallAdapter.Factory() {

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

        val responseType = (returnType as ParameterizedType).actualTypeArguments[0]
        return RemoteErrorCallAdapter(responseType, json)
    }

    private class RemoteErrorCallAdapter<R>(
        private val responseType: Type, private val json: Json
    ) : CallAdapter<R, Call<R>> {

        override fun responseType(): Type = responseType

        override fun adapt(call: Call<R>): Call<R> {
            return object : Call<R> {
                override fun enqueue(callback: Callback<R>) {
                    call.enqueue(object : Callback<R> {
                        override fun onResponse(call: Call<R>, response: Response<R>) {
                            if (response.isSuccessful) {
                                if (response.body() != null)
                                    callback.onResponse(call, response)
                                else {
                                    callback.onFailure(
                                        call, RemoteError(
                                            response = response,
                                            errorCode = 0,
                                            message = "Empty body",
                                        )
                                    )
                                }
                            } else {
                                val errJson = response.errorBody()?.string()
                                val errResp = try {
                                    errJson?.let { json.decodeFromString<NetworkErrorResponse>(it) }
                                } catch (_: Exception) {
                                    null
                                }
                                callback.onFailure(
                                    call, RemoteError(
                                        response = response,
                                        errorCode = errResp?.code ?: 0,
                                        message = errResp?.message ?: response.message(),
                                    )
                                )
                            }
                        }

                        override fun onFailure(call: Call<R>, t: Throwable) {
                            callback.onFailure(call, t)
                        }
                    })
                }

                override fun execute(): Response<R> {
                    val response = call.execute()
                    if (response.isSuccessful) return response
                    val errJson = response.errorBody()?.string()
                    val errResp = try {
                        errJson?.let { json.decodeFromString<NetworkErrorResponse>(it) }
                    } catch (_: Exception) {
                        null
                    }
                    throw RemoteError(
                        response = response,
                        errorCode = errResp?.code ?: 0,
                        message = errResp?.message ?: response.message(),
                    )
                }

                override fun clone(): Call<R> = adapt(call.clone())
                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()
            }
        }
    }
}

4. 실제 발생할 수 있는 에러들을 정의한다.

abstract class RootError: Throwable() {
    open val code: Int = 0

    companion object : ErrorFactory {
        override fun createErrorInstances(): Array<RootError> {
            throw NotImplementedError("createErrorInstances() 메서드가 재정의 되지 않았습니다.")
        }
    }
}

internal interface ErrorFactory {
    fun createErrorInstances(): Array<RootError>
}
에러들을 일관되게 처리할 부모 커스텀 에러 클래스 RootError
sealed class FetchSpotListError : RootError() {

    class InvalidSpotType : FetchSpotListError() {
        override val code: Int = 40018
    }
    class InvalidCategory : FetchSpotListError() {
        override val code: Int = 40019
    }
    class InvalidOption : FetchSpotListError() {
        override val code: Int = 40020
    }
    class NonMatchingSpotTypeAndCategory : FetchSpotListError() {
        override val code: Int = 40021
    }
    class NonMatchingCategoryAndOption : FetchSpotListError() {
        override val code: Int = 40022
    }
    class OutOfServiceAreaError : FetchSpotListError() {
        override val code: Int = 40405
    }

    companion object : ErrorFactory {
        override fun createErrorInstances(): Array<RootError> {
            return arrayOf(
                InvalidSpotType(),
                InvalidCategory(),
                InvalidOption(),
                NonMatchingSpotTypeAndCategory(),
                NonMatchingCategoryAndOption(),
                OutOfServiceAreaError()
            )
        }
    }
}
예시 커스텀 클래스. RootError를 확장한다.

5. 에러 클래스들을 catch하는 runCatching 함수를 새로 정의한다.

internal inline fun <R> runCatchingWith(
    vararg definedErrors: RootError,
    block: () -> R
): Result<R> {
    return try {
        Result.success(block())
    } catch (e: RemoteError) {
        definedErrors.find { definedError ->
            e.errorCode == definedError.code
        }?.let { Result.failure(it) } ?: Result.failure(e)
    } catch (e: CancellationException) {
        throw e
    } catch (e: Throwable) {
        Result.failure(e)
    }
}

6. 활용한다.

// Repository
suspend fun fetchSpotList(
    latitude: Double,
    longitude: Double,
    condition: Condition,
): Result<List<Spot>> {
    return runCatchingWith(*FetchSpotListError.createErrorInstances()) {
        spotRemoteDataSource.fetchSpotList(...).spotList.map { it.toSpot() }
	}
}
// ViewModel
suspend fun fetchSpotList(...) {
    spotRepository.fetchSpotList(...).onSuccess {
        ...
    }.onFailure { e ->
        when(e) {
            is FetchSpotListError.InvalidSpotType -> ...
            is FetchSpotListError.InvalidCategory -> ...
            ...
            else -> ...
        }
    }
}

끝...

개인적으로 사용하기 쉽게 만들었다고는 생각한다...
하지만 보완할 점은 분명히 있다고 생각한다.
가령 발생하지 않은 예외도 일단은 객체화가 된다는 점(createErrorInstances에 의해)이 해당될 수 있다고 생각한다.

그렇지만 이러한 전체적인 고민을 해보고 프로젝트에 적용해 볼 수 있었다는 점이 만족스럽다.

profile
안드로이드 개발자입니다.

0개의 댓글