MVVM(Model-View-ViewModel)은 ViewModel이 Presenter를 대체하고 View와 통신하기 위해 반응형 메커니즘(SwiftUI의 ObservableObject, Android의 LiveData/StateFlow)을 사용하는 아키텍처 패턴입니다. ViewModel은 View에 대한 참조가 없습니다. 데이터는 구독을 통해 전달되므로 ViewContract 인터페이스가 필요하지 않으며 테스트가 더욱 간단해집니다. Apple은 2019년부터 SwiftUI와 함께 MVVM을 권장하고 있으며, Google은 Jetpack과 함께 MVVM을 공식 Android 아키텍처로 권장합니다. 자세한 내용은 Android Architecture Guide에서 확인하세요.
핵심 요점
MVVM(Model-View-ViewModel)은 2005년 John Gossman이 Microsoft의 Windows Presentation Foundation(WPF)을 위해 설명한 아키텍처 패턴입니다. ViewModel은 화면 상태와 비즈니스 로직을 포함하는 중앙 컴포넌트이지만 View에 대한 참조는 없습니다. 데이터는 반응형 바인딩 메커니즘을 통해 전달됩니다. View는 ViewModel의 변경 사항을 구독하고 데이터가 변경되면 자동으로 다시 렌더링됩니다.
MVVM과 MVP의 주요 차이점 — ViewContract가 없다는 점입니다. MVP에서 Presenter는 view.showUser(data) 메서드를 호출합니다. 즉, Presenter가 View로 데이터를 적극적으로 "밀어넣습니다". MVVM에서는 View 자체가 구독을 통해 ViewModel에서 데이터를 "가져옵니다". ViewModel은 구독자가 있는지 알지 못합니다. 이는 분리된 View 문제를 해결합니다. 회전 시 Activity가 소멸되어도 ViewModel은 계속 작동하고 새 Activity는 현재 데이터를 구독하기만 하면 됩니다. IT Sectr에서는 2020년부터 모든 새 프로젝트에서 MVVM을 사용하고 있습니다. 코드의 예측 가능성이 높아지고 테스트가 더 안정적이 되었습니다.
| 컴포넌트 | 책임 | 플랫폼 |
|---|---|---|
| Model | 데이터, 비즈니스 로직, 리포지토리 | Android/iOS |
| View | 표시, ViewModel 구독 | Activity/Composable, UIView/SwiftUI View |
| ViewModel | 화면 상태, 로직, 네비게이션 | ViewModel(Jetpack), ObservableObject |
반응형 바인딩 — MVVM의 기초입니다. Android에서 LiveData(Jetpack의 일부)는 관찰 가능한 데이터 홀더입니다. Activity는 observe()를 통해 구독합니다: viewModel.user.observe(this) { user -> binding.name.text = user.name }. user가 변경되면 모든 구독자가 자동으로 새 값을 받습니다. iOS에서 SwiftUI는 ViewModel에서 @Published 속성을 사용합니다. 변경 사항이 자동으로 View를 다시 렌더링합니다. 이는 MVP에서 필요한 수동 showUser/hideLoading 호출을 제거합니다.
Jetpack의 ViewModel — MVVM 구현을 위한 Google의 공식 컴포넌트입니다. ViewModel은 화면 회전을 견딥니다. 구성이 변경되면 Activity는 소멸되고 다시 생성되지만 ViewModel은 메모리에 유지됩니다. 새 Activity 인스턴스는 ViewModelProvider를 통해 동일한 ViewModel을 얻습니다. ViewModel은 Activity, Context 또는 View에 대한 참조가 없습니다. 깔끔하며 Robolectric 없이 단위 테스트가 가능합니다.
// StateFlow를 사용한 ViewModel — 현대적인 MVVM 구현
class UserViewModel(
private val repository: UserRepository
) : ViewModel() {
private val _state = MutableStateFlow<UserState>(UserState.Loading)
val state: StateFlow<UserState> = _state.asStateFlow()
fun loadUser(userId: Int) {
viewModelScope.launch {
_state.value = UserState.Loading
repository.getUser(userId)
.onSuccess { user ->
_state.value = UserState.Success(user)
}
.onFailure { e ->
_state.value = UserState.Error(e.message ?: "Unknown")
}
}
}
}
sealed interface UserState {
data object Loading : UserState
data class Success(val user: User) : UserState
data class Error(val message: String) : UserState
}
// View(Activity)가 state를 구독
class UserActivity : AppCompatActivity() {
private val viewModel: UserViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.state.onEach { state ->
when (state) {
is UserState.Loading -> /* 로딩 표시 */
is UserState.Success -> /* 데이터 표시 */
is UserState.Error -> /* 오류 표시 */
}
}.launchIn(lifecycleScope)
viewModel.loadUser(42)
}
}
LiveData vs StateFlow — LiveData(2017)는 Jetpack의 첫 번째 반응형 컴포넌트로, Activity 라이프사이클에 최적화되어 있습니다(onStop에서 자동 구독 해지). StateFlow(2021)는 Kotlin Flow 구현으로, 라이프사이클에 묶이지 않지만 lifecycleScope를 통한 수동 구독 해지가 필요합니다. StateFlow는 coroutines, concat, map 및 기타 Flow 연산자를 지원하며, 이는 LiveData에는 없습니다. IT Sectr에서는 모든 새 ViewModel에 StateFlow를 사용합니다. 더 짧고, 더 강력하며, coroutines와 더 잘 통합됩니다.
DataBinding 및 ViewBinding — DataBinding은 ViewModel을 XML에 @{viewModel.user.name}으로 레이아웃에서 직접 바인딩하여 Activity의 코드를 제거합니다. ViewBinding은 View에 접근하기 위한 타입-세이프 클래스를 생성합니다. Google은 간단한 프로젝트에는 ViewBinding을, 복잡한 데이터 바인딩이 있는 프로젝트에는 DataBinding을 권장합니다. Jetpack Compose에서는 DataBinding이 필요하지 않습니다. @Composable 함수는 State가 변경되면 자동으로 다시 렌더링됩니다.
iOS의 MVVM은 Combine의 ObservableObject를 통해 구현됩니다. ViewModel은 ObservableObject를 상속하는 클래스이며 @Published 속성을 가집니다. SwiftUI View는 @ObservedObject 또는 @StateObject를 통해 ViewModel을 구독합니다. @Published 속성이 변경되면 SwiftUI는 이 속성에 의존하는 View를 자동으로 다시 렌더링합니다. Apple은 2019년 WWDC에서 SwiftUI를 Combine과 함께 발표했습니다. 그 이후로 MVVM은 iOS에서 공식적으로 권장되는 패턴이 되었습니다.
import SwiftUI
import Combine
// ViewModel — @Published 필드를 가진 ObservableObject
final class UserViewModel: ObservableObject {
@Published private(set) var state: UserState = .loading
private let service: UserService
init(service: UserService) {
self.service = service
}
func loadUser(id: Int) {
state = .loading
service.fetchUser(id: id) { [weak self] result in
guard let self else { return }
switch result {
case .success(let user):
self.state = .success(user)
case .failure(let error):
self.state = .error(error.localizedDescription)
}
}
}
}
enum UserState {
case loading
case success(User)
case error(String)
}
// SwiftUI View — ViewModel을 구독
struct UserView: View {
@StateObject private var viewModel: UserViewModel
var body: some View {
switch viewModel.state {
case .loading:
ProgressView()
case .success(let user):
VStack {
Text(user.name).font(.title)
Text(user.email).font(.body)
}
case .error(let message):
Text(message).foregroundColor(.red)
}
}
}
@StateObject vs @ObservedObject — @StateObject는 ViewModel을 생성하고 라이프사이클을 관리합니다(View 수명 동안 한 번). @ObservedObject는 ViewModel이 외부에서 생성되어 View에 전달됩니다. WWDC 2022는 생성에는 @StateObject를, View 간 ViewModel 전달에는 @ObservedObject를 권장합니다. iOS 17(2023)에서는 @Observable 매크로가 등장했습니다. 구독을 자동화하고 @Published 어노테이션을 제거합니다. @Observable은 Combine의 진화로, iOS 개발을 Kotlin Flow 반응성에 더 가깝게 만듭니다.
UIKit + MVVM — UIKit 프로젝트(SwiftUI 없음)의 경우 MVVM은 Combine과 @Published를 사용하여 UIViewController에서 sink()를 통한 구독으로 구현됩니다. ViewModel은 동일하며, View는 @Published에 대한 구독이 있는 UIViewController입니다. Combine은 iOS 13(2019)부터 사용 가능하며 시스템에 내장되어 있습니다. 추가 종속성이 필요하지 않습니다. Apple Developer Survey(2025)에 따르면 iOS 프로젝트의 45%가 UIKit에서도 Combine을 사용하고, 35%는 SwiftUI + Combine을, 20%는 RxSwift(레거시)를 사용합니다.
MVVM이 MVP보다 우수합니다 세 가지 주요 측면에서: ViewContract 인터페이스 불필요, 자동 구독 관리, 화면 회전 내구성. MVP에서는 각 화면에 ViewContract 인터페이스 + Presenter 클래스 + onStart/onStop에서의 구독/해지가 필요합니다. MVVM에서는 ViewModel만 생성되며 Activity에서의 구독은 수동 detach() 없이 observe()를 통해 이루어집니다.
| 기준 | MVP | MVVM |
|---|---|---|
| ViewContract 인터페이스 | 화면당 1개 | 필요 없음 |
| 구독 관리 | 수동 attach/detach | 자동(lifecycle-aware) |
| 화면 회전 | Retain-fragment | ViewModel이 회전을 견딤 |
| 테스트 | Mock ViewContract | 종속성 없는 깔끔한 클래스 |
| 반응성 | Presenter의 콜백 | LiveData/StateFlow/Combine |
MVVM의 단점 — 반응형 체인 디버깅의 복잡성과 잘못된 구독으로 인한 메모리 누수 위험. LiveData는 라이프사이클 안전성을 해결하고, StateFlow는 lifecycleScope가 필요하며, Combine은 AnyCancellable과 함께 sink가 필요합니다. MVP에서는 모든 호출이 명시적(view.showUser)이지만, MVVM에서는 데이터가 반응형 스트림을 통해 전달됩니다. 추적에는 subscribe 클로저에서 디버그 중단점이 필요합니다. 여러 StateFlow가 있는 대규모 ViewModel에서 View가 특정 Flow를 구독하지 않으면 UI 업데이트를 놓칠 수 있습니다.
MVP가 여전히 더 나은 경우 — 최소 Android 버전이 API 21(Android 5) 미만인 프로젝트(Jetpack ViewModel을 AndroidX 없이 사용할 수 없음)와 Combine 없는 순수 UIKit(iOS 12 이하)을 사용하는 프로젝트. 전체 코드베이스가 이미 MVP인 레거시 프로젝트의 경우 MVVM으로의 완전한 전환이 항상 정당화되는 것은 아닙니다. 3개월 안에 100개의 화면을 다시 작성하는 것보다 서비스로 로직을 점진적으로 추출하면서 MVP를 유지하는 것이 더 저렴합니다.
ViewModel은 플랫폼 종속성 없이 단위 테스트로 테스트됩니다 — 이것이 MVVM을 지지하는 주요 논거입니다. Android에서 ViewModel은 Activity, Context 또는 View를 포함하지 않습니다. 모든 종속성(Repository, UseCase)은 생성자를 통해 전달되고 모의 객체로 대체됩니다. iOS에서 ObservableObject는 애플리케이션을 실행하지 않고 XCTest를 통해 테스트되어 안정성과 테스트 실행 속도를 제공합니다.
// MockK를 사용한 Android ViewModel 단위 테스트
class UserViewModelTest {
private val repository = mockk<UserRepository>()
private val viewModel = UserViewModel(repository)
@Test
fun loadUser_success_updatesState() = runTest {
val user = User(1, "John", "john@test.com")
coEvery { repository.getUser(1) } returns Result.success(user)
viewModel.loadUser(1)
assertEquals(UserState.Success(user), viewModel.state.value)
}
@Test
fun loadUser_error_updatesErrorState() = runTest {
val error = RuntimeException("Network error")
coEvery { repository.getUser(1) } returns Result.failure(error)
viewModel.loadUser(1)
val state = viewModel.state.value
assertTrue(state is UserState.Error)
assertEquals("Network error", (state as UserState.Error).message)
}
}
iOS ViewModel도 유사하게 테스트됩니다. 모의 UserService를 주입하고 loadUser를 호출하며 XCTestExpectation을 통해 상태를 확인합니다. Combine Publisher는 XCTestCase와 wait(for: expectations, timeout: 1.0)으로 테스트됩니다. UserState 구조체(연관 값이 있는 enum)는 작업 후 정확한 화면 상태를 확인할 수 있게 합니다.
코드 커버리지는 MVVM을 사용하는 IT Sectr 프로젝트에서 ViewModel 및 Repository에 대해 75–90%입니다. ViewModel은 단위 테스트로, Repository는 테스트 데이터베이스를 사용한 통합 테스트로 커버됩니다. SwiftUI 및 Jetpack Compose의 View는 중요한 시나리오에 대해 UI 테스트(XCUITest, Compose Test)로 테스트됩니다. 나머지 UI는 스크린샷 테스트(Snapshot Testing)로 확인됩니다. 이는 UI 테스트보다 빠르며 표시 정확성에 대해 95%의 신뢰도를 제공합니다.
자주 묻는 질문
MVVM에서 ViewModel은 View에 대한 참조가 없습니다. 데이터는 반응형 메커니즘(LiveData, StateFlow, @Published)을 통해 전달됩니다. MVP에서 Presenter는 ViewContract 인터페이스를 통해 View 메서드를 직접 호출합니다. MVVM은 ViewContract와 수동 attach/detach를 제거하지만 반응형 스트림에 대한 이해가 필요합니다. ViewModel은 Android에서 화면 회전을 견디지만 Presenter는 retain-fragment가 필요합니다.
최소 세트: lifecycle-viewmodel-ktx(ViewModel), lifecycle-livedata-ktx 또는 kotlinx-coroutines-core(StateFlow). 주입을 위해 — Hilt 또는 Koin. 비동기 작업을 위해 — Kotlin Coroutines. 복잡한 데이터 바인딩을 위해 — DataBinding. Jetpack Compose(2022년부터 Google 권장)에서는 compose-runtime과 lifecycle-viewmodel-compose로 충분합니다.
SwiftUI(2019)는 반응형 아키텍처용으로 설계되었습니다. @State와 @Published는 데이터 변경 시 자동으로 View를 다시 렌더링합니다. MVVM은 SwiftUI에 자연스럽게 맞습니다. View는 @ViewBuilder, ViewModel은 ObservableObject입니다. Apple은 MVVM을 유일한 패턴으로 강요하지 않지만 2019년 이후 모든 교육 자료는 ViewModel + SwiftUI를 사용합니다. UIKit의 경우 Apple은 MVC 또는 Coordinator를 권장합니다.
Android: viewModelScope는 ViewModel 정리 시 자동으로 coroutines를 취소합니다. iOS: Combine의 AnyCancellable은 보유 객체가 할당 해제될 때 자동으로 구독을 해지합니다. SwiftUI @StateObject는 라이프사이클을 자동으로 관리합니다. 주요 규칙: ViewModel에 View/Context에 대한 참조를 저장하지 말고, 정리 시 장기 실행 작업을 취소하며, 클로저에서 weak self를 사용하세요.
StateFlow가 현대적인 선택입니다. LiveData는 더 간단하고 라이프사이클에 안전하지만 StateFlow가 더 강력합니다. coroutines와 함께 작동하며 flatMap, combine, filter를 지원하고 @Nullable 어노테이션이 필요하지 않습니다. LiveData가 선호되는 유일한 시나리오는 StateFlow(Kotlin Flow API)를 사용할 수 없는 Java 코드로 작업하는 경우입니다. Google은 새로운 Kotlin 프로젝트에 StateFlow를 권장합니다.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.