MVP (Model-View-Presenter) — Presenter가 ViewContract 인터페이스를 통해 Model과 View 사이의 중개자 역할을 하는 아키텍처 패턴입니다. Controller가 UIKit을 통해 View를 직접 제어하는 MVC와 달리, Presenter는 프레임워크에 의존하지 않습니다 — 추상화를 통해 작동하므로 Android SDK나 UIKit 없이도 테스트 가능합니다. MVP는 Jetpack 등장 전 Android 개발에서 널리 사용되었으며 레거시 프로젝트에서 여전히 중요합니다. 자세한 내용은 Martin Fowler의 글을 참조하세요.
핵심 사항
MVP (Model-View-Presenter) — Martin Fowler가 2000년대 초 사용자 인터페이스의 테스트 가능성을 개선하기 위해 MVC의 진화 형태로 제안한 아키텍처 패턴입니다. Model은 데이터와 비즈니스 로직을 관리하고, View는 렌더링 및 사용자 입력 처리를 담당하며, Presenter는 View에서 이벤트를 수신하고 Model에서 데이터를 검색하여 표시할 상태를 형성하는 중앙 구성 요소입니다.
MVP와 MVC의 주요 차이점 — Presenter는 View에 대한 직접 참조가 없습니다. 대신 Presenter는 ViewContract 인터페이스를 통해 View와 상호 작용합니다. View는 이 인터페이스를 구현하고 자신을 Presenter에 전달합니다. 이는 UIKit(iOS) 또는 Android Framework에 대한 의존성을 차단합니다 — Presenter는 ViewContract의 모의 구현으로 격리 테스트할 수 있습니다. MVC에서 UIViewController 컨트롤러는 직접 UILabel을 업데이트하지만, MVP에서는 Presenter가 view.showName(name)을 호출하고 View가 표시 방법을 결정합니다.
| 구성 요소 | 책임 | 테스트 가능성 |
|---|---|---|
| Model | 데이터, 비즈니스 로직, 네트워크 호출 | 단위 테스트(UI와 무관) |
| View | UI 렌더링, Presenter로 이벤트 전달 | 인터페이스를 통한 모의 구현 |
| Presenter | 비즈니스 로직, 상태 관리, 탐색 | 단위 테스트(ViewContract 모의 통해) |
단일 책임 원칙은 MVP에서 MVC보다 더 엄격하게 준수됩니다. View는 렌더링만, Model은 데이터만, Presenter는 로직과 조정만 담당합니다. 실제 프로젝트에서 Presenter는 화면 코드의 40~60%, View는 20~30%, Model은 20~30%를 차지합니다. 이러한 분배를 통해 Android 에뮬레이터나 iOS 시뮬레이터를 실행하지 않고도 핵심 비즈니스 로직을 테스트할 수 있습니다.
Android의 MVP는 Activity 또는 Fragment를 View로 사용하며, 이는 데이터 표시 메서드가 있는 인터페이스인 ViewContract를 구현합니다. Presenter는 Activity에서 생성되고 View를 자신에 연결하며 데이터 로딩을 관리합니다. 화면이 회전하면 Activity가 다시 생성됩니다 — Presenter는 retain 프래그먼트나 외부 저장소를 통해 보존될 수 있어 순수 MVC의 상태 손실 문제를 해결합니다.
// ViewContract — Presenter와 View를 연결하는 인터페이스
interface UserView {
fun showLoading()
fun hideLoading()
fun showUser(user: User)
fun showError(message: String)
}
// Presenter — 테스트 가능한 로직 계층
class UserPresenter(
private val repository: UserRepository
) {
private var view: UserView? = null
fun attachView(view: UserView) {
this.view = view
}
fun detachView() {
view = null
}
fun loadUser(userId: Int) {
view?.showLoading()
repository.getUser(userId) { result ->
view?.hideLoading()
result.onSuccess { user ->
view?.showUser(user)
}.onFailure { e ->
view?.showError(e.message ?: "Unknown error")
}
}
}
}
// View(Activity)가 인터페이스를 구현
class UserActivity : AppCompatActivity(), UserView {
private val presenter = UserPresenter(UserRepository())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
presenter.attachView(this)
presenter.loadUser(42)
}
override fun onDestroy() {
presenter.detachView()
super.onDestroy()
}
override fun showUser(user: User) { /* UI 업데이트 */ }
override fun showLoading() { /* ProgressBar 표시 */ }
override fun hideLoading() { /* ProgressBar 숨기기 */ }
override fun showError(message: String) { /* Snackbar 표시 */ }
}
수명 주기 관리 — Android에서 MVP의 주요 문제입니다. 화면 회전 시 Activity가 소멸되고 presenter.attachView()가 onCreate()에서 다시 호출됩니다. 데이터 로딩이 비동기적(RxJava, 코루틴)인 경우 완료 시점에 View가 분리될 수 있습니다. 해결책 — detachView()에서 구독을 취소하거나 Support Library의 Loader를 사용합니다(Jetpack 없는 프로젝트의 경우). IT Sectr에서는 상업 프로젝트에서 수년간 MVP + RxJava 조합을 사용했습니다 — 패턴은 안정적이지만 구독 관리에 규율이 필요합니다.
Retain 프래그먼트 — 화면 회전 시 Presenter를 보존하는 메커니즘입니다. UI가 없는 프래그먼트(setRetainInstance(true))는 Activity보다 오래 살아남으며 Presenter에 대한 참조를 유지합니다. Activity가 다시 생성되면 프래그먼트는 동일한 Presenter를 새 Activity에 전달합니다. Retain 프래그먼트는 AndroidX부터 더 이상 사용되지 않지만, pre-Jetpack 버전(Fragment.setRetainInstance)은 레거시 프로젝트에서 여전히 작동합니다. 현대 개발에서는 Google이 retain 프래그먼트 대신 ViewModel을 권장합니다.
iOS의 MVP는 View 프로토콜을 통해 구축됩니다. UIViewController가 프로토콜을 구현하고, Presenter는 UIKit을 가져오지 않으며 순수하게 테스트 가능합니다. UIViewController 자체가 로직과 직접 IBOutlet 연결을 포함하는 Apple MVC와 달리, Presenter는 상태를 관리하고 프로토콜 메서드를 통해 View에 명령을 내립니다. View는 결정을 내리지 않습니다 — showUser, showLoading, navigateToProfile 같은 Presenter의 명령을 실행합니다.
import Foundation
// View Protocol — Presenter의 추상화
protocol UserViewProtocol: AnyObject {
func showLoading()
func hideLoading()
func display(user: User)
func displayError(message: String)
}
// Presenter — UIKit 없는 순수 로직
final class UserPresenter {
private weak var view: UserViewProtocol?
private let service: UserServiceProtocol
init(service: UserServiceProtocol) {
self.service = service
}
func attach(view: UserViewProtocol) {
self.view = view
}
func detach() {
view = nil
}
func loadUser(id: Int) {
view?.showLoading()
service.fetchUser(id: id) { [weak self] result in
guard let self else { return }
self.view?.hideLoading()
switch result {
case .success(let user):
self.view?.display(user: user)
case .failure(let error):
self.view?.displayError(message: error.localizedDescription)
}
}
}
}
// View(UIViewController)가 프로토콜을 구현
final class UserViewController: UIViewController, UserViewProtocol {
private let presenter = UserPresenter(service: UserService())
override func viewDidLoad() {
super.viewDidLoad()
presenter.attach(view: self)
presenter.loadUser(id: 42)
}
func display(user: User) {
nameLabel.text = user.name
}
// ... 프로토콜의 나머지 메서드
}
Weak reference — iOS MVP에서 View에 대한 약한 참조는 필수입니다. UIViewController는(탐색 스택에서 pop으로) 소멸될 수 있으며, Presenter의 클로저가 retain cycle을 만듭니다. 약한 참조(weak var)는 Presenter의 비동기 작업과 관계없이 View가 화면을 떠날 때 해제됨을 보장합니다. Android에서는 유사한 문제가 detachView()로 해결됩니다 — onDestroy()에서 호출하면 View에 대한 참조가 무효화됩니다.
Passive View vs Supervising Controller — Martin Fowler의 두 MVP 변형입니다. Passive View: View에 로직이 없으며 Presenter가 상태를 완전히 관리합니다. Supervising Controller: View 자체가 간단한 데이터 바인딩(예: data binding)을 수행하고 Presenter는 복잡한 시나리오에서만 개입합니다. 모바일 개발에서는 Passive View가 더 자주 사용됩니다 — 최대 테스트 가능성과 화면 상태 예측 가능성을 제공합니다.
주요 차이점은 MVP와 MVC의 View와의 통신 방식입니다. MVC에서 Controller는 View(UIViewController.IBOutlets, Activity.findViewById)에 대한 직접 참조를 가집니다. MVP에서 Presenter는 ViewContract 인터페이스를 통해 View와 상호 작용합니다. 이 차이는 테스트 가능성을 근본적으로 변화시킵니다: ViewContract를 구현하는 모의 객체를 통해 앱, 에뮬레이터 또는 UI 프레임워크를 실행하지 않고 Presenter의 로직을 테스트할 수 있습니다.
| 기준 | MVC | MVP |
|---|---|---|
| View 연결 | 직접(Controller → View) | 인터페이스를 통해(Presenter → ViewContract) |
| 로직 테스트 | UIKit/Android Framework 필요 | 플랫폼 의존성 없는 단위 테스트 |
| 수명 주기 | Controller가 화면과 함께 존재 | Presenter가 더 오래 존재 가능(retain) |
| 복잡성 | 최소 | 화면당 +1 인터페이스 |
| Massive Controller | 일반적인 문제 | 로직은 Presenter, View는 얇음 |
Presenter 단위 테스트 예제 Kotlin: 모의 UserView를 생성하고 Presenter에 전달하며 loadUser를 호출하고 showUser가 올바른 데이터로 호출되었는지 확인합니다. 테스트는 밀리초 단위로 실행되며 에뮬레이터가 필요하지 않습니다. iOS에서도 유사하게 — OCMock 또는 프로토콜 스텁이 UserViewProtocol 메서드 호출을 검증합니다. IT Sectr의 MVP 프로젝트에서 비즈니스 로직 단위 테스트 커버리지는 85~90%에 도달했으며, 이는 유사한 MVC 프로젝트보다 2~3배 높습니다.
MVP가 MVC보다 선호되는 경우 — 안정성에 엄격한 요구사항이 있는 프로젝트: 은행 앱, 의료 시스템, 결제 단말기. 이러한 분야에서는 오류 비용이 높고 단위 테스트가 중요합니다. 포스트-MVP 프로젝트(제품이 이미 시장에 나와 있지만 코드베이스가 레거시인 경우)에서 MVP는 완전한 아키텍처 재작성 없이 Massive View Controller에서 테스트 가능한 계층으로 로직을 점진적으로 추출할 수 있습니다.
MVP의 주요 단점 — 인터페이스 수의 증가와 수동 구독 관리입니다. 각 화면에 최소 하나의 ViewContract + Presenter가 필요하며, 50개 화면의 경우 50개의 인터페이스와 50개의 Presenter 클래스가 됩니다. MVVM에서는 ViewModel이 Presenter를 대체하고 반응형 메커니즘(LiveData, StateFlow, ObservableObject)을 사용하여 수동 attach/detach 및 ViewContract 인터페이스의 필요성을 없앱니다.
RxJava와 MVP — Android 2015~2019에서 인기 있는 조합입니다. Presenter가 Repository의 Observable을 구독하고 ViewContract를 통해 결과를 표시합니다. 문제: disposable을 detachView()에서 명시적으로 취소해야 하며, 그렇지 않으면 구독 누수로 분리된 View 업데이트 시 충돌이 발생합니다. RxLifecycle과 AutoDispose 라이브러리가 구독 해제를 부분적으로 자동화했지만 의존성을 추가했습니다. IT Sectr에서는 2020년 MVP+RxJava에서 MVVM+Flow로 전환했습니다 — ViewContract 제거로 코드가 25~30% 짧아졌습니다.
MVP에서 MVVM으로의 마이그레이션 — 단계적 프로세스입니다. 1) Presenter의 ViewContract를 LiveData/StateFlow로 교체합니다. 2) attach/detach 메서드 제거 — 구독은 observe()를 통해 이루어집니다. 3) Presenter 이름을 ViewModel로 변경합니다. 4) ViewModelFactory용 DI(Hilt/Koin)를 통합합니다. 한 화면 마이그레이션에 2~4시간, 전체 코드베이스(50~100개 화면 프로젝트)에 2~4주가 소요됩니다. 마이그레이션 후 ViewContract 인터페이스가 제거되고 코드가 축소되며 테스트는 유지됩니다.
현대 개발에서의 MVP — 패턴은 살아있지만 MVVM과 MVI에 자리를 내주고 있습니다. Google은 새 프로젝트에 Jetpack + MVVM을 공식 권장합니다. Apple은 SwiftUI + MVVM입니다. 그러나 레거시 코드 작업에는 MVP 지식이 필수적입니다: Google Play의 수백 개 Android 앱이 여전히 MVP로 실행되며, 대형 은행, 소매업체, 운송 회사의 앱을 포함합니다. MVP의 이해는 MVI와 Clean Architecture를 마스터하기 위한 기초이며, Presenter는 Robert Martin의 용어로 Use Case의 직접적인 전신입니다.
자주 묻는 질문
MVP에서 Presenter는 ViewContract 인터페이스를 통해 View와 상호 작용하며 직접적이지 않습니다. MVC에서 Controller는 IBOutlet/findViewById를 통해 View에 대한 직접 참조를 가집니다. MVP는 Presenter가 UIKit이나 Android Framework에 의존하지 않으므로 iOS Simulator나 Android Emulator 없이 단위 테스트로 Presenter를 테스트할 수 있습니다. MVC는 컨트롤러 테스트를 위해 앱 실행이 필요합니다.
MVP는 이미 이 패턴으로 구축된 레거시 프로젝트와 반응형 메커니즘(LiveData, StateFlow, Combine)을 지원하지 않는 앱에서 정당화됩니다. 새 프로젝트의 경우 Google은 Jetpack + MVVM(Android)을, Apple은 SwiftUI + MVVM(iOS)을 권장합니다. MVP는 Combine 없이 순수 UIKit 프로젝트에서 비즈니스 로직 단위 테스트가 필요할 때 최선의 선택입니다.
Android — retain 프래그먼트(setRetainInstance(true)) 또는 Jetpack의 ViewModel을 사용합니다. Retain 프래그먼트는 회전 시 Presenter를 저장하고 새 Activity에 전달합니다. Google의 ViewModel은 retain 프래그먼트 없이 회전 시 자동으로 상태를 유지하는 현대적인 대안입니다. iOS — Presenter는 viewDidLoad마다 다시 생성되지만 별도의 코디네이터 서비스에 캐시됩니다.
최소 4개: ViewContract 인터페이스, ViewContract 구현(Activity/Fragment), Presenter, Model(Repository). Dagger/Hilt를 사용하면 DI 모듈이 추가됩니다. 50개 화면의 경우 200개 이상의 클래스입니다. MVVM은 화면당 1개 파일을 줄이고(ViewContract 불필요), MVI는 State 및 Intent 클래스를 추가합니다. 클래스 수는 대규모 프로젝트에서 MVP에 반대하는 주요 논거입니다.
Passive View — View에 로직이 없으며 Presenter가 상태와 데이터를 완전히 관리합니다. Supervising Controller — View 자체가 간단한 바인딩(data binding)을 수행하고 Presenter는 복잡한 시나리오에서만 개입합니다. 모바일 개발에서는 Passive View가 지배적이며 — 최대 테스트 가능성과 예측 가능성을 제공합니다. Supervising Controller는 웹 프레임워크(ASP.NET Web Forms, GWT)에서 사용됩니다.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.