[KMP, Android] 처리 중인 요청의 연속 호출 방지하기

왕왕조현·2026년 8월 30일

android

목록 보기
4/4
post-thumbnail

안녕하세요!

온라인 교환독서 프로젝트인 "여백"에 참여하고 있는 개발자 꿈나무 김조현입니다. 이번 글에서는 중복으로 호출하는 함수를 방어하는 방법에 대한 글을 적어보겠습니다.

방어로직이란?

방어 로직은 예상하지 않은 입력이나 상태, 중복 실행으로 문제가 발생하지 않도록 사전에 차단하는 로직이다. 이번 글에서는 그중에서도 API 중복 요청을 방지하는 방법을 다룬다.

쉽게 보면 다음과 같은 화면에서 나가기 버튼 을 빠르게 두 번 누른다고 해보자. 그러면 나가기가 두 번 호출되면서 오류가 날 수도 있고~ 아닐 수도 있다. 이는 구현 방식에 따라 다르겠죠??

나는 아래와 같은 방식으로 구현했었다.

내가 구현한 코드

DetailViewModel.kt

fun exitGroup(  
    groupId: Long,  
) {  
    uiState = uiState.copy(  
        exitState = ExitState.Loading,  
    )  
  
    viewModelScope.launch {  
        try {  
            groupRepository.exitGroup(groupId = groupId)  
            uiState = uiState.copy(  
                exitState = ExitState.Success,  
            )  
            crashReporter.track(  
                level = CrashLogLevel.INFO,  
                context = crashContext(CrashOperation.GROUP_EXIT_SUCCEEDED),  
            )  
        } catch (e: CancellationException) {  
            throw e  
        } catch (e: Exception) {  
            crashReporter.recordException(  
                throwable = e,  
                context = crashContext(CrashOperation.GROUP_EXIT_FAILED),  
            )  
            uiState = uiState.copy(  
                exitState = ExitState.Failure("모임 탈퇴에 실패했습니다."),  
            )  
        }  
    }  
}

sealed class ExitState {  
    object Loading : ExitState()  
    object Success : ExitState()  
    data class Failure(val message: String) : ExitState()  
}

DetailScreen.kt

LaunchedEffect(uiState.exitState) {  
    when (uiState.exitState) {  
        is ExitState.Success -> {  
            navigateToHome()  
        }  
  
        is ExitState.Failure -> {  
            snackbarHostState.showSnackbar(  
                message = uiState.exitState.message,  
                duration = SnackbarDuration.Short,  
            )  
        }  
  
        is ExitState.Loading -> return@LaunchedEffect  
    }  
}

이 코드의 의도는 단순하다. exitState의 초기값이 Loading이며, 내부에서 API 함수를 호출하여 성공하면 상태를 Success로, 실패했다면 Failure가 되도록 하는 것이다.

그러면 LaunchedEffect 내부에서는 uiState에 맞게 이벤트를 수행할 것이다.

하지만 앞서 제시한 상황처럼 버튼을 겁나 빠르게 두 번 누르게 된다면 어떤 현상이 일어날까?

결론부터 말하자면 API가 두 번 호출될 것이다.

버튼 클릭(Loading) -> exitGroup() 실행(Loading) 
-> 한 번 더 클릭(Loading) -> exitGroup() 실행(Loading) -> ...

이런 식의 시나리오가 될 것이다. 그러면 이 때는 API를 호출한 coroutine이 진행 중일 때 다시 함수가 호출되면서 새로운 요청을 만드는 것이다. 왜일까? 분명 상태를 로딩, 성공, 실패로 잘 나눴다고 생각했는데...

상태를 잘 나눈 것은 맞았다. 하지만 이는 단순하게 함수가 두 번 호출될 수 있기 때문인 것이다. 즉, 함수가 두 번 호출되는 이 상황을 방어해주는 로직이 없는 것이다. 이를 방어 로직이라고 부르는 것이다.

이는 단순하게 해결할 수 있다. 다시 클릭됐을 때 return을 반환하여 함수가 실행이 되지 않도록 하는 것이다.

