(Android/안드로이드) MVVM 패턴 완전 정리

개요


1. MVVM이란

구성 요소 역할
Model 데이터와 비즈니스 로직 — Repository, UseCase, 데이터 클래스
View UI 렌더링 — Activity, Fragment, XML Layout
ViewModel View와 Model 사이의 중재자 — UI 상태 보유, 비즈니스 로직 호출
View ──────────────────────────────▶ ViewModel
 │   (사용자 이벤트 전달)              │
 │                                    │ (데이터 요청)
 │                                    ▼
 │                                  Model
 │                                    │
 │   (LiveData/StateFlow 관찰)         │ (데이터 반환)
 ◀───────────────────────────────────◀

2. MVP / MVVM / MVI 비교

항목 MVP MVVM MVI
View ↔ 중재자 인터페이스(Contract) 관찰(Observer) Intent 전송
상태 표현 Presenter 메서드 호출 LiveData / StateFlow 단일 State 객체
데이터 흐름 양방향 양방향 가능 단방향 강제
View 참조 Presenter가 직접 참조 없음 없음
테스트 용이성 보통 좋음 매우 좋음
복잡도 낮음 보통 보통~높음
적합한 규모 소~중 소~대 중~대

3. ViewModel 기본 구현

Jetpack ViewModel 의존성

// build.gradle.kts (app)
dependencies {
    implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3")
    implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.8.3")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3")
}

LiveData 방식 ViewModel

class UserViewModel(
    private val userRepository: UserRepository
) : ViewModel() {

    // 외부에는 읽기 전용 LiveData로 노출
    private val _user = MutableLiveData<User?>()
    val user: LiveData<User?> = _user

    private val _isLoading = MutableLiveData(false)
    val isLoading: LiveData<Boolean> = _isLoading

    private val _errorMessage = MutableLiveData<String?>()
    val errorMessage: LiveData<String?> = _errorMessage

    fun loadUser(userId: Long) {
        viewModelScope.launch {
            _isLoading.value = true
            _errorMessage.value = null

            userRepository.getUser(userId)
                .onSuccess { _user.value = it }
                .onFailure { _errorMessage.value = it.message }

            _isLoading.value = false
        }
    }

    fun updateUserName(userId: Long, newName: String) {
        viewModelScope.launch {
            userRepository.updateName(userId, newName)
                .onSuccess { _user.value = _user.value?.copy(name = newName) }
                .onFailure { _errorMessage.value = "이름 변경에 실패했습니다" }
        }
    }
}

StateFlow 방식 ViewModel (권장)

// UI 상태를 하나의 data class로 묶기
data class UserUiState(
    val user: User? = null,
    val isLoading: Boolean = false,
    val errorMessage: String? = null
)

class UserViewModel(
    private val userRepository: UserRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow(UserUiState())
    val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()

    fun loadUser(userId: Long) {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true, errorMessage = null) }

            userRepository.getUser(userId)
                .onSuccess { user ->
                    _uiState.update { it.copy(isLoading = false, user = user) }
                }
                .onFailure { error ->
                    _uiState.update { it.copy(isLoading = false, errorMessage = error.message) }
                }
        }
    }

    fun updateUserName(userId: Long, newName: String) {
        viewModelScope.launch {
            userRepository.updateName(userId, newName)
                .onSuccess {
                    _uiState.update { state ->
                        state.copy(user = state.user?.copy(name = newName))
                    }
                }
                .onFailure {
                    _uiState.update { it.copy(errorMessage = "이름 변경에 실패했습니다") }
                }
        }
    }
}

4. View(Fragment) 구현

LiveData 관찰

@AndroidEntryPoint
class UserFragment : Fragment(R.layout.fragment_user) {

    private val viewModel: UserViewModel by viewModels()
    private lateinit var binding: FragmentUserBinding

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        binding = FragmentUserBinding.bind(view)

        observeViewModel()
        setupListeners()

        viewModel.loadUser(userId = 1L)
    }

    private fun observeViewModel() {
        // LiveData 관찰 — 생명주기 자동 처리
        viewModel.user.observe(viewLifecycleOwner) { user ->
            user?.let {
                binding.tvName.text = it.name
                binding.tvEmail.text = it.email
            }
        }

        viewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
            binding.progressBar.isVisible = isLoading
        }

        viewModel.errorMessage.observe(viewLifecycleOwner) { message ->
            message?.let {
                Snackbar.make(binding.root, it, Snackbar.LENGTH_SHORT).show()
            }
        }
    }

    private fun setupListeners() {
        binding.btnUpdate.setOnClickListener {
            val newName = binding.etName.text.toString()
            viewModel.updateUserName(userId = 1L, newName = newName)
        }
    }
}

