Search

애니또 프로젝트 리팩토링 도전(1) - View / ViewController 역할 나눠보기

 들어가며

Apple Developer Academy 에서 ‘애니또: Aenitto’라는 프로젝트를 진행하게 되었고, 앱 스토어 릴리즈 이후에 코드 개선을 위해서 팀원들과 함께 리팩토링을 진행하는 중입니다. 저는 이전에 썼던 아키텍쳐 패턴은 뭘까? 포스팅 이후에 ‘애니또’에 적용된 아키텍쳐 패턴이 MVC인데, 과연 MVC 패턴에 맞춰서 코드가 구성되어 있는걸까 라는 의문을 가지게 되었습니다. 그 후에 MVC에 대한 깊은 고찰을 하게 되었고 아키텍쳐 패턴 - MVC 란? 포스팅을 통해서 제가 현재까지 짰던 코드는 거의 ModelViewController에 가깝다는 걸 깨달았습니다.
그렇다면 어떻게 해야할까?
저는 MVVM, MVP, VIPER, RIBS 등등 이런 패턴을 논할때가 아니라고 판단이 되었습니다. 일단 MVC부터 바르게 잡은 뒤에 문제점을 찾고 프로젝트에 맞는 다음 패턴으로 나아가자 라는 생각이 들더라구요. 따라서 제대로 분리된 Model-View-Controller를 만들어보는걸로 팀원들과 함께 결정했습니다.
뭐부터 해야할까?
MVC 공부를 하고나서 “그래서 뭘 어떻게 나눠야 할까”가 제일 큰 난제였습니다. Cocoa MVC에 따르면 View와 Controller는 거의 한 몸과도 같기 때문에 Model만 분리를 하면 되는건데, Model은 이미 이전에 분리시켜두긴 했거든요. 큰 고민 끝에 MVC에 대한 공부를 기반으로 저만의 기준을 짰습니다.

 View-Controller 기준 잡기

일단 Model에 대한 기준은 View, Controller보다 명확했기 때문에 View, Controller를 중점적으로 보겠습니다.
저는 일단 View와 Controller가 분리가 잘 되었으면 좋겠더라구요. View는 View고, Controller는 Controller였으면 좋겠다는 생각이 들었습니다. 하지만 알다시피 UIKit에서는 UIViewController가 이름 그대로 UI의 역할도 해야하기 때문에 완벽한 Controller의 역할만 하는것이 불가피합니다.
그렇기에 최대한 Controller의 역할만 하도록 구현하자를 목표로 코드를 짜기 시작했습니다.
UIView, UIViewController가 가져야 하는 것들을 한 번 나눠봤습니다.
UIView
UIViewController
1. UI Layout
1. UIViewController와 UIView 연결
2. UI Configuration
2. UIView Method 중 Life Cycle 관련 부분
3. User의 Action를 감지하는 역할
3. UIView에서 위임받은 Method 구현
4. 서버 API Method
UIView에서는 UI Component에 관련된 부분들을 가지고 있습니다. 화면에 display하는 부분들에 대한 일을 담당합니다. MVC에서 UIView는 User의 Action을 감지하는 일을 합니다. 감지된 Action은 UIViewController로 넘어가서 처리됩니다. 하지만 어떤 액션은 단순히 UI만 바꾸는 경우도 있습니다.
따라서 저는 UIView에서 감지한 Action를 처리하는 방법은 총 3가지로 나눴습니다.
감지한 Action이 UI를 변경한다면 UIView 내부에서 Action 처리
감지한 Action이 서버 API를 거쳐서 Model를 업데이트하고 업데이트된 Model를 통해서 UI가 업데이트된다면 UIViewController에게 Action 위임
감지한 Action이 Model 값을 업데이트한다면 UIViewController에게 Action 위임
UIViewController에서 결국 View를 화면에 display 해줘야 하기 때문에 UIView와 연결을 시켜줍니다. 또한 Life Cycle에 대한 부분을 UIViewController에서 하기 때문에 UIView에 Life Cycle과 관련된 함수가 있다면 이를 UIViewController가 관리합니다. 또한 UIView에서 위임받은 일을 대신 진행해줍니다.

 View-Controller 나눠보기

