본문 바로가기

스위프트

[Swift/아키텍쳐 패턴] VIPER 패턴

프로젝트 소스 구조의 큰 그림을 그리는 패턴들을 아키텍쳐 패턴이라고 한다.

다른 아키텍쳐 패턴 참고 문서를 통해서 가장 잘 알려진 패턴들이라고 하면

MVC (Model-View-Controller)

MVP (Model-View-Presenter)

MVVM (Model-View-ViewModel)

VIPER(View, Interactor, Presenter, Entity, Router)

이 네가지를 예를 들 수 있겠다.

 

이 중 실제 프로젝트에서 많이 썼던 형태는 MVVM인데,

이렇게 구조를 나누는 이유는

다음과 같이 세가지의 조건을 만족하는 조건일 경우

각 객체간 결합성이 줄어들고 유닛 테스트가 용이하다는 점에서 사용한다고 보면 된다.

 

1. 책임 분리

책임 분리란

"특정 객체가 가지고 있는 기능을 명확히하고 다른 객체와 정확히 분리하여 구조 이해가 쉽고

객체간에 결합성이 줄어들 수 있는 조건" 이라고 말하고 싶다.

그 이유는 마치 공장의 생산 라인에서 일하는 사람들에 비유하여 이해를 시키고 싶다.

내가 핸드폰 공장에서 일할 때의 이야기다.

컨베이너 벨트가 계속 진행되고 있고 그 위에는 덜 조립된 핸드폰 단말기가 계속 흘러간다.

각 파트별로 카메라 렌즈 스티커를 부착, 단말기 테두리 클린 작업 등등

각 파트별로 "특정 업무"만을 진행한다.

결과로 나온 단말기가 특정 문제가 있을 경우, 그 특정 문제의 위치가 어떤 파트로부터 발생한건지

아주 명확해지기 때문이다.

 

이를 이번에 연구한 VIPER 패턴으로 예를 들자면,

결과로 나온 특정 화면에서

UI에 어글린한 부분이 있다 - View

API 통신시 올바른 형태가 아닌 데이터가 내려왔다 - Interactor

특정 버튼을 눌렀을 때 화면이 제대로 보이지 않는다 - Presenter 혹은 Router

등 이런식으로 문제가 발생했을 때 우선 참고해야할 객체를 떠올릴 수 있게 해준다.

 

2. 테스트 가능성

유닛 테스트란 런타임에서 모든 순서 로직에 의해 나온 결과를 검증하는 것이 아닌,

특정 메서드에 테스트 데이터를 넣어 나온 결과를 검증하는 방법이다.

이는 VIPER 패턴에서 객체의 책임단위가 아주 작게나눠져있기 때문에

각 패턴의 객체를 추상화만 잘한다면 해당 추상화를 통해 테스트 데이터를 연결만시켜도

나머지 로직은 그대로 돌아가기 때문에 테스트가 용이하다는 점이다.

 

예를 들어보자,

VIPER에서 Interactor로의 엔티티 요청에 대한 응답이 올바르지 않았을 경우를 테스트하고 싶을 때

추상화된 Interactor의 테스트 객체를 Presenter에 연결만 하여도

기존의 로직이 정상적으로 돌아가서 결과가 잘못되었는지 확인을 할 수가 있는거다.

 

3. 사용하기 편해야하고 유지보수가 쉬워야한다.

이는 다른 사람들의 의견들을 포함하여 VIPER는 러닝커브가 있다는 점에서 

어쩌면 사용하기 쉽지는 않은 것 처럼 보일 수 있다.

하지만, 개발 동료들이 모두 VIPER패턴을 이해하고있다는 가정하에

어떤 기능이 추가된다면 바로 특정객체에 접근해서 수정해야되는구나라는 아이디어를 떠올릴 수 있기 때문에

유지보수 차원에서는 더 좋은 구조를 띈다고 할 수 있다.

 

실제로 VIPER 패턴을 이해하기위해

