
에러를 던지지 마세요. 값으로 들고 다니세요. 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 표현식이 컴파일 시점에 에러 케이스 누락을 잡아줍니다. AppError에 RateLimited 한 종류만 추가해도 처리 안 한 자리가 즉시 빨갛게 떴습니다. 새 에러 타입을 더해도 두렵지 않은 구조가 됐습니다.

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로 에러를 끌어올린 흐름
Throwable을 AppError로 번역하는 자리는 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의 Either와 Raise DSL로 가는 것도 합리적인 선택이고요.
3주를 돌아보면 가장 크게 바뀐 건 코드가 아니라 PR 리뷰 풍경이었어요. "이 catch에서 어떤 에러가 떨어질 수 있죠?"라는 질문이 사라졌습니다. 타입이 답을 들고 있으니까요. AppError에 한 줄을 더할 때마다 컴파일러가 미처리 자리를 전부 찾아주는 감각이 진짜 마음에 들었네요. 다음 분기엔 이 구조 위에 재시도 정책과 백오프를 어떻게 묶을지 한 번 더 풀어볼 생각입니다.
'Android 개발 > Kotlin' 카테고리의 다른 글
| 직렬화 라이브러리 3개를 kotlinx.serialization 한 곳으로 정리한 후기 (0) | 2026.07.14 |
|---|---|
| Kotlin 2.4 field 키워드로 ViewModel _state/state 보일러플레이트 한 줄로 줄이기 (0) | 2026.06.27 |
| 공유 UI 말고 공유 코어부터, KMP 도입 한 주 일지 (0) | 2026.06.24 |
| Kotlin 2.4 UUID 안정화로 안드 데이터 클래스에서 라이브러리 두 개 지운 후기 (0) | 2026.06.21 |
| 코틀린 2.4-RC 핵심 4축, 안드로이드 6년차의 사이드 프로젝트 적용 기록 (0) | 2026.06.16 |