Android 개발/Kotlin

코틀린 sealed Result 패턴으로 try/catch 좀비를 잡은 3주

stackD 2026. 7. 7. 18:00
반응형

 

에러를 던지지 마세요. 값으로 들고 다니세요. try/catch를 줄였더니 오히려 에러가 또렷하게 보이기 시작했어요.

 

지난 5월에 사이드 프로젝트 피드 화면을 새로 짜다가, 어떤 실패든 화면엔 "일시적인 문제가 발생했어요" 한 줄로 뭉개지는 걸 봤습니다. 401인지 네트워크 끊김인지 JSON 파싱 실패인지 구분이 안 됐어요. 안드로이드 스튜디오(Android Studio) 로그캣(Logcat)을 한참 들여다본 뒤에야 깨달았는데, 문제는 catch 블록 안이 아니라 catch 블록이 받는 타입이었습니다.

 

try/catch와 kotlin.Result가 '알 수 없는 오류'를 만드는 이유

catch (e: Exception) 한 줄로 묶으면 retrofit2.HttpException(401)이든 JsonDataException이든 같은 자리에 떨어집니다. ViewModel은 토큰을 다시 받아야 할지, 재시도 버튼만 보여줄지 판단할 정보가 없어요. 에러의 의미가 catch 진입 순간 날아가버립니다.

 

runCatching은 더 미묘한 함정이 있더라고요. CancellationException까지 잡아버리는데, 코루틴 취소는 예외 형태로 전파되는 게 정상이라 이걸 삼키면 화면을 벗어난 뒤에도 백그라운드 작업이 계속 도는 좀비 코루틴이 생깁니다. 저는 이걸 잡느라 사흘을 썼어요.

 

테스트도 약해지지요. exception.message.contains("timeout") 같은 문자열 매칭은 Retrofit 버전 한 번 올리면 그대로 깨지거든요. 메시지가 아니라 타입으로 비교할 수 있어야 합니다.

 

 

sealed class로 Success·Failure를 값으로 모델링한 첫 설계

핵심은 에러를 흐름 제어가 아니라 값으로 들고 다니는 것이었어요. 성공·실패 둘 다 제네릭으로 받는 컨테이너부터 잡았습니다.

 

sealed interface Result<out T, out E> {
    data class Success<T>(val data: T) : Result<T, Nothing>
    data class Failure<E>(val error: E) : Result<Nothing, E>
}

sealed interface AppError {
    data object Network : AppError
    data object Unauthorized : AppError
    data class Server(val code: Int) : AppError
    data class Unknown(val cause: Throwable) : AppError
}

 

로딩 상태는 일부러 Result 안에 안 넣었습니다. 로딩은 UI 관심사라 ViewModel의 UiState에만 두고, Repository가 돌려주는 Result는 성공·실패 둘로만 끊었어요. 책임을 두 층으로 나눈 셈입니다.

 

이렇게 깔아두니까 when 표현식이 컴파일 시점에 에러 케이스 누락을 잡아줍니다. AppErrorRateLimited 한 종류만 추가해도 처리 안 한 자리가 즉시 빨갛게 떴습니다. 새 에러 타입을 더해도 두렵지 않은 구조가 됐습니다.

 

 

kotlin.Result 대신 sealed Result를 직접 만든 기준

표준 kotlin.Result<T>를 그냥 쓰면 안 됐냐는 질문을 받았는데요, 실패 타입이 Throwable로 고정된 게 결정적이었습니다. 도메인 에러를 표현하려면 매번 예외 객체를 만들어야 하고, 예외는 생성 시점에 스택 트레이스를 수집해서 비용이 적지 않아요. 검증 실패처럼 "예상된 실패"까지 Throwable로 감싸야 하는 게 도메인 계층에선 영 어색했습니다.

 

거기에 kotlin.Result@JvmInline value class라, Flow<Result<Feed>>처럼 제네릭 타입 인자 자리에 넣으면 값 클래스가 박싱되면서 노린 인라인 최적화가 사라집니다. 한때는 반환 타입으로 쓰는 것조차 막혀 있었는데, 그 제약은 Kotlin 1.5에서 풀렸어요. 지금은 컴파일러가 막지는 않지만, Throwable 고정과 박싱이라는 본질 때문에 Flow<Result<Feed>> 같은 구조를 안드로이드 작업에서 편히 쓰긴 어렵다는 게 제 결론입니다.

 

