안드로이드 Retrofit + Coroutines의 API 응답 및 에러 핸들링 - Sandwich

앱 개발이 점차 복잡해지고 데이터 커뮤니케이션 횟수가 증가함에 따라 애플리케이션 아키텍처의 복잡성도 함께 증가합니다. 특히 multi-layered 아키텍처에서는 API 응답을 처리하는 방법에 따라 전체 아키텍처 디자인과 코드 복잡성이 결정됩니다.

이번 포스트에서는 Sandwich를 사용하여 multi-layered 아키텍처에서의 데이터 흐름과 Retrofit 응답 및 에러를 간결하고 명시적으로 다루는 방법에 대하여 살펴봅니다.

이번 포스트는 Retrofit과 Coroutines에 대한 선행지식이 요구됩니다.

Retrofit API Calls with Coroutines

본론에 들어가기 앞서 Retrofit과 Coroutines를 이용한 API 호출의 예제를 살펴보도록 하겠습니다.

interface PosterService {
    @GET("DisneyPosters.json")
    suspend fun fetchPosters(): List<Poster>
}

class PosterRemoteDataSource(
    private val posterService: PosterService
) {
    suspend operator fun invoke(): List<Poster> = try {
        posterService.fetchPosters()
    } catch (e: HttpException) {
        // error handling
        emptyList()
    } catch (e: Throwable) {
        // error handling
        emptyList()
    }
}

fetchPosters 함수는 네트워크를 통해 포스터 목록을 요청하고 PosterRemoteDataSourcefetchPosters 함수의 결과를 반환합니다. 정상적으로 데이터를 요청하고 try-catch를 사용하여 에러 처리를 하고 있습니다.

위의 예시를 multi-layered 아키텍처에 적용하면 API data의 흐름은 다음과 같습니다.

만약 API 응답의 제공자와 소비자가 일차원적으로 커뮤니케이션을 수행하는 single-layered 구조를 이루고 있다면 문제가 되지 않지만, 위와 같은 multi-layered 상황에서는 몇 가지 문제가 발생합니다.

1. 호출자 입장에서 API 결과의 모호성

함수는 단 하나의 결과만을 반환할 수 있습니다. 위의 예제에서는 try-catch를 통해 exception이 발생한 경우 반환형을 일치시키기 위해 empty list를 반환하고 있습니다. 하지만, API 응답의 body가 비어있는 경우 fetchPosters 함수 또한 empty list를 반환할 수 있습니다. 따라서 네트워크 요청이 실패한 경우 empty list 혹은 null을 반환한다면, domain 및 presentation과 같은 다른 레이어 계층들은 결과만 가지고 API 요청이 정상적으로 성공했는지 혹은 어떤 에러가 발생했는지 구체적으로 판단하기 어렵습니다.

2. 에러 핸들링을 위한 보일러 플레이트 코드

Retrofit API 호출은 다양한 exception을 발생시킬 수 있고 (e.g., IOException, HttpException, UnknownHostException), exception은 다른 레이어 계층으로 전파될 수 있기 때문에 레이어 계층 어딘가에서 예외를 반드시 처리해야만 합니다. 즉, 각 API 요청 마다 try-catch 및 exception 처리를 위한 보일러 플레이트 코드를 작성해야합니다.

3. 일차원적인 응답 처리

Domain/Presentation 레이어 계층에서는 API 요청에 대한 순수한 결과 값인 raw data만 전달받게 되고, API 요청마다 달라질 수 있는 반환 타입을 예측할 수 없습니다. 따라서 레이어 계층 사이에서의 복합적인 연산처리 및 데이터 가공이 어렵습니다.

How Could We Improve?

위의 문제를 해결하는 데는 정말 다양한 방법과 솔루션들이 존재하지만 가장 간단하고 명시적인 방법은, API 응답으로부터 발생 가능한 모든 시나리오를 wrapper class를 정의하고 감싸는 것입니다. 다음은 Retrofit API 호출을 통해서 발생할 수 있는 시나리오입니다.

Retrofit API 응답을 수신 여부에 따라 분류한다면 아래와 같이 크게 두 가지 시나리오로 분류할 수 있습니다.

  • Response: 네트워크로부터 정상적으로 응답을 수신한 시나리오입니다. Response는 다시 응답이 정상적으로 수행되었는지 (Success), 수행되지 않았는지 (Failure)에 따라 분류할 수 있습니다.
  • Exception: API 응답을 받 전, 예상치 못한 이유로 요청 및 응답 생성이 실패한 시나리오입니다. (e.g., IOException, UnKnownHostException)

