Android MVVM vs MVI: 2026๋ ์ ์ด๋ค ์ํคํ ์ฒ๋ฅผ ์ ํํด์ผ ํ ๊น?
Android์์ MVVM๊ณผ MVI์ ์ฌ์ธต ๋น๊ต: ์ฅ๋จ์ , ์ฌ์ฉ ์ฌ๋ก, ๊ทธ๋ฆฌ๊ณ 2026๋ ์ ์ฌ๋ฐ๋ฅธ ์ํคํ ์ฒ๋ฅผ ์ ํํ๊ธฐ ์ํ ์ค์ฉ์ ์ธ ๊ฐ์ด๋.

์ฌ๋ฐ๋ฅธ ์ํคํ ์ฒ ์ ํ์ Android ์ ํ๋ฆฌ์ผ์ด์ ์ ์ ์ง๋ณด์์ฑ, ํ ์คํธ ๊ฐ๋ฅ์ฑ, ํ์ฅ์ฑ์ ์ง์ ์ ์ธ ์ํฅ์ ๋ฏธ์น๋ ์ค์ํ ๊ฒฐ์ ์ ๋๋ค. 2026๋ ์๋ ๋ ๊ฐ์ง ํจํด์ด ์ํ๊ณ๋ฅผ ์ฃผ๋ํ๊ณ ์์ต๋๋ค. ์ฐ์ ํ์ค์ธ MVVM๊ณผ Jetpack Compose์ ํจ๊ป ์ฃผ๋ชฉ๋ฐ๊ณ ์๋ ๋ฐ์ํ ์ ๊ทผ ๋ฐฉ์์ธ MVI์ ๋๋ค.
์๋ชป๋ ์ํคํ ์ฒ ์ ํ์ ๊ธฐ์ ๋ถ์ฑ, ์ฌํํ๊ธฐ ์ด๋ ค์ด ๋ฒ๊ทธ, ๊ทธ๋ฆฌ๊ณ ๊ณ ํต์ค๋ฌ์ด ๋ฆฌํฉํ ๋ง์ผ๋ก ์ด์ด์ง ์ ์์ต๋๋ค. ๊ฐ ์ ๊ทผ ๋ฐฉ์์ ๊ฐ์ ๊ณผ ์ฝ์ ์ ์ดํดํ๋ฉด ์ฅ๊ธฐ์ ์ผ๋ก ๋ง์ ๋ฌธ์ ๋ฅผ ์๋ฐฉํ ์ ์์ต๋๋ค.
MVVM ์ดํดํ๊ธฐ: ํ๋ฆฝ๋ ํ์ค
MVVM(Model-View-ViewModel)์ Jetpack ๋์ ์ดํ Google์ด ๊ถ์ฅํด ์จ ์ํคํ ์ฒ์ ๋๋ค. ์ฑ ์์ ์ธ ๊ฐ์ ๋ช ํํ ๋ ์ด์ด๋ก ๋ถ๋ฆฌํ์ฌ ์ฝ๋๋ฅผ ๋ ์ฒด๊ณ์ ์ด๊ณ ํ ์คํธํ๊ธฐ ์ฝ๊ฒ ๋ง๋ญ๋๋ค.
MVVM์ ํต์ฌ ์์น
MVVM ํจํด์ ๋ช ํํ ๋ถ๋ฆฌ๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ํฉ๋๋ค. Model์ ๋ฐ์ดํฐ์ ๋น์ฆ๋์ค ๋ก์ง์ ๊ด๋ฆฌํ๊ณ , View๋ UI๋ฅผ ํ์ํ๋ฉฐ, ViewModel์ ๊ด์ฐฐ ๊ฐ๋ฅํ ์ํ๋ฅผ ์ ๊ณตํ์ฌ ๋์ ์ฐ๊ฒฐํฉ๋๋ค.
์๋์์ MVVM์ ์ฌ์ฉํ์ฌ ์ฌ์ฉ์ ํ๋กํ ํ๋ฉด์ ๊ตฌํํฉ๋๋ค. ์ด ์ฒซ ๋ฒ์งธ ์์๋ ๊ด์ฐฐ ๊ฐ๋ฅํ ์ํ์ ์ฌ์ฉ์ ์ํธ์์ฉ์ ์ํ ๋ฉ์๋๋ฅผ ์ ๊ณตํ๋ ViewModel์ ๊ธฐ๋ณธ ๊ตฌ์กฐ๋ฅผ ๋ณด์ฌ์ค๋๋ค.
// Classic MVVM ViewModel for a user profile screen
// It exposes observable state and methods for actions
class UserProfileViewModel(
private val userRepository: UserRepository,
private val analyticsTracker: AnalyticsTracker
) : ViewModel() {
// Observable state with StateFlow - the View observes these changes
private val _uiState = MutableStateFlow(UserProfileState())
val uiState: StateFlow<UserProfileState> = _uiState.asStateFlow()
// Separate loading state - MVVM allows multiple flows
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
// One-shot error messages
private val _errorMessage = MutableSharedFlow<String>()
val errorMessage: SharedFlow<String> = _errorMessage.asSharedFlow()
// Initial profile loading
fun loadProfile(userId: String) {
viewModelScope.launch {
_isLoading.value = true
try {
// Repository call to fetch data
val user = userRepository.getUser(userId)
// Update state with new data
_uiState.update { currentState ->
currentState.copy(
user = user,
isEditing = false
)
}
// Analytics tracking
analyticsTracker.trackProfileViewed(userId)
} catch (e: Exception) {
// Emit one-shot error message
_errorMessage.emit("Unable to load profile")
} finally {
_isLoading.value = false
}
}
}
// Enable edit mode
fun enableEditMode() {
_uiState.update { it.copy(isEditing = true) }
}
// Save profile changes
fun saveProfile(name: String, bio: String) {
viewModelScope.launch {
_isLoading.value = true
try {
val updatedUser = userRepository.updateUser(
_uiState.value.user?.id ?: return@launch,
name = name,
bio = bio
)
_uiState.update {
it.copy(user = updatedUser, isEditing = false)
}
} catch (e: Exception) {
_errorMessage.emit("Failed to save profile")
} finally {
_isLoading.value = false
}
}
}
}
// Data class representing the screen state
data class UserProfileState(
val user: User? = null,
val isEditing: Boolean = false
)์ด ViewModel์ ์ ํ์ ์ธ MVVM ์ ๊ทผ ๋ฐฉ์์ ๋ณด์ฌ์ค๋๋ค. ์ฌ๋ฌ ๊ฐ์ ๊ด์ฐฐ ๊ฐ๋ฅํ flow(์ฃผ ์ํ, ๋ก๋ฉ, ์ค๋ฅ)์ ๊ฐ ์ฌ์ฉ์ ์ก์ ์ ๋ํ ๊ณต๊ฐ ๋ฉ์๋๋ฅผ ์ ๊ณตํฉ๋๋ค.
MVVM์ ์ฅ์
MVVM์ ๊ด๋ฒ์ํ ์ฑํ์ ์ค๋ช ํ๋ ์ฌ๋ฌ ๊ฐ์ ์ด ์์ต๋๋ค.
- ์น์ํจ: ๋๋ถ๋ถ์ Android ๊ฐ๋ฐ์๊ฐ ์ด ํจํด์ ์๊ณ ์์ต๋๋ค
- ์ ์ฐ์ฑ: ์ํ๋ฅผ ์ํ๋ ๋ฐฉ์์ผ๋ก ๊ตฌ์กฐํํ ์ ์์ต๋๋ค
- ์ํ๊ณ: Jetpack(LiveData, StateFlow, Hilt)๊ณผ ์๋ฒฝํ๊ฒ ํตํฉ๋ฉ๋๋ค
- ๋จ์์ฑ: ์ด๋ณด์๋ฅผ ์ํ ์๋งํ ํ์ต ๊ณก์ ์ ๋๋ค
MVVM์ ๋ค์ํ ๊ฒฝํ ์์ค์ ๊ฐ๋ฐ์๋ก ๊ตฌ์ฑ๋ ํผํฉ ํ์ ํนํ ์ ํฉํฉ๋๋ค. ๊ฐ๋ ์ ๋จ์์ฑ์ด ์จ๋ณด๋ฉ์ ์ฉ์ดํ๊ฒ ํฉ๋๋ค.
MVVM์ ํ๊ณ
๊ทธ๋ฌ๋ ์ ํ๋ฆฌ์ผ์ด์ ์ด ์ฑ์ฅํจ์ ๋ฐ๋ผ MVVM์ ํ๊ณ๋ฅผ ๋๋ฌ๋ ๋๋ค. ์ฃผ์ ๋ฌธ์ ๋ ๋ถ์ฐ๋ ์ํ ๊ด๋ฆฌ์ ๋๋ค. ๋ค์ ์์๋ ์ด ์ผ๋ฐ์ ์ธ ๋ฌธ์ ๋ฅผ ๋ณด์ฌ์ค๋๋ค.
// Example MVVM ViewModel with fragmented state
// This pattern becomes problematic as the screen grows in complexity
class CheckoutViewModel : ViewModel() {
// Problem: state scattered across multiple flows
private val _cart = MutableStateFlow<List<CartItem>>(emptyList())
private val _selectedAddress = MutableStateFlow<Address?>(null)
private val _selectedPayment = MutableStateFlow<PaymentMethod?>(null)
private val _promoCode = MutableStateFlow<String?>(null)
private val _isLoading = MutableStateFlow(false)
private val _error = MutableStateFlow<String?>(null)
// Each modification can create temporary inconsistent states
fun applyPromoCode(code: String) {
viewModelScope.launch {
_isLoading.value = true
_error.value = null // Reset error
try {
val discount = promoRepository.validate(code)
_promoCode.value = code
// Cart state also needs updating...
// but there's a delay between the two updates
recalculateCart()
} catch (e: Exception) {
_error.value = e.message
_promoCode.value = null
} finally {
_isLoading.value = false
}
}
}
// Hard to guarantee consistency across all these states
private fun recalculateCart() {
// Complex logic depending on multiple states...
}
}์ด ์์๋ MVVM์์ ์ํ๊ฐ ์ด๋ป๊ฒ ๋ถ์ฐ๋ ์ ์๋์ง, ๊ทธ๋ฆฌ๊ณ ์ํ ์ ํ์ ์ถ์ ํ๊ณ ๋ฒ๊ทธ๋ฅผ ์ฌํํ๊ธฐ๊ฐ ์ด๋ ค์์ง๋์ง ๋ณด์ฌ์ค๋๋ค.
MVI ์ดํดํ๊ธฐ: ๋จ๋ฐฉํฅ ์ ๊ทผ ๋ฐฉ์
MVI(Model-View-Intent)๋ ๋ค๋ฅธ ์ฒ ํ์ ์ฑํํฉ๋๋ค. ๋จ๋ฐฉํฅ ๋ฐ์ดํฐ ํ๋ฆ๊ณผ ๋จ์ผ ๋ถ๋ณ ์ํ์ ๋๋ค. Redux์์ ์๊ฐ์ ๋ฐ์ ์ด ์ ๊ทผ ๋ฐฉ์์ ์ผ๊ด์ฑ ์๋ ์ํ ๋ฌธ์ ๋ฅผ ์ ๊ฑฐํฉ๋๋ค.
MVI์ ํต์ฌ ์์น
MVI์์๋ ๋ชจ๋ ๊ฒ์ด ๋ช ํํ ์ฌ์ดํด์ ๋ฐ๋ฆ ๋๋ค. ์ฌ์ฉ์๊ฐ Intent(์ก์ )๋ฅผ ๋ฐ์์ํค๋ฉด, Reducer๊ฐ ํ์ฌ ์ํ๋ฅผ ์ ์ํ๋ก ๋ณํํ๊ณ , View๋ ๊ทธ ๋จ์ผ ์ํ๋ฅผ ํ์ํฉ๋๋ค. ์์ธก ๊ฐ๋ฅํ๊ณ , ํ ์คํธ ๊ฐ๋ฅํ๋ฉฐ, ๋๋ฒ๊น ํ๊ธฐ ์ฝ์ต๋๋ค.
์๋์์ MVI๋ฅผ ์ฌ์ฉํ์ฌ ๋์ผํ ํ๋กํ ํ๋ฉด์ ๊ตฌํํฉ๋๋ค. ์ํ๊ฐ ์ค์ ์ง์คํ๋๊ณ ์ก์ ์ด ๋ช ์์ ์ผ๋ก ํ์ ์ด ์ง์ ๋๋ ๋ฐฉ์์ ์ฃผ๋ชฉํ์ญ์์ค.
// MVI ViewModel for the same user profile screen
// Note the structure: Intent โ Reducer โ Single State
class UserProfileMviViewModel(
private val userRepository: UserRepository,
private val analyticsTracker: AnalyticsTracker
) : ViewModel() {
// Single, immutable state - the absolute source of truth
private val _state = MutableStateFlow(UserProfileState())
val state: StateFlow<UserProfileState> = _state.asStateFlow()
// Channel for side effects (navigation, snackbar)
private val _sideEffect = Channel<UserProfileSideEffect>()
val sideEffect: Flow<UserProfileSideEffect> = _sideEffect.receiveAsFlow()
// Single entry point for all user actions
fun onIntent(intent: UserProfileIntent) {
when (intent) {
is UserProfileIntent.LoadProfile -> loadProfile(intent.userId)
is UserProfileIntent.EnableEditMode -> enableEditMode()
is UserProfileIntent.SaveProfile -> saveProfile(intent.name, intent.bio)
is UserProfileIntent.CancelEdit -> cancelEdit()
}
}
private fun loadProfile(userId: String) {
viewModelScope.launch {
// Transition to loading state
_state.update { it.copy(isLoading = true, error = null) }
try {
val user = userRepository.getUser(userId)
// Single atomic state update
_state.update {
it.copy(
user = user,
isLoading = false,
error = null
)
}
analyticsTracker.trackProfileViewed(userId)
} catch (e: Exception) {
// Error state is part of the main state
_state.update {
it.copy(
isLoading = false,
error = "Unable to load profile"
)
}
}
}
}
private fun enableEditMode() {
// Simple, predictable update
_state.update { it.copy(isEditing = true) }
}
private fun saveProfile(name: String, bio: String) {
viewModelScope.launch {
val currentUser = _state.value.user ?: return@launch
_state.update { it.copy(isLoading = true) }
try {
val updatedUser = userRepository.updateUser(
currentUser.id,
name = name,
bio = bio
)
_state.update {
it.copy(
user = updatedUser,
isEditing = false,
isLoading = false
)
}
// Side effect to notify the user
_sideEffect.send(UserProfileSideEffect.ShowSuccess("Profile updated"))
} catch (e: Exception) {
_state.update {
it.copy(isLoading = false, error = "Failed to save profile")
}
}
}
}
private fun cancelEdit() {
_state.update { it.copy(isEditing = false) }
}
}
// All possible actions, explicitly typed
sealed class UserProfileIntent {
data class LoadProfile(val userId: String) : UserProfileIntent()
object EnableEditMode : UserProfileIntent()
data class SaveProfile(val name: String, val bio: String) : UserProfileIntent()
object CancelEdit : UserProfileIntent()
}
// Single, complete screen state
data class UserProfileState(
val user: User? = null,
val isLoading: Boolean = false,
val isEditing: Boolean = false,
val error: String? = null
)
// One-shot side effects
sealed class UserProfileSideEffect {
data class ShowSuccess(val message: String) : UserProfileSideEffect()
data class NavigateTo(val destination: String) : UserProfileSideEffect()
}์ฐจ์ด๊ฐ ๋ช ํํฉ๋๋ค. ๋จ์ผ ์ํ ํ๋ฆ, ๋ช ์์ ์ก์ , ๊ทธ๋ฆฌ๊ณ ์์์ ์ํ์ ์ผํ์ฑ ํจ๊ณผ ์ฌ์ด์ ๊น๋ํ ๋ถ๋ฆฌ์ ๋๋ค.
MVI๋ฅผ ์ฌ์ฉํ๋ฉด ๋ชจ๋ Intent์ ๋ชจ๋ ์ํ ์ ํ์ ๋ก๊น ํ ์ ์์ต๋๋ค. ๋ฒ๊ทธ๋ฅผ ์ฌํํ๋ ๊ฒ์ด ๊ฐ๋จํด์ง๋๋ค. Intent ์ํ์ค๋ฅผ ๋ค์ ์ฌ์ํ๋ฉด ๋ฉ๋๋ค.
Jetpack Compose์ MVI
MVI๋ Jetpack Compose์ ํจ๊ป ํนํ ๋น์ ๋ฐํฉ๋๋ค. ๋ ๋ค ๋ถ๋ณ ์ํ์ ์ ์ธ์ UI๋ผ๋ ๋์ผํ ์ฒ ํ์ ๊ณต์ ํ๊ธฐ ๋๋ฌธ์ ๋๋ค. ViewModel์ Compose ํ๋ฉด์ ์ฐ๊ฒฐํ๋ ๋ฐฉ๋ฒ์ ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
// Compose screen consuming MVI state
// The connection between ViewModel and UI is elegant and reactive
@Composable
fun UserProfileScreen(
viewModel: UserProfileMviViewModel = hiltViewModel(),
onNavigateBack: () -> Unit
) {
// Collect the single state
val state by viewModel.state.collectAsStateWithLifecycle()
// Handle side effects
LaunchedEffect(Unit) {
viewModel.sideEffect.collect { effect ->
when (effect) {
is UserProfileSideEffect.ShowSuccess -> {
// Show snackbar
}
is UserProfileSideEffect.NavigateTo -> {
// Navigate
}
}
}
}
// Purely declarative UI based on state
UserProfileContent(
state = state,
onIntent = viewModel::onIntent
)
}
@Composable
private fun UserProfileContent(
state: UserProfileState,
onIntent: (UserProfileIntent) -> Unit
) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
// Conditional rendering based on the single state
when {
state.isLoading -> {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.CenterHorizontally)
)
}
state.error != null -> {
ErrorMessage(
message = state.error,
onRetry = {
state.user?.id?.let {
onIntent(UserProfileIntent.LoadProfile(it))
}
}
)
}
state.user != null -> {
ProfileCard(
user = state.user,
isEditing = state.isEditing,
onEditClick = { onIntent(UserProfileIntent.EnableEditMode) },
onSaveClick = { name, bio ->
onIntent(UserProfileIntent.SaveProfile(name, bio))
},
onCancelClick = { onIntent(UserProfileIntent.CancelEdit) }
)
}
}
}
}UI๋ ์ํ์ ์์ ํจ์๊ฐ ๋ฉ๋๋ค. ์์ธก ๊ฐ๋ฅํ๊ณ , ํ ์คํธ ๊ฐ๋ฅํ๋ฉฐ, ์จ๊ฒจ์ง ๋ถ์ ํจ๊ณผ๊ฐ ์์ต๋๋ค.
์์ธ ๋น๊ต
๋ ํจํด์ ์ค์ ๋ก ์ดํด๋ณธ ํ, ํ๋ก๋์ ์์ ์ ๋ง ์ค์ํ ๊ธฐ์ค์ผ๋ก ๋น๊ตํ ์ ์์ต๋๋ค.
์ํ ๊ด๋ฆฌ
๊ทผ๋ณธ์ ์ธ ์ฐจ์ด๋ ์ํ ๊ด๋ฆฌ์ ์์ต๋๋ค. ์ด ์ฐจ์ด๋ ์ฅ๊ธฐ์ ์ธ ์ ์ง๋ณด์์ฑ์ ์ง์ ์ ์ผ๋ก ์ํฅ์ ๋ฏธ์นฉ๋๋ค.
// MVVM: potentially fragmented state
class MvvmViewModel : ViewModel() {
// Multiple sources of truth - manual synchronization needed
private val _users = MutableStateFlow<List<User>>(emptyList())
private val _selectedUser = MutableStateFlow<User?>(null)
private val _isLoading = MutableStateFlow(false)
private val _searchQuery = MutableStateFlow("")
// What happens if _selectedUser points to a user
// that's no longer in _users after a refresh?
// โ Inconsistent state that's hard to detect
}
// MVI: consistent state by construction
class MviViewModel : ViewModel() {
// Single source of truth - inconsistencies are impossible
private val _state = MutableStateFlow(UsersState())
data class UsersState(
val users: List<User> = emptyList(),
val selectedUser: User? = null, // Always consistent with users
val isLoading: Boolean = false,
val searchQuery: String = ""
)
// Each update automatically maintains invariants
private fun selectUser(userId: String) {
_state.update { currentState ->
currentState.copy(
selectedUser = currentState.users.find { it.id == userId }
)
}
}
}MVVM์์ ์ผ๊ด์ฑ ์๋ ์ํ๋ ์ฌํํ๊ธฐ ์ด๋ ค์ด ๊ฐํ์ ๋ฒ๊ทธ๋ก ๋ํ๋๋ ๊ฒฝ์ฐ๊ฐ ๋ง์ต๋๋ค. MVI์์๋ ์ํ๊ฐ ์ ํจํ์ง ์์ผ๋ฉด ๊ฒฐ์ ๋ก ์ ์ผ๋ก ์ ํจํ์ง ์์ต๋๋ค.
์ํคํ ์ฒ ํ ์คํธ ๊ฐ๋ฅ์ฑ
๋ ์ํคํ ์ฒ ๋ชจ๋ ํ ์คํธ ๊ฐ๋ฅํ์ง๋ง, MVI๋ ์์ธก ๊ฐ๋ฅ์ฑ ๋๋ถ์ ์๋นํ ์ด์ ์ ์ ๊ณตํฉ๋๋ค.
// MVVM test: requires verifying multiple flows
@Test
fun `loadUsers should update state correctly`() = runTest {
val viewModel = MvvmViewModel(fakeRepository)
// Observe multiple flows simultaneously
val users = mutableListOf<List<User>>()
val loadingStates = mutableListOf<Boolean>()
val job1 = launch { viewModel.users.toList(users) }
val job2 = launch { viewModel.isLoading.toList(loadingStates) }
viewModel.loadUsers()
advanceUntilIdle()
// Assertions on different flows
assertThat(users.last()).isEqualTo(expectedUsers)
assertThat(loadingStates).containsExactly(false, true, false)
job1.cancel()
job2.cancel()
}
// MVI test: single flow to verify, clear state sequence
@Test
fun `LoadUsers intent should produce correct state sequence`() = runTest {
val viewModel = MviViewModel(fakeRepository)
// Collect all states in order
val states = mutableListOf<UsersState>()
val job = launch { viewModel.state.toList(states) }
// Send the intent
viewModel.onIntent(UsersIntent.LoadUsers)
advanceUntilIdle()
// Verify the exact state sequence
assertThat(states).containsExactly(
UsersState(), // Initial
UsersState(isLoading = true), // Loading
UsersState(users = expectedUsers, isLoading = false) // Success
)
job.cancel()
}MVI๋ ์ ํํ ์ํ ์ ํ ์์๋ฅผ ํ ์คํธํ ์ ์๊ฒ ํด์ฃผ๋ฉฐ, ์ด๋ ๋ง์ ์ํธ์์ฉ์ด ์๋ ๋ณต์กํ ํ๋ฉด์ ํนํ ์ ์ฉํฉ๋๋ค.
๋ณต์ก์ฑ๊ณผ ๋ณด์ผ๋ฌํ๋ ์ดํธ
ํธ๋ ์ด๋์คํ์ ๋ํด ์์งํด์ผ ํฉ๋๋ค. MVI๋ ๋ ๋ง์ ๋ณด์ผ๋ฌํ๋ ์ดํธ ์ฝ๋์ ๊ฐ๋ ์ ๋ํ ๋ ๊น์ ์ดํด๋ฅผ ์๊ตฌํฉ๋๋ค.
// MVVM: quick start, less code
class SimpleViewModel : ViewModel() {
private val _name = MutableStateFlow("")
val name: StateFlow<String> = _name.asStateFlow()
fun updateName(newName: String) {
_name.value = newName
}
}
// Total: ~10 lines
// MVI: more structure, more code
class SimpleMviViewModel : ViewModel() {
private val _state = MutableStateFlow(SimpleState())
val state: StateFlow<SimpleState> = _state.asStateFlow()
fun onIntent(intent: SimpleIntent) {
when (intent) {
is SimpleIntent.UpdateName -> {
_state.update { it.copy(name = intent.name) }
}
}
}
}
data class SimpleState(val name: String = "")
sealed class SimpleIntent {
data class UpdateName(val name: String) : SimpleIntent()
}
// Total: ~20 lines๊ฐ๋จํ ํ๋ฉด์ ๊ฒฝ์ฐ MVI๊ฐ ๊ณผํ๊ฒ ๋๊ปด์ง ์ ์์ต๋๋ค. ๊ทธ๋ฌ๋ ํ๋ฉด์ ๋ณต์ก์ฑ์ด ์ฆ๊ฐํ ์๋ก ์ด ๊ตฌ์กฐ๋ ๊ฐ์น๋ฅผ ๋ฐํํฉ๋๋ค.
Android ๋ฉด์ ์ค๋น๊ฐ ๋์ จ๋์?
์ธํฐ๋ํฐ๋ธ ์๋ฎฌ๋ ์ดํฐ, flashcards, ๊ธฐ์ ํ ์คํธ๋ก ์ฐ์ตํ์ธ์.
MVVM์ ์ ํํด์ผ ํ ๋
MVVM์ ์ฌ๋ฌ ์ํฉ์์ ์ค์ฉ์ ์ธ ์ ํ์ผ๋ก ๋จ์ ์์ต๋๋ค.
๊ธฐ์กด ํ๋ก์ ํธ
์ ํ๋ฆฌ์ผ์ด์ ์ด ์ด๋ฏธ MVVM์ ์ฌ์ฉํ๊ณ ์๋ค๋ฉด, MVI๋ก ๋ง์ด๊ทธ๋ ์ด์ ํ๋ ๊ฒ์ ์๋นํ ๋ ธ๋ ฅ์ด ํ์ํฉ๋๋ค. ๊ธฐ์กด MVVM ๊ตฌ์กฐ๋ฅผ ๊ฐ์ ํ๋ ๊ฒ์ด ๋ ํ๋ช ํ ๊ฒฝ์ฐ๊ฐ ๋ง์ต๋๋ค.
Junior ๋๋ ํผํฉ ํ
MVVM์ด ๋ ์ ๊ทผํ๊ธฐ ์ฝ์ต๋๋ค. ์ด๋ณด ๊ฐ๋ฐ์๋ก ๊ตฌ์ฑ๋ ํ์ MVI๋ณด๋ค MVVM์ผ๋ก ๋ ๋น ๋ฅด๊ฒ ์์ฐ์ฑ์ ๋ฐํํ ์ ์์ต๋๋ค.
๋จ์ํ ํ๋ฉด
์ํ์ ์ํธ์์ฉ์ด ์ ์ ํ๋ฉด์ ๊ฒฝ์ฐ MVI๋ ๋น๋ก์ ์ธ ์ด์ ์์ด ๋ณต์ก์ฑ์ ์ถ๊ฐํฉ๋๋ค.
// For a simple settings screen, MVVM is plenty
class SettingsViewModel(
private val preferencesRepository: PreferencesRepository
) : ViewModel() {
val darkMode = preferencesRepository.darkModeFlow
.stateIn(viewModelScope, SharingStarted.Lazily, false)
val notificationsEnabled = preferencesRepository.notificationsFlow
.stateIn(viewModelScope, SharingStarted.Lazily, true)
fun toggleDarkMode() {
viewModelScope.launch {
preferencesRepository.setDarkMode(!darkMode.value)
}
}
fun toggleNotifications() {
viewModelScope.launch {
preferencesRepository.setNotifications(!notificationsEnabled.value)
}
}
}MVI๋ฅผ ์ ํํด์ผ ํ ๋
MVI๋ ํน์ ์ํฉ์์ ๊ทธ ๊ฐ์น๋ฅผ ์ฆ๋ช ํฉ๋๋ค.
๋ณต์กํ ์ํ๋ฅผ ๊ฐ์ง ์ ํ๋ฆฌ์ผ์ด์
ํ๋ฉด์ ์ํธ ์์กด์ ์ธ ์ํ๊ฐ ๋ง์ ๋, MVI๋ ์ผ๊ด์ฑ์ ๋ณด์ฅํฉ๋๋ค.
// Checkout screen with complex state: MVI excels
data class CheckoutState(
val cartItems: List<CartItem> = emptyList(),
val selectedAddress: Address? = null,
val selectedPayment: PaymentMethod? = null,
val promoCode: PromoCode? = null,
val deliveryOptions: List<DeliveryOption> = emptyList(),
val selectedDelivery: DeliveryOption? = null,
val subtotal: Money = Money.ZERO,
val discount: Money = Money.ZERO,
val deliveryFee: Money = Money.ZERO,
val total: Money = Money.ZERO,
val isLoading: Boolean = false,
val error: CheckoutError? = null,
val step: CheckoutStep = CheckoutStep.CART
) {
// Verifiable invariants
init {
require(total == subtotal - discount + deliveryFee) {
"Total inconsistent with components"
}
}
}
sealed class CheckoutIntent {
data class AddItem(val item: CartItem) : CheckoutIntent()
data class RemoveItem(val itemId: String) : CheckoutIntent()
data class SelectAddress(val address: Address) : CheckoutIntent()
data class SelectPayment(val method: PaymentMethod) : CheckoutIntent()
data class ApplyPromo(val code: String) : CheckoutIntent()
object RemovePromo : CheckoutIntent()
data class SelectDelivery(val option: DeliveryOption) : CheckoutIntent()
object ProceedToPayment : CheckoutIntent()
object ConfirmOrder : CheckoutIntent()
}์ค์๊ฐ ์ ํ๋ฆฌ์ผ์ด์
WebSocket, ํธ์ ์๋ฆผ, ๋๋ ์ค์๊ฐ ๋๊ธฐํ๊ฐ ์๋ ์ฑ์ ๊ฒฝ์ฐ MVI๋ ์ฌ๋ฌ ๋ฐ์ดํฐ ํ๋ฆ์ ์ฐ์ํ๊ฒ ์ฒ๋ฆฌํฉ๋๋ค.
์๊ฒฉํ ๋๋ฒ๊น ์๊ตฌ์ฌํญ
๊ท์ ๋ ๋๋ฉ์ธ(ํํ ํฌ, ํฌ์ค์ผ์ด)์์ ์ด๋ฒคํธ์ ์ ํํ ์ํ์ค๋ฅผ ์ฌํํ๋ ๋ฅ๋ ฅ์ ๋งค์ฐ ์์คํฉ๋๋ค.
MVI๋ "์๊ฐ ์ฌํ ๋๋ฒ๊น "์ ์ฝ๊ฒ ๊ตฌํํ ์ ์๊ฒ ํฉ๋๋ค. ๋ชจ๋ ์ํ๋ฅผ ๊ธฐ๋กํ๊ณ ์ฌ์ฉ์ ์ธ์ ์ ์ฌ์ํ๋ ๋ฐฉ์์ ๋๋ค.
ํ์ด๋ธ๋ฆฌ๋ ์ ๊ทผ ๋ฐฉ์: ๋ ์ธ๊ณ์ ์ต์
์ค์ ๋ก ๋ง์ ํ์ด ํ์ด๋ธ๋ฆฌ๋ ์ ๊ทผ ๋ฐฉ์์ ์ฑํํฉ๋๋ค. ๋ณต์กํ ํ๋ฉด์๋ MVI๋ฅผ, ๋จ์ํ ํ๋ฉด์๋ ๊ฐ์ํ๋ MVVM์ ์ฌ์ฉํฉ๋๋ค. ๊ถ์ฅ ํจํด์ ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
// Base ViewModel with lightweight MVI structure
// Reusable for all screens
abstract class MviViewModel<S, I>(initialState: S) : ViewModel() {
private val _state = MutableStateFlow(initialState)
val state: StateFlow<S> = _state.asStateFlow()
protected val currentState: S get() = _state.value
// Single entry point for intents
abstract fun onIntent(intent: I)
// Helper to update state
protected fun updateState(reducer: S.() -> S) {
_state.update { it.reducer() }
}
}
// Concrete implementation stays simple
class ProfileViewModel(
private val userRepository: UserRepository
) : MviViewModel<ProfileState, ProfileIntent>(ProfileState()) {
override fun onIntent(intent: ProfileIntent) {
when (intent) {
is ProfileIntent.Load -> load(intent.userId)
is ProfileIntent.Refresh -> refresh()
is ProfileIntent.ToggleFavorite -> toggleFavorite()
}
}
private fun load(userId: String) {
viewModelScope.launch {
updateState { copy(isLoading = true) }
val user = userRepository.getUser(userId)
updateState {
copy(user = user, isLoading = false)
}
}
}
private fun refresh() = load(currentState.user?.id ?: return)
private fun toggleFavorite() {
updateState {
copy(user = user?.copy(isFavorite = !user.isFavorite))
}
}
}์ด ์ ๊ทผ ๋ฐฉ์์ ๊ณผ๋ํ ๋ณด์ผ๋ฌํ๋ ์ดํธ ์์ด MVI์ ์ด์ (๋จ์ผ ์ํ, ํ์ ์ด ์ง์ ๋ Intent)์ ์ ๊ณตํฉ๋๋ค.
2026๋ ๊ถ์ฅ ์ฌํญ
์ํฉ์ ๋ฐ๋ผ ๋ ์ํคํ ์ฒ ์ค์์ ์ ํํ๊ธฐ ์ํ ๊ถ์ฅ ์ฌํญ์ ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
Compose๋ฅผ ์ฌ์ฉํ๋ ์ ํ๋ก์ ํธ
์ฒ์๋ถํฐ MVI๋ฅผ ๋์ ํ์ญ์์ค. Compose์ MVI๋ ๋์ผํ ์ฒ ํ์ ๊ณต์ ํ๋ฉฐ, ์ด๊ธฐ ํฌ์๋ ๋น ๋ฅด๊ฒ ํ์๋ฉ๋๋ค.
๊ธฐ์กด View ๊ธฐ๋ฐ ํ๋ก์ ํธ
MVVM์ ์ ์งํ๋, MVI ๋ชจ๋ฒ ์ฌ๋ก๋ฅผ ์ ์ง์ ์ผ๋ก ๋์ ํ์ญ์์ค. ViewModel์ ๋จ์ผ ์ํ, sealed class๋ฅผ ์ฌ์ฉํ ํ์ ์ด ์ง์ ๋ ์ก์ ์ ๋๋ค.
๋๊ท๋ชจ ํ
ํ๋์ ์ ๊ทผ ๋ฐฉ์์ผ๋ก ํ์คํํ๊ณ ๋ฌธ์ํํ์ญ์์ค. ์ฝ๋๋ฒ ์ด์ค์ ์ผ๊ด์ฑ์ด ํจํด ์ ํ ์์ฒด๋ณด๋ค ๋ ์ค์ํฉ๋๋ค.
์ต์ ์ ํจํด์ ํ์ด ์ดํดํ๊ณ ์ฌ๋ฐ๋ฅด๊ฒ ์ ์ฉํ๋ ํจํด์ ๋๋ค. ์ ๊ตฌํ๋ MVVM์ด ์๋ชป ์ดํด๋ MVI๋ณด๋ค ๋ซ์ต๋๋ค.
๊ฒฐ๋ก
MVVM๊ณผ MVI๋ ๋ชจ๋ Android ์ ํ๋ฆฌ์ผ์ด์ ์ ์ํคํ ์ฒํ๋ ๋ฐ ์ ํจํ ์ ๊ทผ ๋ฐฉ์์ ๋๋ค. MVVM์ ๋จ์์ฑ๊ณผ ์น์ํจ์ ์ ๊ณตํ๊ณ , MVI๋ ์์ธก ๊ฐ๋ฅ์ฑ๊ณผ ๋ ์ฌ์ด ๋๋ฒ๊น ์ ๊ฐ์ ธ์ต๋๋ค.
๊ฒฐ์ ์ฒดํฌ๋ฆฌ์คํธ
- MVVM ์ ํ ์กฐ๊ฑด: ์ฃผ๋์ด ํ, ๋จ์ํ ํ๋ก์ ํธ, ๋น์ฉ์ด ๋ง์ด ๋๋ ๋ง์ด๊ทธ๋ ์ด์
- MVI ์ ํ ์กฐ๊ฑด: ๋ค์ดํฐ๋ธ Compose, ๋ณต์กํ ์ํ, ์ค์ํ ๋๋ฒ๊น ํ์
- ํ์ด๋ธ๋ฆฌ๋ ์ ๊ทผ ๋ฐฉ์ ๊ถ์ฅ: ๋จ์ผ ์ํ์ ๊ฒฝ๋ MVI, ๊ณผ๋ํ ์์ง๋์ด๋ง ์์ด
- ์ต์ฐ์ ์ฌํญ: ์ฝ๋๋ฒ ์ด์ค ์ ๋ฐ์ ์ผ๊ด์ฑ
์ฐ์ต์ ์์ํ์ธ์!
๋ฉด์ ์๋ฎฌ๋ ์ดํฐ์ ๊ธฐ์ ํ ์คํธ๋ก ์ง์์ ํ ์คํธํ์ธ์.
์ด๋ค ์ ํ์ ํ๋ , ํต์ฌ์ ๊ฐ ์ ๊ทผ ๋ฐฉ์์ ๊ฐ์ ๊ณผ ์ฝ์ ์ ์ดํดํ์ฌ ์ ๋ณด์ ์ ๊ฐํ ๊ฒฐ์ ์ ๋ด๋ฆฌ๋ ๊ฒ์ ๋๋ค. ์ต์ ์ ์ฝ๋๋ ํ์ด ์ฅ๊ธฐ์ ์ผ๋ก ํ์จํ๊ฒ ์ ์งํ ์ ์๋ ์ฝ๋์ ๋๋ค.
ํ๊ทธ
๊ณต์
๊ด๋ จ ๊ธฐ์ฌ

