Coroutine 에러 처리 패턴: 여러 API 호출을 한 번에 성공/실패 판정하기

seoyoon·2025년 8월 12일
1

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)
            },
        )
    }
}

profile
seoyoon's development blog

0개의 댓글