나는 공공데이터 API를 통해 관광지별 동네 예보를 받아서 리스트를 보여주는 형태의 프로젝트를 개발해보았다.

[주의 : 모든 소스코드의 추상화 및 실 객체 소스는 모두 나의 아이디어로 구현해본 코드이다. 정답이 아니다.]

 

먼저, 추상화를 하지않고 객체화만 진행한다면 테스트시에 객체를 수정해야하는 경우도 생길거다.

그러므로, 추상화 객체를 VIPER의 객체별로 해보자.

단, 각 객체별의 파라미터, 반환값등은 달라질 수 있기에 추상화 개수 = 구현 객체 개수가

실제 프로젝트에는 맞아보이겠지만,

여기서는 Generic이라는 이름하에

추상화를 진행해서 사용해보았다.

 

 

[1] Interactor

인터렉터는 독립적으로 존재할 수 있는 객체이다.

이유는 내부에 비즈니스 로직을 통해 엔티티 인스턴스를 생산만 할 뿐

인터렉터가 직접 다른 VIPER 요소를 참조하여 메서드를 호출하는 케이스가 없기 때문이다.

 

/**
 * Interactor
 * - is responsible for processing entity such as API request and client business logic, etc.
 * - only communicate with Presenter
 * - Interactor -> Presenter : send entities after processing business logic
 */
protocol GenericInteractor: AnyObject {
    func fetchEntity<T>(entityType: T.Type, completion: @escaping (T?) -> Void)
}

 

[2] Entity

엔티티는 정크 데이터 단위이다.

객체간에 통신을 하기위해 정의된 데이터 모델일 뿐

이 엔티티가 로직에서 특정 기능을 하는 일은 없어야한다.

아래는 공공데이터 API를 통해 응답으로 얻는 데이터 모델이다.

 

struct TouristSpotEntity: Decodable {
    private let time: String
    private let theme: String
    private let courseID: String
    private let courseAreaID: String
    private let courseAreaName: String
    private let courseName: String
    private let spotAreaID: Int
    private let spotAreaName: String
    private let spotName: String
    private let skyCondition: Int
    private let humidity: Int
    private let posibilityOfRain: Int

    enum CodingKeys: String, CodingKey {
        case time = "tm"
        case theme = "thema"
        case courseID = "courseId"
        case courseAreaID = "courseAreaId"
        case courseAreaName
        case courseName
        case spotAreaID = "spotAreaId"
        case spotAreaName
        case spotName
        case skyCondition = "sky"
        case humidity = "rhm"
        case posibilityOfRain = "pop"
    }

    // MARK: - Internal Methods

    func getCourseName() -> String {
        courseName
    }

    func getSpotName() -> String {
        spotName
    }

    func getThemeAndDateText() -> String {
        "확인 시간: \(time), 테마: \(theme)"
    }

    func getWhetherInfoText() -> String {
        "하늘 상태: \(skyCondition) 습도: \(humidity), 강수확률: \(posibilityOfRain)"
    }
}

 

[3] Router

자신의 뷰에 해당하는 표현을 제공하거나

다른 뷰로의 네비게이션 인터페이스를 제공하는 객체이다.

이 또한 인터렉션과 마찬가지로 스스로가 VIPER의 다른 요소들을 참조하여 메서드를 호출할일이 없기 때문에

독립적으로 존재한다.

/**
 * Router
 * - is responsible for navigating to another view
 * - only communicate with Presenter
 * - Presenter -> Router : request to show specific another view
 */
protocol GenericRouter: AnyObject {
    var presentingViewController: UIViewController? { get set }

    /// Make self view controller and set itself.
    init(presentingViewController: UIViewController)

    static func createViewModule() -> UIViewController
}

 

[4] Presenter

VIPER 패턴의 중추 객체이다.

View와는 뷰 모델을 요청/응답하거나 인터렉션에 의한 다른 뷰로의 네비게이션을 요청을 하고,

