[Design Pattern] VIPER Pattern - iOS Architecture

2021. 2. 2. 23:32Design Pattern

반응형

iOS의 Design Pattern에는 많은 것들이 있죠...?

 

MVC, MVVM, VIPER, RIBs, MVP등이 있는 것으로 알고있습니다.

제가 여기서 사용해본 것은 MVC, MVVM 정도입니다..

 

아직 참 갈길이 먼 것 같네요 😢

 

오늘은 이 중에서 VIPER Pattern에 대해 공부할 기회가 생겨 VIPER Pattern으로 간단한 앱을 구현해봤는데 정리를 해보겠습니다.

 

 

VIPER Pattern이란?

View, Interactor, Presenter, Entity, Router의 약자를 따와서 VIPER Pattern이라는 이름이 명명되었다. 아무래도 각각의 구분이 많은 만큼 역할 단위의 구분이 명확하다.

VIPER Pattern의 아키텍쳐 모식도는 다음과 같다.

 

  • View : 어떤 패턴이든 사용하는 View 역할이다. 주로 UIViewController, UIView들이 이 역할을 하게된다. 사용자의 입력에 반응하고 보여주는 역할을 한다. 주로 Presenter와 상호작용한다.
  • Presenter : UI 관련 비즈니스 로직을 포함한다. View의 액션을 받아서 Interactor로 전달하고 Interactor로부터 데이터를 전달받아 View을 Update하는 로직을 가지고 있다.
  • Interactor : 앱의 비즈니스 로직을 가지고 있는 단위이다. 주로 API 호출, data 관련된 로직을 가지고 있는 단위의 모듈이다. (뷰와는 완전 독립적인 역할을 하는 모듈이다.)
  • Entity : 일반적인 데이터 객체들이다. API 콜을 통해 불러오는 객체들이 해당할 수 있다.
  • Router : 화면이 언제 표시되어야하는지 어떤 화면을 띄울지 등 Navigatoin 로직을 가지고 있다.

 

 

Flow of VIPER

  1. 새롭게 View를 Router로부터 가져온다. (Router에 의존성을 주입하고 초기 View을 세팅하는 코드를 넣음)
  2. View는 새로운 데이터가 필요하다고 Presenter에게 알린다.
  3. Presenter는 Interactor에게 데이터를 요청한다.
  4. Interactor은 데이터를 Fetch하는 비즈니스 로직을 실행하고 Entity를 패치해온다.
  5. Interactor은 Fetch한 Entity를 Presenter에게 전달한다.
  6. Presenter는 Entity를 View에게 전달한다.
  7. View는 해당 Entity를 화면에 표시한다.

정도의 로직으로 VIPER의 Flow를 정리할 수 있을 것 같습니다.

 

한 번 직접 간단한 앱으로 Pattern을 구현해보면서 알아보겠습니다.

 

 

Practice

MEDIUM 블로그에 간단한 예시가 있어서 따라해보면서 공부를 진행했습니다. 

정말 간단한 앱입니다.

 

우선 TableView를 구성한 후, 각 셀 단위에 자동차의 모델과 제조사를 넣어주었습니다. 그리고 각 셀을 클릭하면 자동차의 세부 정보를 볼 수 있고 리로드를 진행하면 다시 데이터를 받아오는 앱입니다.

 

먼저 간단하게 구성되는 객체들의 그림을 그려보면 다음과 같이 구성한 후, 프로젝트가 동작할 수 있게 하였습니다.

 

Entity

우선 서버와의 통신에서 받아올 데이터를 정의하는 것으로 시작했습니다.

 

Car이라는 데이터는 Interactor로부터 외부 모듈과 의존성을 가진 APIServiceProtocol과 상호작용하여 Interactor로부터 요청받는 객체입니다.

 

CarDTO라는 데이터는 Car로부터 View에 보여주기 위한 데이터만을 추출되어 만들어진 객체입니다. Interactor로부터 받아온 데이터를 Presenter에서 View로 보내주기 전에 뷰에 보여질 정보만을 가공하기 위해 만들어지는 객체입니다.

// 서버로부터 받아오는 데이터
struct Car {
    let id: String
    let make: String
    let model: String
    let trim: String
    
    func makeCarDTO() -> CarDTO {
        return CarDTO(make: self.make,
                      model: self.model)
    }
}

// 화면에 보여줄 데이터
struct CarDTO {
	let make: String
	let model: String
}

 

Interactor

