주완 님의 블로그
결제 요청 시 collectLatest 와 collect 의 사용 본문
동아리에서 합동 세미나를 진행하던 와중…. 아래와 같은 교환하기 뷰를 담당하게 되었습니다

기획과 디자인에서 요구한 플로우는 간단했습니다. 교환하기 버튼을 누르면 API 호출 후 화면이 전환되는 것까지였죠. 다이얼로그나 교환 완료 처리 같은 건 필요하지 않았습니다. 하지만 Coroutine에 대해 배웠던 내용을 떠올리며, 문득 이런 생각이 들었습니다.
만약 사용자가 교환하기 버튼을 연타하면 어떻게 될까?
문제 상황을 가정하자면 : 교환하기 버튼을 연타하면 요청된 수만큼 포인트가 차감되는 상황으로 잡아봤습니다.
private val _purchaseSuccess = MutableStateFlow(false)
val purchaseSuccess: StateFlow<Boolean> = _purchaseSuccess
// 현재 코드*
fun postPurchase(productId: Int) {
viewModelScope.launch {
goodsCheckRepository.postPurchase(userId, productId)
.onSuccess {
_purchaseSuccess.value = true
}
}
}
사용자가 버튼을 클릭하면 postPurchase 함수가 호출되고, 교환 API 요청이 이루어집니다
이때, 빠르게 두 번 클릭하면 launch가 두 번 실행되고, API 요청도 두 번 날아갑니다. 결제와 같은 예민한 부분의 기능을 생각해보면 매우 아찔한 상황이죠
왜 이런 일이 발생할까?
*// 사용자가 버튼을 두 번 클릭*
onPurchaseClick() *// 1번째 launch 시작*
onPurchaseClick() *// 2번째 launch 시작// 두 개의 독립적인 코루틴이 동시에 실행*
viewModelScope.launch { *API 호출 1* }
viewModelScope.launch { *API 호출 2* }
launch 는 "새로운 작업을 시작하고 바로 반환" 합니다. 이전 작업이 끝나길 기다리지 않죠.
따라서 버튼을 연타하면 누른 횟수만큼 독립적인 코루틴들이 동시에 실행되고, 각각의 API 요청이 서버로 날아가게 됩니다.

해 자료를 보면서 저는 이렇게 생각했습니다.
"결제는 마지막 요청만 처리하면 되잖아? collectLatest를 쓰면 중복 요청을 막을 수 있지 않을까?"
제가 생각한 바로는, 어차피 사용자가 실수로 중복해서 누르거나 의도치 않게 계속 누른다고 했을 때, 결국 마지막에 보낸 요청만 처리하면 결제는 한 번에 이루어지는 거니까 괜찮지 않나..라고 생각했는데요...