Router에게는 View의 인터렉션이 전달됨에 따라 필요한 뷰를 보여주도록 요청을 하고,

Interactor에겐 뷰 모델로 변환할 엔티티를 반환하도록 요청을 한다.

그러므로, Presenter는 Interactor와 Router를 소유하고 있어야한다.

 

/**
 * Presenter
 * - is responsible for processing logics related with UI but not refer UI object directly
 * - is center concept to control View, Router, Interactor
 * - Presenter -> Interactor : request new entity in following with request of View
 * - Presenter -> Router : request to show specific view
 * - Presenter -> View : give new entity to View for updating data on each view
 */
protocol GenericPresenter: AnyObject {
    var interactor: GenericInteractor { get set }
    var router: GenericRouter { get set }

    /// Presenter should have interactor and router to request entity or another view
    init(interactor: GenericInteractor, router: GenericRouter)

    /// This method should be called in View to update itself
    func requestViewModel<T>(type viewModelType: T.Type, completion: @escaping (T) -> Void)
}

 

[5] View

UI를 어떻게 그릴지 UI관련 객체를 가지는 요소이다.

View는 반드시 Presenter를 가져야한다.

왜냐면 View에 그려질 뷰 모델과 다른 뷰로의 네비게이션 로직은 모두 Presenter를 통하기 때문에

소유하고 있어야한다.

그리고 이런 VIPER패턴에서 소유된 모든 객체의 메모리 해제는 View 해제를 기점으로 발생한다.

(뷰는 프레젠터를 소유하고, 프레젠터는 인터렉터와 라우터를 소유하므로 뷰의 해제는 해당 VIPER패턴의 해제를 의미한다)

 

/**
 * View
 * - is responsible for representing UI
 * - only communicate with Presenter to request view model for refresh UI
 * - View -> Presenter : request needed entity to update UI
 */
protocol GenericRepresentableView {
    var presenter: GenericPresenter? { get set }
}

// MARK: - Default Implement

extension GenericRepresentableView {
    mutating func injectPresenter(_ presenter: GenericPresenter) {
        self.presenter = presenter
    }
}

 

이렇게 위와 같이 바이퍼 요소들을 추상화하였다.

추상화된 인터페이스를 준수하는 객체를 생성하고 바이퍼 패턴의 룰에 따라

연결을 하면 모든 로직은 정상적으로 돌아간다.

아래와 같이 공공데이터를 리스트로 보여주는 형태의 앱을 

각 객체별로 VIPER 추상화를 통해 생성하였다.

 

// 라우터
final class TouristSpotRouter: GenericRouter {
    var presentingViewController: UIViewController?

    // MARK: - Initialzer

    init(presentingViewController: UIViewController) {
        self.presentingViewController = presentingViewController
    }

    deinit {
        print("\(self) deinit")
    }

    // MARK: - Internal Methods
    
    static func createViewModule() -> UIViewController {
        var view = TouristSpotViewController()
        view.modalPresentationStyle = .fullScreen

        let interactor = TouristSpotInteractor()
        let router = TouristSpotRouter(presentingViewController: view)
        let presenter = TouristSpotPresenter(interactor: interactor, router: router)

        view.injectPresenter(presenter)

        return view
    }

    func showEmptyViewController() {
        let emptyPageViewController = EmptyPageRouter.createViewModule()

        presentingViewController?.present(emptyPageViewController, animated: true)
    }
}


// 인터렉터 : VIPER패턴 연습이기에 API 통신을 내부로 넣었지만, API 통신은 반드시 서비스 객체를 통해 진행되어야한다.
final class TouristSpotInteractor: GenericInteractor {
    private let serverConfig = APIServerConfig()
    private var pageNo = 1
    private var numOfRows = 100
    private var courseID = 1
    private var hour = 24

    // MARK: - Initialzer

    deinit {
        print("\(self) deinit")
    }

    // MARK: - GenericInteractor Method

