iOS Architecture Patterns 뿌시기: Model View Controller (MVC)
Thanks to Radu Dan for allowing the translation.
Reference: Battle of the iOS Architecture Patterns: Model View Controller (MVC)
본 글은 위의 내용에 대한 번역본입니다.
순서는 다음과 같습니다.
iOS Architecture Patterns 뿌시기: Model View Controller (MVC)
iOS 개발에서 가장 기본 아키텍처 패턴 (architecture pattern) 으로 시작합시다.
아키텍처 시리즈 — Model View Controller (MVC)
동기 (Motivation)
iOS 앱을 개발하기 전에 프로젝트의 구조에 대해 생각해야 합니다. 나중에 앱의 일부분을 다시 볼 때 코드의 조각을 추가하는 방법과 다른 개발자들과 “언어” 라고 알려진 형식을 고려해야 합니다.
iOS 개발에서 가장 잘 알려진 패턴을 통해 하나의 애플리케이션을 사용하는 글을 시작하려고 합니다. 각 패턴에 대한 빌드 시간과 장점과 단점을 확인하지만 가장 중요한 것은 실제 구현과 소스 코드를 확인해 볼 것입니다.
코드를 보기 원한다면 이 글을 건너뛰어도 됩니다. 코드는 GitHub 에 오픈 소스로 공개되어 있습니다.
iOS 앱에 아키텍처 패턴이 필요한 이유? (Why an Architecture Pattern for Your iOS App?)
고려해야 할 가장 중요한 것은 유지될 수 있는 앱을 가지는 것입니다. View 가 있다는 것을 알고 View Controller 가 Y 가 아닌 X 를 수행해야 한다는 것도 알고 있습니다. 더 중요한 것은 다른 사람들도 알고 있다는 것입니다.
좋은 아키텍처 패턴을 고르는 것에 대한 이점은 다음과 같습니다:
- 유지보수가 쉬움
- 비지니스 로직을 테스트하기 쉬움
- 다른 팀원들과 공통 언어로 개발
- 역할 분리 (각 기능별 분리)
- 더 적은 버그
요구사항 정의 (Defining the Requirements)
6개 또는 7개의 화면을 가진 iOS 애플리케이션이 주어지고 iOS 세계에서 가장 유명한 아키텍처 패턴을 사용하여 개발할 것입니다: MVC, MVVM, MVP, VIPER, VIP, 그리고 Coordinators.
데모앱은 Football Gather 라고 불리며 아마추어 축구 경기의 점수를 추적하는 간단한 앱입니다.
주요 요소 (Main features)
가능한 것:
- 앱에 선수 추가
- 선수에게 팀 할당
- 선수 수정
- 경기에 대한 카운트다운 타이머 설정
화면 목업 (Screen mockups)
iOS 앱인 “Football Gather” 에 화면 목업
백엔드 (Backend)
이 앱은 Vapor 웹 프레임워크로 개발된 웹 앱으로 구동됩니다. Vapor 3 initial article 와 article about Migrating to Vapor 4 에서 앱을 확인할 수 있습니다.
MVC 란? (What Is MVC?)
MVC 는 세상에서 가장 잘 알려진 아키텍처 패턴입니다.
3개의 컴포넌트가 있습니다: Model, View, 그리고 Controller.
Model
- 모든 데이터 클래스 (data classes), 헬퍼 (helpers), 네트워크 코드 (networking code)를 포함합니다.
- 앱 고유의 데이터를 모두 가지고 있으며, 데이터를 처리하는 로직을 정의합니다.
- 앱에서 모델 (model) 은 Utils, Storage, 그리고 Networking 그룹에 있는 것을 나타냅니다.
- 여러 선수들과 여러 팀 (M:M) 또는 선수들/팀들의 사용자 (1:M) 와 같이 다른 모델 객체들은 1:M 과 M:M 관계를 가질 수 있습니다.
- View 와 직접적으로 통신하면 안되며 유저 인터페이스에 대해 관여하면 안됩니다.
통신 (Communication)
- 예를 들어 사용자가 액션을 시작하는 것과 같이 View layer 에서 어떠한 것이 발생하면 Controller 를 통해 Model 로 전달됩니다.
- 예를 들어 새로운 데이터가 가능한 것과 같이 Model 이 변경되면 Model 은 Controller 로 알립니다.
View
- View 는 화면에서 유저가 보는 컴포넌트를 나타냅니다.
- 유저 액션에 반응합니다.
- View 의 목적은 Model 로 부터 데이터를 보여주고 유저 상호작용을 가능하게 해줍니다.
- 주요 Apple 프레임워크는 UIKit 과 AppKit 입니다.
- 앱에서의 예제는 다음과 같습니다:
LoadingView
,EmptyView
,PlayerTableViewCell
, 그리고ScoreStepper
.
통신 (Communication)
- View 는 Model 과 직접적으로 통신할 수 없습니다. 모든 것은 Controller 를 통해 수행됩니다.
Controller
- MVC 의 핵심 레이어 (core layer) 입니다.
- View 업데이트를 처리하고 Model 을 변경합니다.
- Model 업데이트를 처리하고 View 를 업데이트 합니다.
- Controller 는 다른 객체의 생명주기를 관리하기 위해 설정 메서드나 작업을 가질 수 있습니다.
통신 (Communication)
- Model 과 View 모든 레이어와 통신할 수 있습니다.
- Controller 는 사용자 액션을 해석하고 Model 레이어로 부터 데이터 변경사항을 트리거 합니다.
- 데이터가 변경되면 해당 변경사항이 유저 인터페이스에 전달되어 View 를 업데이트 합니다.
MVC 의 다른 점 (Different Flavours of MVC)
전통적인 MVC 는 Cocoa 의 MVC 와 다릅니다: View 와 Model 레이어는 서로 통신할 수 있습니다.
View 는 상태를 보존하지 않고 Model 이 업데이트 되면 Controller 에 의해 렌더링됩니다.
Smalltalk-79 에서 소개되었으며 여러 디자인 패턴을 기반으로 만들어 졌습니다: 복합 (composite), 전략 (strategy), 그리고 관찰자 (observer). (아래의 정의는 Apple’s Model-View-Controller documentation 기반으로 작성되었습니다.)
복합 (Composite)
“애플리케이션에서 View 객체는 실제로 조정된 방식으로 함께 동작하는 중첩된 View 의 합성물입니다 (이것은 View 계층구조 (hierachy)입니다). 이러한 디스플레이 요소는 window 부터 table view, 버튼과 같은 개별 view 와 같은 복합 view 까지 다양합니다. 사용자 입력과 디스플레이는 복합 구조의 모든 수준에서 수행될 수 있습니다.”
UIView
의 계층구조에 대해 생각해 봅시다. View 는 사용자의 인터페이스의 주요 요소입니다. View 는 다른 subview 를 포함할 수 있습니다. 예를 들어 해당 앱에서 LoginViewController
는 stack view 가 많이 포함된 main view 를 가지고 있으며 그 안에 사용자 이름과 비밀번호를 입력하는 text field 와 로그인 버튼이 있습니다.
전략 (Strategy)
“Controller 객체는 하나 이상의 View 객체에 대한 전략을 구현합니다. View 객체는 시각적 측면에 국한되며 인터페이스 동작의 애플리케이션에 대한 모든 결정을 Controller 에 위임합니다.”
관찰자 (Observer)
“Model 객체는 애플리케이션에서 보통 View 객체에 상태 변경을 알려줍니다.”
기존의 MVC 의 주 단점은 세 레이어가 밀접하게 결합되어 있는 것입니다. 테스트하고 유지관리하고 일부 로직을 재사용 하는데에도 어려움이 있습니다.
어떻게 그리고 언제 MVC 를 사용할까 (How and When To Use MVC)
때에 따라 다릅니다.
제대로 사용한다면 모든 앱에서 사용할 수 있습니다. 명확하게 yes 또는 no 는 없습니다 — 앱, 팀, 조직, 프로젝트의 크기, 개발자의 역량, 일정, 등에 따라 다릅니다.
그러나 고려해야 될 몇가지 취약점이 있습니다:
- 보시다시피 Controller 는 이 아키텍처 패턴의 중심입니다. Controller 는 View 와 Model 레이어와 강하게 결합되어 있습니다.
- Controller 는 잘 알려진 Massive View Controller 가 될 수 있습니다.
- 이 아키텍처 패턴은 테스트하기 어렵습니다.
위에서 언급한 내용을 다루는 방법이 있습니다. 그 중 하나는 View Controller 를 mini-View Controller 로 나누는 것입니다. 여기서 coordinator 로 동작하는 큰 Container/Parent View Controller 를 가지고 있으며 앱의 각 영역은 다르거나 child View Controller 에 의해 처리됩니다.
왜 MVC 를 사용해야 할까:
- Apple 에서 권장하며 예를 들어 UIKit 과 같은 프레임워크에서 사용됩니다.
- 많은 개발자들이 이 패턴을 알고 있기 때문에 협업하기 쉽습니다.
- 다른 아키텍처 패턴보다 코드 작성 속도가 빠릅니다.
코드에 MVC 적용 (Applying MVC to Our Code)
Football Gather 에서 View Controller 로 각 화면을 구현합니다:
Login Screen — LoginViewController
Login Screen
설명 (Description)
- 사용자가 로그인하거나 새로운 사용자를 생성하기 위한 랜딩 페이지
UI elements
usernameTextField
— 사용자가 사용자 이름을 작성하는 text fieldpasswordTextField
— 암호를 입력하기 위한 보안 text fieldrememberMeSwitch
— 로그인 후 사용자 이름을 키체인에 저장하여 다음 앱을 실행할 때 text field 를 자동으로 채우는UISwitch
loadingView
— 서버 호출을 하는 동안 로딩을 보여주기 위해 사용
Services
loginService
— 입력한 자격증명으로 Login API 호출하기 위해 사용usersService
— 새로운 사용자를 생성하는 Register API 를 호출하기 위해 사용
보이는 것 처럼 이 클래스는 세가지 주요 기능이 있습니다: 로그인, 등록, 그리고 사용자 이름 기억. performSegue
으로 다음 화면으로 이동합니다.
Code snippet
@IBAction private func login(_ sender: Any) {
// This is the minimum validation that we do, we check if the fields are empty. If yes, we display an alert to the user.
guard let userText = usernameTextField.text, userText.isEmpty == false,
let passwordText = passwordTextField.text, passwordText.isEmpty == false else {
AlertHelper.present(in: self, title: "Error", message: "Both fields are mandatory.")
return
}
// Adds the loading view while the Network call is performed
showLoadingView()
// Creates the request model
let requestModel = UserRequestModel(username: userText, password: passwordText)
// Calls the Login webservice API and handles the Result
loginService.login(user: requestModel) { [weak self] result in
guard let self = self else { return }
DispatchQueue.main.async {
self.hideLoadingView()
switch result {
case .failure(let error):
// An error has occured. Make sure the user is informed
AlertHelper.present(in: self, title: "Error", message: String(describing: error))
case .success(_):
// Hides the loading view, stores rememberMe option in Keychain or cleares the existing one and performs the segue so we advance to next screen.
self.handleSuccessResponse()
}
}
}
}
Player List Screen — PlayerListViewController
Player List and Selection Screen
설명 (Description)
- 로그인 한 사용자에 대한 선수를 보여줍니다. 각 선수는 분리된 열로 표시된 주 table view 로 구성됩니다.
UI elements
playerTableView
— 선수를 보여주는 table viewconfirmOrAddPlayersButton
— 선수를 추가하거나 선택한 선수를 확인할 수 있는 View 의 하단 버튼loadingView
— 서버 호출을 하는 동안 로딩을 보여주기 위해 사용emptyView
— 사용자가 추가한 선수가 없는 경우 보여줍니다barButtonItem
— View 모드에 따라 다른 상태를 가질 수 있는 우상단에 버튼입니다. 선수들을 모으기 위해 선택 모드에선 “Cancel” 타이틀을 가지지만 보기 모드에선 “Select” 타이틀을 가집니다.
Services
playersService
— 선수의 목록을 조회하고 선수를 삭제하기 위해 사용됩니다.
Models
players
— 사용자에 의해 생성된 선수의 배열입니다.playerTableView
에서 열로 볼 수 있습니다.selectedPlayersDictionary
— 선택된 선수의 열 인덱스를 키로 선택된 선수를 값으로 저장하는 딕셔너리 입니다.
Main.storyboard
를 열어보면 해당 view controller 에서 세개의 segue 를 수행할 수 있다는 것을 확인할 수 있습니다.
ConfirmPlayersSegueIdentifier
— 모을 선수를 선택한 후에 속할 팀을 지정하는 화면으로 이동합니다.PlayerAddSegueIdentifier
— 새로운 선수를 생성할 수 있는 화면으로 이동합니다.PlayerDetailSegueIdentifier
— 선수의 상세정보를 볼 수 있는 화면으로 이동합니다.
이 View Controller 에 대한 model 을 검색하는 함수를 다음과 같이 제공합니다.
// Performs a GET request to Players API to retrieve the list of the players.
private func loadPlayers() {
// UI setup methods, show Network activity indicator
view.isUserInteractionEnabled = false
// Perfoms the GET request
playersService.get { [weak self] (result: Result<[PlayerResponseModel], Error>) in
DispatchQueue.main.async {
self?.view.isUserInteractionEnabled = true
switch result {
case .failure(let error):
// In case of failures, present an alert to the user.
self?.handleServiceFailures(withError: error)
case .success(let players):
// Server returned a successful response with the array of players
self?.players = players
self?.handleLoadPlayersSuccessfulResponse()
}
}
}
}
그리고 선수를 삭제하기 원하면 다음과 같이 수행합니다:
// Table delegate that asks the data source to commit the insertion or deletion of a specified row in the receiver.
func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
// We just use it for deletion and not editing
guard editingStyle == .delete else { return }
// Show a confirmation alert if we are sure to delete the player
let alertController = UIAlertController(title: "Delete player", message: "Are you sure you want to delete the selected player?", preferredStyle: .alert)
let confirmAction = UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in
self?.handleDeletePlayerConfirmation(forRowAt: indexPath)
}
alertController.addAction(confirmAction)
let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
alertController.addAction(cancelAction)
present(alertController, animated: true, completion: nil)
}
private func handleDeletePlayerConfirmation(forRowAt indexPath: IndexPath) {
showLoadingView()
// Try to delete the player from the server and if successful, delete it
requestDeletePlayer(at: indexPath) { [weak self] result in
guard result, let self = self else { return }
// Deletes the player from the data source and performs the table row animations.
self.playerTableView.beginUpdates()
self.players.remove(at: indexPath.row)
self.playerTableView.deleteRows(at: [indexPath], with: .fade)
self.playerTableView.endUpdates()
if self.players.isEmpty {
self.showEmptyView()
}
}
}
서비스 호출은 다음과 같습니다:
private func requestDeletePlayer(at indexPath: IndexPath, completion: @escaping (Bool) -> Void) {
let player = players[indexPath.row]
var service = playersService
service.delete(withID: ResourceID.integer(player.id)) { [weak self] result in
DispatchQueue.main.async {
switch result {
case .failure(let error):
self?.handleServiceFailures(withError: error)
completion(false)
case .success(_):
completion(true)
}
}
}
}
Add Player Screen — PlayerAddViewController
선수 추가하는 화면
설명 (Description)
- 이 화면은 선수를 생성하기 위해 사용됩니다.
UI Elements
playerNameTextField
— 선수의 이름을 입력하기 위해 사용됩니다.doneButton
— 생성할 선수를 확인하고 서비스 호출을 시작하는데 사용되는 Bar 버튼 아이템.loadingView
— 서비스 호출이 되는 동안 보여지는 로딩화면 입니다.
Services
- /api/players 를 포인트로 하는
StandardNetworkService
를 사용합니다. 선수를 추가하기 위해 POST 로 요청합니다.
Code snippet
private func createPlayer(_ player: PlayerCreateModel, completion: @escaping (Bool) -> Void) {
let service = StandardNetworkService(resourcePath: "/api/players", authenticated: true)
service.create(player) { result in
if case .success(_) = result {
completion(true)
} else {
completion(false)
}
}
}
Player Details Screen — PlayerDetailViewController
선수 상세화면
설명 (Description)
- 선수의 정보를 보여주는 화면 (이름, 나이, 포지션, 기술, 그리고 원하는 팀)
UI elements
playerDetailTableView
— 선수의 정보를 보여주는 table view 입니다.
Model
player
—PlayerResponseModel
로 선수의 Model 입니다.
이 ViewController
에는 서비스가 없습니다. 선수의 정보를 업데이트 하는 요청은 PlayerEditViewController
로 부터 수신되고 위임을 통해 PlayerListViewController
로 전달됩니다.
섹션은 factory 패턴으로 만들어 집니다:
// There are three sections: Personal, Play and Likes
private func makeSections() -> [PlayerSection] {
return [
PlayerSection(
title: "Personal",
rows: [
PlayerRow(title: "Name",
value: self.player?.name ?? "",
editableField: .name),
PlayerRow(title: "Age",
value: self.player?.age != nil ? "\(self.player!.age!)" : "",
editableField: .age)
]
),
PlayerSection(
title: "Play",
rows: [
PlayerRow(title: "Preferred position",
value: self.player?.preferredPosition?.rawValue.capitalized ?? "",
editableField: .position),
PlayerRow(title: "Skill",
value: self.player?.skill?.rawValue.capitalized ?? "",
editableField: .skill)
]
),
PlayerSection(
title: "Likes",
rows: [
PlayerRow(title: "Favourite team",
value: self.player?.favouriteTeam ?? "",
editableField: .favouriteTeam)
]
)
]
}
Edit Player Screen — PlayerEditViewController
선수 수정 — 원하는 포지션, 기술 또는 나이
설명 (Description)
- 선수의 정보를 수정합니다.
UI Elements
playerEditTextField
— 수정하기 원하는 선수의 정보를 입력하는 필드입니다.playerTableView
— 정보를 수정하기 위해 iOS General Setting 과 유사한 동작과 UI 를 원합니다. 이 table view 는 text field 가 있는 하나의 행 또는 선택 동작을 하는 여러개 행을 가집니다.loadingView
— 서버 호출을 하는 동안 보여지는 로딩화면 입니다.doneButton
— 수정 액션을 수행하는UIBarButtonItem
입니다.
Services
StandardNetworkService
로 사용되는 Player API 업데이트:
private func updatePlayer(_ player: PlayerResponseModel, completion: @escaping (Bool) -> Void) {
var service = StandardNetworkService(resourcePath: "/api/players", authenticated: true)
service.update(PlayerCreateModel(player), resourceID: ResourceID.integer(player.id)) { result in // players have an ID that is Int
if case .success(let updated) = result {
completion(updated)
} else {
completion(false)
}
}
}
Models
viewType
—.text
(키보드로 입력된 선수 정보) 또는.selection
(하나의 셀을 선택하여 선택된 예를 들어 원하는 포지선과 같은 선수 정보) 를 사용할 수 있는 enum 입니다.player
— 수정하기 원하는 선수items
— 원하는 포지션 또는 기술에 대한 가능한 모든 옵션의 해당 문자열의 배열 입니다. 문자가 수정되면 이 배열은nil
입니다.
Confirm Screen — ConfirmPlayersViewController
선수 확인 화면
설명 (Description)
- Gather 화면에 가기 전에 팀에 선수를 배치할 수 있습니다.
UI elements
playerTableView
— 선택한 선수들을 보여주기 위해 세개의 섹션 (Bench, Team A, 그리고 Team B) 으로 분리된 table view 입니다.startGatherButton
— 초기에는 비활성화 되어 있고 탭하면 팀 모집을 시작하기 위한 Network API 를 호출하는 액션을 트리거 하고 마지막으로 다음 화면을 푸쉬합니다.loadingView
— 서버 호출을 하는 동안 보여지는 로딩 화면입니다.
Services
Create Gather
—/api/gathers
로 POST 요청을 생성하여 새로운 수집을 추가합니다.Add Player to Gather
— 선수의 팀을 선택한 후에api/gathers/{gather_id}/players/{player_id}
로 POST 요청으로 수집에 선수를 추가합니다.
Models
playersDictionary
— 각 팀은 선수를 배열로 가지고 있으므로 딕셔너리는 키 (Team A, Team B, 또는 Bench) 로 팀을 가지며, 값으로는 선택된 선수들 (선수 배열) 로 되어 있습니다.
선택 (UI)이 완료되면 새로운 수집은 생성되고 각 선수는 팀에 할당됩니다.
@IBAction func startGatherAction(_ sender: Any) {
showLoadingView()
// Authenticated request to create a new gather
createGather { [weak self] uuid in
guard let self = self else { return }
guard let gatherUUID = uuid else {
self.handleServiceFailure()
return
}
// Each player is added to the gather
self.addPlayersToGather(havingUUID: gatherUUID)
}
}
선수를 추가하기 위한 for 루프는 아래와 같습니다:
private func addPlayersToGather(havingUUID gatherUUID: UUID) {
let players = self.playerTeamArray
// [1] Using a DispatchGroup so we wait all requests to finish
let dispatchGroup = DispatchGroup()
var serviceFailed = false
players.forEach { playerTeamModel in
dispatchGroup.enter()
self.addPlayer(
playerTeamModel.player,
toGatherHavingUUID: gatherUUID,
team: playerTeamModel.team,
completion: { playerWasAdded in
if !playerWasAdded {
serviceFailed = true
}
dispatchGroup.leave()
})
}
// [2] All requests finished, now update the UI
dispatchGroup.notify(queue: DispatchQueue.main) {
self.hideLoadingView()
if serviceFailed {
self.handleServiceFailure()
} else {
self.performSegue(withIdentifier: SegueIdentifiers.gather.rawValue,
sender: GatherModel(players: players, gatherUUID: gatherUUID))
}
}
}
Gather Screen — ConfirmPlayersViewController
Gather 화면
설명 (Description)
- 수집 모드 (gather mode) 에서 타이머를 시작/일시중지 또는 중단, 경기를 끝내는 애플리케이션의 주요 화면입니다.
UI elements
playerTableView
— 2개의 섹션 (Team A, Team B) 로 나누어진 선수를 보여주는데 사용됩니다.scoreLabelView
— 하나는 Team A 그리고 다른 하나는 Team B 의 점수를 보여주는 2개의 라벨을 가지는 view 입니다.scoreStepper
— 팀에 대한 2개의 스테퍼를 가지고 있는 view 입니다.timerLabel
— mm:ss 포맷으로 남은 시간을 보여주는데 사용됩니다.timerView
—UIPickerView
를 사용하여 수집 시간을 선택하는 view 입니다.timePickerView
— 수집 시간을 선택하기 위해 2개의 요소 (분과 초)를 가진 피커 view 입니다.actionTimerButton
— 카운트다운 타이머 (다시시작, 일시정지, 그리고 시작)를 관리하는 상태 버튼 입니다.loadingView
— 서버 호출을 하는 동안 보여지는 로딩 view 입니다.
Services
Update Gather
— 수집이 종료되었을 때 PUT 요청으로 승리 팀과 점수를 업데이트 합니다.
Models
GatherTime
— Int 로 분과 초를 가지는 튜플 입니다.gatherModel
— 수집 ID 와 선수 팀 model 의 배열 (선수 응답 model 과 선수가 속한 팀) 을 포함합니다. 생성되고ConfirmPlayersViewController
로 부터 전달됩니다.timer
— 수집의 분과 초를 카운트다운 하는데 사용됩니다.timerState
— 3개의 상태를 가질 수 있습니다:stopped
,running
, 그리고paused
. 값 중 하나가 설정되는 것을 관찰하여actionTimerButton
의 버튼 이름을 변경할 수 있습니다. 일시정지 되면 버튼의 이름은Resume
이 됩니다. 동작 중일 때는 버튼의 이름은Pause
가 되고 타이머가 멈추면Start
로 됩니다.
actionTimerButton
이 탭되면 타이머를 invalidate 또는 시작할 지 확인합니다:
@IBAction func actionTimer(_ sender: Any) {
// [1] Check if the user selected a time more than 1 second
guard selectedTime.minutes > 0 || selectedTime.seconds > 0 else {
return
}
switch timerState {
// [2] The timer was in a stopped or paused state. Now it becomes in a running state.
case .stopped, .paused:
timerState = .running
// [3] If it’s running, we pause it. For cancelling we have a different IBAction.
case .running:
timerState = .paused
}
if timerState == .paused {
// [4] Stop the timer
timer.invalidate()
} else {
// [5] Start the timer and call each second the selector updateTimer.
timer = Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(updateTimer), userInfo: nil, repeats: true)
}
}
타이머를 취소하기 위해 다음의 액션 구현을 가집니다:
@IBAction func cancelTimer(_ sender: Any) {
// [1] Set the state
timerState = .stopped
timer.invalidate()
// [2] Set to 10 minutes
selectedTime = Constants.defaultTime
timerView.isHidden = true
}
매 초마다 updateTimer
가 호출됩니다:
@objc func updateTimer(_ timer: Timer) {
// [1] Before substracting a second, we verify the current timer state. If the seconds are 0, we substract the minutes.
if selectedTime.seconds == 0 {
selectedTime.minutes -= 1
selectedTime.seconds = 59
} else {
selectedTime.seconds -= 1
}
// [2] If timer reached out to 0, we stop it.
if selectedTime.seconds == 0 && selectedTime.minutes == 0 {
timerState = .stopped
timer.invalidate()
}
}
수집이 종료되기 전에 우승 팀을 확인합니다:
guard let scoreTeamAString = scoreLabelView.teamAScoreLabel.text,
let scoreTeamBString = scoreLabelView.teamBScoreLabel.text,
let scoreTeamA = Int(scoreTeamAString),
// [1] Get the score from the labels and convert it to Ints.
let scoreTeamB = Int(scoreTeamBString) else {
return
}
//[2] Format of the score
let score = "\(scoreTeamA)-\(scoreTeamB)"
var winnerTeam: String = "None"
if scoreTeamA > scoreTeamB {
winnerTeam = "Team A"
} else if scoreTeamA < scoreTeamB {
winnerTeam = "Team B"
}
// [3] Set the winner team, by default being None.
let gather = GatherCreateModel(score: score, winnerTeam: winnerTeam)
서버 호출은 다음과 같습니다:
private func updateGather(_ gather: GatherCreateModel, completion: @escaping (Bool) -> Void) {
guard let gatherModel = gatherModel else {
completion(false)
return
}
// [1] We are using the StandardNetworkService where we pass the UUID of the gather and the update model (winner team and score)
var service = StandardNetworkService(resourcePath: "/api/gathers", authenticated: true)
service.update(gather, resourceID: ResourceID.uuid(gatherModel.gatherUUID)) { result in
if case .success(let updated) = result {
completion(updated)
} else {
completion(false)
}
}
}
private 메서드 updateGather
는 endGather
로 부터 호출 됩니다:
private func updateGather(_ gather: GatherCreateModel, completion: @escaping (Bool) -> Void) {
guard let gatherModel = gatherModel else {
completion(false)
return
}
// [1] We are using the StandardNetworkService where we pass the UUID of the gather and the update model (winner team and score)
var service = StandardNetworkService(resourcePath: "/api/gathers", authenticated: true)
service.update(gather, resourceID: ResourceID.uuid(gatherModel.gatherUUID)) { result in
if case .success(let updated) = result {
completion(updated)
} else {
completion(false)
}
}
}
비지니스 로직 테스트 (Testing Our Business Logic)
MVC 를 적용하는 첫번째 데모 앱 Football Gather 를 보았습니다. 물론, 코드를 리팩토링 하고 일부 로직을 더 좋게 만들고 분리하여 다른 클래스로 나눌 수 있지만 연습을 위해 이 코드 베이스를 유지할 것입니다.
클래스에 유닛 테스트를 어떻게 작성할 수 있는지 보시기 바랍니다. GatherViewController
를 기반으로 100% 에 가까운 코드 커버리지에 도달하려고 노력할 것 입니다.
먼저 GatherViewController
는 Main storyboard
의 부분입니다. 편리함을 위해 식별자를 사용하고 storyboard.instantiateViewController
메서드로 인스턴스화 합니다. 이 로직을 위해 setUp
메서드를 사용합니다:
final class GatherViewControllerTests: XCTestCase {
var sut: GatherViewController!
override func setUp() {
super.setUp()
let storyboard = UIStoryboard(name: "Main", bundle: nil)
if let viewController = storyboard.instantiateViewController(identifier: "GatherViewController") as? GatherViewController {
sut = viewController
sut.gatherModel = gatherModel
_ = sut.view
} else {
XCTFail("Unable to instantiate GatherViewController")
}
}
//…
}
첫번째 테스트에서 모든 아웃렛 (outlet) 이 nil
이 아닌지 확인합니다:
func testOutlets_whenViewControllerIsLoadedFromStoryboard_areNotNil() {
XCTAssertNotNil(sut.playerTableView)
XCTAssertNotNil(sut.scoreLabelView)
XCTAssertNotNil(sut.scoreStepper)
XCTAssertNotNil(sut.timerLabel)
XCTAssertNotNil(sut.timerView)
XCTAssertNotNil(sut.timePickerView)
XCTAssertNotNil(sut.actionTimerButton)
}
이제 viewDidLoad
가 호출되는지 확인해 봅니다. 타이틀이 설정되고 일부 프로퍼티가 설정됩니다. public 파라미터를 확인합니다:
func testViewDidLoad_whenViewControllerIsLoadedFromStoryboard_setsVariables() {
XCTAssertNotNil(sut.title)
XCTAssertTrue(sut.timerView.isHidden)
XCTAssertNotNil(sut.timePickerView.delegate)
}
timerView
는 사용자가 경기 타이머를 설정하면 팝업되는 view 입니다.
이제 table view 메서드를 유닛 테스트 해봅시다:
func testNumberOfSections_whenGatherModelIsSet_returnsTwoTeams() {
XCTAssert(sut.playerTableView?.numberOfSections == Team.allCases.count - 1)
}
두 팀만 존재합니다: Team A
그리고 Team B
. Bench
팀은 화면에 보이지 않습니다.
func testTitleForHeaderInSection_whenSectionIsTeamAAndGatherModelIsSet_returnsTeamATitleHeader() {
let teamASectionTitle = sut.tableView(sut.playerTableView, titleForHeaderInSection: 0)
XCTAssertEqual(teamASectionTitle, Team.teamA.headerTitle)
}
func testTitleForHeaderInSection_whenSectionIsTeamBAndGatherModelIsSet_returnsTeamBTitleHeader() {
let teamBSectionTitle = sut.tableView(sut.playerTableView, titleForHeaderInSection: 1)
XCTAssertEqual(teamBSectionTitle, Team.teamB.headerTitle)
}
table view 는 팀 이름 (Team A 그리고 Team B) 으로 헤더 타이틀이 설정된 2개의 섹션을 가집니다.
행의 갯수를 확인하기 위해 mock 수집 model 을 주입합니다:
private let gatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 4)
static func makeGatherModel(numberOfPlayers: Int, gatherUUID: UUID = ModelsMock.gatherUUID) -> GatherModel {
let allSkills = PlayerSkill.allCases
let allPositions = PlayerPosition.allCases
var playerTeams: [PlayerTeamModel] = []
(1...numberOfPlayers).forEach { index in
let skill = allSkills[Int.random(in: 0..<allSkills.count)]
let position = allPositions[Int.random(in: 0..<allPositions.count)]
let team: Team = index % 2 == 0 ? .teamA : .teamB
let playerResponseModel = makePlayerResponseModel(id: index, name: "Player \(index)", age: 20 + index, favouriteTeam: "Fav team \(index)", skill: skill, preferredPosition: position)
let playerTeamModel = PlayerTeamModel(team: team, player: playerResponseModel)
playerTeams.append(playerTeamModel)
}
return GatherModel(players: playerTeams, gatherUUID: gatherUUID)
}
그리고 섹션에 행의 수를 확인하는 유닛 테스트와 섹션이 유효하지 않을 때 Nil 시나리오도 추가합니다.
func testNumberOfRowsInSection_whenTeamIsA_returnsNumberOfPlayersInTeamA() {
let expectedTeamAPlayersCount = gatherModel.players.filter { $0.team == .teamA }.count
XCTAssertEqual(sut.playerTableView.numberOfRows(inSection: 0), expectedTeamAPlayersCount)
}
func testNumberOfRowsInSection_whenTeamIsB_returnsNumberOfPlayersInTeamB() {
let expectedTeamBPlayersCount = gatherModel.players.filter { $0.team == .teamB }.count
XCTAssertEqual(sut.playerTableView.numberOfRows(inSection: 1), expectedTeamBPlayersCount)
}
func testNumberOfRowsInSection_whenGatherModelIsNil_returnsZero() {
sut.gatherModel = nil
XCTAssertEqual(sut.tableView(sut.playerTableView, numberOfRowsInSection: -1), 0)
}
선수 세부정보를 보여주기 위해 일반 table view 를 사용하여 선수의 이름으로 textLabel
로 설정하고 선수의 원하는 포지션으로 detailTextLabel
을 설정합니다.
다음 프로퍼티가 설정되었는지 확인합니다:
func testCellForRowAtIndexPath_whenSectionIsTeamA_setsCellDetails() {
let indexPath = IndexPath(row: 0, section: 0)
let playerTeams = gatherModel.players.filter({ $0.team == .teamA })
let player = playerTeams[indexPath.row].player
let cell = sut.playerTableView.cellForRow(at: indexPath)
XCTAssertEqual(cell?.textLabel?.text, player.name)
XCTAssertEqual(cell?.detailTextLabel?.text, player.preferredPosition?.acronym)
}
func testCellForRowAtIndexPath_whenSectionIsTeamB_setsCellDetails() {
let indexPath = IndexPath(row: 0, section: 1)
let playerTeams = gatherModel.players.filter({ $0.team == .teamB })
let player = playerTeams[indexPath.row].player
let cell = sut.playerTableView.cellForRow(at: indexPath)
XCTAssertEqual(cell?.textLabel?.text, player.name)
XCTAssertEqual(cell?.detailTextLabel?.text, player.preferredPosition?.acronym)
}
좋습니다! model 이 성공적으로 테스트 되었습니다. 어렵지는 않지만 비지니스 로직을 확인하기 위해 많은 UI stuff, 스토리보드를 사용하고 table view 위임과 데이터 소스를 확인하기 위해 mock stuff 를 가집니다.
계속해서 pickerView
메서드를 사용합니다.
func testPickerViewNumberOfComponents_returnsAllCountDownCases() {
XCTAssertEqual(sut.timePickerView.numberOfComponents, GatherViewController.GatherCountDownTimerComponent.allCases.count)
}
컴포넌트의 수를 확인하는 방법은 enum GatherCountDownTimerComponent
을 public 으로 만드는 것 입니다.
컴포넌트에서 행의 갯수를 테스틑 하는 것은 선수 tableView
와 유사합니다.
func testPickerViewNumberOfRowsInComponent_whenComponentIsMinutes_returns60() {
let minutesComponent = GatherViewController.GatherCountDownTimerComponent.minutes.rawValue
let numberOfRows = sut.pickerView(sut.timePickerView, numberOfRowsInComponent: minutesComponent)
XCTAssertEqual(numberOfRows, 60)
}
func testPickerViewNumberOfRowsInComponent_whenComponentIsSecounds() {
let secondsComponent = GatherViewController.GatherCountDownTimerComponent.seconds.rawValue
let numberOfRows = sut.pickerView(sut.timePickerView, numberOfRowsInComponent: secondsComponent)
XCTAssertEqual(numberOfRows, 60)
}
행은 분과 초의 수와 같아야 합니다. 그리고 행의 타이틀도 같아야 합니다:
func testPickerViewTitleForRow_whenComponentIsMinutes_containsMin() {
let minutesComponent = GatherViewController.GatherCountDownTimerComponent.minutes.rawValue
let title = sut.pickerView(sut.timePickerView, titleForRow: 0, forComponent: minutesComponent)
XCTAssertTrue(title!.contains("min"))
}
func testPickerViewTitleForRow_whenComponentIsSeconds_containsSec() {
let secondsComponent = GatherViewController.GatherCountDownTimerComponent.seconds.rawValue
let title = sut.pickerView(sut.timePickerView, titleForRow: 0, forComponent: secondsComponent)
XCTAssertTrue(title!.contains("sec"))
}
IBActions
을 어떻게 테스트 하는지 살펴봅시다.
setTimer
액션은 picker view (선택된 분과 초로 picker view 로 설정합니다. 설정된 시간이 없다면 기본적으로 10 분으로 합니다)를 구성하고 timerView
를 숨깁니다.
public 프로퍼티는 timerView
만 있습니다. setTimer
를 호출하고 timerView
가 숨겨져 있지 않은지 확인합니다:
func testSetTimer_whenActionIsSent_showsTimerView() {
sut.setTimer(UIButton())
XCTAssertFalse(sut.timerView.isHidden)
}
cancelTimer
에 대한 동일한 로직 이지만 이번엔 timerView
을 숨겨야 합니다:
func testCancelTimer_whenActionIsSent_hidesTimerView() {
sut.cancelTimer(UIButton())
XCTAssertTrue(sut.timerView.isHidden)
}
timerView
팝업에 버튼에 대해 유사한 메서드가 있습니다:
func testTimerCancel_whenActionIsSent_hidesTimerView() {
sut.timerCancel(UIButton())
XCTAssertTrue(sut.timerView.isHidden)
}
이것은 점점 더 어려워 집니다:
func testTimerDone_whenActionIsSent_hidesTimerViewAndSetsMinutesAndSeconds() {
sut.timerDone(UIButton())
let minutes = sut.timePickerView.selectedRow(inComponent: GatherViewController.GatherCountDownTimerComponent.minutes.rawValue)
let seconds = sut.timePickerView.selectedRow(inComponent: GatherViewController.GatherCountDownTimerComponent.seconds.rawValue)
XCTAssertTrue(sut.timerView.isHidden)
XCTAssertGreaterThan(minutes, 0)
XCTAssertEqual(seconds, 0)
}
timerView
가 숨겨져 있고 분과 초가 설정되었는지 확인합니다. ViewController
의 기본 시간에 접근하지 못하므로 분 컴포넌트에 대한 XCTAssertGreaterThan
을 사용합니다.
타이머가 행 변경에 반응하는지 살펴 봅시다:
func testActionTimer_whenSelectedTimeIsZero_returns() {
let minutesComponent = GatherViewController.GatherCountDownTimerComponent.minutes.rawValue
let secondsComponent = GatherViewController.GatherCountDownTimerComponent.seconds.rawValue
sut.timePickerView.selectRow(0, inComponent: minutesComponent, animated: false)
sut.timePickerView.selectRow(0, inComponent: secondsComponent, animated: false)
sut.timerDone(UIButton())
sut.actionTimer(UIButton())
XCTAssertEqual(sut.timePickerView.selectedRow(inComponent: minutesComponent), 0)
XCTAssertEqual(sut.timePickerView.selectedRow(inComponent: secondsComponent), 0)
}
이 테스트에서 첫번째 행을 선택하므로 시간은 00:00 이 됩니다. 그러면 timerDone
메서드 와 actionTimer
를 호출합니다. 이것은 guard 구문에 빠질 것입니다:
guard selectedTime.minutes > 0 || selectedTime.seconds > 0 else {
return
}
이제, 테스트의 즐거움 차례입니다:
func testActionTimer_whenSelectedTimeIsSet_updatesTimer() {
// given
let minutesComponent = GatherViewController.GatherCountDownTimerComponent.minutes.rawValue
let secondsComponent = GatherViewController.GatherCountDownTimerComponent.seconds.rawValue
sut.timePickerView.selectRow(0, inComponent: minutesComponent, animated: false)
// set to 1 second
sut.timePickerView.selectRow(1, inComponent: secondsComponent, animated: false)
// initial state
sut.timerDone(UIButton())
XCTAssertEqual(sut.actionTimerButton.title(for: .normal), "Start")
// when
sut.actionTimer(UIButton())
// then
XCTAssertEqual(sut.actionTimerButton.title(for: .normal), "Pause")
// make sure timer is resetted
let exp = expectation(description: "Timer expectation")
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
XCTAssertEqual(self.sut.actionTimerButton.title(for: .normal), "Start")
exp.fulfill()
}
waitForExpectations(timeout: 5, handler: nil)
}
초기 상태에서 actionTimerButton
타이틀이 Start
인지 확인합니다. actionTimer
호출 후에 actionTimerButton
타이틀은 Pause
여야 합니다 (타이머는 카운팅 되고 경기는 시작했기 때문입니다).
초 컴포넌트를 1로 설정합니다. 2초 후에는 멈춰야 하고 타이머는 무효화 되고 actionTimerButton
는 초기화 상태의 타이틀인 Start
가 되어야 합니다.
기대하는 결과를 기다리기 위해 5초의 타임아웃을 가지는 waitForExpectations
을 사용합니다.
Resume
과 Pause
의 변경을 확인하는 것은 유사합니다:
func testActionTimer_whenTimerIsSetAndRunning_isPaused() {
// given
let sender = UIButton()
let minutesComponent = GatherViewController.GatherCountDownTimerComponent.minutes.rawValue
let secondsComponent = GatherViewController.GatherCountDownTimerComponent.seconds.rawValue
sut.timePickerView.selectRow(0, inComponent: minutesComponent, animated: false)
sut.timePickerView.selectRow(3, inComponent: secondsComponent, animated: false)
// initial state
sut.timerDone(sender)
XCTAssertEqual(sut.actionTimerButton.title(for: .normal), "Start")
sut.actionTimer(sender)
// when
let exp = expectation(description: "Timer expectation")
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.sut.actionTimer(sender)
XCTAssertEqual(self.sut.actionTimerButton.title(for: .normal), "Resume")
exp.fulfill()
}
waitForExpectations(timeout: 5, handler: nil)
}
초 컴포넌트의 시간을 3초로 더 길게 설정합니다. actionTimer
를 호출하고 1초 후에 다시 함수를 호출합니다. 경기는 일시정지하고 actionTimerButton
는 Resume
타이틀을 가집니다.
selector 가 호출되는지 확인하기 위해 timerLabel
문구를 확인할 수 있습니다:
func testUpdateTimer_whenSecondsReachZero_decrementsMinuteComponent() {
let sender = UIButton()
let timer = Timer()
let minutesComponent = GatherViewController.GatherCountDownTimerComponent.minutes.rawValue
let secondsComponent = GatherViewController.GatherCountDownTimerComponent.seconds.rawValue
sut.timePickerView.selectRow(1, inComponent: minutesComponent, animated: false)
sut.timePickerView.selectRow(0, inComponent: secondsComponent, animated: false)
sut.timerDone(sender)
XCTAssertEqual(sut.timerLabel.text, "01:00")
sut.updateTimer(timer)
XCTAssertEqual(sut.timerLabel.text, "00:59")
}
이 테스트에서 분 컴포넌트가 0이 되면 초가 줄어드는지 확인하였습니다.
아웃렛 (outlet) 에 접근하면 더 쉽게 stepperDidChangeValue
위임 (delegate) 을 확인할 수 있습니다:
func testStepperDidChangeValue_whenTeamAScores_updatesTeamAScoreLabel() {
sut.scoreStepper.teamAStepper.value = 1
sut.scoreStepper.teamAStepperValueChanged(UIButton())
XCTAssertEqual(sut.scoreLabelView.teamAScoreLabel.text, "1")
XCTAssertEqual(sut.scoreLabelView.teamBScoreLabel.text, "0")
}
func testStepperDidChangeValue_whenTeamBScores_updatesTeamBScoreLabel() {
sut.scoreStepper.teamBStepper.value = 1
sut.scoreStepper.teamBStepperValueChanged(UIButton())
XCTAssertEqual(sut.scoreLabelView.teamAScoreLabel.text, "0")
XCTAssertEqual(sut.scoreLabelView.teamBScoreLabel.text, "1")
}
func testStepperDidChangeValue_whenTeamIsBench_scoreIsNotUpdated() {
sut.stepper(UIStepper(), didChangeValueForTeam: .bench, newValue: 1)
XCTAssertEqual(sut.scoreLabelView.teamAScoreLabel.text, "0")
XCTAssertEqual(sut.scoreLabelView.teamBScoreLabel.text, "0")
}
마지막으로 GatherViewController
에서 가장 어렵고 가장 중요한 메서드는 아마도 endGather
메서드 입니다. 다음은 수집 model 을 업데이트 하는 서비스 호출을 수행합니다. 경기의 winnerTeam
과 점수를 전달합니다.
이 메서드는 매우 크고 하나 이상의 작업을 수행하고 private 입니다. (하나의 예제로서 사용합니다: 함수는 크지 않고 하나의 작업을 수행해야 합니다!).
이 함수의 응답은 아래에 자세히 나와있습니다. endGather
은 다음을 수행합니다:
scoreLabelViews
에서 점수를 가져옵니다.- 점수를 비교하여 우승 팀을 계산합니다.
- 서비스 호출에 대한
GatherModel
을 생성합니다. - 로딩 스피너를 보여줍니다.
updateGather
서비스 호출을 수행합니다.- 로딩 스피너를 숨깁니다.
- 성공과 실패를 처리합니다.
- 성공할 경우 view controller 는
PlayerListViewController
로 pop 됩니다 (이 view 는 스택에 있어야 합니다). - 실패할 경우 alert 을 보여줍니다.
이 모든 테스트를 어떻게 수행해야 할까요? (다시 말하지만 이 함수는 여러 함수로 분리되어야 합니다.)
천천히 해보도록 합시다.
mock 서비스를 생성하고 sut
에 주입합니다:
private let session = URLSessionMockFactory.makeSession()
private let resourcePath = "/api/gathers"
private let appKeychain = AppKeychainMockFactory.makeKeychain()
let endpoint = EndpointMockFactory.makeSuccessfulEndpoint(path: resourcePath)
sut.updateGatherService = StandardNetworkService(session: session, urlRequest: AuthURLRequestFactory(endpoint: endpoint, keychain: appKeychain))
성공 처리를 위한 테스트는 PlayerListViewController
클래스 대신에 프로토콜을 사용하고 테스트 클래스에 mock 합니다:
protocol PlayerListTogglable {
func toggleViewState()
}
class PlayerListViewController: UIViewController, PlayerListTogglable { .. }
private extension GatherViewControllerTests {
final class MockPlayerTogglableViewController: UIViewController, PlayerListTogglable {
weak var viewStateExpectation: XCTestExpectation?
private(set) var viewState = true
func toggleViewState() {
viewState = !viewState
viewStateExpectation?.fulfill()
}
}
}
이것은 navigation controller 의 일부여야 합니다:
let playerListViewController = MockPlayerTogglableViewController()
let window = UIWindow()
let navController = UINavigationController(rootViewController: playerListViewController)
window.rootViewController = navController
window.makeKeyAndVisible()
_ = playerListViewController.view
XCTAssertTrue(playerListViewController.viewState)
let exp = expectation(description: "Timer expectation")
playerListViewController.viewStateExpectation = exp
navController.pushViewController(sut, animated: false)
초기 viewState
를 확인합니다. true 여야 합니다.
유닛 테스트의 나머지는 다음과 같습니다:
// mocked endpoint expects a 1-1 score
sut.scoreLabelView.teamAScoreLabel.text = "1"
sut.scoreLabelView.teamBScoreLabel.text = "1"
let endpoint = EndpointMockFactory.makeSuccessfulEndpoint(path: resourcePath)
sut.updateGatherService = StandardNetworkService(session: session, urlRequest: AuthURLRequestFactory(endpoint: endpoint, keychain: appKeychain))
sut.endGather(UIButton())
let alertController = (sut.presentedViewController as! UIAlertController)
alertController.tapButton(atIndex: 0)
waitForExpectations(timeout: 5) { _ in
XCTAssertFalse(playerListViewController.viewState)
}
mock 엔드 포인트를 준비하고 endGather
를 호출합니다.
확인창이 화면에 나타나야 합니다 (UIAlertController
). 수집을 종료하기 위해 OK 를 탭합니다.
waitForExpectation
을 사용하여 클로저가 실행될 때까지 기다리고 성공을 확인합니다; viewState
는 이제 false 이어야 합니다.
endGather
는 private 메서드 이므로 이 메서드를 호출하는 IBAction
을 사용해야 합니다. 그리고 alert controller 에서 OK 를 탭하기 위해 private API 를 사용해야 합니다:
private extension UIAlertController {
typealias AlertHandler = @convention(block) (UIAlertAction) -> Void
func tapButton(atIndex index: Int) {
guard let block = actions[index].value(forKey: "handler") else { return }
let handler = unsafeBitCast(block as AnyObject, to: AlertHandler.self)
handler(actions[index])
}
}
Swift 다음 버전에서 이 유닛 테스트가 동작하리라 보장할 수 없습니다.
주요지표 (Key Metrics)
코드 라인 수 (Lines of code)
유닛 테스트 (Unit tests)
빌드 시간 (Build times)
- 테스트는 iOS 14.3, Xcode 12.4 그리고 i9 MacBook Pro 2019 사양에 iPhone 8 시뮬레이터에서 실행했습니다.
결론 (Conclusion)
MVC 는 iOS 개발에서 가장 잘 알려진 아키텍처 패턴입니다.
이 글에서 작은 애플리케이션에 적용해 보았습니다. 각 화면을 View Controller 로 구현하는 간단한 접근법을 사용하였습니다.
실무에서는 많은 동작을 가지는 화면에 대해 이 접근 방식을 따르면 안됩니다. 대신에 분리해야 합니다. 한가지 방법으로는 child view controller 를 사용하는 것입니다.
앱의 각 화면을 가지고 와서 역할을 설명하고 간단한 설명, 화면의 일부 인 UI 요소, 그리고 controller 가 상호 작용하는 model 그리고 주요 메서드의 코드 일부분을 보았습니다.
마지막으로 주요 클래스 인 GatherViewController
의 유닛 테스트를 작성하였습니다. 기대한 만큼 쉽지 않았습니다. 좋지 않은 연습으로 UIAlertController
의 private 메서드를 사용해야 했습니다. Apple 은 후에 릴리즈에서 해당 클래스의 API 를 public 으로 변경하여 유닛 테스트를 중단할 수 있습니다.
그러나 잘 사용하며 MVC 는 여전히 iOS 앱 개발에서 좋은 방식입니다.
주요 지표를 보고 아직 많은 얘기를 할 수 없습니다. 다른 패턴은 어떤지 살펴볼 필요가 있습니다. MVC 에서 코드와 클래스 수가 훨씬 적다고 추측할 수 있습니다. 다른 패턴은 더 많은 레이어를 도입하므로 더 많은 코드 수를 가집니다.
끝까지 읽어 주셔서 감사합니다! 아래 유용한 링크도 참고 바랍니다.
유용한 링크 (Useful Links)
- The iOS App, Football Gather — GitHub Repo Link
- The web server application made in Vapor — GitHub Repo Link
- Vapor 3 Backend APIs article link
- Migrating to Vapor 4 article link
- Model View Controller (MVC) — GitHub Repo Link and article link
- Model View ViewModel (MVVM) — GitHub Repo Link and article link
- Model View Presenter (MVP) — GitHub Repo link and article link
- Coordinator Pattern — MVP with Coordinators (MVP-C) — GitHub Repo link and article link
- View Interactor Presenter Entity Router (VIPER) — GitHub Repo linkand article link
- View Interactor Presenter (VIP) — GitHub Repo link and article link