StateFlow 관찰 (권장)

@AndroidEntryPoint
class UserFragment : Fragment(R.layout.fragment_user) {

    private val viewModel: UserViewModel by viewModels()
    private lateinit var binding: FragmentUserBinding

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        binding = FragmentUserBinding.bind(view)

        observeUiState()
        setupListeners()

        viewModel.loadUser(userId = 1L)
    }

    private fun observeUiState() {
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { state ->
                    renderState(state)
                }
            }
        }
    }

    private fun renderState(state: UserUiState) {
        with(binding) {
            progressBar.isVisible = state.isLoading
            tvError.isVisible = state.errorMessage != null
            tvError.text = state.errorMessage

            state.user?.let { user ->
                tvName.text = user.name
                tvEmail.text = user.email
                groupContent.isVisible = true
            } ?: run {
                groupContent.isVisible = false
            }
        }
    }

    private fun setupListeners() {
        binding.btnUpdate.setOnClickListener {
            val newName = binding.etName.text.toString()
            viewModel.updateUserName(userId = 1L, newName = newName)
        }
    }
}

5. Hilt를 이용한 ViewModel 의존성 주입

// ViewModel
@HiltViewModel
class UserViewModel @Inject constructor(
    private val userRepository: UserRepository  // Hilt가 자동 주입
) : ViewModel() { ... }

// Module
@Module
@InstallIn(SingletonComponent::class)
object RepositoryModule {

    @Provides
    @Singleton
    fun provideUserRepository(
        userApi: UserApi,
        userDao: UserDao
    ): UserRepository = UserRepositoryImpl(userApi, userDao)
}

// Fragment — by viewModels()로 Hilt 주입된 ViewModel 획득
@AndroidEntryPoint
class UserFragment : Fragment() {
    private val viewModel: UserViewModel by viewModels()
}

6. DataBinding 연동

DataBinding을 사용하면 XML에서 직접 ViewModel 데이터를 바인딩할 수 있습니다.

<!-- fragment_user.xml -->
<layout xmlns:android="http://schemas.android.com/apk/res/android">
    <data>
        <variable
            name="viewModel"
            type="com.example.UserViewModel" />
    </data>

    <LinearLayout ...>
        <ProgressBar
            android:visibility="@{viewModel.isLoading ? View.VISIBLE : View.GONE}" />

        <TextView
            android:text="@{viewModel.user.name}"
            android:hint="이름" />

        <TextView
            android:text="@{viewModel.user.email}"
            android:hint="이메일" />

        <Button
            android:onClick="@{() -> viewModel.updateUserName(1L, etName.getText().toString())}"
            android:text="수정" />
    </LinearLayout>
</layout>
// Fragment — DataBinding + ViewModel 연결
class UserFragment : Fragment() {
    private val viewModel: UserViewModel by viewModels()
    private lateinit var binding: FragmentUserBinding

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        binding = FragmentUserBinding.bind(view).apply {
            this.viewModel = this@UserFragment.viewModel
            lifecycleOwner = viewLifecycleOwner  // LiveData 자동 관찰
        }
    }
}

7. Repository 패턴과 조합

MVVM에서 ViewModel은 Repository를 통해 데이터를 요청합니다.

// Repository 인터페이스 — Domain 레이어
interface UserRepository {
    suspend fun getUser(userId: Long): Result<User>
    suspend fun updateName(userId: Long, name: String): Result<Unit>
}

// Repository 구현 — Data 레이어
class UserRepositoryImpl(
    private val userApi: UserApi,      // 원격 데이터 소스
    private val userDao: UserDao       // 로컬 데이터 소스
) : UserRepository {

    override suspend fun getUser(userId: Long): Result<User> {
        return runCatching {
            // 로컬 캐시 우선, 없으면 서버 호출
            userDao.getUser(userId)
                ?: userApi.getUser(userId).also { userDao.insertUser(it) }
        }
    }

    override suspend fun updateName(userId: Long, name: String): Result<Unit> {
        return runCatching {
            userApi.updateName(userId, name)
            userDao.updateName(userId, name)
        }
    }
}

