가정

  • 멀티 모듈클린 아키텍처를 적용한 상태한 안드로이드 앱이다.
  • 네트워크 라이브러리로, okHttp3와, Retrofit2를 사용하고 있다.
  • 직렬화 라이브러리로 kotlinx.serialization을 사용하고 있다.
  • 서버 개발자와 http response에 대한, request를 충분히 논의하고 합의할 수 있는 상태이다.

네트워크 예외 처리를 할 때, 참여하는 모든 안드로이드 개발자가 일관되고 가독성이 높게 개발하려면 어떻게 해야 할까?

기준

  • 예외 처리 코드의 Indent 가 깊지 않고, 읽기 쉽게 한다.
  • data layer 와 domain layer의 예외(Exception)Presentation layer 에서 catch 가능하게 한다.
    • 이유: 유저에게 Error Event를 보여줘야 함으로
  • data layer의 예외는 data layer에서, throw 하게 한다.
  • 예외 이벤트의 이유를 세분화하여, 명확히 그 이유를 사용자에게 보여준다.
  • 중복 코드를 최대한 줄인다.

과정

  1. 서버 개발자와 협의 하여, success 한 상태가 아닌 상황에 대한 커스텀 예외와 Error 타입을 정의한다.

    • 345e1767-f2e7-4d1c-849a-165c05bdc397.png

    • 3b03232f-61f1-42d7-85c4-17377129c3c1.png

    • a2de43ca-38e4-4ef0-ae41-e26d841615e9.png

  2. OkHttp Interceptor를 활용해서, 발생 가능한, data layer의 네트워크 예외(IOException)를 throw 한다.

    • a29e7a8b-4e56-420a-ab21-fe253638850d.png

    • 1fe215f8-100f-4f01-a4bf-80a1ebe9149c.png

  3. viewModel에서, CoroutineExceptionHandler를 선언하고, 여기서 일관되게 Error에 대한 Event를 발생시킨다.

    • 유저에게 Event를 보여주는 것 외에, 다른 작업이 필요한 경우는 그에 맞게 다르게 처리해야 한다.
  4. Error Event 에 대해서, 유저에게 보여주는 방법은 일관되게 한다.

    • Fragment or Activity들 에서 Event 처리함수를 동일하게 사용하여, Error 이유를 유저에게 Toast or SnackBar로 보여준다.

예시 코드

ErrorResponse (data layer)

1
2
3
4
5
6
@Serializable
data class ErrorResponse(
    val message: String,
    val errorCode: Int = 0,
    val statusCode: Int = 0
)

SuccessResponse (data layer)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@Serializable
data class PlaylistResponse(
    val playlistId: Int,
    val playlistTitle: String,
    val trackSize: Int,
    val thumbnailUrl: String?
) {
    internal fun toDomain(): Playlist {
        return Playlist(
            id = playlistId,
            title = playlistTitle,
            thumbnailUrl = thumbnailUrl ?: "",
            trackSize = trackSize
        )
    }
}

Api or Service interface, (data layer)

1
2
3
4
5
6
7
@GET("playlists")
suspend fun getPlaylists(): List<PlaylistResponse>

@POST("playlists")
suspend fun postPlaylist(
    @Body title: PlaylistRequest
)

RepositoryImpl (data layer)

1
2
3
4
5
6
7
8
override fun getPlaylists(): Flow<List<Playlist>> = flow {
    val playlistResponse = playlistApi.getPlaylists()
    emit(playlistResponse.map { it.toDomain() })
}

override suspend fun postPlaylist(title: String) {
    playlistApi.postPlaylist(PlaylistRequest(title = title))
}

Repository (domain layer)

1
2
3
4
5
interface PlaylistRepository {
    fun getPlaylists(): Flow<List<Playlist>>

    suspend fun postPlaylist(title: String)
}

ViewModel (presentation layer)

79250c84-90e2-4cba-a28d-e99a293b2f0e.png

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
fun fetchPlaylists() { // flow 를 사용한 경우
    playlistRepository.getPlaylists().onEach { playlists ->
        _uiState.update { it.copy(playlists = playlists) }
    }.launchIn(viewModelScopeWithExceptionHandler)
}

fun createPlaylist(playlistTitle: String) { // suspend 함수를 사용한 경우
    viewModelScopeWithExceptionHandler.launch {
        playlistRepository.postPlaylist(playlistTitle)
    }
}

Fragment or Activity (presentation layer)

3d68dfbf-99a1-42e0-9ea0-714ff23d496f.png

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
private fun observeEvents() {
    repeatOnStarted {
        viewModel.events.collect { event ->
            when (event) {
                is PlaylistsEvent.ShowMessage -> {
                    showMessage(event.error.toMessageId())
                }
            }
        }
    }
}

fun showMessage(@StringRes messageId: Int) {
    Snackbar.make(this.requireView(), messageId, Snackbar.LENGTH_LONG).show()
}

참고 사항

  • BaseViewModel 를 추상 클래스로 선언하고, 추상 함수(onError) 을 상속 받은 viewModel 을 통해 중복된 코드를 줄일 수도 있다.

    • 70380064-bec3-4b55-afa3-e32308dc09db.png

    • c6164908-b052-457e-882d-8b3d682c9b9b.png

  • 네트워크 예외가 아닌, domain layer의 Usecase의 비지니스 로직에서 예외하는 것 까지 모두 일관되게 처리할 수 없다.

    • 이 경우는 viewModel의 exceptionHandler나 viewModel의 domain 호출 코드에서 예외처리를 그에 맞게 처리 해야 한다.

Reference