API 3개의 요청으로 받은 데이터 -> 하나의 화면에 보여주는 경우 에러 핸들링에 관한 경힘

위의 사진과 같이 3개의 API를 통해 얻은 데이터로 화면을 구성한다. 이때 각 데이터들은 독립적으로 호출 가능하기 때문에 실행 순서를 보장하지 않아도 된다.
따라서 처음 구조는 각 API를 호출하는 함수를 정의하고 scope.launch 방식으로 구현 -> LaunchedEffect에서 세 개의 함수를 실행한다. (각 함수가 병렬로 실행)
val scope = rememberCoroutineScope()
fun getBookDetail() {
scope.launch {
bookRepository.getBookDetail(screen.isbn13)
.onSuccess {}
.onFailure {}
}
}
fun getSeedsStats() {
scope.launch {
bookRepository.getSeedsStats(screen.userBookId)
.onSuccess {}
.onFailure {}
}
}
fun getReadingRecords(startIndex: Int = START_INDEX) {
scope.launch {
recordRepository.getReadingRecords(
userBookId = screen.userBookId,
sort = currentRecordSort.value,
page = startIndex,
size = PAGE_SIZE,
).onSuccess {}
}.onFailure {}
}
}
LaunchedEffect(Unit) {
getSeedsStats()
getBookDetail()
getReadingRecords()
}
하지만 에러 핸들링을 하면서 고민이 생겼다. API 중 하나라도 실패하면 어떻게 대응할 것인가?
현재 각 데이터는 독립적으로 불러올 수 있는 구조이지만, 각 구역마다 에러뷰가 디자인 정책상으로 정의되어있지 않기 때문에 불완전한 뷰를 보여주는 대신 한개라도 실패하게 될 경우 -> 전체 에러뷰를 띄우는 방향으로 핸들링하기로 결정했다.
기존 api통신을 각각 launch로 병렬 처리하면 부분 성공/실패/로딩 상태가 섞여 UI 상태 전이가 복잡해지기 때문에 '하나라도 실패 시 전체 실패'라는 정책을 보장하기 위해 async/await + 단일 try-catch로 감싸 일관된 상태 전이를 하도록 했다.
fun initialLoad() {
uiState = UiState.Loading
try {
scope.launch {
val bookDetailDef = async { bookRepository.getBookDetail(screen.isbn13).getOrThrow() }
val seedsDef = async { bookRepository.getSeedsStats(screen.userBookId).getOrThrow() }
val readingRecordsDef = async {
recordRepository.getReadingRecords(
userBookId = screen.userBookId,
sort = currentRecordSort.value,
page = START_INDEX,
size = PAGE_SIZE,
).getOrThrow()
}
val detail = bookDetailDef.await()
val seeds = seedsDef.await()
val records = readingRecordsDef.await()
// 중략
uiState = UiState.Success
}
} catch (e: Throwable) {
uiState = UiState.Error(e)
val handleErrorMessage = { message: String ->
Logger.e(message)
sideEffect = BookDetailSideEffect.ShowToast(message)
}
handleException(
exception = e,
onError = handleErrorMessage,
onLoginRequired = {
navigator.resetRoot(LoginScreen)
},
)
}
}
하지만 이렇게 만들 경우 또 문제가 생기는데, launch 코루틴 빌더를 감싼 try-catch 블록이 코루틴 생성에 대한 예외만 처리하고 코루틴 실행된 이후 발생하는 예외는 처리하지 못한다. -> 결국 내부에 코루틴을 각각 try-catch로 감싸야 하는데 별로 효율적인 구조라고 생각되진 않는다.
내가 생각한 요구사항을 처리하기 위해 coroutineScope로 감싸 suspend 함수로 만들 경우 블록 내 자식 완료까지 대기되며 성공/실패를 한 번에 판정할 수 있고, 예외가 coroutineScope 호출자에게 던질 수 있다.
suspend fun initialLoad() {
uiState = UiState.Loading
try {
coroutineScope {
val bookDetailDef = async { bookRepository.getBookDetail(screen.isbn13).getOrThrow() }
val seedsDef = async { bookRepository.getSeedsStats(screen.userBookId).getOrThrow() }
val readingRecordsDef = async {
recordRepository.getReadingRecords(
userBookId = screen.userBookId,
sort = currentRecordSort.value,
page = START_INDEX,
size = PAGE_SIZE,
).getOrThrow()
}
val detail = bookDetailDef.await()
val seeds = seedsDef.await()
val records = readingRecordsDef.await()
bookDetail = detail
currentBookStatus = BookStatus.fromValue(detail.userBookStatus) ?: BookStatus.BEFORE_READING
selectedBookStatus = currentBookStatus
seedsStates = seeds.categories.toImmutableList()
readingRecords = records.content.toPersistentList()
readingRecordsPageInfo = records.page
isLastPage = records.content.size < PAGE_SIZE
currentStartIndex = START_INDEX
uiState = UiState.Success
}
} catch (ce: CancellationException) {
throw ce
} catch (e: Throwable) {
uiState = UiState.Error(e)
val handleErrorMessage = { message: String ->
Logger.e(message)
sideEffect = BookDetailSideEffect.ShowToast(message)
}
handleException(
exception = e,
onError = handleErrorMessage,
onLoginRequired = {
navigator.resetRoot(LoginScreen)
},
)
}
}
