Notice
Recent Posts
Recent Comments
«   2026/07   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Tags
more
Archives
Today
Total
관리 메뉴

훌륭한 개발자가 되기 위하여

클린아키텍처에서의 예외 처리하기 본문

안드로이드

클린아키텍처에서의 예외 처리하기

jay20033 2025. 11. 28. 17:13

예외 처리(exception handling)란?

프로그램 실행 중에 발생하는 비정상적이거나 예외적인 상황에 대응하는 과정이다.

예외는 네트워크 오류, 서버 오류, 데이터 파싱 실패 등 개발자가 완전히 통제할 수 없는 상황에서 주로 발생한다.

[예외란?] https://jay20033.tistory.com/68 

 

예외 처리가 필요한 이유?

  • 프로그램의 비정상 종료를 막고, 오류 상황에 맞는 대응을 할 수 있기 때문이다.
  • 예외 처리가 없다면, 앱이 갑자기 종료되거나 개발자만 이해할 수 있는 에러 메시지가 그대로 노출된다.

💥기존 방식의 문제점

프로젝트에는 이미 클린 아키텍처 구조가 적용되어 있었다.

계층 분리는 되어 있었지만, 예외 처리 관점에서는 발생한 Exception을 그대로 UI에 노출하는 방식으로 처리하고 있었다.

is DataResource.Error -> {
    _uiState.update { state -> state.copy(isLoading = false) }
    _uiEvent.emit(
        UIEvent.ShowSnackBar(
            resource.throwable.message ?: "운동 상세 정보 불러오기 실패"
        )
    )
}

😁개선 방향 : Exception Handler 도입

이 문제를 해결하기 위해 예외를 계층별로 변환하였다.

현재 아키텍처 구조 : Presentation(ViewModel) ➡️ Domain ⬅️ Data ⬅️ Remote, Local

  • Remote, Local 모듈에서 기술적인 예외 발생
  • Data 모듈에서 이를 Custom Exception으로 매핑
  • Domain 모듈에서 비즈니스 관점의 에러만 전달
  • Presentation 모듈에서 사용자 메시지로 변환

Custom Exception 정의

sealed class DomainException : Exception() {
    object NoInternet : DomainException()
    object Timeout : DomainException()
    object ServiceUnavailable : DomainException()
    ...
    data class Unknown(val origin: Throwable) : DomainException()
}
sealed class RemoteException : Exception() {
    object NoInternetConnection : RemoteException()
    object Timeout : RemoteException()
    ...
    data class HttpError(val code: Int, override val message: String) : RemoteException()
    data class Unknown(val origin: Throwable) : RemoteException()
}
sealed class LocalException : Exception() {
    data class DBError(val origin: Throwable) : LocalException()
    object TransactionFailed : LocalException()
    object DiskFull : LocalException()
    ...
    data class Unknown(val origin: Throwable) : LocalException()
}

Exception Handler(Mapper)

Remote ➡️ Data

class RemoteExceptionMapper @Inject constructor() {
    fun map(e: Throwable): RemoteException {
        return when (e) {
            is HttpException -> RemoteException.HttpError(e.code(), e.message())
            is UnknownHostException, is IOException -> RemoteException.NoInternetConnection
            ...
            is FirebaseFirestoreException -> mapFirestore(e)
            else -> RemoteException.Unknown(e)
        }
    }

    private fun mapFirestore(e: FirebaseFirestoreException): RemoteException.FirebaseFirestore {
        return when (e.code) {
            FirebaseFirestoreException.Code.UNAVAILABLE -> RemoteException.FirebaseFirestore.Offline
            FirebaseFirestoreException.Code.PERMISSION_DENIED -> RemoteException.FirebaseFirestore.PermissionDenied
            ...
            else -> RemoteException.FirebaseFirestore.Unknown(e)
        }
    }
}

Local ➡️ Data

