본문 바로가기

Android/Tech

OkHttp3 Interceptor 를 통해 표준화된 응답의 에러 처리하기

Unsplash, Tim Foster.

동기

저는 보통 프로젝트 진행 시, 백엔드 개발자분께 '표준화된 형태로 성공 및 실패 응답을 반환해달라'고 요청하는 편입니다. 하나의 data class 를 통해 응답을 내려 받아 이에 대한 핸들링을 용이하게 하기 위해서입니다. 일관된 방식으로 요청의 성공 및 실패를 처리할 수 있으며, 더 나은 가독성을 가진 코드를 작성할 수 있게 되죠. 

 

이번에 새롭게 진행하게 된 프로젝트 역시 마찬가지로, 백엔드 동료분께 같은 요청을 드렸습니다. 오늘은 표준화된 JSON 에서 에러를 파싱하기 위해 수행한 과정에 대해 기록하고자 합니다.


응답 표준화의 목적

응답을 표준화하는 이유는 간단합니다. 요청의 성공 또는 실패에 대한 처리를 간편화, 일관화할 수 있습니다.

 

data class CTResponse<T>(
    val code: Int,
    val message: String,
    val data: T
)

 

이와 같이 data class 를 정의하면, 타입 파라미터 T 에 대한 정보만 넘겨주어 간편하게 통신할 수 있습니다. 요청에 성공하면 data 프로퍼티에 값이 할당됩니다. 요청 실패의 경우, code 및 message 프로퍼티를 활용해 핸들링할 수 있는 환경이 조성됩니다.

 

물론, 꼭 이와 같이 특정한 구조로 모든 응답을 일관화할 필요는 없습니다. 대신, 요청 실패에 대한 응답을 하나의 구조로 일관화하는 것은 꼭 필요합니다. 그래야 Interceptor 를 통한 처리가 가능하기 때문입니다.


OkHttp3 Interceptor 활용하기

Interceptor 는 실제 통신 전에 요청을 가로채어(Intercept) 정의한 작업을 수행할 수 있도록 합니다. Retrofit 에서 Interceptor 는 주로 'Authorization 을 위한 토큰 적용하기', 그리고 '응답 실패를 앱 크래시로 연결되지 않도록 방지하기' 등을 위해 사용됩니다.

 

다음 코드는 프로젝트에 적용한 에러 처리 Interceptor 소스입니다.

 

@ErrorInterceptor
@Singleton
@Provides
fun provideErrorInterceptor(
    gson: Gson
): Interceptor = Interceptor { chain ->
    try {
        val response = chain.proceed(chain.request())
        if (response.isSuccessful && response.body != null) {
            return@Interceptor response
        } else {
            val errorString = response.body?.string()
            val errorResponse = gson.fromJson(errorString, CTError::class.java)
            throw CTException(errorResponse)
        }
    } catch (exception: Exception) {
        throw CTException(CTError(-1, exception.message ?: "UNKNOWN_ERROR"))
    }
}

 

기본적으로 실패한 요청은 예외에 해당하기 때문에, try-catch 와 같은 예외 처리 솔루션을 사용하지 않으면 앱 크래시가 발생합니다.

 

response 변수에는 통신의 결과가 저장됩니다. 이후 요청이 성공적인지, 그렇지 않은지에 대한 판별이 이어지는데요, Response<T> 의 isSuccessful 은 응답 코드를 기준으로 결정됩니다. 200~299 사이의 코드가 주어지면 성공, 그렇지 않으면 실패입니다.

 

요청에 성공하여 응답을 받은 경우에는 그대로 response 를 반환합니다. 이후에는 지정한 직, 역직렬화 솔루션으로 이행됩니다. 소스에서는 Gson 을 사용하였습니다. 

 

요청에 실패한 경우에는 응답의 Body 에 접근하여 에러 메시지를 획득합니다.

 

{
    "code":400,
    "message":"token parsing error",
    "data":null
}

 

이와 같이 일반화된 JSON 의 형태로 에러가 주어지면, 직, 역직렬화 과정을 통해 이를 객체화하여 예외에 담아 던집니다. 예외 역시 별도의 클래스를 구성하면 좋은데요.

 

import java.io.IOException

class CTException(val ctError: CTError) : IOException()

 

