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 를 자동으로 채우는UISwitchloadingView— 서버 호출을 하는 동안 로딩을 보여주기 위해 사용
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