다만 runCatching 자체를 안 쓴다는 얘기는 아닙니다. JSON 한 덩어리 파싱처럼 좁은 로컬 작업에서는 보일러플레이트를 줄여주니까요. 그 결과를 바로 AppError로 옮기는 어댑터 한 단만 끼우는 식으로 굴리고 있어요.

 

 

Repository→ViewModel→Compose로 에러를 끌어올린 흐름

ThrowableAppError로 번역하는 자리는 Repository 한 곳으로 모았습니다. 이게 이번 리팩터링의 가장 큰 효과였어요.

 

class FeedRepository(private val api: FeedApi) {
    suspend fun loadFeed(): Result<Feed, AppError> = try {
        Result.Success(api.fetchFeed())
    } catch (e: CancellationException) {
        throw e  // 취소는 반드시 다시 던지기
    } catch (e: IOException) {
        Result.Failure(AppError.Network)
    } catch (e: HttpException) {
        when (e.code()) {
            401 -> Result.Failure(AppError.Unauthorized)
            else -> Result.Failure(AppError.Server(e.code()))
        }
    } catch (e: Throwable) {
        Result.Failure(AppError.Unknown(e))
    }
}

 

CancellationException을 다시 던지는 한 줄이 좀비 코루틴을 막는 핵심입니다. UseCase·ViewModel에선 try/catch가 완전히 사라졌어요. UseCase는 Result를 그대로 흘려보내고, ViewModel은 when으로 UiState에 옮깁니다.

 

sealed interface FeedUiState {
    data object Loading : FeedUiState
    data class Content(val feed: Feed) : FeedUiState
    data class Error(val error: AppError, val onRetry: () -> Unit) : FeedUiState
}

 

참고로 FeedUiState.Error는 처음에 Failure로 뒀다가 바꿨습니다. Result.Failure와 이름이 겹쳐서 PR 리뷰에서 한 번 헷갈린 적이 있었거든요. 동명 회피만으로도 문맥 가독성이 한결 좋아졌어요.

 

Compose 쪽은 when (state) 한 번이면 분기가 끝납니다. sealed의 컴파일 시점 안정성이 UI 계층까지 그대로 올라온 셈이에요.

 

AppError 메시지 매핑을 ErrorTextProvider 한 곳에 모은 자리

ViewModel에서 R.string.error_network 같은 리소스 ID를 직접 참조하면 단위 테스트가 안드로이드 의존을 끌고 들어옵니다. 그래서 변환 한 층을 더 떠냈어요.

 

class ErrorTextProvider {
    fun textOf(error: AppError): TextResource = when (error) {
        AppError.Network -> TextResource(R.string.error_network)
        AppError.Unauthorized -> TextResource(R.string.error_login)
        is AppError.Server -> TextResource(R.string.error_server, error.code)
        is AppError.Unknown -> TextResource(R.string.error_unknown)
    }
    fun isRetryable(error: AppError): Boolean = error !is AppError.Unauthorized
}

 

사용자에게 보이는 한 줄은 이 자리에서만 정해지고, 개발자용 스택 트레이스는 Unknown.cause에 그대로 남깁니다. 재시도 가능 여부도 에러 타입에서 끌어오니까 Compose는 if (isRetryable) RetryButton() 한 줄로 끝나요.

 

개인적으로는 화면 두세 개짜리 앱에 이 구조를 통째로 도입하는 건 추천드리지 않습니다. 보일러플레이트가 만만치 않거든요. 작은 앱이라면 runCatching + sealed UiState 조합 정도가 충분하다고 봅니다. 함수형이 익숙한 팀이라면 Arrow의 EitherRaise DSL로 가는 것도 합리적인 선택이고요.

 

3주를 돌아보면 가장 크게 바뀐 건 코드가 아니라 PR 리뷰 풍경이었어요. "이 catch에서 어떤 에러가 떨어질 수 있죠?"라는 질문이 사라졌습니다. 타입이 답을 들고 있으니까요. AppError에 한 줄을 더할 때마다 컴파일러가 미처리 자리를 전부 찾아주는 감각이 진짜 마음에 들었네요. 다음 분기엔 이 구조 위에 재시도 정책과 백오프를 어떻게 묶을지 한 번 더 풀어볼 생각입니다.

 

반응형
LIST