Jetpack Compose: ๊ณ ๊ธ ์ ๋๋ฉ์ด์ ๋จ๊ณ๋ณ ๊ฐ์ด๋
Compose ๊ณ ๊ธ ์ ๋๋ฉ์ด์ ์๋ฒฝ ๊ฐ์ด๋: ์ ํ, AnimatedVisibility, Animatable, ์ ์ค์ฒ, ๋ถ๋๋ฌ์ด Android ์ธํฐํ์ด์ค๋ฅผ ์ํ ์ฑ๋ฅ.

Jetpack Compose ๋ฉด์ ์ง๋ฌธ 20์ (2026๋ )
Jetpack Compose ๋ฉด์ ์์ ์์ฃผ ์ถ์ ๋๋ 20๊ฐ์ง ์ง๋ฌธ์ ํด์คํฉ๋๋ค. ๋ฆฌ์ปดํฌ์ง์ , ์ํ ๊ด๋ฆฌ, ์ฌ์ด๋ ์ดํํธ, ๋ด๋น๊ฒ์ด์ , ์ฑ๋ฅ ์ต์ ํ, ์ํคํ ์ฒ ํจํด์ ํฌ๊ด์ ์ผ๋ก ๋ค๋ฃน๋๋ค.

Android ์์กด์ฑ ์ฃผ์ : Hilt vs Koin ์์ ๊ฐ์ด๋ ๋ฐ ๋ฉด์ ์ง๋ฌธ 2026
Hilt์ Koin์ ์ฝ๋ ์์ , ์ฑ๋ฅ ๋ฒค์น๋งํฌ, ๋ฉด์ ์ง๋ฌธ์ผ๋ก ์ฒ ์ ํ ๋น๊ตํฉ๋๋ค. Hilt 2.57, Koin 4.2 ๊ธฐ๋ฐ 2026๋ ์ต์ ๊ฐ์ด๋.