collectLatest vs collect의 차이
먼저 두 연산자의 차이를 정리하자면
- collect: 모든 값을 순차적으로 처리하며, 이전 처리가 끝날 때까지 대기
- collectLatest: 새 값이 오면 이전 처리를 즉시 취소하고, 최신 값만 처리
이론적으로는 collectLatest가 "마지막 것만 처리"하니까 중복 요청을 막을 수 있을 것 같았습니다.
근데 안되더라구요 ㅋㅋ (럴수 럴수 이럴수가...)
왜 작동하지 않을까?
*// 처음엔 이렇게 시도해봤습니다*
fun postPurchase(productId: Int) {
viewModelScope.launch {
flow { emit(productId) }
.collectLatest { id ->
goodsCheckRepository.postPurchase(userId, id)
.onSuccess {
_purchaseSuccess.value = true
}
}
}
}
하지만 이건 작동하지 않았습니다..
왜냐하면:
- 버튼을 두 번 클릭하면 → postPurchase()가 두 번 호출됨
- 각 호출마다 새로운 Flow가 생성되고, 새로운 코루틴이 실행됨
- collectLatest는 같은 Flow 내에서 새 값이 올 때만 이전 처리를 취소함
- 우리 경우엔 Flow 자체가 매번 새로 만들어지므로, collectLatest가 무용지물
*// 실제로 일어나는 일*
첫 번째 클릭: Flow1 생성 → collectLatest 시작 → API 호출 1
두 번째 클릭: Flow2 생성 → collectLatest 시작 → API 호출 2
*// Flow1과 Flow2는 서로 독립적이므로, collectLatest로 취소 불가!*
collectLatest 는 하나의 Flow 스트림 안에서 연속적으로 들어오는 값들을 처리할 때 유용합니다. 하지만 함수를 호출할 때마다 새로운 Flow를 만드는 경우엔 아무 소용이 없던 것이였습니다(멍청)
그럼 Flow를 재사용하면 되지 않나?
호출마다 새로운 Flow를 생성하는 거니까, 동일한 flow 로 처리하면 어떨까? 해서 수정을 해봤습니다
*// SharedFlow로 이벤트 전달*
private val purchaseEvents = MutableSharedFlow<Int>()
init {
viewModelScope.launch {
purchaseEvents
.collectLatest { productId ->
goodsCheckRepository.postPurchase(userId, productId)
.onSuccess {
_purchaseSuccess.value = true
}
}
}
}
fun postPurchase(productId: Int) {
viewModelScope.launch {
purchaseEvents.emit(productId) *// Flow에 값만 전달*
}
}
이론적으로는 가능한 것 같긴한데, 그렇게 좋은 접근을 아니었던 것 같습니다.
일단, init 블록에 로직 분리, Flow 관리 등을 통해서 관리하기에 복잡해지기도 하고
"중복 실행 방지"가 목적이지, "이전 요청 취소"가 아니기에 의도와 다르게 흘러 갔던 것 같습니다.
collectLatest는 "같은 스트림에서 새 값이 계속 들어올 때"유용합니다. 예를 들어,, 검색어 입력처럼 하나의 Flow에서 값이 계속 변경되는 상황에서 유용할 것 같습니다. 하지만 매번 새로운 작업을 시작하는 경우에는 전혀 도움이 되지 않습니다.
그럼 어떻게 할까
그래서, 가장 보편적으로 해결할 수 있는 방법을 생각해봤을 때는, 버튼으로 교환 요청 시 loading 상태를 따로 관리하여 나타내는 것입니다.
물론, 저희 피그마에는 따로 없었지만 보통 결제 요청 시 로딩 화면을 띄워, 처리중임을 알려줄 수 있는 방법이라 생각했습니다.
1. 로딩 상태로 중복 클릭 방지
@HiltViewModel
class PointDetailViewModel @Inject constructor(
private val goodsCheckRepository: GoodsCheckRepository,
) : ViewModel() {
private val _goodsCheckState = MutableStateFlow(GoodsCheck.Empty)
val goodsCheckState: StateFlow<GoodsCheck> = _goodsCheckState
private val _purchaseSuccess = MutableStateFlow(false)
val purchaseSuccess: StateFlow<Boolean> = _purchaseSuccess
*// 로딩 상태 추가*
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading
private val userId: Long = 1L
fun postPurchase(productId: Int) {
*// 이미 로딩 중이면 무시*
if (_isLoading.value) return
viewModelScope.launch {
_isLoading.value = true *// 로딩 시작*
goodsCheckRepository.postPurchase(userId, productId)
.onSuccess {
_purchaseSuccess.value = true
}
.onFailure {
*// 에러 처리*
}
_isLoading.value = false *// 로딩 종료*
}
}
}
*// UI에서 로딩 상태 반영*
@Composable
fun PointDetailRoute(
currentPoint: Int,
productId: Int,
navController: NavController,
viewModel: PointDetailViewModel = hiltViewModel(),
) {
val goodsCheckState by viewModel.goodsCheckState.collectAsStateWithLifecycle()
val purchaseSuccess by viewModel.purchaseSuccess.collectAsStateWithLifecycle()
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
PointDetailScreen(
currentPoint = currentPoint,
goodsCheckState = goodsCheckState,
isLoading = isLoading,
onPurchaseClick = {
viewModel.postPurchase(productId)
}
)
}
@Composable
fun PointDetailScreen(
currentPoint: Int,
goodsCheckState: GoodsCheck,
isLoading: Boolean,
modifier: Modifier = Modifier,
onPurchaseClick: () -> Unit = {},
) {
PointPaySection(
label = "교환하기",
myPoint = currentPoint.toString(),
productPrice = goodsCheckState.price,
modifier = modifier.align(alignment = Alignment.BottomCenter),
canPayed = canPurchase && !isLoading, *// 로딩 중이면 버튼 비활성화*
iconRes = R.drawable.ic_point,
onPayClick = onPurchaseClick,
isLoading = isLoading *// 로딩 인디케이터 표시*
)
}
⇒ 이렇게 되면 요청을 보낼 시에 로딩화면을 통해서 사용자에게 명확한 피드백 제공 (로딩 중임을 알려줌)
++ Mutex로 동시 실행 방지
운영체제 공부할 때 들어본 친구인데요… 사실 이건 써본적도 없고, 잘 모르겠어서 사용하게 된다면 어떻게 사용할 수 있을지만 살짝 낋여와 봤습니다.
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@HiltViewModel
class PointDetailViewModel @Inject constructor(
private val goodsCheckRepository: GoodsCheckRepository,
) : ViewModel() {
private val purchaseMutex = Mutex()
fun postPurchase(productId: Int) {
viewModelScope.launch {
*// Mutex로 보호: 한 번에 하나의 코루틴만 실행*
purchaseMutex.withLock {
goodsCheckRepository.postPurchase(userId, productId)
.onSuccess {
_purchaseSuccess.value = true
}
.onFailure {
*// 에러 처리*
}
}
}
}
}
동작 원리:
- Mutex는 뮤텍스(상호 배제, Mutual Exclusion)의 줄임말로, 여러 코루틴이 동시에 같은 코드 블록을 실행하지 못하도록 막아줍니다.
- 첫 번째 클릭: withLock 진입 → API 호출 → 완료 후 lock 해제
- 두 번째 클릭: lock이 해제될 때까지 대기 → 첫 번째 완료 후 실행
이렇게 사용할 수 있다고 합니다.. 이 쪽은 공부를 더 해봐야겠어요..
[Kotlin] Mutex 객체 사용해 코루틴에 락 걸기
[Kotlin] Mutex 객체 사용해 코루틴에 락 걸기
코루틴에서의 락우리는 이전 글에서 코루틴에서 ReentrantLock 사용해 락을 걸게 될 경우, 락이 해제 되기 전에 코루틴의 스레드 양보가 일어나면 데드락이 발생할 수 있다는 것을 알아보았다. [Kotli
kotlinworld.com
암튼 그래서, 합동 세미나를 진행하면서 결제 관련해서 실습을 좀 해봤습니다…
flow 랑 coroutine 은 뭔가 계속 공부하는데도 이해가 잘 안되는 부분이 많네요,
세미나 복습 겸 해당 주제를 들고와 봤구요,, 결제 요청 상황에서 다른 분들은 어떻게 처리하는지 궁금하기도 해서 작성해 봤습니다.
'Android' 카테고리의 다른 글
| [Kotlin] Stable 과 Immutable 톺아보기 (0) | 2025.12.20 |
|---|---|
| Hilt 의 정의와 사용법 (0) | 2025.12.10 |
| Android에서 Domain Layer와 UseCase: 언제 필요하고 언제 생략할까? (0) | 2025.12.03 |
| Android - jetpack navigation 사용시 중첩 네비게이션에 대하여 (0) | 2025.11.11 |
| android- 이미지 라이브러리 Coil (1) | 2025.11.07 |