위의 시나리오대로 API 응답을 분류하기 위해서는, Kotlin의 standard library에서 기본적으로 제공하는 Result를 사용하거나, 직접 sealed class를 작성할 수도 있습니다.

하지만, 여전히 각 API 요청마다 try-catch 및 exception 처리를 위한 보일러 플레이트 코드를 (e.g., runCatching) 작성해야 하는 문제가 남아있습니다.

Hello, Sandwich

위의 복합적인 문제들을 한 번에 해결하기 위해 Sandwich라는 라이브러리가 개발되었습니다. Sandwich는 Retrofit 응답 모델링 및 예외 처리에 들어가는 비용을 절감시켜주고, 개발자들이 비즈니스 로직에 집중할 수 있도록 하는 오픈소스 라이브러리입니다.

API 응답 시나리오를 모델링하는 ApiResponse, 글로벌 응답 및 에러 핸들링, Mapper, Operator를 제공하며 toLiveData, toFlow와 같은 다양한 호환성을 지원합니다.

먼저, Sandwich를 사용하기 위해 build.gradle 파일에 아래의 의존성을 추가합니다.

dependencies {
    implementation "com.github.skydoves:sandwich:1.2.4"
}

다음으로, Retrofit.Builder에 다음과 같이 call adapter factory를 추가합니다.

Retrofit.Builder()
    .baseUrl(BASE_URL)
    .addCallAdapterFactory(ApiResponseCallAdapterFactory.create()) // Here!
    .addConverterFactory(..)
    .build()

이제 Sandwich에서 제공하는 API 응답 모델인 ApiResponse를 통해 API 데이터 모델링 및 에러 핸들링을 할 수 있습니다. 아래의 예시와 같이 Retrofit service에서 ApiResponse를 사용하는 예시입니다. API의 반환 타입을 ApiResponse 타입으로 감싸고 리턴 타입으로 지정해 주면 모든 설정이 끝나게 됩니다.

interface PosterService {
    @GET("DisneyPosters.json")
    suspend fun fetchPosters(): ApiResponse<List<Poster>>
}

class PosterRemoteDataSource(
    private val posterService: PosterService
) {
    suspend operator fun invoke(): ApiResponse<List<Poster>> = 
        posterService.fetchPosters()
}

아래는 domain/presentation 레이어 계층에서의 API 응답 및 에러 핸들링의 예시입니다.

val response = posterRemoteDataSource.invoke()
response.onSuccess {
   // handles the success case.
   posterListLiveData.post(data)
}.onError {
   // handles the error cases.
}.onException {
   // handles the exceptional cases.
}

에러 핸들링을 위한 try-catch 표현식을 따로 사용하지 않아도 모든 에러 및 예외 처리를 ApiResponse.Failure 시나리오를 통해서 간결하게 처리 할 수 있습니다.

따라서 아래 그림과 같이 API 응답을 sealed class로 포장하거나 try-catch를 이용한 에러 핸들링을 별도의 레이어에서 해주지 않아도, ApiResponse 라는 응답 모델을 통해 각 시나리오를 레이어들로 전달할 수 있습니다.

ApiResponse

ApiResponse는 Sandwich에서 제공하는 Retrofit API 응답 모델입니다. ApiResponse는 Retrofit service에서 반환형으로 지정될 수 있으며 다음과 같이 총 3가지의 하위 타입이 존재합니다.

  • ApiResponse.Success: 네트워크 요청으로부터 정상적인 응답을 내려받은 경우를 의미합니다. 아래와 같이 body 데이터와 status code를 포함한 네트워크 응답에 대한 정보를 제공합니다. (e.g., 200 OK, 201 Created, 202 Accepted)
val data: List<Poster>? = response.data
val statusCode: StatusCode = response.statusCode
val headers: Headers = response.headers
  • ApiResponse.Failure.Error: 네트워크로부터 에러 응답을 내려받은 경우를 의미합니다. 아래와 같이 status code를 포함한 네트워크 에러에 대한 정보를 제공합니다. (e.g., 400 Bad Request, 401 Unauthorized, 403 Forbidden)