원하는 타입의 객체를 담아 예외를 던질 수 있게 됩니다. 물론, IOException 에 에러 코드나 메시지만 담아서 예외를 던져도 무방합니다. 


UI 수준에서 처리하기 용이한 형태로 전달하기

응답을 일반화하는 것만으로는 에러를 UI 까지 전달하기 부족합니다. 요청의 성공 및 실패를 예상할 수 없으므로, 응답을 래핑하는 또 다른 클래스를 하나 생성해주어야 합니다. 저는 보통 다음과 같이 작성합니다.

 

sealed class ResponseState<out T> {
    data class OnSuccess<out T>(val data: T) : ResponseState<T>()
    data class OnFailure(val error: CTError) : ResponseState<Nothing>()

    inline fun handle(
        crossinline onSuccess: (T) -> Unit,
        crossinline onFailure: (CTError) -> Unit
    ) {
        when (this) {
            is OnSuccess -> {
                onSuccess(data)
            }

            is OnFailure -> {
                onFailure(error)
            }
        }
    }
}

 

요청에 성공하면 지정한 타입 파라미터 를 포함한 OnSuccess 객체를, 실패하면 Interceptor 에서 던지는 예외의 커스텀 에러 객체를 포함하는 OnFailure 객체를 반환합니다. handle 메서드는 ViewModel 수준에서 이를 처리할 때에 보일러 플레이트 코드를 줄이기 위해 구현한 메서드입니다. 

 

override suspend fun sendVerification(
    platform: String,
    token: String
): ResponseState<Verification> {
    return try {
        val response = mainApi.postVerification(
            VerificationRequest(
                platform = platform,
                token = token
            )
        )
        ResponseState.OnSuccess(response.data.toDomainModel())
    } catch (exception: CTException) {
        ResponseState.OnFailure(exception.ctError)
    }
}

 

Interceptor 에서 try-catch 문을 통해 발생한 예외를 Repository 수준에서 처리할 수 있는 Exception 으로 래핑하여 다시 던져주고 있으므로, 또 다시 한 번 try-catch 문을 사용하였습니다.

래핑한 예외에서 커스텀 에러 객체를 획득하여, ResponseState.OnFailure 객체에 담아 ViewModel 에 반환합니다.

 

private suspend fun serviceLogin(
    platform: String,
    token: String
) {
    verifyUserUseCase(
        platform = platform,
        token = token
    ).handle(
        onSuccess = { verification ->
            onSuccessServiceLogin(
                verification = verification,
                platform = platform
            )
        },
        onFailure = { error ->
            _uiEffect.emit(
                when(error.code) {
                    404 -> ShowSnackbar(R.string.404_login)
                    409 -> ShowSnackbar(R.string.409.login)
                    else > ShowDialog(getCommonErrorMessage(error.code))
                }
            )
        }
    )
}

 

앞선 과정을 통해, ViewModel 에서는 최소한의 분기 처리만 해주면 됩니다. 예시 속 verifiyUserUseCase 의 반환 타입은 ResponseState<Verification> 이므로, 이에 대한 처리를 진행해주면 됩니다. 

 

ViewModel 에서 when 키워드를 통해 분기 처리를 수행해도 좋겠지만, 앞서 첨부한 코드의 handle() 메서드와 같은 코드를 작성해두면 보다 간편하고 깔끔하게 응답을 처리할 수 있습니다. 지금 생각해보니, onFailure { } 에서 에러 객체를 넘기지 않고 code 만 넘기는 것도 좋겠단 생각이 드네요.

 

아무튼, 정상적인 실패 응답을 받은 경우에 대한 모든 처리가 가능해졌습니다.

추가적으로, 통신을 수행하는 CoroutineContext 에 CoroutineExceptionHandler 까지 추가하면, 요청과 별개로 해당 CoroutineScope 내에서 발생할 수 있는 모든 예외에 대한 처리가 가능합니다. 


최근에는 에러 처리에 큰 관심을 가지며 개발하고 있습니다. 기능이 잘 작동하는 것도 물론 중요하지만, 의도치 않은 문제 상황이 발생했을 때 유저에게 큰 불편(앱 크래시 등..)을 주지 않고 매끄럽게 진행될 수 있도록 하는 것도 몹시 중요하니까요.