class LocalExceptionMapper @Inject constructor() {
    fun map(e: Throwable): LocalException {
        return when (e) {
            is SQLiteFullException -> LocalException.DiskFull
            is SQLiteConstraintException, is SQLiteException -> LocalException.DBError(e)
            ...
            else -> LocalException.Unknown(e)
        }
    }
}

Data ➡️ Domain

class DataExceptionMapper @Inject constructor() {

    fun map(e: Throwable): DomainException {
        return when (e) {
            is RemoteException -> mapRemote(e)
            is LocalException -> mapLocal(e)
            else -> DomainException.Unknown(e)
        }
    }

    private fun mapRemote(e: RemoteException): DomainException {
        return when (e) {
            is RemoteException.HttpError -> handleHttpException(e.code)
            is RemoteException.NoInternetConnection -> DomainException.NoInternet
            ...
            is RemoteException.Unknown -> DomainException.Unknown(e.origin)
        }
    }

    private fun mapLocal(e: LocalException): DomainException {
        return when (e) {
            is LocalException.EmptyResult -> DomainException.NotFound
            is LocalException.DBError -> DomainException.DBError
            ...
            is LocalException.Unknown -> DomainException.Unknown(e.origin)
        }
    }

    private fun handleHttpException(code: Int): DomainException {
        return when (code) {
            400 -> DomainException.BadRequest
            401 -> DomainException.Unauthenticated
            403 -> DomainException.Forbidden
            404 -> DomainException.NotFound
            ...
            else -> DomainException.Unknown(
                IllegalStateException("Unknown HTTP code: $code")
            )
        }
    }
}

UI 계층에서의 처리

Presentation 계층(ViewModel, UI)은 Exception을 직접 분기하거나 해석하지 않는다.

UI의 역할은 전달받은 오류를 어떻게 사용자에게 표현할지 결정하는 것이다.

 

예외 변환 흐름

Throwable ➡️ DomainException ➡️ UiError ➡️ UiText ➡️ UI

 

DomainException ➡️ UiError

fun DomainException.toUIError(): UiError = when (this) {
    // Network
    is DomainException.NoInternet -> UiError.Network.NoInternet
    is DomainException.Timeout -> UiError.Network.Timeout
    is DomainException.ServiceUnavailable -> UiError.Network.ServiceUnavailable
    ...

    // Local
    is DomainException.DBError -> UiError.Local.DBError
    is DomainException.DiskFull -> UiError.Local.DiskFull
    ...
}

👉 이 변환을 통해 UI는 기술적인 예외나 비즈니스 규칙을 알 필요가 없어진다.

 

UiError ➡️ UiText

fun UiError.asUIText(): UiText {
    return when (this) {
        UiError.Network.NoInternet ->
            UiText.StringResource(R.string.error_no_internet)

        UiError.Network.Timeout ->
            UiText.StringResource(R.string.error_timeout)

        UiError.Network.ServiceUnavailable ->
            UiText.StringResource(R.string.error_service_unavailable)
       ...
}

ViewModel에서 사용하기

is DataResource.Error -> {
    _uiState.update { it.copy(isLoading = false) }
    _uiEvent.emit(UIEvent.ShowSnackBar(resource.throwable.toUIText()))
}

👉 ViewModel은 예외의 종류를 판단하지 않고 단순히 UiText를 UI 이벤트로 전달한다.

👉 "무엇을 보여줄지"만 신경 쓴다.

 

최종적으로 얻은 이점

이번 개선의 핵심은 "예외를 그대로 UI까지 전달하지 않는다는 것"이다.

  • 계층 간 책임이 명확해지고 UI가 기술적인 세부 구현으로부터 완전히 분리되었다.
  • 예외 추가 / 변경 시 영향 범위가 명확해졌다.
  • 사용자에게 항상 의도된 메시지만 노출 할 수 있게 되었다.

참고자료

https://proandroiddev.com/android-error-handling-in-clean-architecture-844a7fc0dc03

https://rkdrkd-history.tistory.com/55

https://medium.com/dmsfordsm/clean-architecture-errorhandling-in-android-48eaef35010c?