Interactor는 보통 외부 모듈(Alamofire, Moya)을 사용해서 Fetch해오는 APIServiceProtocol에 의존해서 데이터를 가져온다.

 

지금은 APIServiceProtocol.requestCars(_ completion: ([Car]?, NSError) → Void)을 호출해서 API를 호출하고 데이터를 Fetch해오고 있다. 직접적인 API 호출에 대한 구현은 신경쓰지 않고 데이터를 Presenter에 가공해서 전달해주는 역할만을 하고 있다.

 

지금은 단순히 데이터를 Get해오는 부분이라 별다른 로직이 존재하지 않지만 만약 특정 데이터의 가공이 필요하거나 로직이 필요한 경우에는 Interactor의 구현부에 메소드를 만들고 사용할 수 있다.

/*
 Interactor Protocol
 : Cars의 Entity를 가져오는 비즈니스 로직을 가지고 있는 Object
 : 보통 API 콜 또는 가져온 Entity의 데이터를 가공해서 Presenter로 전달하는 역할을 한다.
 */
protocol CarsInteractorProtocol {
    func fetchCars(_ completion: ([Car]?) -> Void)
}

/*
 Interactor의 실제 Object
 : Presenter에서는 이 Object를 사용해서 필요한 데이터를 Fetch해오고 데이터를 가공
 */
class CarsInteractor: CarsInteractorProtocol {
    let apiService: CarsAPIServiceProtocol
    
    init(apiService: CarsAPIServiceProtocol) {
        self.apiService = apiService
    }
    
    func fetchCars(_ completion: ([Car]?) -> Void) {
        apiService.requestCars { cars, error in
            guard let cars = cars else {
                completion([])
                return
            }
            
            completion(cars)
        }
    }
}

 

Presenter

사용자의 요청을 받아 Interactor, Router와 각자 통신을 진행합니다.

 

만약 Navigation 관련 로직이 있다면 Router에게 요청하여 새롭게 뷰를 이동할 수 있게 만들고 데이터를 Fetch해와야하는 로직이 있다면 Intercator에게 요청하여 받아올 수 있게 진행합니다.

 

UI관련 로직을 가지지만 UIKit에는 의존하지 않는 객체로 현재 각 셀을 클릭했을 때, 새로운 UIViewContoller가 Navigation되는 로직을 Router로 요청하고 있고 리로딩, 화면 데이터 표시 등의 요청이 있는 경우는 Interactor의 Fetch를 요청해 원하는 데이터를 가져와서 View로 전달해주는 역할을 하고 있다.

/*
 Presenter Protocol
 : Cars의 정보를 Interactor로부터 Fetch해오고 View에게 해당 Entity를 넘겨주는 Protocol
 : 실제 객체를 담고있지는 않고 의존성을 줄이기 위해 추상화 Protocol
 */
protocol CarsPresenterProtocol {
    func showCars(_ completion: (_ cars: [CarDTO]) -> Void)
    func showCarDetail(for carDTO: CarDTO)
    func showCreateCarScreen()
}

/*
 Presenter의 실제 Object
 : Presenter 역할을 하는 실제 객체
 : Interactor와 View 사이에서 뷰를 보여주기 위한 로직들을 수행하는 UIKit과는 의존성이 없지만 뷰를 보여주는 비즈니스 로직을 가진 객체
 */
class CarsPresenter: CarsPresenterProtocol {
    let interactor: CarsInteractorProtocol
    let router: CarsRouterProtocol
    
    init(interactor: CarsInteractorProtocol,
         router: CarsRouterProtocol) {
        self.interactor = interactor
        self.router = router
    }
    
    // View에서 요청 후, Interactor를 통해 Fetch 후, 화면에 보여주는 메소드
    func showCars(_ completion: ([CarDTO]) -> Void) {
        interactor.fetchCars { cars in
            guard let cars = cars else {
                completion([])
                return
            }
            
            let carsDTO = createCarsViewModels(from: cars)
            completion(carsDTO)
        }
    }
    
    func showCarDetail(for carDTO: CarDTO) {
        router.showCarDetail(for: carDTO)
    }
    
    // Interactor 객체로부터 Entity를 View에 띄우기 위해 가공하는 로직
    // 이 DTO나 뷰에 보일 데이터로 가공하는 로직이 Interactor에서 JSON Deconding을 진행하고 여기서는 뷰에 보이는 형식으로 가공
    private func createCarsDTOs(from cars: [Car]) -> [CarDTO] {
        return cars.map { car in
            return CarDTO(make: car.make,
                                model: car.model)
        }
    }
}

 

