기존 프로젝트는 Retrofit을 매번 모든 Api 통신 클래스 하위에 가지고 있었습니다.
또한 통신 과정에서 예외 처리하는 부분까지 반환되는 타입이 매번 다를 뿐 거의 유사했기 때문에
코드 관리 및 낭비를 줄이기 위한 정리가 필요했습니다.
API 통신 클래스들이 추상 클래스를 상속받는 형태로 처리를 진행했습니다.
먼저 각각의 API 통신 결과 타입은 항상 상이할 수 있기에 제네릭을 활용하여 값을 받을 Result Class를 만들어줍니다.
sealed class Result<out T> {
data class Success<out T>(val data: T) : Result<T>()
data class Error(val message: String? = "ERROR", val code: Int) : Result<Nothing>()
}
매번 다른 타입을 제네릭으로 처리해서 성공시에는 Success <T>를, api 통신에 실패할 경우에는 에러 메시지와 response code를 담아 Error()를 반환하도록 합니다. 제네릭 변환에는 실패했으므로 무의 의미인 코틀린의 Nothing으로 명시해 줍니다.
각 Api Task Class가 상속받을 추상 클래스를 생성합니다.
abstract class ApiTaskBase<T> {
protected abstract fun execute(): Result<T>
fun <I>getApi(apiClass: Class<I>, url: String = BASE_URL): I {
return Retrofit.Builder()
.baseUrl(url)
.client(getClient())
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(apiClass)
}
fun getClient(): OkHttpClient {
val builder = OkHttpClient.Builder()
.connectTimeout(120, TimeUnit.SECONDS)
.writeTimeout(120, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.authenticator(TokenAuthenticator())
.addInterceptor(RetryInterceptor())
if (BuildConfig.DEBUG) {
val interceptor = HttpLoggingInterceptor().apply {
this.level = HttpLoggingInterceptor.Level.BODY
}
builder.addInterceptor(interceptor)
}
return builder.build()
}
...
}
먼저 추상 클래스에는 매번 Retrofit과 OkHttpClient를 선언할 필요가 없도록 함수로 정리하여 처리가 됩니다.
getApi() 함수는 매번 API 호출 처리 시, getClient를 생성하게 됩니다.
그리고 매번 다른 구조로 정의되는 api interface를 Retrofit에 등록하기 위해 <I> 제네릭을 활용했습니다.
이런 구조에 따라 ApiTaskBase를 상속받는 하위 클래스들은 getApi()를 호출하는 것으로 처리 가능하겠습니다.
그리고 그런 개별 로직은 모두 abstract 메서드 execute()를 구현해 주는 역할을 하겠습니다.
각 api 호출 클래스에서는 해당 execute()를 호출함으로써 실제 api 호출이 발생할 예정입니다.
그러면 내부적으로 getApi를 거쳐 서버로부터 반환된 결과 값은 목적으로 한 타입에 따라 Result로 반환되겠죠.
class TestApiTask(private val id: Int): ApiTaskBase<Data?>() {
override fun execute(): Result<Data?> {
val response = getApi(TestApi::class.java).checkData(headers, TestBody(id)).execute()
if (response.isSuccessful) {
return Result.Success(response.body()?.data)
} else {
val errorType: NormalErrorResponse? = response.errorBody()?.parse<NormalErrorResponse>()
return Result.Error(errorType?.message, response.code())
}
}
interface TestApi {
@POST("api/test")
fun checkData(
@HeaderMap headers : HashMap<String, String>,
@Body testBody: TestBody
): Call<Data>
}
}
예시 코드로 execute()가 구현된 모습입니다.
api 통신에 성공 시에는 Result.Success를 실패했다면 erroType을 통해 변환을 거치고 Result.Error를 반환하도록 구성되었습니다.
ErrorResponse 타입의 구조는 서버와의 협의를 통해 일관성 있는 구조로 만들어서 활용할 수 있겠습니다.
그렇다면.. TimeOut 등 모종의 이유로 Exception이 발생했을 때에는 어떻게 처리가 되어야 할까요?
매번 호출 클래스에서 try ~ catch 든, coroutine exception 처리를 적용할 수도 있겠으나
이 부분도 ApiTaskBase에서 구성한 함수로 하나의 로직을 거쳐 중복 요소를 회피할 수 있습니다.
fun start(): Result<T> {
val result: Result<T> = try {
val task = execute()
if (task is Result.Error) {
val e = Exception("response code: ${task.code}")
checkApiTaskException(e, task.code)
Result.Error(e.message, task.code)
} else task
} catch (e: IOException) {
checkApiTaskException(e)
Result.Error(e.message, 500)
}
return result
}
Api 호출에 실패했거나 도중에 Exception이 발생했다면 FireBase Crashlytics 등 분석을 위해 로그 처리를 진행합니다.
그리고 try ~ catch는 앱 자체의 Crash를 방지합니다.
UI 레벨에서 아래와 같이 호출하면 Success, Error 상태에 따라 처리가 가능합니다.
CoroutineScope(Dispatchers.IO).launch {
val result = PurchaseItemApiTask(storeItem.id).executeSync()
when (result) {
is Result.Success -> {
}
is Result.Error -> {
}
}
}
네트워크 처리를 담당하는 Coroutine IO Dispatcher 기반으로 호출해 주면 됩니다.