val message: String = response.message()
val errorBody: ResponseBody? = response.errorBody
val statusCode: StatusCode = response.statusCode
val headers: Headers = response.headers
  • ApiResponse.Failure.Exception: 네트워크로 응답을 받기 전, 예상치 못한 이유로 요청이 실패했음을 의미합니다. API 응답을 받거나 구성하기 전에 예외가 발생했기 때문에, exception에 대한 정보만 포함하고 있습니다. (e.g., 네트워크 미연결, 요청 생성하는 도중 예외 발생, 응답 처리 과정에서 예외 발생)
val message: String = response.message()

ApiResponse는 기본적으로 when 표현식을 사용하여 아래와 같이 모든 시나리오를 처리할 수 있습니다.

val response = posterRemoteDataSource.invoke()
when (response) {
  is ApiResponse.Success -> // 성공 응답 처리
  is ApiResponse.Failure.Error -> // 에러 응답 처리
  is ApiResponse.Failure.Exception -> // 예외 처리
}

또는 아래와 같이 onSuccess, onError, onException extension을 사용하여 연쇄적이고 선택적으로 API 응답 및 에러를 핸들링 할 수도 있습니다.

val response = posterRemoteDataSource.invoke()
response.onSuccess {
   // handles the success case.
   posterListLiveData.post(data)
}.onError {
   // handles the error cases.
}.onException {
   // handles the exceptional cases.
}

만약 onErroronException에 대하여 구분하지 않고, API 요청 실패에 대한 시나리오를 한 번에 핸들링하고 싶다면 onFailure extension을 사용할 수도 있습니다.

val response = posterRemoteDataSource.invoke()
response.onSuccess {
  // handles the success case.
}.onFailure {
  // handles the failure case.
}

ApiResponse and Flow

Flow에 data를 송출하는 작업은 반드시 Coroutines body 내에서 이루어져야 합니다. Sandwich는 이를 위해 suspend function을 사용할 수 있도록 suspendOnSuccess, suspendOnError, suspendOnException과 같은 extensions을 제공합니다.

아래의 예시와 같이 ApiResponse와 Coroutines의 Flow를 사용하여 복합적으로 응답을 핸들링 할 수 있습니다.

val response = mainRepository.fetchPosters()
response.suspendOnSuccess {
  _posterListFlow.emit(data)
}.onFailure {
  _toastFlow.emit(message)
}

혹은 data 및 domain layer에서만 ApiResponse를 처리하고 presentation layer에서 API 응답 모델에 대한 의존성을 최소화하고 싶은 경우는 다음과 같이 Flow를 생성하여 presentation layer에게 전달할 수 있습니다.

class DetailRepository @Inject constructor(
  private val pokedexClient: PokedexClient,
  private val pokemonInfoDao: PokemonInfoDao,
  private val ioDispatcher: CoroutineDispatcher
) : Repository {

  @WorkerThread
  fun fetchPokemonInfo(
    name: String,
    onError: (String?) -> Unit
  ) = flow<PokemonInfo> {
    val response = pokedexClient.fetchPokemonInfo(name = name)
    response.suspendOnSuccess {
      pokemonInfoDao.insertPokemonInfo(data)
      emit(data)
    }.onFailure { onError(this) }
  }.flowOn(ioDispatcher)
}

만약 API 성공 응답 처리에 대한 별도의 처리 없이 바로 Flow로 변환하고자 하는 경우에는 toFlow 메서드를 사용하여 간결하게 처리할 수 있습니다.

val response = pokedexClient.fetchPokemonList(page = page)
  .onFailure {
    // handling errors
  }.toFlow()

Sandwich의 예제 및 사용 사례와 관련하여 추가적인 내용이 궁금하시면 아래의 포스트를 참고해 보실 수 있습니다.

Conclusion

이번 포스트에서는 Sandwich를 활용하여 Retrofit API 응답 및 에러 핸들링 하는 방법에 대하여 살펴보았습니다.

Sandwich는 매달 글로벌한 프로젝트들로부터 수만 건의 다운로드가 집계되고 있습니다. 또한 Pokedex, DisneyMotions, Neko 등과 같은 오픈소스 프로젝트에서 사용 예시를 살펴보실 수 있습니다. 자세한 내용은 리파지토리의 README 파일을 참고해 주세요.

즐거운 코딩 되시길 바랍니다!

작성자 엄재웅 (skydoves)

좋은 웹페이지 즐겨찾기