Skils/Android

[Android] - Interceptor를 이용한 Retrofit 에러 핸들링

재한 2024. 10. 19. 19:47

개요

매번 프로젝트를 진행할 때마다 네트워크 응답 처리에 대한 구조를 고민하곤 합니다. 
어떻게 하면 직관적인 코드로 깔끔하게 처리할지, 다른 사람이 이해하기 쉬울까를 고민하다 보니

매 프로젝트때마다 다른 에러 핸들링 코드가 만들어지는 것 같습니다.

이번 프로젝트에서는 조금 새롭게 Interceptor를 활용한 에러 핸들링 코드를 구현했고, 그에 대한 내용을 작성할까 합니다.

 

 

Interceptor

Interceptor의 역할은 다양하게 있는데, 주된 역할은 다음과 같습니다.

 

로깅

Retrofit을 사용한다면 반드시 들어봤거나, 사용해봤을 것이라 생각합니다.

addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY))

 

아마 네트워크 통신에 대한 로그를 확인하기 위해 Interceptor를 추가할 것입니다.

요청 가로 채기

저 같은 경우 요청 가로채기를 위해 Interceptor를 사용하는 경우는 한 가지가 있는데요,

바로 요청 헤더에 AccessToken을 넣어줄 때 사용했습니다.

class AccessTokenInterceptor
    @Inject
    constructor(
        private val tokenDataSource: TokenDataSource,
    ) : Interceptor {
        override fun intercept(chain: Interceptor.Chain): Response {
            val originalRequest = chain.request()
            val requestBuilder = originalRequest.newBuilder()
            val accessToken: String =
                runBlocking {
                    val token: String =
                        tokenDataSource.getAccessToken() ?: run {
                            ""
                        }
                    token
                }
            val request =
                requestBuilder
                    .header(
                        "Authorization",
                        accessToken,
                    ).build()
            return chain.proceed(request)
        }
    }

 

이처럼 Interceptor는 요청을 가로채어, 추가적인 행동을 취할 수 있습니다.

응답 가로채기

이제 해당 글의 목적인 응답 가로채기입니다.

응답을 가로채어, 내 입맛대로 응답 데이터나 상태를 수정할 수 있습니다.

 

Api의 리턴 타입을 설정할 때 성공과 실패에 대한 클래스 객체를 둘 다 열어 두는 것보단 하나의 타입으로 고정시키는 것이 처리를 하는데 용이하다고 생각했습니다.

왜냐하면 에러일 경우는 처리해야 할 작업이 꽤 명확하다고 생각했습니다.

  1. 에러 메시지 출력
  2. API 재요청

따라서 Api의 리턴 타입을 성공과 실패를 담는 상위의 클래스보단, API 통신이 성공할 경우 핸들링하는 것이 깔끔하다고 생각했습니다.

따라서 서버와의 공통된 객체인 BaseResponse를 구현하고, API  각 메서드의 반환타입으로 설정했습니다.

 @GET("api/user/me")
    suspend fun loadMyInfo(): BaseResponse<User>

data class BaseResponse<T>(
    val code: String,
    val data: T?,
    val message: String,
)

 

하지만 이럴 경우 서버에서 BaseResponse가 성공만을 담당한다면 에러가 발생할 경우 어떻게 처리할 것이냐가 문제였습니다.

서버에서 통신이 실패할 경우 반환되는 타입은 BaseResponse와는 다르기 때문이었습니다. 

data class ErrorResponse(
    val errorCode: String,
    val message: String,
)

 

따라서 에러가 발생할 경우 응답을 가로채 ErrorResponse의 객체로 바꿔주고, 해당 데이터를 내려보내줘야 했습니다.

 

반환타입이 다른데? 어떻게 내려보내줘야 할까에 대해서 고민을 했고, Exception으로 내려줘서 runCatching에서 처리하면 좋지 않을까?
그러면 BaseResponse와도 충돌이 나지 않고, 깔끔하게 처리할 수 있겠다 생각했습니다.

따라서 Custom Error인 ApiException과, 사용자 인증 오류인 RefreshTokenExpiredException을 구현해 줬습니다.

 

Custom Exception

class ApiException(
    override val message: String? = null,
    override val cause: Throwable? = null,
    val error: ErrorResponse,
) : IOException(message, cause)

class RefreshTokenExpiredException(
    val error: ErrorResponse,
    override val message: String? = null,
    override val cause: Throwable? = null,
) : IOException(message)

 

IoException을 상속받으면서, 추가로 서버에서 보내주는 Error 타입에 객체를 파라미터로 갖게 했습니다.

사실 이러한 네트워크 오류 처리가 부족해서 IOException으로 처리를 해버렸는데, 잘못된 부분이라고 생각이 듭니다.

ErrorHandlingInterceptor