Router

새로운 뷰로 이동하기 위한 Navigation 로직을 가지고 있는 단위이다.

 

현재 앱에서는 자세한 Car의 정보를 보여주기 위한 UIViewController로 Push되는 로직을 가지고 있다. 그리고 static func createCarsListModule()을 사용해서 각 객체들에 의존성을 주입해주고 CarsViewController를 사용하기 위한 세팅을 진행하고 있다.

 

처음에 자동차의 리스트를 보여주는 모듈의 세팅을 어디서 진행하는게 좋을지 생각했었는데 View의 viewDidLoad()에 세팅을 진행하는 것은 View 화면 관련 표시만 진행하고 있는 역할에 위배될 것 같아서 Navigation 로직을 담당하고 UIKit과 연관이 있는 Router로 해당 역할을 분배하였습니다.

/*
 Router Protocol
 : 어떤 화면이 어떤 화면으로 넘어가는지 로직을 가진 Router Protocol
 : Router의 형상만 가지고 있는 Protocol, 실제 구현을 다루지는 않는다.
 */
protocol CarsRouterProtocol {
    static func createCarsListModule() -> UIViewController
    func showCarDetail(for viewModel: CarDTO)
}

/*
 Router Object
 : 어떤 화면에서 어떤 화면으로 넘어갈지 로직을 다루고 있는 Router Object
 */
class CarsRouter: CarsRouterProtocol {
    let presentingViewController: UIViewController
    
    init(presentingViewController: UIViewController) {
        self.presentingViewController = presentingViewController
    }
    
    @discardableResult
    static func createCarsListModule() -> UIViewController {
        guard let rootNavi = UIApplication.shared.windows.filter({ $0.isKeyWindow }).first?.rootViewController as? UINavigationController,
              let carsListVC = rootNavi.topViewController as? CarsViewController else { return UIViewController() }
        let carsAPIService: CarsAPIServiceProtocol = CarsAPIService()
        let carsInteractor: CarsInteractorProtocol = CarsInteractor(apiService: carsAPIService)
        let carsRouter: CarsRouterProtocol = CarsRouter(presentingViewController: carsListVC)
        let carsPresenter: CarsPresenterProtocol = CarsPresenter(interactor: carsInteractor,
                                                                 router: carsRouter)
        
        carsListVC.presenter = carsPresenter
        return carsListVC
    }
    
    /*
     - 현재 뷰로부터 다음 Detial 뷰를 띄우기 위한 로직이 필요한 Method
     */
    func showCarDetail(for viewModel: CarDTO) {
        guard let navigationController = presentingViewController.navigationController else {
            return
        }
        
        guard let carDetailVC = UIComponents.mainStoryboard.instantiateViewController(withIdentifier: CarDetailViewController.identifier) as? CarDetailViewController else { return }
        carDetailVC.carDetailDTO = viewModel
        
        navigationController.pushViewController(carDetailVC, animated: true)
    }
}

 

View

차의 리스트를 보여주는 화면에 관련된 역할이다. Presenter와 소통하고 화면을 보여줍니다.

 

현재 앱에서는 UITableView 관련 세팅, RefreshControl관련 세팅, 유저의 요청에 어떻게 응답할지에 대한 반응만 있지 비즈니스 로직이나 특별 구현은 전부 Presenter에게 보내고 화면만 표시합니다.

 

보통 UIView, UIViewController들이 이 역할을 하고 사용자의 입력을 받거나 입력에 따른 응답만을 하는 객체입니다. 또한 비즈니스 로직과는 완전히 별개의 역할을하는 View입니다.

class CarsViewController: UIViewController {
    static let identifier = "CarsViewController"
    
    @IBOutlet weak var tableView: UITableView!
    
    var presenter: CarsPresenterProtocol!
    var viewModels: [CarDTO] = []
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        CarsRouter.createCarsListModule()
        setTableView()
        showCars()
    }
    
    private func setTableView() {
        tableView.dataSource = self
        tableView.delegate = self
        tableView.estimatedRowHeight = UITableView.automaticDimension
        
        let refreshControl = UIRefreshControl()
        refreshControl.addTarget(self, action: #selector(reloadCars), for: .valueChanged)
        tableView.refreshControl = refreshControl
    }
    
    @objc func reloadCars() {
        DispatchQueue.main.asyncAfter(deadline: .now()+3, execute: { [weak self] in
            self?.showCars()
            self?.tableView.refreshControl?.endRefreshing()
        })
    }
    
    private func showCars() {
        presenter.showCars { viewModels in
            self.viewModels = viewModels
            self.tableView.reloadData()
        }
    }
}

