ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Model-View-Presenter(MVP)
    Swift 2022. 4. 4. 20:41

    안녕하세요.

     

    지난 시간에는 MVC에 대해서 알아보았습니다.

     

    MVC 역시 선배 개발자 분들의 수많은 고민이 담긴 훌륭한 아키텍처였지만, 해결하지 못한 문제들이 존재했습니다.

     

    1. Controller의 역할이 너무 커진다.(Massive View Controller..)

    2. ViewController가 UIKit에 의존적이기 때문에 테스트하기 어렵다.

     

    이러한 문제를 해결하기 위해서 고민한 결과 등장한 것이 바로 Model-View-Presenter(MVP)입니다.


    1. MVP

    MVP는 Controller가 UIKit과 의존적이기 때문에 테스트가 어려우니

    UIKit에 독립적이면서 Controller의 역할을 하는 객체를 만드는 게 어떨까라는데서 시작합니다.

     

    구조는 다음과 같습니다.

    MVP

    얼핏 봐서는 MVC와 뭐가 다르지?라고 생각하실 수 있습니다.

     

    주의깊게 볼 부분은 바로 두 곳입니다.

     

    우선 첫 번째로 MVP에서는 UIViewController를 Controller가 아닌 View로 취급합니다.

    UIViewController를 View로 취급

     

    그리고 두 번째로 MVC와 달리 중간자 역할을 하는 Presenter는 UIKIt에 완전히 독립적입니다.

     

    즉, UIKit과 관련된 코드는 모두 View로 취급하는 것이죠.

     

    이렇게 코드를 작성한다면 UI 관련된 코드와 비즈니스 로직이 완전히 분리되어 테스트하기 훨씬 용이한 구조가 되는 것이죠.

     

    그럼 코드를 통해 직접 살펴보도록 하겠습니다.

     

     

    + 버튼을 누르면 값이 증가하고 - 버튼을 누르면 값이 감소하는 간단한 앱입니다.

     

    Model

    해당 앱에서 Model은 수량을 나타내기 위한 데이터입니다.

    class Amount: NSObject {
        @objc dynamic var number: Int
        
        override init() {
            self.number = 0
        }
        
        func increase() {
            self.number += 1
        }
        
        func decrease() {
            self.number -= 1
        }
    }

    View

    먼저 ViewController가 View로서 행할 메서드를 정의한 프로토콜을 작성해야 합니다.

    이번 앱에서는 값이 변할 경우 그 값을 Label에 보여주기 위한 함수를 정의했습니다.

    protocol CountView: AnyObject {
        func onValueChange(number: Int)
    }

     

     

    그리고 ViewController가 해당 프로토콜을 채택하도록 합니다.

    변한 값을 Label에 적용시켜주도록 구현하였습니다.

    extension ViewController: CountView {
        func onValueChange(number: Int) {
            self.amountLabel.text = String(number)
        }
    }

    Presenter

    Presenter 또한 Presenter로서 필요한 메서드들을 정의한 Protocol을 작성해야 합니다.

    해당 프로토콜은 위에서 정의한 View Protocol을 매개변수로 하는 생성자와 ViewController로부터 전달받은 이벤트들을 처리할 메서드들을 필요로 합니다.

     

    이번 앱에서는 +버튼과 -버튼이 눌렸을 때 동작할 메서드들을 정의했습니다.

    protocol CountViewPresenter {
        init(view: CountView)
        func viewDidLoad()
        func increaseButtonTouched()
        func decreaseButtonTouched()
    }

    작성한 프로토콜을 준수하는 Presenter를 만들겠습니다.

    class CountPresenter: CountViewPresenter {
        private unowned var view: CountView
        
        private var amount: Amount!
        private var observation: NSKeyValueObservation?
        
        required init(view: CountView) {
            self.view = view
        }
        
        func viewDidLoad() {
            self.amount = Amount()
            self.view.onValueChange(number: 0)
            self.observation = self.amount.observe(\.number, options: .new) { object, change in
                guard let number = change.newValue else { return }
                self.view.onValueChange(number: number)
            }
        }
        
        func increaseButtonTouched() {
            self.amount.increase()
        }
        
        func decreaseButtonTouched() {
            self.amount.decrease()
        }
    }

    주의해야 할 점은 ViewController에서도 Presenter를 소유하고 있기 때문에 Presenter가 알고 있는 View는 순환 참조를 막기 위해서 Weak 또는 Unowned로 작성해야 합니다.

     

    ViewController를 마저 작성해보겠습니다.

    ViewController가 Presenter를 소유하도록 합니다.

     

    이벤트가 발생하면 해당하는 Presenter의 메서드를 호출하도록 코드를 작성합니다.

    class ViewController: UIViewController {
        /*
        View 선언
        */
        
        var presenter: CountViewPresenter?
        
        override func viewDidLoad() {
            super.viewDidLoad()
            self.presenter?.viewDidLoad()
        }
        
        @objc func increaseButtonTouched(_sender: UIButton) {
            self.presenter?.increaseButtonTouched()
        }
        
        @objc func decreaseButtonTouched(_sender: UIButton) {
            self.presenter?.decreaseButtonTouched()
        }
    }
    
    extension ViewController: CountView {
        func onValueChange(number: Int) {
            self.amountLabel.text = String(number)
        }
    }

     

    마지막으로 SceneDelegate와 같은 상위에서 ViewController를 생성할 때 Presenter 역시 생성해 주입하면 됩니다.

    class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    
        var window: UIWindow?
    
    
        func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
            guard let scene = (scene as? UIWindowScene) else { return }
            
            let view = ViewController()
            let presenter = CountPresenter(view: view)
            view.presenter = presenter
            
            self.window = UIWindow(windowScene: scene)
            self.window?.rootViewController = view
            self.window?.makeKeyAndVisible()
        }
    }

    코드는 이곳에서 확인하실 수 있습니다.


     

    위와 같이 코드를 작성하면 Presenter가 View를 직접 알지 않고 Protocol을 통해 알고 있기 때문에 View의 Mock을 만들기 쉬운 구조입니다.

    class CountViewMock: CountView {
        var number: Int = 0
        func onValueChange(number: Int) {
            self.number = number
        }
    }

    테스트만을 위한 Mock 객체를 만들고 테스트를 진행할 수 있습니다.

    //설명을 위해 작성된 코드입니다.
    class PresenterTests: XCTestCase {
        
        private var presenter: CountPresenter!
        private var viewMock: CountViewMock!
        
        override func setUpWithError() throws {
            self.viewMock = CountViewMock()
            self.presenter = CountPresenter(view: viewMock)
            self.presenter.viewDidLoad()
        }
    
        override func tearDownWithError() throws {
            self.presenter = nil
            self.viewMock = nil
        }
        
    
        func test_increaseButton() {
            self.presenter.increaseButtonTouched()
            XCTAssertEqual(self.viewMock.number, 1, "잘못된 값입니다.")
            self.presenter.increaseButtonTouched()
            XCTAssertEqual(self.viewMock.number, 2, "잘못된 값입니다.")
        }
        
        func test_decreaseButton() {
            self.presenter.decreaseButtonTouched()
            XCTAssertEqual(self.viewMock.number, -1, "잘못된 값입니다.")
            self.presenter.decreaseButtonTouched()
            XCTAssertEqual(self.viewMock.number, -2, "잘못된 값입니다.")
        }
    }

    MVC에서는 테스트하기 어렵던 Controller에 작성된 코드들을 UIKit과 무관한 Presenter에 작성함으로써 훨씬 테스트하기 좋은 구조가 되었습니다.

     

    하지만 보일러 플레이트 코드의 양이 늘어나 결과적으로 MVC에 비해서 코드 양이 어마어마하게 늘어난다는 단점이 존재합니다.

    또한 Presenter 역시도 직접 View에게 화면을 그리라고 지시합니다. 즉 View와 Presenter가 1:1 관계를 가집니다.

    이 말은 비슷한 UI일지라도 완전히 같지 않다면 새로운 Presenter를 필요로 하게 된다는 점입니다.

     

    이러한 문제점은 과연 어떻게 해결할 수 있을까요??

    또 다른 아키텍처를 살펴보면서 선배 개발자 분들의 고민의 흔적을 따라가 보도록 하겠습니다.

     

    감사합니다.


    Reference

    https://betterprogramming.pub/implement-a-model-view-presenter-architecture-in-swift-5-dfa21bbb8e0b

     

    Implement a Model-View-Presenter Architecture in Swift 5

    Using the Realm database as a business logic layer

    betterprogramming.pub

    https://medium.com/ios-os-x-development/ios-architecture-patterns-ecba4c38de52

     

    iOS Architecture Patterns

    Demystifying MVC, MVP, MVVM and VIPER

    medium.com

     

     

     

    'Swift' 카테고리의 다른 글

    Model-View-ViewModel(MVVM)  (0) 2022.04.08
    Model-View-Controller(MVC)  (0) 2022.04.03
    Property Wrapper  (0) 2022.03.25
    SOLID 원칙  (0) 2022.01.28
    Codable  (0) 2021.12.28
Designed by Tistory.