// ViewModel — Repository만 알고, API/DB는 모름
@HiltViewModel
class UserViewModel @Inject constructor(
    private val userRepository: UserRepository  // 구현체가 아닌 인터페이스에 의존
) : ViewModel() { ... }

8. SharedViewModel — Fragment 간 데이터 공유

같은 Activity의 여러 Fragment가 하나의 ViewModel을 공유할 수 있습니다.

// ViewModel
@HiltViewModel
class CartViewModel @Inject constructor(
    private val cartRepository: CartRepository
) : ViewModel() {

    private val _cartItems = MutableStateFlow<List<CartItem>>(emptyList())
    val cartItems: StateFlow<List<CartItem>> = _cartItems.asStateFlow()

    val totalPrice: StateFlow<Int> = cartItems
        .map { items -> items.sumOf { it.price * it.quantity } }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0)

    fun addItem(item: CartItem) {
        _cartItems.update { current ->
            val existing = current.find { it.id == item.id }
            if (existing != null) {
                current.map { if (it.id == item.id) it.copy(quantity = it.quantity + 1) else it }
            } else {
                current + item
            }
        }
    }

    fun removeItem(itemId: Long) {
        _cartItems.update { it.filter { item -> item.id != itemId } }
    }
}

// Fragment A — activityViewModels()로 Activity 범위 ViewModel 획득
class ProductFragment : Fragment() {
    private val cartViewModel: CartViewModel by activityViewModels()

    private fun onAddToCart(item: CartItem) {
        cartViewModel.addItem(item)
    }
}

// Fragment B — 동일한 ViewModel 인스턴스 공유
class CartFragment : Fragment() {
    private val cartViewModel: CartViewModel by activityViewModels()

    private fun observeCart() {
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                cartViewModel.cartItems.collect { items ->
                    adapter.submitList(items)
                }
            }
        }
    }
}

9. LiveData vs StateFlow 비교

항목 LiveData StateFlow
라이브러리 AndroidX (Android 전용) Kotlin Coroutines (플랫폼 무관)
초기값 없어도 됨 필수
생명주기 자동 처리 (LifecycleOwner) repeatOnLifecycle 필요
백그라운드 업데이트 postValue() 별도 사용 update {} 동일하게 사용
변환 연산자 map, switchMap (제한적) Flow 연산자 전부 사용 가능
ViewModel 밖 테스트 어려움 쉬움
권장 단순 UI 상태 복잡한 상태·Flow 변환 필요 시

10. MVVM 적용 시 주의사항

❌ ViewModel에서 Context를 직접 참조하지 않는다

// ❌ 메모리 누수 위험 — ViewModel이 Activity보다 오래 살아남음
class BadViewModel(private val context: Context) : ViewModel() { }

// ✅ AndroidViewModel 또는 Hilt로 ApplicationContext 주입
class GoodViewModel(application: Application) : AndroidViewModel(application) {
    private val appContext = getApplication<Application>().applicationContext
}

❌ View에서 ViewModel 상태를 직접 변경하지 않는다

// ❌ View가 ViewModel 내부 상태를 직접 조작
(viewModel.uiState as MutableStateFlow).value = UserUiState()

// ✅ ViewModel의 공개 함수를 호출
viewModel.loadUser(userId)

❌ ViewModel에서 View를 직접 참조하지 않는다

// ❌ View 참조 보유 — 생명주기 문제
class BadViewModel : ViewModel() {
    var fragment: UserFragment? = null
    fun onSuccess() { fragment?.showToast("완료") }  // 위험
}

// ✅ 일회성 이벤트는 SharedFlow나 Channel로 전달
private val _event = MutableSharedFlow<UiEvent>()
val event: SharedFlow<UiEvent> = _event.asSharedFlow()

11. 정리

항목 내용
목적 UI와 비즈니스 로직 분리, 생명주기 안전한 상태 관리
ViewModel UI 상태 보유, 비즈니스 로직 위임, View 참조 없음
상태 발행 LiveData (단순) 또는 StateFlow (복잡·변환 필요)
데이터 접근 Repository 인터페이스를 통해 추상화
DI Hilt로 Repository 주입, @HiltViewModel 활용
Fragment 공유 activityViewModels()로 SharedViewModel 구현
DataBinding lifecycleOwner 설정으로 LiveData 자동 반영
주의 Context 직접 참조 금지, View 직접 참조 금지

참고



Related Posts