(Android/Retrofit) Retrofit + Kotlin Sealed class 에러 처리 패턴


1. 예외 기반 처리의 한계

try {
    val user = api.getUser(id)
} catch (e: HttpException) { ... } catch (e: IOException) { ... }

Sealed class로 결과를 타입으로 표현하면 when에서 모든 경우를 컴파일 타임에 강제할 수 있습니다.


2. 결과 모델링

sealed interface ApiResult<out T> {

    data class Success<T>(val data: T) : ApiResult<T>

    sealed interface Failure : ApiResult<Nothing> {
        data class Http(val code: Int, val message: String?) : Failure   // 4xx, 5xx
        data object Network : Failure                                    // 연결 불가, 타임아웃
        data class Unknown(val cause: Throwable) : Failure               // 파싱 오류 등
    }
}

필요하면 Unauthorized(401), NotFound(404) 같은 앱에서 의미 있는 에러를 별도 케이스로 추가합니다.


3. 수동 변환 — 공통 함수

suspend fun <T> apiCall(block: suspend () -> T): ApiResult<T> =
    try {
        ApiResult.Success(block())
    } catch (e: HttpException) {
        ApiResult.Failure.Http(e.code(), e.toApiError()?.message)
    } catch (e: IOException) {
        ApiResult.Failure.Network
    } catch (e: SerializationException) {
        ApiResult.Failure.Unknown(e)
    }

// Repository
suspend fun getUser(id: Long): ApiResult<User> = apiCall { api.getUser(id) }

4. CallAdapter — API 선언에서 바로 ApiResult 반환

Retrofit의 CallAdapter를 만들면 API 인터페이스 반환 타입에 ApiResult를 직접 쓸 수 있어 변환 코드가 사라집니다.

interface UserApi {
    @GET("users/{id}")
    suspend fun getUser(@Path("id") id: Long): ApiResult<User>    // 예외 대신 결과로 반환
}
class ApiResultCallAdapterFactory : CallAdapter.Factory() {

    override fun get(returnType: Type, annotations: Array<out Annotation>, retrofit: Retrofit): CallAdapter<*, *>? {
        // suspend fun은 Call<T>로 전달됨
        if (getRawType(returnType) != Call::class.java) return null
        val resultType = getParameterUpperBound(0, returnType as ParameterizedType)
        if (getRawType(resultType) != ApiResult::class.java) return null

        val successType = getParameterUpperBound(0, resultType as ParameterizedType)
        return ApiResultCallAdapter<Any>(successType)
    }
}

private class ApiResultCallAdapter<T>(private val successType: Type) :
    CallAdapter<T, Call<ApiResult<T>>> {

    override fun responseType(): Type = successType
    override fun adapt(call: Call<T>): Call<ApiResult<T>> = ApiResultCall(call)
}

private class ApiResultCall<T>(private val delegate: Call<T>) : Call<ApiResult<T>> {

    override fun enqueue(callback: Callback<ApiResult<T>>) {
        delegate.enqueue(object : Callback<T> {
            override fun onResponse(call: Call<T>, response: Response<T>) {
                val result: ApiResult<T> = if (response.isSuccessful) {
                    response.body()?.let { ApiResult.Success(it) }
                        ?: ApiResult.Failure.Unknown(IllegalStateException("빈 응답"))
                } else {
                    ApiResult.Failure.Http(response.code(), response.message())
                }
                callback.onResponse(this@ApiResultCall, Response.success(result))   // 항상 성공으로 감싸 전달
            }

            override fun onFailure(call: Call<T>, t: Throwable) {
                val result = if (t is IOException) ApiResult.Failure.Network
                             else ApiResult.Failure.Unknown(t)
                callback.onResponse(this@ApiResultCall, Response.success(result))
            }
        })
    }

    override fun clone() = ApiResultCall(delegate.clone())
    override fun execute(): Response<ApiResult<T>> = throw UnsupportedOperationException()
    override fun isExecuted() = delegate.isExecuted
    override fun cancel() = delegate.cancel()
    override fun isCanceled() = delegate.isCanceled
    override fun request(): Request = delegate.request()
    override fun timeout(): Timeout = delegate.timeout()
}
Retrofit.Builder()
    .baseUrl(BASE_URL)
    .addCallAdapterFactory(ApiResultCallAdapterFactory())
    .addConverterFactory(GsonConverterFactory.create())
    .build()

이 어댑터는 suspend 호출에서 예외 대신 결과를 돌려줍니다. 코루틴 취소는 delegate.cancel()로 전달되고, execute()는 지원하지 않아도 suspend 방식에서는 호출되지 않습니다.


5. 사용 — when으로 모든 경우 강제

@HiltViewModel
class UserViewModel @Inject constructor(private val api: UserApi) : ViewModel() {

    private val _state = MutableStateFlow<UiState>(UiState.Loading)
    val state = _state.asStateFlow()

    fun load(id: Long) = viewModelScope.launch {
        _state.value = when (val result = api.getUser(id)) {
            is ApiResult.Success -> UiState.Content(result.data)
            is ApiResult.Failure.Http -> when (result.code) {
                401 -> UiState.NeedLogin
                404 -> UiState.Empty
                else -> UiState.Error("서버 오류 (${result.code})")
            }
            ApiResult.Failure.Network -> UiState.Error("네트워크 연결을 확인해 주세요.")
            is ApiResult.Failure.Unknown -> UiState.Error("알 수 없는 오류가 발생했습니다.")
        }
    }
}

Failure에 새 케이스를 추가하면 처리하지 않은 모든 when에서 컴파일 에러가 나서 누락을 막습니다.


6. 결과 변환 확장 함수

inline fun <T, R> ApiResult<T>.map(transform: (T) -> R): ApiResult<R> = when (this) {
    is ApiResult.Success -> ApiResult.Success(transform(data))
    is ApiResult.Failure -> this           // 실패는 그대로 전달
}

inline fun <T> ApiResult<T>.onSuccess(block: (T) -> Unit): ApiResult<T> {
    if (this is ApiResult.Success) block(data); return this
}

api.getUser(1).map { it.toDomain() }       // DTO → 도메인 변환 후에도 에러는 유지

7. 정리



Related Posts