- Published on
Kotlin Flow flatMapLatest로 중복 요청 막는 패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
앱에서 검색창 입력, 필터 토글, 스크롤 기반 페이지 로딩처럼 사용자의 이벤트가 연속으로 발생하면 네트워크 요청이 쉽게 폭주합니다. Kotlin Flow는 flatMapLatest로 “마지막 값만 유지”하는 스트림을 만들 수 있지만, 실제 앱에서는 중복 요청이 여전히 발생하거나, 반대로 취소가 기대대로 전파되지 않아 서버 부하와 UI 깜빡임을 유발하는 경우가 많습니다.
이 글에서는 flatMapLatest를 중심으로 중복 요청을 막는 실전 패턴을 정리합니다.
- 같은 파라미터로 반복 호출되는 요청을 어떻게 차단할지
- 빠른 입력에서 이전 요청을 어떻게 취소할지
- 여러 구독자가 붙어도 요청을 한 번만 보내려면 어떻게 해야 할지
- Retrofit/OkHttp에서 취소가 실제로 동작하도록 만들려면 무엇을 확인해야 할지
네트워크가 불안정할 때 재시도/타임아웃 이슈까지 같이 보려면 Node.js fetch ECONNRESET·ETIMEDOUT 해결법도 맥락이 비슷합니다(클라이언트 관점에서 “요청 폭주 + 타임아웃”이 한꺼번에 터지는 케이스).
flatMapLatest가 ‘중복 요청 방지’의 만능이 아닌 이유
flatMapLatest의 핵심은 다음입니다.
- upstream에서 새 값이 오면
- 이전에 실행 중이던 inner flow를 취소하고
- 새 inner flow로 갈아탄다
하지만 이것만으로 중복 요청이 완전히 막히지 않는 이유가 몇 가지 있습니다.
- 동일 파라미터가 연속으로 들어오면 마지막만 남겨도 “마지막 요청”은 결국 실행됩니다. 즉, 동일 값이 10번 들어오면 취소되더라도 요청이 10번 시작될 수 있습니다(특히 취소가 늦게 전파되면).
- 취소 전파가 안 되는 네트워크 호출이면, Flow는 취소했지만 HTTP 요청은 계속 진행됩니다.
- 여러 곳에서 collect 하면 동일한 Flow 체인을 여러 번 실행하여 요청이 중복될 수 있습니다(Cold Flow의 기본 특성).
따라서 실전에서는 flatMapLatest 앞뒤로 “입력 정규화 + 중복 제거 + 공유”를 같이 넣는 경우가 많습니다.
기본 골격: UI 이벤트를 Query Flow로 만들기
예시는 검색 API를 기준으로 하겠습니다.
- 사용자가 검색어를 입력한다
- 일정 시간 입력이 멈추면 호출한다
- 같은 검색어면 다시 호출하지 않는다
- 새 검색어가 오면 이전 요청은 취소한다
권장 패턴 1: debounce + distinctUntilChanged + flatMapLatest
class SearchViewModel(
private val repository: SearchRepository
) : ViewModel() {
private val query = MutableStateFlow("")
fun onQueryChanged(text: String) {
query.value = text
}
val uiState: StateFlow<SearchUiState> = query
.map { it.trim() }
.debounce(300)
.distinctUntilChanged()
.flatMapLatest { q ->
if (q.isBlank()) {
flowOf(SearchUiState.Empty)
} else {
repository.search(q)
.map<SearchResult, SearchUiState> { SearchUiState.Success(it) }
.onStart { emit(SearchUiState.Loading(q)) }
.catch { e -> emit(SearchUiState.Error(q, e)) }
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = SearchUiState.Empty
)
}
sealed interface SearchUiState {
data object Empty : SearchUiState
data class Loading(val query: String) : SearchUiState
data class Success(val data: SearchResult) : SearchUiState
data class Error(val query: String, val error: Throwable) : SearchUiState
}
이 패턴에서 중복 요청을 막는 핵심은 distinctUntilChanged()입니다.
debounce(300)은 “입력 폭주”를 줄이고distinctUntilChanged()는 “동일 값 반복”을 제거하며flatMapLatest는 “새 값이 오면 이전 요청 취소”를 보장합니다.
여기서 자주 빠지는 부분이 trim() 같은 정규화(normalization) 입니다. 공백 하나 차이로 동일 요청이 다시 날아가는 것을 막습니다.
취소가 진짜로 HTTP 요청을 멈추게 만들기
Flow에서 취소가 일어나도 네트워크 계층이 취소를 무시하면 서버에는 요청이 계속 들어갑니다. 특히 다음 상황에서 문제가 커집니다.
- 사용자가 입력을 계속 바꾸는데 서버에 요청이 계속 쌓임
- 서버가 느리거나 타임아웃이 길어서 이전 요청이 오래 살아있음
- 결과가 뒤늦게 도착해 UI를 덮어쓰거나, 캐시를 오염시킴
Retrofit suspend 함수는 취소 전파가 되는가
일반적으로 Retrofit의 suspend API는 코루틴 취소 시 OkHttp Call.cancel()이 호출되어 요청이 취소됩니다. 하지만 다음을 확인해야 합니다.
- 인터셉터에서 블로킹 작업을 길게 하지 않는지
withContext(Dispatchers.IO)로 감쌌는데 내부에서 또 블로킹이 있는지- 커스텀 CallAdapter나 래퍼에서 취소를 삼키지 않는지
Flow에서 네트워크를 감쌀 때는 cancellable하게
레거시 콜백 기반 API를 Flow로 감싸는 경우 callbackFlow를 쓰는데, 이때 awaitClose에서 취소 처리를 반드시 해야 합니다.
fun SearchApi.searchAsFlow(query: String): Flow<SearchResult> = callbackFlow {
val call = searchCall(query) // OkHttp Call 혹은 Retrofit Call
call.enqueue(object : Callback {
override fun onResponse(result: SearchResult) {
trySend(result)
close()
}
override fun onFailure(t: Throwable) {
close(t)
}
})
awaitClose {
call.cancel() // 이게 없으면 flatMapLatest가 취소해도 요청이 살아있을 수 있음
}
}
이 지점이 flatMapLatest 기반 중복 요청 방지에서 가장 중요한 “숨은 함정”입니다. Flow 레벨에서 취소를 잘해도, 실제 I/O가 취소되지 않으면 서버 관점에서는 중복 요청이 계속됩니다.
동일 파라미터 중복 호출을 더 강하게 막는 방법
distinctUntilChanged()는 “연속된 중복”만 제거합니다. 예를 들어 a -> b -> a는 다시 요청이 나갑니다. 이것이 원하는 동작일 수도 있지만, 다음처럼 “짧은 시간 내 동일 파라미터 재요청”까지 막고 싶을 때가 있습니다.
- 화면 회전/재진입으로 동일 쿼리가 다시 흘러들어옴
- 여러 UI 이벤트가 같은 쿼리를 다시 발행
권장 패턴 2: 파라미터 키 기반 in-flight dedupe
요청이 진행 중일 때 같은 키로 또 호출되면 기존 요청 결과를 공유하거나 무시하는 방식입니다.
class DedupeSearchRepository(
private val api: SearchApi,
private val scope: CoroutineScope
) {
private val inFlight = mutableMapOf<String, SharedFlow<Result<SearchResult>>>()
fun search(query: String): Flow<Result<SearchResult>> {
synchronized(inFlight) {
inFlight[query]?.let { return it }
val shared = flow {
emit(Result.success(api.search(query)))
}
.catch { e -> emit(Result.failure(e)) }
.onCompletion {
synchronized(inFlight) { inFlight.remove(query) }
}
.shareIn(
scope = scope,
started = SharingStarted.WhileSubscribed(5_000),
replay = 1
)
inFlight[query] = shared
return shared
}
}
}
포인트는 다음입니다.
- key를
query로 두고 in-flight map에서 공유 Flow를 꺼냅니다. shareIn으로 실제 네트워크 호출은 한 번만 일어나고, 여러 collector가 결과를 공유합니다.- 완료되면 map에서 제거합니다.
이 패턴은 “중복 결제 방지” 같은 문제와 구조가 유사합니다. 요청을 멱등하게 설계하는 관점은 MSA 사가(Saga) 패턴 구현으로 중복결제 방지하기에서 다룬 내용과도 연결됩니다.
여러 collector로 인한 중복 요청 차단: shareIn/stateIn
Flow는 기본적으로 Cold입니다. 즉, 아래처럼 uiState를 두 군데에서 collect하면 네트워크 요청이 두 번 나갈 수 있습니다.
- Fragment에서 collect
- Compose에서 collect
- 혹은 테스트/로깅 collector가 추가됨
이를 막으려면 네트워크를 포함한 체인을 hot으로 만들어 공유해야 합니다.
권장 패턴 3: 결과 스트림을 stateIn으로 고정
앞의 예시처럼 stateIn을 쓰면 collector가 여러 개여도 upstream 실행은 1회로 수렴합니다(같은 StateFlow 인스턴스를 공유한다는 전제).
추가로, repository 레벨에서 다음처럼 캐시를 둔 StateFlow를 제공하는 방식도 자주 씁니다.
class SearchStore(
private val repository: SearchRepository,
private val scope: CoroutineScope
) {
private val query = MutableStateFlow("")
val results: StateFlow<SearchUiState> = query
.debounce(300)
.map { it.trim() }
.distinctUntilChanged()
.flatMapLatest { q ->
if (q.isBlank()) flowOf(SearchUiState.Empty)
else repository.search(q)
.map<SearchResult, SearchUiState> { SearchUiState.Success(it) }
.onStart { emit(SearchUiState.Loading(q)) }
.catch { e -> emit(SearchUiState.Error(q, e)) }
}
.stateIn(scope, SharingStarted.WhileSubscribed(5_000), SearchUiState.Empty)
fun setQuery(q: String) {
query.value = q
}
}
UI는 results만 구독하고, query만 세팅합니다. 이렇게 하면 “구독자가 늘어나면서 요청이 늘어나는 문제”를 구조적으로 차단할 수 있습니다.
flatMapLatest를 어디에 걸어야 하는가: 이벤트 vs 상태
중복 요청 방지에서 흔한 실수는 flatMapLatest를 “너무 아래”에 거는 것입니다.
- 잘못된 예: 네트워크 호출 이후의 가공 단계에
flatMapLatest를 둠 - 올바른 예: 네트워크 호출을 생성하는 지점에
flatMapLatest를 둠
즉, “새 입력이 오면 이전 HTTP 호출 자체를 취소”하려면, HTTP 호출을 감싼 Flow를 flatMapLatest의 inner로 넣어야 합니다.
예외/재시도까지 포함한 실전형 템플릿
실무에서는 실패 시 재시도 정책을 넣되, 사용자 입력이 바뀌면 즉시 취소되어야 합니다. 아래는 재시도를 포함한 형태입니다.
val uiState: StateFlow<SearchUiState> = query
.map { it.trim() }
.debounce(300)
.distinctUntilChanged()
.flatMapLatest { q ->
if (q.isBlank()) return@flatMapLatest flowOf(SearchUiState.Empty)
flow {
emit(api.search(q))
}
.retryWhen { cause, attempt ->
// 입력이 바뀌면 flatMapLatest가 취소하므로 여기까지 오지 않음
val shouldRetry = attempt < 2
val isTransient = cause is java.io.IOException
if (shouldRetry && isTransient) {
kotlinx.coroutines.delay(200L * (attempt + 1))
true
} else {
false
}
}
.map<SearchResult, SearchUiState> { SearchUiState.Success(it) }
.onStart { emit(SearchUiState.Loading(q)) }
.catch { e -> emit(SearchUiState.Error(q, e)) }
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SearchUiState.Empty)
여기서도 중요한 점은 “재시도는 하되 입력 변경이 최우선으로 취소를 이겨야 한다”입니다. flatMapLatest 바깥에서 retry를 걸면 이전 요청이 살아남을 수 있으니, 재시도는 inner flow에 두는 편이 안전합니다.
체크리스트: 중복 요청이 계속 날아갈 때 확인할 것
distinctUntilChanged()가 빠져 있지 않은가- 입력 정규화(
trim,lowercase, 정렬 등)가 필요한데 안 하고 있지 않은가 - 네트워크 호출이 취소 전파 가능한 형태인가
- Retrofit
suspend사용 여부 callbackFlow라면awaitClose { cancel }가 있는지
- Retrofit
- 동일 Flow 체인을 여러 곳에서 새로 만들고 collect하고 있지 않은가
stateIn혹은shareIn으로 공유하고 있는지
- “연속 중복”이 아니라 “시간 간격 중복”도 막아야 하는 요구사항인가
- in-flight dedupe 또는 캐시 전략이 필요한지
마무리
flatMapLatest는 “마지막 입력만 반영”하는 데 매우 강력하지만, 중복 요청을 제대로 막으려면 다음 조합이 사실상 표준입니다.
debounce로 입력 폭주 완화distinctUntilChanged로 동일 파라미터 중복 제거flatMapLatest로 이전 요청 취소stateIn또는shareIn으로 다중 구독 중복 실행 방지- 네트워크 계층에서 취소 전파 보장(
awaitClose { cancel }등)
이 조합을 템플릿처럼 잡아두면, 검색/필터/자동완성/추천 등 대부분의 “연속 입력 + 네트워크” 화면에서 중복 요청 문제를 안정적으로 해결할 수 있습니다.