class ErrorHandlingInterceptor : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain.request()
        try {
            val response = chain.proceed(request)
            if (response.isSuccessful) return response
            val errorBody = response.body?.string() ?: return response
            val errorResponse = Gson().fromJson(errorBody, ErrorResponse::class.java)
            errorResponse?.let {
                if (it.errorCode == "AU004") {
                    throw RefreshTokenExpiredException(it)
                }
            }
            throw ApiException(error = errorResponse)
        } catch (e: Throwable) {
            when (e) {
                is ApiException -> throw e
                is RefreshTokenExpiredException -> throw e
                is IOException -> throw IOException(e)

                else -> throw e
            }
        }
    }
}

 

응답 가로채기에서 Interceptor가 하는 일은 꽤 명확한데요, 

response의 상태코드가 200, 즉 성공했다면 그대로 응답을 반환합니다.

만약 실패했다면, Response의 body를 구현한 ErrorResponse의 객체로 직렬화합니다.

만약 errorCode가 서버에서 지정한 코드(사용자 인증 오류) 일 경우 RefreshTokenExpiredException을 던져줍니다.

아니라면 통상적인 서버 오류이기에 ApiException에 담아서 던집니다.

 

ApiResponse

이제 Api 통신의 결과를 핸들링해줘야 합니다.

저는 API의 응답 객체를 만들어 성공과 실패를 구현했습니다.

실패 안에서는 확장에 용이하게 에러를 구분했지만, 크게 사용하는 일은 없었습니다.

sealed class ApiResponse<out D> {
    data class Success<out D>(
        val data: D,
    ) : ApiResponse<D>()

    sealed class Error(
        open val errorCode: String = "",
        open val errorMessage: String = "",
    ) : ApiResponse<Nothing>() {
        data class ServerError(
            override val errorCode: String,
            override val errorMessage: String,
        ) : Error(errorCode, errorMessage)

        data class TokenError(
            override val errorCode: String,
            override val errorMessage: String,
        ) : Error(errorCode, errorMessage)

        data class NetworkError(
            override val errorMessage: String,
        ) : Error(errorMessage = errorMessage)

        data class UnknownError(
            override val errorMessage: String,
        ) : Error(errorMessage = errorMessage)
    }
}

성공일 경우는 반환타입인 data만을, 실패일 경우는 에서 메시지와 에러 코드를 가지는 클래스를 에러 클래스를 구현했습니다.

 

 

emitApiResponse

suspend fun <T> emitApiResponse(
    apiResponse: suspend () -> BaseResponse<T>,
    default: T,
): ApiResponse<T> =
    runCatching {
        apiResponse()
    }.fold(
        onSuccess = { result ->
            ApiResponse.Success(data = result.data ?: default)
        },
        onFailure = { e ->
            when (e) {
                is ApiException ->
                    ApiResponse.Error.ServerError(
                        errorCode = e.error.errorCode,
                        errorMessage = e.error.message,
                    )

                is RefreshTokenExpiredException -> {
                    ApiResponse.Error.TokenError(
                        errorMessage = e.error.message,
                        errorCode = e.error.errorCode,
                    )
                }

                is IOException ->
                    ApiResponse.Error.NetworkError(
                        errorMessage = e.message ?: "",
                    )

                else ->
                    ApiResponse.Error.UnknownError(
                        errorMessage = e.message ?: "",
                    )
            }
        },
    )

 

Api의 response를 핸들링하는 함수입니다.

fold를 통해서 성공과 실패 블록을 구분하고,  

성공한 경우는 ApiResponse.Success에 data를 담아서 반환합니다.

가끔 서버 통신에서 성공했지만, body가 null인 경우도 있어서 data 타입을 nullable로 지정하는 것보단 default 객체를 파라미터로 받아서 null 일경우 default 객체를 반환하도록 했습니다.

실패한 경우는 에러 타입에 따라 구분해서 반환해 줍니다.

 

사용법은 다음과 같습니다.

override suspend fun publishNft(request: PublishNftRequest): Flow<ApiResponse<Nft>> =
    flow {
        val response =
            emitApiResponse(apiResponse = { api.publishNft(request) }, default = Nft())
        emit(response)
    }

emitApiResponse의 반환타입이 ApiResponse이기에 해당 반환값을 emit 해주면 끝입니다.

 

하지만 추후 반환값을 전부 사용하지 않는 통신이 발생했고, 변환하는 작업이 필요해서, 

override suspend fun getUserNft(userId: Long): Flow<ApiResponse<List<MyFrame>>> =
    flow {
        val response =
            emitApiResponse(
                apiResponse = { api.getUserNft(userId) },
                default = GetMyFrameResponse(),
            )
        when (response) {
            is ApiResponse.Error -> emit(response)
            is ApiResponse.Success -> emit(ApiResponse.Success(response.data.content))
        }
    }

 

위 코드처럼 사용했습니다.

 

지금 와서 생각해 보면 mapper까지 넘겨줘서, mapper로 변환해서 깔끔하게 처리하는 것이 좋았다고 생각이 듭니다..ㅠ