앱을 사용하면서 사용자는 다양한 요청을 하게 된다. 서버의 문제이거나, 사용자 권한 문제로 사용자가 예상하지 못한 결과를 보여주어야 하는 경우가 생긴다. 이런 경우에 대처하는 것이 에러핸들링이다. 에러핸들링을 하지 않으면 그냥 빈 화면, 최악의 경우 앱이 튕겨버리기 때문에 앱의 신뢰성과 디버깅 용이성을 위해서 꼭 필요하다.
개발하고 있는 HMOA앱에서는 에러핸들링을 크게 2가지로 주고 있다.네비게이션 기능이 있는 다이얼로그, 취소 기능이 있는 다이얼로그이다.
HMOA앱의 데이터는 모두 아래와 같이 흘러간다.
viewmodel까지 전달된 데이터를 바탕으로 UiState의 값을 변경시킬 건지, ErrorState의 값을 변경시킬 건지 판단함한다. 모든 상태는 StateFlow, 즉 HotFlow 파이프라인으로 이루어졌고 흘러오는 데이터를 받는 화면에서 컨텐츠와 에러 다이얼로그를 띄우게 되는 구조로 설계했다.
가장 고민했던 점은 아래와 같다.
- 데이터 계층의 어떤 역할을 하는 객체에서 에러메세지를 선별해서 캡슐화할까?
- 에러를 어떤 형식으로 캡슐화할까?
- 이미 작성된 코드들을 많이 고치지 않고 에러핸들링을 하는 방법이 뭘까?
repository는 사용자의 데이터요청을 받는 입구이고, 그 외의 특별한 역할이 주어져선 안되기 때문에
datastore에서 요청한 데이터의 실패, 성공 유무를 판별하면서 에러메세지를 캡슐화해야 한다고 생각했다.
viewmodel까지 전달되는 모든 데이터클래스는 ResultResponse에 한번 더 담겨서 전달된다.
data class ResultResponse<T>(
var data: T? = null,
var errorMessage: ErrorMessage? = null
)
Sandwich라이브러리의 ApiResponse라는 래퍼클래스로 응답데이터 클래스를 감싸서 받으면 데이터 요청결과를 Success, Failure 두 가지 시나리오로 분기했다.
아래와 같이 요청이 성공하면 ResultResponse라는 데이터클래스의 data값으로 넣고, 실패하면 ResultResponse 데이터클래스의 errorMessage값으로 넣는다. 받은 에러메세지를 viewmodel까지 전달해야 하기 때문이다.
class MemberDataStoreImpl @Inject constructor(
private val memberService: MemberService
) : MemberDataStore {
override suspend fun getMember(): ResultResponse<MemberResponseDto> {
val result = ResultResponse<MemberResponseDto>()
memberService.getMember().suspendOnSuccess {
result.data = this.data
}.suspendOnError {
val errorMessage = Json.decodeFromString<ErrorMessage>(this.message())
result.errorMessage = errorMessage
}
return result
}
}
서버에서 주기로 한 에러메세지들은 아래와 같다. viewmodel까지 이어지는 Flow 파이프라인에서 나오는 데이터에서 에러메세지가 있는 경우 그 메세지의 타입을 아래 enum class 값을 이용해서 분기한다.
이렇게 하드코딩을 최소화해야 오류가 줄어든다는 걸 명심하자!
enum class ErrorMessageType(val code: Int, val message: String) {
EXPIRED_TOKEN(401, "ACCESS Token이 만료되었습니다."),
WRONG_TYPE_TOKEN(401, "변조된 토큰입니다."),
UNKNOWN_ERROR(404, "jwt가 존재하지 않습니다.")
}
useCase는 선택사항이지만 내가 하고 있는 프로젝트에서는 useCase를 사용하는 상황을 따로 정의해두었다. 2개 이상의 레포지토리에서 데이터를 가져와 전처리가 필요한 경우인데 이하 생략하고..!
가장 중요한 점은 viewmodel 파이프라인의 말단에서 Result.Success, Result.Loading, Result.Error 확장함수를 사용한다.
sealed interface Result<out T> {
data class Success<T>(val data: T) : Result<T>
data class Error(val exception: Throwable) : Result<Nothing>
data object Loading : Result<Nothing>
}
fun <T> Flow<T>.asResult(): Flow<Result<T>> = map<T, Result<T>> { Result.Success(it) }
.onStart { emit(Result.Loading) }
.catch { emit(Result.Error(it)) }
에러핸들링을 위해 Result.Error에 데이터를 전달할 수 있으려면 위의 Flow.asResult함수가 catch문을 실행할 수 있게해야 한다.
그래서 flow 안에서 exception을 throw해줘야 한다는 것이다.
class GetPerfumeUsecase @Inject constructor(
private val perfumeRepository: PerfumeRepository
) {
suspend operator fun invoke(perfumeId: String): Flow<ResultResponse<Perfume>> {
val perfumeInfo1 = perfumeRepository.getPerfumeTopDetail(perfumeId)
val perfumeInfo2 = perfumeRepository.getPerfumeBottomDetail(perfumeId)
val result = Perfume(
brandEnglishName = perfumeInfo1.data?.brandEnglishName ?: "",
brandKoreanName = perfumeInfo1.data?.brandName ?: "",
brandId = perfumeInfo1.data?.brandId.toString(),
brandImgUrl = perfumeInfo1.data?.brandImgUrl ?: "",
perfumeEnglishName = perfumeInfo1.data?.englishName ?: "",
perfumeKoreanName = perfumeInfo1.data?.koreanName ?: "",
baseNote = perfumeInfo1.data?.baseNote ?: "",
heartNote = perfumeInfo1.data?.heartNote ?: "",
topNote = perfumeInfo1.data?.topNote ?: "",
likedCount = perfumeInfo1.data?.heartNum ?: 0,
liked = perfumeInfo1.data?.liked ?: false,
notePhotos = perfumeInfo1.data?.notePhotos?.map { mapIndexToTastingNoteImageUrl(it.toInt()) }
?: emptyList(),
perfumeId = perfumeInfo1.data?.perfumeId.toString(),
perfumeImageUrl = perfumeInfo1.data?.perfumeImageUrl ?: "",
price = "%,d".format(perfumeInfo1.data?.price ?: 0),
review = perfumeInfo1.data?.review,
sortType = perfumeInfo1.data?.sortType ?: 0,
perfumeVolumeList = perfumeInfo1.data?.volume ?: emptyArray(),
perfumeVolume = perfumeInfo1.data?.priceVolume ?: 0,
commentInfo = PerfumeCommentGetResponseDto(
commentCount = perfumeInfo2.data?.commentInfo?.commentCount ?: 0,
comments = get3CommentAHeadOfCommentCounts(perfumeInfo2.data?.commentInfo?.comments ?: emptyList()),
lastPage = perfumeInfo2.data?.commentInfo?.lastPage ?: false
),
similarPerfumes = perfumeInfo2.data?.similarPerfumes ?: emptyArray()
)
return flow {
val exception = mapException(perfumeInfo1, perfumeInfo2)
if (exception == null) {
emit(ResultResponse(data = result, errorMessage = exception))
} else {
throw Exception(exception.message)
}
}
}
}
UiState에 Loading, Data, Error가 있는 것처럼 에러상태도 동일하게 ErrorUiState라는 인터페이스를 만들고 Loading, Data로 구현해주었다.
ErrorData 데이터 클래스의 속성들은 enum class에서 미리 정의해둔 종류 3가지와, 그 외 기타에러들이라는 뜻으로 generalError라고 표현했다. Boolean을 쓴 이유는 error가 발생했다/하지 않았다를 판별할 수 있는 가장 컴팩트한 타입이라고 생각해서이다.
sealed interface ErrorUiState {
data class ErrorData(
val expiredTokenError: Boolean,
val wrongTypeTokenError: Boolean,
val unknownError: Boolean,
val generalError: Pair<Boolean, String?>
) : ErrorUiState
data object Loading : ErrorUiState
}
에러타입별 MutableStateFlow를 만들고 private 접근제한자를 사용해서 viewmodel에서만 수정할 수 있게 만들고, 앞서 구현한 ErrorUiState를 구현한 Loading과 ErrorData객체를 이용해서 StateFlow를 변화시킨다. 이 StateFlow가 화면에 에러상태를 전달해주는 유일한 데이터 파이프라인이다.
private var expiredTokenErrorState = MutableStateFlow<Boolean>(false)
private var wrongTypeTokenErrorState = MutableStateFlow<Boolean>(false)
private var unLoginedErrorState = MutableStateFlow<Boolean>(false)
private var generalErrorState = MutableStateFlow<Pair<Boolean, String?>>(Pair(false, null))
val errorUiState: StateFlow<ErrorUiState> = combine(
expiredTokenErrorState,
wrongTypeTokenErrorState,
unLoginedErrorState,
generalErrorState
) { expiredTokenError, wrongTypeTokenError, unknownError, generalError ->
ErrorUiState.ErrorData(
expiredTokenError = expiredTokenError,
wrongTypeTokenError = wrongTypeTokenError,
unknownError = unknownError,
generalError = generalError
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ErrorUiState.Loading
)
설명하기 위해서 일부 코드들이 생략되었는데 아래의 함수는 위의 코드와 함께 동일한 viewmodel에 있다. 이렇게 전달된 데이터 result가 Result.Error라면, 유효한 result.exception.message값의 내용이 enum class의 message로 정의했던 값과 같은지 비교해서 알맞는 타입의 에러상태로 업데이트 시켜준다.
그 업데이트 값은 HotFlow 데이터로써 화면으로 전달되게 된다.
fun initializePerfume() {
viewModelScope.launch(Dispatchers.IO) {
//getPerfume은 위에서 설명했던 GetPerfumeUseCase의 인스턴스이다.
getPerfume(perfumeId.toString()).asResult().collectLatest { result ->
when (result) {
is Result.Success -> {
perfumeState.update { result.data.data }
perfumeCommentsState.update { result.data.data?.commentInfo }
}
is Result.Loading -> {}
is Result.Error -> {
when (result.exception.message) {
ErrorMessageType.EXPIRED_TOKEN.message -> {
expiredTokenErrorState.update { true }
}
ErrorMessageType.WRONG_TYPE_TOKEN.message -> {
wrongTypeTokenErrorState.update { true }
}
ErrorMessageType.UNKNOWN_ERROR.message -> {
unLoginedErrorState.update { true }
}
else -> {
generalErrorState.update { Pair(true, result.exception.message) }
}
}
}
}
}
}
}
아래와 같이 ErrorUiState를 인자값으로 받고, ErrorUiState 인터페이스를 상속한 ErrorData 데이터 클래스 객체의 속성값이 true/false인 점을 이용해 어떤 타입의 에러인지, 어떤 디자인의 에러 다이얼로그를 화면에 띄울지 결정하게 만들었다.
이렇게 재사용성을 높여서 필요한 화면에 붙일 수 있게 되었다.
@Composable
fun ErrorUiSetView(onConfirmClick: () -> Unit, errorUiState: ErrorUiState, onCloseClick: () -> Unit) {
var isOpen by remember { mutableStateOf(true) }
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
when (errorUiState) {
is ErrorUiState.ErrorData -> {
if (errorUiState.expiredTokenError) {
AppDesignDialog(
isOpen = isOpen,
modifier = Modifier.wrapContentHeight()
.width(screenWidth - 88.dp),
title = "리프레시 토큰이 만료되었습니다",
content = "다시 로그인해주세요",
buttonTitle = "로그인 하러가기",
onOkClick = {
isOpen = false
onConfirmClick()
},
onCloseClick = {
isOpen = false
onCloseClick()
}
)
} else if (errorUiState.wrongTypeTokenError) {
AppDesignDialog(
isOpen = isOpen,
modifier = Modifier.wrapContentHeight()
.width(screenWidth - 88.dp),
title = "유효하지 않은 토큰입니다",
content = "유효하지 않은 토큰입니다",
buttonTitle = "로그인 하러가기",
onOkClick = {
isOpen = false
onConfirmClick()
},
onCloseClick = {
isOpen = false
onCloseClick()
}
)
} else if (errorUiState.unknownError) {
AppDesignDialog(
isOpen = isOpen,
modifier = Modifier.wrapContentHeight()
.width(screenWidth - 88.dp),
title = "로그인 후 이용가능한 서비스입니다",
content = "입력하신 내용을 다시 확인해주세요",
buttonTitle = "로그인 하러가기",
onOkClick = {
isOpen = false
onConfirmClick()
},
onCloseClick = {
isOpen = false
onCloseClick()
}
)
} else if (errorUiState.generalError.first) {
AppDefaultDialog(
isOpen = isOpen,
title = "이런 오류가 발생했어요 :(",
content = (errorUiState as ErrorUiState.ErrorData).generalError.second ?: "",
onDismiss = {
isOpen = false
onCloseClick()
},
modifier = Modifier.wrapContentHeight()
.width(screenWidth - 88.dp)
)
}
}
ErrorUiState.Loading -> {
AppLoadingScreen()
}
}
}
viewModel의 errorUiState의 상태를 라이프사이클 수명주기 동안 수집하고, 그 값이 변화될 때 ErrorUiSetView가 작동하게 된다.
@Composable
fun PerfumeScreen(
onBackClick: () -> Unit,
...중략...
) {
...중략....
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val perfumeCommentIdToReport by viewModel.perfumeCommentIdStateToReport.collectAsStateWithLifecycle()
val errorUiState by viewModel.errorUiState.collectAsStateWithLifecycle()
ErrorUiSetView(
onConfirmClick = { onErrorHandleLoginAgain() },
errorUiState = errorUiState,
onCloseClick = { onBackClick() }
)
...중략...
}
에러핸들링을 이런식으로 하게될 줄 몰랐지만... Result인터페이스와 .asResult 확장함수를 만들어 viewmodel에서 데이터 분기 및 상태관리를 하고, LiveData가 아닌 Flow 중심의 데이터처리를 구성했었다. 이미 구성한 구조와 패턴이 비슷한 에러처리를 한 것 같고, 컴포넌트로 분리한 것도 빠른 에러처리 작업을 끝낼 수 있게 해주었다. 하지만 에러처리 컴포넌트는 리팩토링이 필요해 보인다. 또한 viewmodel마다 에러메세지 분기문이 반복되기 때문에 이런 역할만하는 객체로 분기해보는 것도 필요하다고 생각한다.