훌륭한 개발자가 되기 위하여
클린아키텍처에서의 예외 처리하기 본문
예외 처리(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?
'안드로이드' 카테고리의 다른 글
| Jetpack Compose로 ViewModel 공유하기 (0) | 2025.11.13 |
|---|---|
| Jetpack Compose로 Custom Calendar 구현하기 (0) | 2025.10.26 |
| Paging 검색 기능에 Debounce를 적용하여 성능 개선하기 (0) | 2025.10.02 |
| Jetpack Compose에서 Paging3로 대용량 데이터 처리하기 (0) | 2025.09.18 |
| [JetpackCompose] Compose Side Effect (0) | 2025.08.14 |