    func fetchEntity<T>(entityType: T.Type, completion: @escaping (T?) -> Void) {
        let requestModel = makeTouristSpotRequestModel()
        let encoder = makeEncodedFormParameterEncoder()

        AF.request(serverConfig.host, parameters: requestModel, encoder: encoder).response { result in
            guard let data = result.data,
                  let statusCode = result.response?.statusCode,
                  (200..<300).contains(statusCode) else {
                      print(result)
                      return
                  }

            do {
                let responseModel = try JSONDecoder().decode(TouristSpotResponseModel.self, from: data)
                guard let entities = responseModel.response.body.items.item as? T else {
                    throw VIPERCommonError.wrongCastingError
                }

                completion(entities)
            }
            catch {
                print(error)
            }
        }
    }

    // MARK: - Private Methods

    private func makeTouristSpotRequestModel() -> TouristSpotRequestModel {
        let requestModel = TouristSpotRequestModel(
            serviceKey: serverConfig.serviceKey,
            pageNo: pageNo,
            numOfRows: numOfRows,
            dataType: serverConfig.dataType,
            currentDate: serverConfig.getCurrentDateString(),
            hour: hour,
            courseID: courseID
        )

        return requestModel
    }

    private func makeEncodedFormParameterEncoder() -> URLEncodedFormParameterEncoder {
        var characterSet = CharacterSet.afURLQueryAllowed
        characterSet.insert(charactersIn: "%")
        let encodedForm = URLEncodedFormEncoder(allowedCharacters: characterSet)
        let encoder = URLEncodedFormParameterEncoder(encoder: encodedForm, destination: .queryString)

        return encoder
    }
}


// 프레젠터
final class TouristSpotPresenter: GenericPresenter {
    var interactor: GenericInteractor
    var router: GenericRouter

    // MARK: - Initializer

    init(interactor: GenericInteractor, router: GenericRouter) {
        self.interactor = interactor
        self.router = router
    }

    deinit {
        print("\(self) deinit")
    }

    // MARK: - GenericPresenter Methods

    func requestViewModel<T>(type viewModelType: T.Type, completion: @escaping (T) -> Void) {
        interactor.fetchEntity(entityType: [TouristSpotEntity].self) { entities in
            guard let entities = entities else {
                return
            }

            let cellModels = entities.map { entity in
                TouristSpotTableViewCellModel(entity: entity)
            }

            do {
                guard let cellModels = cellModels as? T else {
                    throw VIPERCommonError.wrongCastingError
                }

                completion(cellModels)
            }
            catch {
                print(error)
            }
        }
    }

    func presentEmptyViewController() {
        guard let touristRouter = router as? TouristSpotRouter else {
            return
        }

        touristRouter.showEmptyViewController()
    }
}


// 뷰
class TouristSpotViewController: UIViewController, GenericRepresentableView {
    var presenter: GenericPresenter?
    private let layout = Layout()
    private weak var closeButton: UIButton?
    private weak var showEmptyButton: UIButton?
    private weak var indicator: UIActivityIndicatorView?
    private var tableView: UITableView?
    private var cellModels = [TouristSpotTableViewCellModel]()

    // MARK: - Initializer

    deinit {
        print("\(self) deinit")
    }

    // MARK: - Lifecycle Methods

    override func viewDidLoad() {
        super.viewDidLoad()

        setupViews()
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        refreshTouristSpot()
    }

    // MARK: - Setup Method