extension CarsViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return viewModels.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let carCell = tableView.dequeueReusableCell(withIdentifier: CarCell.identifier) as? CarCell else { return UITableViewCell() }
        carCell.configure(from: viewModels[indexPath.row])
        return carCell
    }
}

extension CarsViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        presenter.showCarDetail(for: viewModels[indexPath.row])
    }
}

 

 

사용하고 느낀점

우선 VIPER라는 패턴을 들어보기만 했지 직접 구현해보고 정리를 해보는 것은 처음이었습니다. 가장 크게 느꼈던 것은 역시나 V, I, P, E, R이라는 각각의 모듈로 역할이 분리되어 있기 때문에 Unit Test에 매우 용이할 것 같다는 느낌을 받았습니다.

PresenterRouter의 경우 UI나 각각에 의존하고 있기 때문에 테스트다 힘들다고 생각할 수 있지만 Protocol로 의존성을 주입받기 때문에 Protocol을 상속받는 각 Test Mock 객체를 생성하고 XCTestCase 클래스 내에서 의존성을 주입해주고 각 메소드가 제대로 불리고 변수가 잘 들어오는지 확인을 진행하면 된다.

또한 모듈이 어떻게보면 지나치게..(?) 구분이 되어 있는데 이러한 점으로 인해 팀으로 개발을 진행할 때, 뷰 단위의 역할 분배가 아닌 모듈 단위의 역할 분배도 가능해 더욱 생산성 있게 역할 분배와 개발이 가능할 것 같다는 생각도 들었습니다. 또한, 모듈이 나누어지기 때문에 각자 다른 파일만을 건드려 Merge Conflict도 덜 발생할 것 같습니다.

 

안좋은 점도 발생할 것 같다는 생각이 들었습니다. 특히 위의 말한 장점들이 또한 단점으로 다가올 수도 있을 것 같습니다. 모듈이 너무 나누어지기 때문에 정말 작은 기능을 개발하더라도 작성해야할 코드가 많다. 실제 기존 MVC, MVVM 패턴에서는 간단하게 작성을 했으면 끝났을 기능도 모듈 단위로 코드가 늘어나면서 길어졌습니다.

 

(처음이라..?) 또한 VIPER 패턴을 적용하기 위해서는 기존에 사용해 본 경험이 없다면 기존의 패턴과는 완전 다른 패턴의 형식이기 때문에 새로운 공부가 필요해 약간의 러닝 커브가 발생해 빠르게 프로젝트에 적용해야한다면 맞지 않는 패턴인 것 같았습니다.

 

결론적으로 느낀 점은 각 패턴마다 장점과 단점이 있는 것 같지만 VIPER의 경우에는 현재 급한 프로젝트의 경우에는 기존에 익숙한 MVC, MVVM 패턴을 사용해서 개발하는 것이 더욱 생산성이 있을 것 같았다. 그러나 조금 복잡한 기능이 있는 경우, 꼭 테스트를 해야하는 모듈이 있는 경우, 개발자가 많아서 생산성이 중요할 경우해 모듈로 작업을 나누어야 하는 경우에는 VIPER 패턴을 적용하면 좋고 효율성 있는 패턴이라고 생각이 들었다.

 

👉 아,, 그리고 프로젝트에 적용하기 위해서는 VIPER 패턴을 적용하기 위해 따로 모범적인 템플릿이 없기 때문에 꼭 팀에서 어떻게 사용할지 명시를 하고 시작하면 더욱 통일성 있게 사용할 수 있을 것 같다는 생각이 들었다.

무조건 좋은 패턴은 없는 것 같다.

 

 

오늘은 iOS의 Design Pattern 중 하나인 VIPER Pattern에 대해 정리해보았습니다.

혹시 VIPER Pattern에 대해 잘못된 내용이 있거나 더 고치면 좋을 점이 있으면 말해주세요 :)

감사합니다!!

 

 


참고 레퍼런스

 

Clean Architecture For iOS Development Using The VIPER Pattern

As time goes on, MVC Controllers tend to become more bloated and untestable. The VIPER pattern tries to solve this problem.

medium.com

 

VIPER Design Pattern in Swift for iOS Application Development.

Design patterns are God’s gift to software developers. These are techniques that minimize code duplication, prevents high coupling and…

medium.com

 

반응형