들어가며
노션 블로그에 Unit Test에 대한 글을 작성했습니다. Unit Test 란? 글을 작성하면서 Testable한 아키텍쳐를 공부해야만 한다는 생각이 들더라구요. 지금 프로젝트에서 사용하고 있는 아키텍쳐, MVC는 Unit Test를 제한적으로만 할 수 있는 아키텍쳐였거든요.
저는 Unit Test를 진행해보고 싶었기 때문에 성공적인 테스트를 위한 첫 스텝인 아키텍쳐 갈아끼워보기를 진행해보려고 합니다. 그리고 갈아끼울 아키텍쳐에 대한 공부를 진행해보려고 합니다.
첫 시작은 가볍게 MVP 패턴으로 해보려고 합니다. 이전부터 MVP 패턴이 Model-View-Presenter를 나타내는 말이라는건 알았지만 각자가 하는 역할은 뭔지, MVP가 해결해주고자 하는 바가 뭔지를 정확하게 몰랐거든요.
그래서 한 번 알아가보려고 합니다.
MVC의 문제점
MVP 패턴에 대한 공부를 진행하기 전에 MVC 패턴에서 MVP 패턴으로 갈아타야만 하는 이유를 한 번 더 짚고 넘어가보려고 합니다.
MVC 패턴은 애플리케이션 컴포넌트들을 Model-View-Controller 로 분리합니다. Model은 데이터 및 기본적인 behavior를 캡슐화하고, View는 사용자에게 Data를 보여주는 역할을 합니다. Controller는 그런 Model, View 사이에 다리 역할을 합니다. View에서 발생하는 액션을 받아서 Model를 업데이트 시켜주고 업데이트된 Model를 다시 View에서 보여줄 수 있게끔 중간에서 일을 해주는거죠.
이론적으로는 아무 문제가 없어보이지만, MVC 패턴을 사용해서 프로젝트를 설계했을 때 문제가 발생했습니다.
1.
Lack of Distribution
MVC 패턴에서는 Controller가 너무 많은 역할을 지게 됩니다. View는 Data를 보여주는 역할만 하면 되고, Model은 데이터 및 기본적인 behavior만 가지고 있기 때문에 그 외의 모든 역할은 Controller가 해줘야 하는거죠.
•
User Interaction 처리
•
View Setting
•
Network 호출
•
Data parsing
•
…
Controller는 결국 Massive View Controller가 되어버립니다.
2.
Low Test Coverage
Cocoa MVC 패턴은 Controller가 View Life cycle과 밀접하게 연결되어 있습니다. View와 Controller가 완벽하게 분리된 형태가 아니라 ViewController 클래스로 존재하기 때문입니다. 따라서, Controller가 완벽하게 View Life cycle에서 벗어나기란 쉽지 않습니다.
또한, Controller가 Model 객체를 소유하게 되어서 의존관계가 생기게 되는데, Cocoa MVC 에서는 Controller가 곧 ViewController의 모습이기 때문에 View가 Model과 의존관계가 생겨버리는 일이 발생하게 됩니다.
따라서, MVC 패턴에서는 Controller를 테스트하기 힘든 환경이 만들어 집니다. 비즈니스 로직이 있는 Controller가 View와 밀접한 연결을 가지고 있기 때문에 View Life cycle과 UIKit 이라는 외부 프레임워크 영향을 받을 수 밖에 없는 상황인거죠.
따라서, 우리는 MVP 패턴을 사용해서 이 상황을 개선할 수 있습니다.
MVP의 등장
MVP 패턴의 기본적인 틀은 이러합니다. View가 사용자의 액션을 받아서 이를 Presenter에게 넘겨주고 Presenter는 Model를 Update 시키고 Update된 모델을 다시 화면에 띄워주도록 합니다.
어디서 많이 본 플로우 아닌가요?
MVC 패턴이 아마 떠오를겁니다. MVP에 대한 깊은 공부 이전에는 Controller의 이름이 단지 Presenter로 변경된 패턴처럼 보입니다. 실제로 MVP 패턴은 MVC 패턴의 개념을 기반으로 한 UI Presentation 패턴이기 때문에 처음에 MVC 패턴이랑 비슷한데 라는 생각이 드는건 당연할 수도 있겠네요.
그렇다면, MVC 패턴이랑 어떤 차이점이 있길래 MVP 패턴이 MVC 패턴보다 낫다고 할 수 있을까요?
누가 사용자 입력을 핸들링하는가?
MVC에서는 항상 Controller가 User Action를 핸들링하는 책임을 가집니다. 하지만 MVP에서는 GUI 컴포넌트 자체가 처음에는 User Action를 핸들링하지만, 이 입력을 해석하는 일은 Presenter로 위임합니다.
Apple SW Framework 환경에서 보면 추가로 생기는 차이점이 하나 더 있습니다.
바로 ViewController가 이젠 View로써 역할을 한다는 점입니다. MVC 패턴에서는 ViewController가 Controller의 역할을 하면서도 View의 역할도 겸업했기 때문에 Testable하지 못한 모습을 가졌습니다.
하지만, MVP 패턴에서는 ViewController가 가진 Controller 역할을 Presenter가 가지고 갔기 때문에 ViewController 안에는 뷰 관련 코드만 포함하면 됩니다. 모든 비즈니스 로직은 Presenter가 가지고 갈 겁니다.
그럼 각각의 역할을 조금 더 살펴보겠습니다.
Model
Data 및 기본적인 Bahaviors 캡슐화
MVC 패턴에서의 모델과 동일한 역할을 맡고 있습니다. 비즈니스 동작과 상태 관리를 담당하고 있습니다.
View
UI 요소 렌더링 및 Data Display
View는 UI 요소를 세팅하고 사용자 이벤트를 감지하는 일을 합니다. 이전과는 다르게 Views와 View Controllers를 모두 View로 봅니다.
MVP 패턴에서의 View는 Passive View(수동적인 뷰) 입니다. 마틴 파울러는 MVP 패턴이 첫 등장하고 나서 MVP를 2가지로 변형해서 제안했습니다. 그 중 하나가 Passive View 입니다.
A screen and components with all application specific behavior extracted into a controller so that the widgets have their state controlled entirely by controller.
전체적으로 Controller에 의해 상태를 제어하도록 Controller에 모든 동작을 포함하고 있는 화면 및 구성요소 입니다.
Passive View 이름 그대로,
•
사용자 이벤트에 대한 응답 처리
•
View의 모든 업데이트를 수행하는 Controller 로직
이러한 부분들은 Presenter로 넘기고 View가 가지고 있는 UI Component의 동작은 최소로 줄이는거죠. View는 Display를 하는 역할만 수행해주면 됩니다. View는 Controller(Presenter)가 시키는 일만을 하기 때문에 뷰에서 문제가 발생할 위험이 줄어들고, Controller(Presenter)에서는 UI Component와 관계없이 Testable한 코드를 작성할 수 있게 됩니다.
View는 View Interface를 가지고 있어서, Presenter를 View에 느슨하게 연결하는데 사용합니다. 이후 섹션에서 프로토콜을 사용해서 Presenter에 View를 연결하는 코드 예시를 보여드리도록 하겠습니다. View Interface 덕분에 이후에 Presenter에 대한 Unit Test를 진행할 때에도 쉽게 View에 대한 Mock View 코드를 작성할 수 있습니다.
Presenter
View, Model과 상호 작용
Presenter는 View, Model 사이에서 상호 작용하는 역할을 합니다. 따라서, 상호 작용을 하기 위한 모든 로직들을 Presenter가 들고 있어야 하는거죠.
User Action이 발생하면 Presenter는 Model를 Update 시키고 업데이트된 모델을 받아서 다시 View를 업데이트 시키는 일을 합니다. View와 Model 사이에서 발생하는 모든 일을 도맡아 하는 겁니다.
그렇다면 View, Model과는 어떻게 상호 작용하는걸까요?
View
View는 View Interface를 가지고 있습니다. View Interface 객체를 Presenter에서 가지고 있으면서 Delegate를 통해서 UI Update를 진행합니다. Presenter는 View와 느슨하게 연결되어 있지만, View는 Presenter를 소유합니다. 따라서 의존성이 완전히 독립된 건 아니기 때문에 프로젝트가 커질수록 의존성이 높아진다는 문제점이 있습니다.
하지만 View에 있는 UI 관련 코드와는 Presenter가 완전히 분리될 수 있기 때문에, 외부 프레임워크인 UIKit에 의존하지 않아도 됩니다. Presenter에 있는 코드들을 View Life cycle, UIKit에 영향을 받지 않고 테스트해볼 수 있는 환경이 만들어진겁니다.
Model
Model은 Service(Controller) 계층을 만들어서 Presenter가 해당 Service와 상호 작용하며 Model를 검색하고 사용하게 합니다.
Service를 만드는 건 Model에 대한 Unite Test를 더 쉽게 작성할 수 있도록 도와줍니다.
MVP 패턴 Deep Dive
MVP 패턴 공부를 하면서 MVP 패턴이 최초로 언급된 논문인 MVP: Model-View-Presenter The Taligent Programming Model for C++ and Java 를 보게 되었습니다.
해당 논문에서는 MVC 패턴을 기반으로 MVP 패턴을 만들어낸 과정을 자세하게 설명해줍니다.
Taligent에서는 두 가지 개념을 제시합니다. 해당 개념은 프로그래머가 다뤄야 하는 가장 기본적인 디자인 문제를 담고 있습니다.
•
데이터를 어떻게 관리하지?
•
사용자가 데이터와 어떻게 상호작용하지?
그리고 두 가지 개념에 대한 답을 6가지 질문으로 풀어냅니다. Taligent는 6가지의 질문이 프로그래머가 MVP 기반의 프로그램을 설계할 때 고려해야 하는 부분으로 봅니다.
데이터를 어떻게 관리하지?
해당 논문에서는 데이터 관리를 세분화하여 Model/Selection/Command로 분리합니다.
•
Model : 캡슐화된 데이터, 읽기 및 쓰기 액세스 메서드 등을 갖춘 모델
•
Selection : 데이터 선택 방법, Model 데이터의 여러 Subset를 지정하기 위한 추상화
•
Command : 데이터 변경 방법, Model의 Selection에서 수행할 수 있는 작업을 나타내는 추상화
사용자가 데이터와 어떻게 상호작용하지?
사용자 인터페이스를 세분화하여 View/Interactor/Presenter로 분리합니다.
•
View : 데이터 표시
•
Interactor : 이벤트에 따른 데이터의 변경 사항 요청
•
Presenter : Interactor에 따른 적절한 Command를 매핑하는 비즈니스 로직
Presenter는 Interactor를 통해서 이벤트와 제스처를 해석해서 의도한 방식으로 모델을 가공하기 위한 적절한 Command에 매핑을 합니다. Presenter가 사용자가 데이터와 상호작용하기 위한 Interactor와 데이터를 관리하기 위한 Command의 역할을 적절한 섞어서 가지고 있다고 보시면 될 거 같습니다.
이를 통해서 MVP의 어원이 탄생하게 되었습니다.
저렇게 구분하는 것이 과연 가치가 있는걸까?
MVP Example(with Swift)
미디엄 블로그에 나오는 신호등 예제를 참고해서 Example를 만들었습니다. 예제를 약간 수정한 부분도 있습니다. 참고 부탁드려요.
만들어볼 예제
1.
먼저 Model를 생성해보겠습니다.
TrafficLight 라는 간단한 구조체를 만들고 내부에는 색깔 이름과 색깔이 뜻하는 바(Description)를 넣을 수 있는 부분을 만들어줄게요.
struct TrafficLight {
let colorName: String
let description: String
}
Swift
복사
2.
Data Provider 역할을 할 Service 클래스를 하나 만들게요.
내부엔 색깔에 맞춰서 trafficLight를 가져오는 메서드를 하나 만들겠습니다.
final class TrafficLightService {
func trafficLight(colorName: String, completion: @escaping (Result<TrafficLight, TrafficError>) -> Void) {
let trafficLights = [
TrafficLight(colorName: "Red", description: "Stop"),
TrafficLight(colorName: "Green", description: "Go"),
TrafficLight(colorName: "Yellow", description: "About to change to red")
]
if let targetTrafficLight = trafficLights.first(where: { $0.colorName == colorName }) {
completion(.success(targetTrafficLight))
} else {
completion(.failure(.invalidTraffic))
}
}
}
Swift
복사
저는 Result 값을 사용했는데, TrafficError 라는 에러 타입을 하나 만들어서 사용했습니다. trafficLight에 포함되지 않는 색상이 들어오면 에러 메시지를 전송하도록 만들었어요.
enum TrafficError: LocalizedError {
case invalidTraffic
var errorDescription: String {
switch self {
case .invalidTraffic: return "input wrong traffic color"
}
}
}
Swift
복사
3.
TrafficLightViewDelegate를 하나 만들었습니다.
해당 Delegate 프로토콜에는 이벤트가 일어나면 Traffic Light에 대한 description를 보여줄 수 있게끔 메서드를 하나 넣어뒀습니다. Presenter에서 뷰(ViewController)로 정보를 전달하고 싶을 때 사용할 겁니다.
protocol TrafficLightViewDelegate: AnyObject {
func displayTrafficLight(description: String)
}
Swift
복사
4.
TrafficLightPresenter를 하나 만들고 TrafficLightPresenterProtocol를 conform하도록 했습니다.
TrafficLightPresenterProtocol에 있는 trafficLightColorSelected 메서드는 신호등 버튼을 눌렀을 때 실행됩니다.
protocol TrafficLightPresenterProtocol: AnyObject {
func trafficLightColorSelected(colorName: String)
}
Swift
복사
Presenter 내부에 trafficLightService는 Presenter가 소유하고, Presenter는 View, ViewController가 소유하기 때문에 View Delegate에는 약한 참조를 걸었습니다.
final class TrafficLightPresenter: TrafficLightPresenterProtocol {
// MARK: - property
private let trafficLightService: TrafficLightService
weak private var trafficLightViewDelegate: TrafficLightViewDelegate?
// MARK: - init
init(trafficLightService: TrafficLightService) {
self.trafficLightService = trafficLightService
}
// MARK: - func
func setViewDelegate(trafficLightViewDelegate: TrafficLightViewDelegate?) {
self.trafficLightViewDelegate = trafficLightViewDelegate
}
func trafficLightColorSelected(colorName: String) {
self.trafficLightService.trafficLight(colorName: colorName) { [weak self] result in
switch result {
case .success(let trafficLight):
self?.trafficLightViewDelegate?.displayTrafficLight(description: trafficLight.description)
case .failure(let error):
self?.trafficLightViewDelegate?.displayTrafficLight(description: error.errorDescription)
}
}
}
}
Swift
복사
trafficLightColorSelected 메서드에 의해서 View에서 이벤트가 들어오면 이벤트와 함께 들어온 color 이름을 받아서 trafficLightService가 제공하는 trafficLight 메서드에 넣어주고 이를 통해서 나온 값은 뷰에서 보일 수 있게끔 해줄겁니다.
func trafficLightColorSelected(colorName: String) {
self.trafficLightService.trafficLight(colorName: colorName) { [weak self] result in
switch result {
case .success(let trafficLight):
self?.trafficLightViewDelegate?.displayTrafficLight(description: trafficLight.description)
case .failure(let error):
self?.trafficLightViewDelegate?.displayTrafficLight(description: error.errorDescription)
}
}
}
Swift
복사
5.
뷰를 만들어봅시다.
TrafficViewController는 아까 만들어둔 TrafficLightViewDelegate를 conform 합니다. 따라서 프로토콜에서 제공하는 displayTrafficLight 메서드를 내부 내용을 채워줘야 합니다. 그리고 View는 Presenter를 소유하기 때문에 Presenter 객체를 가지고 있습니다.
displayTrafficLight를 통해서 description Label에 description이 써질겁니다. 각 버튼에서는 Presenter로 이벤트를 전달해줍니다.
final class TrafficViewController: UIViewController, TrafficLightViewDelegate {
// MARK: - ui component
@IBOutlet weak var descriptionLabel: UILabel!
// MARK: - property
private let trafficLightPresenter = TrafficLightPresenter(trafficLightService: TrafficLightService())
// MARK: - life cycle
override func viewDidLoad() {
super.viewDidLoad()
self.trafficLightPresenter.setViewDelegate(trafficLightViewDelegate: self)
}
// MARK: - func
func displayTrafficLight(description: String) {
self.descriptionLabel.text = description
}
// MARK: - selector
@IBAction func touchUpRedLight(_ sender: Any) {
self.trafficLightPresenter.trafficLightColorSelected(colorName: "Red")
}
@IBAction func touchUpYellowLight(_ sender: Any) {
self.trafficLightPresenter.trafficLightColorSelected(colorName: "Yellow")
}
@IBAction func touchUpGreenLight(_ sender: Any) {
self.trafficLightPresenter.trafficLightColorSelected(colorName: "Green")
}
@IBAction func touchUpBlueLight(_ sender: Any) {
self.trafficLightPresenter.trafficLightColorSelected(colorName: "Blue")
}
}
Swift
복사
6.
스토리보드는 알아서 자유자재로 꾸며주시면 됩니다. 4개의 버튼과 1개의 라벨만 넣어주세요.
7.
실행이 잘 되는지 확인해주세요!
Unit Test
위의 예제를 가지고 단위 테스트를 작성해보았습니다. 먼저 테스트 클래스에 Presenter 객체를 만들고 빨간색을 넣었을때 올바른 description이 출력되는지 확인해보는 함수를 하나 만들어뒀습니다.
import XCTest
@testable import MVP_Example
final class MVP_ExampleTests: XCTestCase {
var sut: TrafficLightPresenter?
override func setUpWithError() throws {
self.sut = TrafficLightPresenter(trafficLightService: TrafficLightService())
}
func test_presenter_빨간색을_넣었을때_올바른_description_출력하는가() {
self.sut?.trafficLightColorSelected(colorName: "Red")
}
}
Swift
복사
만들고 나니 올바른 description를 출력했는지를 알아볼 수 있는 부분이 없다는 걸 깨달았습니다. description를 잘 출력했는지 알기 위해선 Mock View가 필요했습니다. 따라서 이전에 만들어둔 ViewDelegate를 conform하는 Mock View를 하나 만들었습니다.
@testable import MVP_Example
final class TrafficView_Mock: TrafficLightViewDelegate {
// MARK: - property
var displayText: String?
// MARK: - func
func displayTrafficLight(description: String) {
self.displayText = description
}
}
Swift
복사
Mock 뷰에서는 Label에 값이 들어갔는지 중요한 것이 아니고 그 값이 View로 잘 들어왔는지를 확인하면 되기 때문에 String 타입으로 받았습니다.
그리고 Test 클래스에 mockView 객체를 만들었습니다.
import XCTest
@testable import MVP_Example
final class MVP_ExampleTests: XCTestCase {
var mockView: TrafficView_Mock?
var sut: TrafficLightPresenter?
override func setUpWithError() throws {
self.mockView = TrafficView_Mock()
self.sut = TrafficLightPresenter(trafficLightService: TrafficLightService())
self.sut?.setViewDelegate(trafficLightViewDelegate: self.mockView)
}
}
Swift
복사
그리고 올바른 description인 “Stop”과 mockView 안에 있는 displayText가 같은 값을 나타내는지 확인할 수 있게 XCTAssertEqual를 사용해서 값을 비교했습니다.
func test_presenter_빨간색을_넣었을때_올바른_description_출력하는가() {
self.sut?.trafficLightColorSelected(colorName: "Red")
XCTAssertEqual("Stop", self.mockView?.displayText)
}
Swift
복사
결과는!
마무리
실제로 MVP로 코드를 짜보고 단위 테스트를 직접해보면서 MVC 보다 테스트 하기 쉽다는 걸 많이 느꼈습니다. 테스트도 쉽고 MVC 패턴으로 만들 때보다 각자의 역할이 훨씬 명확해진 느낌이었습니다.
MVP 패턴 글을 읽으면서 Test Double, Supervising Controller, Presentation Model 등 생소한 용어들이 많이 나왔는데, 그 부분들도 얼른 공부해보고 싶네요. 얼른 MVVM도 만나보고 싶네요. ٩(。•◡•。)۶
위의 예제 코드는 해당 레포지토리에서 보실 수 있습니다. 추가적인 MVP 예제 코드가 생기면 더 올려두겠습니다.
[23.04.11(화)]
Unsplash에서 원하는만큼의 이미지를 가져오는 MVP 패턴 예제를 만들었습니다. 저도 각잡고 만들어본 건 처음이라서 부족한 부분이 많을 수 있습니다..