1. 개요
Firebase에 저장된 데이터를 불러오다가 예외가 날 수도 있다.
이때 ViewModel에서 예외 처리를 어떻게 하면 좋을지 알아보자.
2. 문제 상황
ViewModel에서 하드코딩된 문자열이 있는 상황이다.
viewModelScope.launch {
try {
val categorySums = repository.fetchMonthlyCategoryTotals(
type = type.name.lowercase(),
year = year,
month = month,
uid = uid,
householdId = householdId
)
val entries = categorySums.map { (category, amount) ->
PieEntry(
value = amount.toFloat(),
pieLabel = category,
pieColor = getColorForCategory(category)
)
}
_uiState.value = _uiState.value.copy(
chartData = entries,
isLoading = false,
errorMessage = null
)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
errorMessage = e.message ?: "알 수 없는 오류가 발생했습니다.",
isLoading = false
)
}
}
이 방식은 간단해 보이지만 실제로 다음과 같은 문제가 있다.
- 다국어 지원 불가
- 문자열이 하드코딩되어 유지보수가 어려움
- context 의존성이 생김
보통 다국어 지원을 하기 위해서 strings.xml파일에 문자열을 정의하고,
context.getString() 방식으로 가져온다.
하지만 이런 방식을 적용하면 ViewModel에서는 context에 의존하게 되어버린다.
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
errorMessage = e.message ?: context.getString(R.string.unknown_error_message),
isLoading = false
)
}
ViewModel은 왜 Context에 의존하면 안 될까?
ViewModel은 UI와 무관한 비즈니스 로직을 처리하는 곳으로 가장 큰 특징 중 하나는 UI Lifecycle과 분리되어 있다는 점이다.
다시 말해 ViewModel은 화면 회전, 컴포넌트 변경 등에도 상태를 유지할 수 있다.
그렇다면 ViewModel에서 Context를 참조하면 어떤 일이 생길까?
만약 참조를 하게 되면 메모리 누수가 발생할 수 있다.
예를들어 세로 모드에서 Activity가 실행되고, 이 Activity에 대한 Context를 ViewModel이 참조한다고 가정해 보자.
이후 기기를 가로 모드로 전환하면 기존 Activity는 파괴되고 새로운 Activity가 생성된다.
하지만 ViewModel은 Lifecycle이 더 길기 때문에 파괴된 Activity의 Context를 계속 참조하게 된다.
이는 결국 메모리 누수로 이어지게 된다.
따라서 ViewModel에서는 가능한 Context에 직접 접근하지 않고 UI 계층에서 처리하거나 리소스 ID를 통해 전달받는 방식으로 분리해야 한다.
3. 해결
먼저 각 상황에 대한 예외 메시지를 다르게 표시하기 위해 각 예외에 대한 sealed class를 정의했다.
왜 sealed class로 만들었을까?
sealed class는 컴파일 타임에 하위 클래스가 모두 정해져 있어
when 문에서 모든 경우를 강제적으로 처리할 수 있기 때문이다.
data class StatisticsUiState(
val currentYear: Int = 2025,
val currentMonth: Int = 1,
val chartData: List<PieEntry> = emptyList(),
val isLoading: Boolean = false,
val error: StatisticsError? = null,
val currentType: TransactionType = TransactionType.EXPENSE,
)
sealed class StatisticsError(@StringRes val messageResId: Int) {
object NetworkError : StatisticsError(R.string.error_network)
object FirestoreError : StatisticsError(R.string.error_firestore)
object UnknownError : StatisticsError(R.string.error_unknown)
}
StatisticsError에는 @StringRes가 들어가는데 이는 해당 필드가 문자열 리소스 ID임을 명시해 준다.
즉, 해당 예외에 대한 메시지가 messageResId에 들어간다.
또한 error를 기존 String 타입에서 StatisticsError 타입으로 변경하였다.
nullable 타입으로 선언하여 예외가 발생하지 않을 때는 null 값이 들어가게 하였다.
private fun fetchMonthlyCategoryTotals(
year: Int,
month: Int,
uid: String,
householdId: String,
type: TransactionType,
) {
_uiState.value = _uiState.value.copy(
isLoading = true,
error = null,
currentYear = year,
currentMonth = month,
currentType = type,
)
viewModelScope.launch {
try {
val categorySums = repository.fetchMonthlyCategoryTotals(
type = type.name.lowercase(),
year = year,
month = month,
uid = uid,
householdId = householdId
)
val entries = categorySums.map { (category, amount) ->
PieEntry(
value = amount.toFloat(),
pieLabel = category,
pieColor = getColorForCategory(type, category),
labelResId = getLabelResIdForCategory(type, category)
)
}
_uiState.value = _uiState.value.copy(
chartData = entries,
error = null
)
} catch (e: FirebaseNetworkException) {
_uiState.value = _uiState.value.copy(
error = StatisticsError.NetworkError
)
} catch (e: FirebaseFirestoreException) {
_uiState.value = _uiState.value.copy(
error = StatisticsError.FirestoreError
)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
error = StatisticsError.UnknownError
)
} finally {
_uiState.value = _uiState.value.copy(
isLoading = false
)
}
}
}
여기서 유심히 봐야 할 부분은 catch문이다.
각 다른 예외가 발생했을 때 그에 맞는 uiState.error를 업데이트한다.
uiState.error != null -> Text(
text = stringResource(id = uiState.error!!.messageResId),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.align(Alignment.Center)
)
그 후, UI에서는 error가 null이 아니면,
즉, error가 발생했다면 해당 error의 messageResId를 텍스트로 표시한다.
4. 회고
이번 작업을 통해 ViewModel에서 문자열을 처리하고 그에 관한 예외를 처리하는 방법에 대해 고민하게 되었다.
이전까지는 단순히 ""로 문자열을 감싸 에러 메시지를 출력했지만, 다국어 지원이 어렵고, 무엇보다 ViewModel이 context에 의존해야 하는 문제가 있었다.
이번 개선을 통해 context에 의존하지 않고도 에러 메세지를 관리할 수 있는 구조를 만들었고, ViewModel의 역할을 명확히 분리하는 계기가 되었다.
또한, 사용자에게도 어떤 이유로 오류가 발생했는지를 명확하게 안내할 수 있게 되어 더 나은 사용자 경험을 제공할 수 있게 된 것 같다.
'Android' 카테고리의 다른 글
| [Android] MPAndroidChart의 PieChart를 분석해서 나만의 CustomPieChart 만들기 (2) (4) | 2025.07.18 |
|---|---|
| [Android] MPAndroidChart의 PieChart를 분석해서 나만의 CustomPieChart 만들기 (1) (3) | 2025.07.18 |