SharedFlow는 Kotlin Coroutines 라이브러리의 핫 리액티브 플로우로, 화면 회전이나 구독자 재생성 시 반복되지 않아야 하는 단발성 이벤트(one-shot events)에 최적화되어 있습니다. SharedFlow가 StateFlow와 어떻게 다른지 보여드립니다. StateFlow와 달리 SharedFlow는 새 구독자를 위해 마지막 값을 저장하지 않으며 replay, extraBufferCapacity 및 onBufferOverflow 구성을 지원합니다. Google(Android Developers, 2025)에 따르면 SharedFlow는 탐색 명령, Snackbar 메시지 및 정확히 한 번 처리되어야 하는 기타 이벤트에 권장되는 솔루션입니다.
주요 포인트
SharedFlow는 kotlinx.coroutines.flow 라이브러리의 핫 플로우(hot flow)로, StateFlow와 달리 단일 상태에 묶여 있지 않으며 임의의 구독자에게 임의의 수의 이벤트를 발행할 수 있습니다. SharedFlow는 StateFlow의 기본 유형입니다. StateFlow는 실제로 replay = 1인 SharedFlow를 통해 구현됩니다.
SharedFlow의 주요 특징은 마지막 값을 저장할 필요가 없다는 것입니다. 기본적으로(replay = 0) 새 이벤트가 전송될 때까지 새 구독자는 아무 것도 받지 않습니다. 이로 인해 SharedFlow는 이벤트를 정확히 한 번 처리해야 하는 시나리오(탐색, Snackbar, 시스템 알림, QR 코드 스캔 결과)에 이상적입니다.
SharedFlow는 kotlinx.coroutines 1.4.0(2020년 11월)에서 StateFlow와 함께 안정화되었습니다. Kotlin Coroutines 문서(2025)에 따르면 SharedFlow는 구독자 동기화를 위해 세밀한 잠금을 사용하며 JetBrains 테스트를 통해 확인된 성능 저하 없이 1000개 이상의 동시 구독자까지 선형 확장성을 제공합니다.
SharedFlow와 StateFlow 중 선택은 전송되는 데이터의 의미론(상태는 StateFlow, 이벤트는 SharedFlow)에 따라 달라집니다. 아래에 예시와 함께 명확한 기준이 나와 있습니다.
| 기준 | SharedFlow | StateFlow |
|---|---|---|
| 의미론 | 단발성 이벤트(탐색, 토스트, 알림) | UI 상태(목록, 로딩, 오류) |
| 초기 값 | 필요 없음 | 필요함 |
| 구독 시 재생 | replay > 0인 경우에만 | 항상 마지막 값 |
| 병합 | 아니오 — 이벤트가 손실되지 않음(버퍼가 가득 차지 않은 경우) | 예 — 최신 값만 저장 |
| 버퍼링 | replay + extraBufferCapacity를 통해 구성 가능 | 1만 가능(replay=1 고정) |
| 사용법 | navigationEvent, showSnackbar, openDialog | items, isLoading, uiState |
가장 간단한 규칙: 데이터가 화면 회전 시 표시되어야 하면 상태(StateFlow)입니다. 화면 회전 시 이벤트가 반복되지 않아야 하면 단발성 이벤트(SharedFlow)입니다. 예를 들어, 오류 메시지가 있는 토스트는 SharedFlow입니다. 회전 시 토스트가 다시 나타나지 않아야 합니다. 상품 목록은 StateFlow입니다. 회전 시 목록이 화면에 유지되어야 합니다.
IT Sectr에서는 SharedFlow를 다음 용도로 사용합니다: 탐색 명령(화면 전환, 딥 링크 열기), UI 이벤트(Snackbar, AlertDialog), 시스템 알림(백그라운드 데이터 업데이트, 결제 결과), 분석 이벤트(로깅, 추적).
MutableSharedFlow는 SharedFlow의 변경 가능한 버전으로, 이벤트 전송을 위한 emit()(suspend) 및 tryEmit()(비-suspend) 메서드가 있습니다. emit()은 버퍼가 가득 차고 onBufferOverflow = SUSPEND인 경우 일시 중단됩니다. tryEmit()은 이벤트가 버퍼에 성공적으로 추가되었는지 여부를 나타내는 Boolean을 반환합니다.
class EventBus {
private val _events = MutableSharedFlow<UiEvent>(
replay = 0,
extraBufferCapacity = 10,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val events: SharedFlow<UiEvent> get() = _events
suspend fun sendEvent(event: UiEvent) {
_events.emit(event)
}
fun trySendEvent(event: UiEvent): Boolean {
return _events.tryEmit(event)
}
}
sealed interface UiEvent {
data class ShowSnackbar(val message: String) : UiEvent
data class NavigateTo(val route: String) : UiEvent
data class ShowDialog(val title: String, val message: String) : UiEvent
}
생성자 매개변수는 매우 중요합니다. replay = 0은 새 구독자에게 이벤트가 반복되지 않도록 보장합니다. extraBufferCapacity = 10은 UI가 구독하기 전에 빠른 이벤트 발행을 위한 버퍼를 제공합니다. DROP_OLDEST는 오버플로우 전략으로, 오래된 이벤트는 버려지고 새 이벤트는 보존됩니다. Kotlin Coroutines Performance(JetBrains, 2024)에 따르면 extraBufferCapacity = 64인 SharedFlow는 손실 없이 초당 100,000개 이상의 이벤트를 처리합니다.
Event 패턴(또는 UiEvent)은 ViewModel에서 View로 단발성 이벤트를 전달하는 Google 권장 방법입니다. 상태(StateFlow)와 달리 이벤트는 정확히 한 번 처리되어야 하며 화면 회전 시 반복되지 않아야 합니다. replay = 0인 SharedFlow가 이 작업에 이상적입니다.
class CheckoutViewModel : ViewModel() {
private val _uiState = MutableStateFlow<CheckoutState>(CheckoutState.Idle)
val uiState: StateFlow<CheckoutState> get() = _uiState
private val _event = MutableSharedFlow<CheckoutEvent>()
val event: SharedFlow<CheckoutEvent> get() = _event
fun placeOrder() {
viewModelScope.launch {
_uiState.value = CheckoutState.Loading
try {
val orderId = orderRepository.createOrder(cart)
_uiState.value = CheckoutState.Success(orderId)
_event.emit(CheckoutEvent.NavigateToOrderTracking(orderId))
} catch (e: Exception) {
_uiState.value = CheckoutState.Error(e.message)
_event.emit(CheckoutEvent.ShowErrorSnackbar(e.message ?: "레이아웃 오류"))
}
}
}
}
sealed interface CheckoutEvent {
data class NavigateToOrderTracking(val orderId: String) : CheckoutEvent
data class ShowErrorSnackbar(val message: String) : CheckoutEvent
}
View(Activity/Fragment) 측: 이벤트 구독은 lifecycleScope에서 repeatOnLifecycle(STATE.STARTED)로 수행해야 합니다. STARTED에 진입할 때마다 구독이 다시 생성되지만 replay=0인 SharedFlow가 이미 이벤트를 해제했으므로 이벤트가 반복되지 않습니다. 이렇게 하면 주문 추적 화면으로의 탐색이 회전할 때마다가 아니라 한 번만 발생합니다.
MutableSharedFlow 생성자는 버퍼 동작을 결정하는 세 개의 매개변수를 받습니다. 잘못된 구성은 이벤트 손실 또는 emit() 차단으로 이어질 수 있습니다.
| 매개변수 | 유형 | 기본값 | 설명 |
|---|---|---|---|
| replay | Int | 0 | 새 구독자에게 재생되는 최근 이벤트 수. 0 = 재생 안 함, 1 = StateFlow처럼 |
| extraBufferCapacity | Int | 0 | replay를 초과하는 추가 버퍼. 이벤트는 순환 버퍼에 저장됩니다. 대부분의 시나리오에서 권장 제한은 64 |
| onBufferOverflow | BufferOverflow | SUSPEND | 버퍼가 가득 찼을 때 전략: SUSPEND, DROP_OLDEST, DROP_LATEST |
// 다양한 시나리오에 대한 구성:
// 1. 단발성 UI 이벤트(탐색, 토스트)
val uiEvents = MutableSharedFlow<UiEvent>(
replay = 0,
extraBufferCapacity = 5,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
// 2. 상태 동기화를 위한 재생 플로우(StateFlow처럼)
val stateLike = MutableSharedFlow<AppState>(
replay = 1,
extraBufferCapacity = 0
)
// 3. 고빈도 이벤트 발행(분석, 로그)
val analytics = MutableSharedFlow<AnalyticsEvent>(
replay = 0,
extraBufferCapacity = 100,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
중요: extraBufferCapacity + replay = 총 버퍼 크기입니다. 구독자가 이벤트를 처리하는 것보다 emit()이 더 빠르게 호출되면 버퍼가 가득 차고 onBufferOverflow가 작동합니다. UI 이벤트의 경우 DROP_OLDEST가 안전한 전략입니다. 오래된 이벤트(더 이상 관련 없는 탐색)는 새 이벤트를 위해 버려집니다. 금융 거래의 경우 SUSPEND를 사용하세요. 이는 발신자를 차단하는 대가로 어떤 이벤트도 손실되지 않도록 보장합니다.
탐색 명령은 SharedFlow의 전형적인 사용 사례입니다. Fragment가 이벤트를 구독하고 탐색을 수행합니다. 화면 회전 시 명령이 반복되지 않습니다.
// ViewModel
class AuthViewModel : ViewModel() {
private val _navEvent = MutableSharedFlow<NavEvent>()
val navEvent: SharedFlow<NavEvent> get() = _navEvent
fun onLoginSuccess() {
viewModelScope.launch {
_navEvent.emit(NavEvent.NavigateTo(NavRoutes.HOME))
}
}
fun onLogout() {
viewModelScope.launch {
_navEvent.emit(NavEvent.NavigateTo(NavRoutes.LOGIN))
}
}
}
sealed interface NavEvent {
data class NavigateTo(val route: String) : NavEvent
data class NavigateBack(val popUpTo: String? = null) : NavEvent
}
// Fragment에서:
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.navEvent.collect { navEvent ->
when (navEvent) {
is NavEvent.NavigateTo -> findNavController().navigate(navEvent.route)
is NavEvent.NavigateBack -> findNavController().popBackStack()
}
}
}
}
복잡한 시나리오: UI용 StateFlow와 결합된 백그라운드 이벤트 알림용 SharedFlow.
class NotificationViewModel : ViewModel() {
private val _toastMessage = MutableSharedFlow<String>()
val toastMessage: SharedFlow<String> get() = _toastMessage
private val _notifications = MutableStateFlow<List<Notification>>(emptyList())
val notifications: StateFlow<List<Notification>> get() = _notifications
init {
viewModelScope.launch {
notificationChannel
.consumeAsFlow()
.collect { notification ->
_notifications.value = _notifications.value + notification
_toastMessage.emit("새 알림: ${notification.title}")
}
}
}
fun dismissNotification(id: String) {
_notifications.value = _notifications.value.filter { it.id != id }
}
fun markAllRead() {
viewModelScope.launch {
_notifications.value = _notifications.value.map { it.copy(isRead = true) }
_toastMessage.emit("모든 알림을 읽음으로 표시")
}
}
}
이 예제에서: StateFlow는 알림 목록을 저장하고(상태 — 회전 시 보존됨), SharedFlow는 토스트 메시지를 발행합니다(단발성 이벤트 — 회전 시 반복되지 않음). 두 Flow 유형의 조합은 2022년부터 ViewModel에 대한 Google 권장 패턴입니다.
자주 묻는 질문
네, 버퍼가 가득 차고 onBufferOverflow = DROP_OLDEST 또는 DROP_LATEST인 경우 가능합니다. SharedFlow는 모든 이벤트의 전달을 보장하지 않습니다. 이는 메시지 대기열(Channel과 같은)이 아닙니다. 모든 이벤트의 보장된 전달이 필요한 경우 무제한 버퍼(UNLIMITED) 또는 BroadcastChannel(더 이상 사용되지 않음)이 있는 Channel을 사용하세요. UI 이벤트의 경우 오래된 이벤트(예: 이전 탐색)의 손실은 예상된 동작이며 버그가 아닙니다.
Channel은 FIFO 대기열로, 각 이벤트가 정확히 한 구독자에게 전달됩니다(점대점). SharedFlow는 브로드캐스트로, 각 이벤트가 모든 활성 구독자에게 전달됩니다. SharedFlow는 BroadcastChannel(더 이상 사용되지 않음)에 가깝고 일대다 시나리오에 적합합니다. Channel은 일대일(스레드 풀, 파이프라인)용입니다. JetBrains 권장 사항에 따르면 SharedFlow는 모든 새 프로젝트에서 BroadcastChannel을 대체합니다.
SharedFlow는 이미 스레드 안전합니다. emit()과 collect()는 적절히 동기화됩니다. 여러 스레드가 잠금 없이 emit()을 호출할 수 있으며 모든 활성 구독자가 올바른 순서로 이벤트를 받습니다. tryEmit()은 비차단이며 버퍼가 가득 차면 false를 반환합니다. 고부하 시스템의 경우 DROP_OLDEST와 함께 tryEmit()을 사용하세요. 이렇게 하면 스레드 차단이 방지됩니다.
replay=1이 없는 SharedFlow는 마지막 값을 저장하지 않습니다. 화면 회전 시 새 구독자는 현재 상태를 받지 못하고 UI가 비어 있게 됩니다. replay=1이 있으면 SharedFlow는 StateFlow처럼 동작하지만 equals()를 통한 비교 최적화를 잃어 동일한 값이 다시 발행될 때 불필요한 알림이 발생합니다. StateFlow는 상태에 적합한 선택이며 SharedFlow는 이벤트용입니다.
SharedFlow 테스트에는 Flow 테스트용 Kotlin 라이브러리인 Turbine을 사용하세요. Turbine을 사용하면 타임아웃 및 완료 확인과 함께 각 발행을 개별적으로 확인할 수 있습니다. 예: viewModel.event.test { assertEquals(UiEvent.ShowSnackbar("OK"), awaitItem()) }. 예상 이벤트 수를 지정하여 runTest에서 .toList()를 사용할 수도 있습니다.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.