    private func setupViews() {
        view.backgroundColor = .white

        let closeButton = UIButton()
        closeButton.setTitle("닫기", for: .normal)
        closeButton.backgroundColor = .gray
        closeButton.addTarget(self, action: #selector(tapCloseButton), for: .touchUpInside)
        view.addSubview(closeButton)
        closeButton.snp.makeConstraints { maker in
            maker.top.equalTo(view.safeAreaLayoutGuide.snp.top)
            maker.left.right.equalToSuperview()
            maker.height.equalTo(40)
        }

        let showEmptyButton = UIButton()
        showEmptyButton.setTitle("빈 페이지 열기", for: .normal)
        showEmptyButton.backgroundColor = .systemOrange
        showEmptyButton.addTarget(self, action: #selector(tapShowEmptyButton), for: .touchUpInside)
        view.addSubview(showEmptyButton)
        showEmptyButton.snp.makeConstraints { maker in
            maker.top.equalTo(closeButton.snp.bottom).offset(5)
            maker.left.right.equalToSuperview()
            maker.height.equalTo(40)
        }

        let refreshControl = UIRefreshControl()
        refreshControl.addTarget(self, action: #selector(refreshTouristSpot), for: .valueChanged)

        let tableView = UITableView()
        tableView.register(TouristSpotTableViewCell.self)
        tableView.dataSource = self
        tableView.delegate = self
        tableView.estimatedRowHeight = layout.estimatedRowHeight
        tableView.refreshControl = refreshControl
        view.addSubview(tableView)

        tableView.snp.makeConstraints { maker in
            maker.top.equalTo(showEmptyButton.snp.bottom).offset(layout.tableViewTop)
            maker.left.right.bottom.equalToSuperview()
        }

        let indicatorView = UIActivityIndicatorView(style: .large)
        view.addSubview(indicatorView)
        indicatorView.snp.makeConstraints { maker in
            maker.centerX.equalToSuperview()
            maker.centerY.equalToSuperview()
            maker.width.height.equalTo(50)
        }

        self.tableView = tableView
        self.closeButton = closeButton
        self.showEmptyButton = showEmptyButton
        self.indicator = indicatorView
    }

    // MARK: - Selector Methods

    @objc
    private func refreshTouristSpot() {
        showIndicator()

        presenter?.requestViewModel(type: [TouristSpotTableViewCellModel].self) { [weak self] cellModels in
            guard let strongSelf = self else {
                return
            }

            strongSelf.hideIndicator()

            strongSelf.cellModels = cellModels

            if let refreshControl = strongSelf.tableView?.refreshControl,
               refreshControl.isRefreshing {
                refreshControl.endRefreshing()
            }

            if let indicator = strongSelf.indicator,
               indicator.isAnimating {
                indicator.stopAnimating()
            }

            strongSelf.tableView?.reloadData()
        }
    }

    @objc
    private func tapCloseButton() {
        dismiss(animated: true)
    }

    @objc
    private func tapShowEmptyButton() {
        guard let touristSpotPresenter = presenter as? TouristSpotPresenter else {
            return
        }

        touristSpotPresenter.presentEmptyViewController()
    }

    // MARK: - Private Methods

    private func showIndicator() {
        if let refreshControl = tableView?.refreshControl,
           refreshControl.isRefreshing {
            refreshControl.beginRefreshing()
        }
        else {
            indicator?.startAnimating()
        }
    }

    private func hideIndicator() {
        guard let refreshControl = tableView?.refreshControl,
              refreshControl.isRefreshing else {
                  indicator?.stopAnimating()
                  return
              }

        refreshControl.endRefreshing()
    }
}

// MARK: - Extension - UITableViewDataSource

extension TouristSpotViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        cellModels.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cellModel = cellModels[indexPath.row]
        let cell = tableView.dequeueReusableCell(
            withIdentifier: cellModel.cellType.reuseIdentifier,
            for: indexPath) as! TouristSpotTableViewCell
        cell.inject(for: cellModel)
        return cell
    }
}

// MARK: - Extension - UITableViewDelegate

extension TouristSpotViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return UITableView.automaticDimension
    }
}

 

다음과 같이 바이퍼 패턴을 통해 리스트 앱이 실행되는 걸 볼 수 있다.

 

프로젝트 전체 소스

 

GitHub - tddhot2/VIPERPatternTutorial: This repository make me to practice VIPER pattern.

This repository make me to practice VIPER pattern. - GitHub - tddhot2/VIPERPatternTutorial: This repository make me to practice VIPER pattern.

github.com