📱 Android

[Android] MVI 패턴, 이름을 왜이렇게 헷갈리게 지었어요?

콩드로이드 2025. 5. 27. 21:43

 

💡 MVI란?

MVI는 Model - View - Intent 

여기서 말하는 Intent는 Android Intent가 아니라 !!!

👉 “사용자의 의도(Intent)부터 상태(State) 변경까지 이어지는 흐름 전체”를 뜻하는 개념적인 용어,,

❗️그러니까 MVI의 Intent ≠ UIEvent ≠ 안드로이드 Intent → 절대 헷갈리면 안된다.. 

 

 

 MVI 흐름

[사용자 행동 (UIEvent)]
→ ViewModel이 받음
→ 상태(UIState) 변경 or 효과(UIEffect) 발생
→ View가 변화됨

- 이 흐름의 출발점은 항상 UIEvent

- 화면은 항상 UIState 하나로만 관리

 

🧩 자주 사용되는 타입들 

🧠 UIState 현재 화면의 상태 (ex. 키워드, 로딩 중, 결과 등) data class
👆 UIEvent 사용자 액션 (ex. 버튼 클릭, 키 입력 등) sealed class
⚡ UIEffect Toast, 화면 이동 등 일회성 처리 sealed class

MVI의 구성요소는 아니고, MVI를 Android에서 구현할 때 자주 등장하는 타입들

 


 

📦 왜 타입이 다를까?

-  UIState → data class

  • 현재 화면의 상태를 나타내는 데이터 덩어리 라서 → data class로 관리
  • 일부만 바꿀 땐 copy()로 간편하게 처리 가능 
val newState = oldState.copy(keyword = "아몬드")

 

  • 이렇게 하면 상태도 immutable하게 유지되고, Compose에서 recomposition도 정확히 됨

 

 

- UIEvent, UIEffect → sealed class

  • 사용자 액션이나, 그에 따른 동작이 다양하니 → sealed class로 묶어 관리
sealed class BookSearchEvent {
    data class SearchKeywordChanged(val keyword: String) : BookSearchEvent()
    object SearchButtonClicked : BookSearchEvent()
    data class BookClicked(val bookId: String) : BookSearchEvent()
}
sealed class BookSearchEffect {
    data class ShowToast(val message: String) : BookSearchEffect()
    data class NavigateToDetail(val bookId: String) : BookSearchEffect()
}

 

 


 

✅ 

  • MVI는 구조이고, Intent는 흐름 전체를 의미함
  • 실제 구현할 때는 이 3가지 단위를 다룸:
    • UIEvent → 사용자 입력
    • UIState → 화면 상태
    • UIEffect → 일회성 작업 (토스트 같은..) 
  • UIEvent에서 시작해서 ViewModel이 처리 → State or Effect로 변환 → View 변화

 

예시 코드

// UIEvent
sealed class BookSearchEvent {
    data class SearchKeywordChanged(val keyword: String) : BookSearchEvent()
    object SearchButtonClicked : BookSearchEvent()
    data class BookClicked(val bookId: String) : BookSearchEvent()
}

// UIState
data class BookSearchState(
    val keyword: String = "",
    val isLoading: Boolean = false,
    val results: List<Book> = emptyList(),
    val errorMessage: String? = null
)

// UIEffect
sealed class BookSearchEffect {
    data class ShowToast(val message: String) : BookSearchEffect()
    data class NavigateToDetail(val bookId: String) : BookSearchEffect()
}

 

 

[viewModel]

@HiltViewModel
class BookSearchViewModel @Inject constructor() : ViewModel() {

    private val _uiState = MutableStateFlow(BookSearchState())
    val uiState: StateFlow<BookSearchState> = _uiState

    private val _effect = MutableSharedFlow<BookSearchEffect>()
    val effect = _effect.asSharedFlow()

    fun onEvent(event: BookSearchEvent) {
        when (event) {
            is BookSearchEvent.SearchKeywordChanged -> {
                _uiState.update { it.copy(keyword = event.keyword) }
            }

            BookSearchEvent.SearchButtonClicked -> {
                _uiState.update { it.copy(isLoading = true) }

                // 가짜 API 호출 예시
                viewModelScope.launch {
                    delay(1000)
                    val results = getFakeBooks(event = _uiState.value.keyword)
                    _uiState.update { it.copy(isLoading = false, results = results) }

                    if (results.isEmpty()) {
                        _effect.emit(BookSearchEffect.ShowToast("검색 결과가 없습니다"))
                    }
                }
            }

            is BookSearchEvent.BookClicked -> {
                viewModelScope.launch {
                    _effect.emit(BookSearchEffect.NavigateToDetail(event.bookId))
                }
            }
        }
    }

    private fun getFakeBooks(event: String): List<Book> {
        return if (event.contains("아몬드")) listOf(Book("1", "아몬드")) else emptyList()
    }
}

 

 

 

[Screen]

@Composable
fun BookSearchScreen(
    navController: NavController,
    viewModel: BookSearchViewModel = hiltViewModel()
) {
    val uiState by viewModel.uiState.collectAsState()
    val context = LocalContext.current

    // 상태 반영
    Column(modifier = Modifier.padding(16.dp)) {
        TextField(
            value = uiState.keyword,
            onValueChange = {
                viewModel.onEvent(BookSearchEvent.SearchKeywordChanged(it))
            },
            label = { Text("검색어 입력") }
        )

        Spacer(modifier = Modifier.height(8.dp))

        Button(onClick = {
            viewModel.onEvent(BookSearchEvent.SearchButtonClicked)
        }) {
            Text("검색")
        }

        Spacer(modifier = Modifier.height(16.dp))

        if (uiState.isLoading) {
            CircularProgressIndicator()
        } else {
            LazyColumn {
                items(uiState.results) { book ->
                    Text(
                        text = book.title,
                        modifier = Modifier
                            .fillMaxWidth()
                            .clickable {
                                viewModel.onEvent(BookSearchEvent.BookClicked(book.id))
                            }
                            .padding(8.dp)
                    )
                }
            }
        }
    }

    // 일회성 Effect 처리
    LaunchedEffect(Unit) {
        viewModel.effect.collect { effect ->
            when (effect) {
                is BookSearchEffect.ShowToast -> {
                    Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
                }

                is BookSearchEffect.NavigateToDetail -> {
                    navController.navigate("detail/${effect.bookId}")
                }
            }
        }
    }
}