위의 기준을 참고해서 UIView와 UIViewController를 분리해보았습니다. 먼저 원래 코드를 봐볼게요.
이전의 CreateLetterViewController:
import UIKit import SnapKit final class CreateLetterViewController: BaseViewController { // MARK: - ui component private let indicatorView: UIView = UIView() private let cancelButton: UIButton = UIButton() private let sendButton: UIButton = UIButton() // MARK: - property var createLetter: (() -> ())? private let letterSevice: LetterAPI = LetterAPI(apiService: APIService()) var manitteeId: String var roomId: String // MARK: - init init(manitteeId: String, roomId: String) { self.manitteeId = manitteeId self.roomId = roomId super.init() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - life cycle override func viewDidLoad() { super.viewDidLoad() self.setupNavigationItem() self.setupButtonAction() } // MARK: - override override func setupLayout() { self.view.addSubview(self.indicatorView) self.indicatorView.snp.makeConstraints { $0.top.equalToSuperview().inset(9) $0.centerX.equalToSuperview() $0.height.equalTo(3) $0.width.equalTo(40) } } override func setupNavigationBar() { guard let navigationBar = self.navigationController?.navigationBar else { return } let appearance = UINavigationBarAppearance() let font = UIFont.font(.regular, ofSize: 16) appearance.titleTextAttributes = [.font: font] navigationBar.standardAppearance = appearance navigationBar.compactAppearance = appearance navigationBar.scrollEdgeAppearance = appearance self.title = TextLiteral.createLetterViewControllerTitle } // MARK: - func private func setupNavigationItem() { let cancelButton = self.makeBarButtonItem(with: cancelButton) let sendButton = self.makeBarButtonItem(with: sendButton) sendButton.isEnabled = false self.navigationItem.leftBarButtonItem = cancelButton self.navigationItem.rightBarButtonItem = sendButton } private func setupButtonAction() { let sendAction = UIAction { [weak self] _ in guard let roomId = self?.roomId else { return } self?.dispatchLetter(roomId: roomId) self?.dismiss(animated: true) } self.sendButton.addAction(sendAction, for: .touchUpInside) } // MARK: - network private func dispatchLetter(roomId: String) { Task { do { if let content = self.letterTextView.text, let image = self.letterPhotoView.image, image != ImageLiterals.btnCamera { guard let jpegData = image.jpegData(compressionQuality: 0.3) else { return } let dto = LetterDTO(manitteeId: self.manitteeId, messageContent: content) let status = try await self.letterSevice.dispatchLetter(roomId: roomId, image: jpegData, letter: dto) if status == 201 { self.createLetter?() } } else if let content = self.letterTextView.text { let dto = LetterDTO(manitteeId: self.manitteeId, messageContent: content) let status = try await self.letterSevice.dispatchLetter(roomId: roomId, letter: dto) if status == 201 { self.createLetter?() } } else if let image = self.letterPhotoView.image, image != ImageLiterals.btnCamera { guard let jpegData = image.jpegData(compressionQuality: 0.3) else { return } let dto = LetterDTO(manitteeId: self.manitteeId) let status = try await self.letterSevice.dispatchLetter(roomId: roomId, image: jpegData, letter: dto) if status == 201 { self.createLetter?() } } } catch NetworkError.serverError { print("serverError") } catch NetworkError.clientError(let message) { print("clientError:\(String(describing: message))") } } } }
Swift
복사
UI Component들의 configuration, Layout 뿐만 아니라 서버 연결 로직까지도 Controller에 묶여있다보니 Controller 내부에서 하는 일이 너무 많습니다. View도, View와 Model를 연결하는 부분도 다 Controller가 관여해야 합니다. 따라서, 위의 기준에 맞춰서 View를 분리해보았습니다.
새로 만든 CreateLetterView:
import UIKit import SnapKit final class CreateLetterView: UIView { // MARK: - ui component private let indicatorView: UIView = UIView() private let cancelButton: UIButton = UIButton() private let sendButton: UIButton = UIButton() // MARK: - init override init(frame: CGRect) { super.init(frame: frame) self.setupLayout() self.setupButtonAction() self.observeSendButtonEnabledState() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - func private func setupLayout() { self.addSubview(self.indicatorView) self.indicatorView.snp.makeConstraints { $0.top.equalToSuperview().inset(9) $0.centerX.equalToSuperview() $0.height.equalTo(3) $0.width.equalTo(40) } } private func setupButtonAction() { let sendAction = UIAction { [weak self] _ in } self.sendButton.addAction(sendAction, for: .touchUpInside) } func configureNavigationBar(_ navigationController: UINavigationController) { let navigationBar = navigationController.navigationBar let appearance = UINavigationBarAppearance() let font = UIFont.font(.regular, ofSize: 16) appearance.titleTextAttributes = [.font: font] navigationBar.standardAppearance = appearance navigationBar.compactAppearance = appearance navigationBar.scrollEdgeAppearance = appearance } func configureNavigationItem(_ navigationController: UINavigationController) { let navigationItem = navigationController.topViewController?.navigationItem let cancelButton = UIBarButtonItem(customView: self.cancelButton) let sendButton = UIBarButtonItem(customView: self.sendButton) sendButton.isEnabled = false navigationItem?.leftBarButtonItem = cancelButton navigationItem?.rightBarButtonItem = sendButton } }
Swift
복사
UI Component 선언
UI Layout 잡는 부분
Navigation Controller의 Configuration 잡는 부분
Button의 Action를 감지하는 부분
해당 부분들을 UIViewController에서 분리해왔습니다. 그렇다면 UIViewController는 어떻게 되었을까요?
수정된 UIViewController:
import UIKit final class CreateLetterViewController: BaseViewController { // MARK: - ui component private let createLetterView: CreateLetterView = CreateLetterView() // MARK: - property private let letterSevice: LetterAPI = LetterAPI(apiService: APIService()) private let manitteeId: String private let roomId: String // MARK: - init init(manitteeId: String, roomId: String) { self.manitteeId = manitteeId self.roomId = roomId super.init() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - life cycle override func loadView() { self.view = self.createLetterView } override func viewDidLoad() { super.viewDidLoad() self.configureNavigationController() } // MARK: - func private func configureNavigationController() { guard let navigationController = self.navigationController else { return } self.createLetterView.configureNavigationBar(navigationController) self.createLetterView.configureNavigationItem(navigationController) }
Swift
복사
전반적으로 UI 관련 코드들이 줄어든걸 볼 수 있습니다.
CreateLetterView를 loadView에서 self.view에 넣어주는 방식으로 코드를 짰습니다. UIViewController의 View를 해당 View로 갈아끼우게 되면 굳이 CreateLetterView의 Layout를 superview의 leading, trailing, top, bottom으로 잡는다는 식의 코드는 안적어도 됩니다.
→ 자세한 이유는 UIViewController, UIView 톺아보기(1) - UIViewController 편 에서 확인 가능합니다.
UINavigationController는 왜 UIView 내부에서 잡아주셨나요?
NavigationController는 UIViewController의 hierarchy에 속한 ancestor view 입니다. 따라서 UIView에서 NavigationController를 사용하려면 결국 UIViewController에서 이를 받아서 써야 합니다. "NavigationController의 Configuration를 UIViewController에서 잡아주면 되겠다." 라고 처음엔 생각했습니다. UIViewController도 결국 View의 일을 하고 있긴 하니깐요.
하지만 제가 생각하는 MVC는 Controller가 Model-View 의 다리 역할을 해주는 겁니다. View에서 단독으로 처리할 수 없는 부분이 있어서 ViewController가 결국 View의 역할도 어느 정도 위임받아서 해야하지만 View가 할 수 있는 일에 대해서는 최대한 View가 처리하도록 하는게 맞지 않을까 싶었습니다.
MVC에서의 View 특징 중 하나가 재사용이 가능해야 한다는 부분입니다. NavigationController의 Configuration를 잡는 건 결국 View의 UI를 설정하는 것이기 때문에, 다른 View에서 현재 View를 받아서 self.view = currentView로 사용한다고 할 때 NavigationController Configuration 부분을 중복해서 ViewController에 써줘야 할 겁니다.
이러한 이유로 NavigationController Configuration코드의 위치를 CreateLetterView 내부로 넣었습니다. NavigationController는 UIViewController를 통해서 받는 방식으로 잡았습니다.

 Delegate 시키기

UIViewController에서 UIView가 시킨 일을 위임받아서 대신해야하는 경우가 있습니다. 수정 전 CreateLetterViewController를 보게 되면 sendButton를 눌렀을 때 서버 통신이 일어나는 걸 볼 수 있습니다.
현재 sendButton이 눌렸다는 코드는 UIView(CreateLetterView) 내부에 적혀있습니다. 하지만 서버 통신 코드 블럭은 Controller에 적혀있습니다.
private func setupButtonAction() { let sendAction = UIAction { [weak self] _ in } self.sendButton.addAction(sendAction, for: .touchUpInside) }
Swift
복사
이를 어떻게 UIViewController(CreateLetterViewController)에게 전달할 수 있을까요? 바로 Delegate를 사용해서 해당 액션을 대신 실행해달라고 부탁하는겁니다.
일단 Protocol를 만들어보겠습니다.
protocol CreateLetterViewDelegate: AnyObject { func sendLetterToManittee(with content: String?, _ image: UIImage?) }
Swift
복사
그리고 해당 Protocol를 UIViewController가 extension으로 conform 받도록 할겁니다.
// MARK: - CreateLetterViewDelegate extension CreateLetterViewController: CreateLetterViewDelegate { func sendLetterToManittee(with content: String?, _ image: UIImage?) { // self.dispatchLetter(with: letterDTO, jpegData) } }
Swift
복사
이제 delegate Protocol을 사용해서 View와 ViewController를 연결시켜주겠습니다.
1.
delegate를 CreateLetterView에 선언해줍니다.
private weak var delegate: CreateLetterViewDelegate?
Swift
복사
2.
delegate를 설정하는 메소드를 만들고 Controller에서 연결해줍니다.
// CreateLetterView.swift func configureDelegation(_ delegate: CreateLetterViewDelegate) { self.delegate = delegate }
Swift
복사
// CreateLetterViewController.swift private func configureDelegation() { self.createLetterView.configureDelegation(self) }
Swift
복사
3.
sendButton를 touch하면 해당 메소드가 실행되도록 해줍니다.
private func setupButtonAction() { let sendAction = UIAction { [weak self] _ in self?.delegate?.sendLetterToManittee(with: content, image) } self.sendButton.addAction(sendAction, for: .touchUpInside) }
Swift
복사

 마치며

View와 ViewController를 나누면서 Controller의 역할이 조금 더 명확해졌습니다. 하지만 Controller에 남아있는 서버 통신 코드는 아직 MVC에 맞는 함수라고 보기엔 애매한 것 같습니다. 따라서 포스팅에서 조금 더 나은 모습으로 개선해보는 시간을 가져보겠습니다.
*실제 프로젝트 코드랑은 다르기 때문에 수정하는 부분에서 오타나 잘못된 로직이 들어가는 경우가 생겼을 수도 있습니다. 보시면 알려주세요!

 참고자료