내 코드를 수정하면 다음과 같이 수정할 수 있다.

수정한 코드

DetailViewModel.kt - 수정본

fun exitGroup(  
    groupId: Long,  
) {  
	if (uiState.exitState is ExitState.Loading) return
	
    uiState = uiState.copy(  
        exitState = ExitState.Loading,  
    )  
  
    viewModelScope.launch {  
        try {  
            groupRepository.exitGroup(groupId = groupId)  
            uiState = uiState.copy(  
                exitState = ExitState.Success,  
            )  
            crashReporter.track(  
                level = CrashLogLevel.INFO,  
                context = crashContext(CrashOperation.GROUP_EXIT_SUCCEEDED),  
            )  
        } catch (e: CancellationException) {  
            throw e  
        } catch (e: Exception) {  
            crashReporter.recordException(  
                throwable = e,  
                context = crashContext(CrashOperation.GROUP_EXIT_FAILED),  
            )  
            uiState = uiState.copy(  
                exitState = ExitState.Failure("모임 탈퇴에 실패했습니다."),  
            )  
        }  
    }  
}

sealed class ExitState {  
	object Idle : ExitState()
    object Loading : ExitState()  
    object Success : ExitState()  
    data class Failure(val message: String) : ExitState()  
}

DetailScreen.kt - 수정본

LaunchedEffect(uiState.exitState) {  
    when (uiState.exitState) {  
        is ExitState.Success -> {  
            navigateToHome()  
        }  
  
        is ExitState.Failure -> {  
            snackbarHostState.showSnackbar(  
                message = uiState.exitState.message,  
                duration = SnackbarDuration.Short,  
            )  
        }  
  
        is ExitState.Idle, ExitState.Loading -> return@LaunchedEffect  
    }  
}

이 때는 시나리오가 다음과 같이 중복 호출이 방어된다.

버튼 클릭(Idle) -> exitGroup(Loading) -> 한 번 더 클릭(Loading) -> return

변경 사항에 대한 설명

수정할 때 Idle이라는 상태를 추가했다. 이를 추가한 의도는 Loading이라는 상태가 초기 상태와 진행 중일 때의 상태를 전부 담당하기 때문에 방어를 위해 구분할 수 없었기 때문이다.

이게 무슨 말이냐면 방어를 위해 로딩 중일 때 (Loading) return을 하도록 해도, 초기 상태가 Loading이기 때문에 함수 호출이 원천적으로 차단이 되는 것이다. 이를 위해 초기 상태, 로딩 상태의 두 가지 역할을 가지고 있는 기존의 Loading의 책임을 분리하기 위해 Idle이라는 상태를 추가한 것이다.

나는 이 로직을 이해하려고 할 때 코드만 보고, 어? 상태가 Success나 Failure이면 exitGroup이 호출될 수 있는건 수정 전 코드나 수정 후 코드나 같잖아?? 그러면 이게 의미가 있나? 라는 생각을 했었다.

이 말도 맞다. API 호출이 끝난 후에는 다시 호출하면 함수가 다시 실행되는 것이다. 하지만 우리가 방어하고자 하는 시나리오는 API가 호출 중일 때 다시 호출되는 상황을 막는 것이다.

회고

지금의 코드에서는 실패했을 때 exitGroup의 상태가 Failure로 바뀌며 상태가 더 이상 바뀌지 않는다. 실패에서 끝? 이라는 생각도 들지만 내가 생각한 사용자 시나리오는 실패 -> 스낵바 확인 -> 사용자가 뒤로 나가기 였다. 이 시나리오가 된다면 Failure에서 exitState가 멈추는 것이 아니라 나가고 다시 사용자가 들어오면서 Idle로 바뀌게 되는 것이다.

내가 생각했을 때 가장 자연스럽다고 느껴지는 시나리오지만, 이게 정답일까? 라는 생각도 한편으로 든다.

profile
천천히, 꾸준히, 한 걸음씩

0개의 댓글