아래 문서는 medium - iOS Development and the Wrong Kind of MVC (opens new window) 문서를 참조하였습니다. member-only 문서라서 모든 내용을 정리하기는 어렵고, 참고할만한 예제 코드만 작성하려고 합니다. 미디엄을 구독하신 분들은 꼭 읽어보시길 추천드립니다!

# MVC

흔히 swift 개발에 적용하는 디자인 패턴으로는 MVC패턴이 있다. 모델-뷰-뷰컨트롤러로 구성된다.

모델은 비즈니스 로직과 모델 자체에 대한 정의를 담는다. 뷰는 사용자로부터 이벤트를 받고, 뷰 컨트롤러에게 모델을 향한 비즈니스로직 호출 요청을 한다. 뷰 컨트롤러는 모델과 뷰 사이를 중개하는 역할을 한다.

비즈니스 로직 vs 비즈니스 룰

스택 오버플로우의 답변을 보면 아래와 같은 내용이 적혀있다.

Perhaps the admin's email should never be removed from the list. That's a business rule, that knowledge belongs in the model. The view may ultimately represent this rule somehow -- perhaps the model exposes an "IsDeletable" property which is a function of the business rule, so that the delete button in the view is disabled for certain entries - but the rule itself isn't contained in the view.

비즈니스 로직은 데이터베이스의 데이터에 변경요청을 하는 동작을 정의한 것이고, 비즈니스 룰은 모델을 어떻게 다뤄야 하는 지에 대한 규칙들을 정의해놓은 것이다.

어쨌든 비즈니스 로직과 비즈니스 룰은 모두 모델의 일부분이며, 데이터 변경에 대한 로직 처리는 모델 내에서 일어나면서 특정 데이터에 대한 예외처리들을 비즈니스 룰을 통해 하게 된다.

데이터 변경 요청을 컨트롤러에서 하는 것이지, 모델에서 뷰로부터 이벤트를 받는 등의 일을 하지 않는다는 것이다.

엄밀하게 코드를 분리하게 되면 아래와 같다.

  1. Model - 모델 정의 / 모델 데이터 관리 객체를 싱글톤으로 생성 / 비즈니스로직 정의
  2. Controller - 뷰로부터 이벤트 프로토콜 채택 / 비즈니스로직 호출
  3. View - UI 및 이벤트 핸들러 함수 정의 / 델리게이트 패턴으로 컨트롤러 프로토콜 정의

# View - Controller 연결

기존에 작성해왔던 컨트롤러 코드를 살펴보자.

//
//  LoginViewController.swift
//  instagram
//
//  Created by 박경준 on 2023/05/03.
//

import UIKit

class LoginViewController: UIViewController {

    let emailTextField: UITextField = {
        let tf = UITextField()
        return tf
    }()

    let passwordTextField: UITextField = {
        let tf = UITextField()
        return tf
    }()

    let loginButton: UIButton = {
        let btn = UIButton(type: .system)
        return btn
    }()

    let logoContainerView: UIView = {
        let lv = UIView()
        return lv
    }()

    let dontHaveAccountButton: UIButton = {
        let btn = UIButton(type: .system)
        return btn
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
    }

    func setupUI(){
        view.backgroundColor = .white

        let stackview = UIStackView(arrangedSubviews: [emailTextField,passwordTextField,loginButton])
        view.addSubview(stackview)
        view.addSubview(logoContainerView)
        view.addSubview(dontHaveAccountButton)
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        self.view.endEditing(true)
    }
}

컨트롤러 그룹으로 분류된 대상이 뷰와 관련된 요소들을 함께 관리하고 있다. 이런 식의 코드 작성은 관심사의 분리 원칙에 어긋난다고 볼 수 있다. 뷰와 관련된 요소들을 분리하면 아래와 같다.

Views 그룹 아래에 LoginView.swift파일을 생성, UIView를 상속받는 커스텀 클래스를 정의한다.

// Views/LoginView.swift
import UIKit

class LoginView: UIView {

    weak var delegate: ViewButtonDelegate?

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupUI()
    }

    required init?(coder aDecoder: NSCoder) {
      super.init(coder: aDecoder)
      setupUI()
    }

    let emailTextField: UITextField = {
        let tf = UITextField()
        return tf
    }()

    let passwordTextField: UITextField = {
        let tf = UITextField()
        return tf
    }()

    let loginButton: UIButton = {
        let btn = UIButton(type: .system)
        return btn
    }()

    let logoContainerView: UIView = {
        let lv = UIView()
        return lv
    }()

    let dontHaveAccountButton: UIButton = {
        let btn = UIButton(type: .system)
        return btn
    }()

    func setupUI(){
        self.backgroundColor = .white
        layoutViews()

        dontHaveAccountButton.addTarget(self, action: #selector(accountButtonTapped), for: .touchUpInside)
    }

    func layoutViews(){
        let stackview = UIStackView(arrangedSubviews: [emailTextField,passwordTextField,loginButton])
        self.addSubview(stackview)
        self.addSubview(logoContainerView)
        self.addSubview(dontHaveAccountButton)
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        self.resignFirstResponder()
    }

    @objc func accountButtonTapped(){
        delegate?.didTapButton()
    }
}

protocol ViewButtonDelegate: AnyObject{
    func didTapButton()
}

View의 분리를 통해 컨트롤러는 아래와 같이 정의된다.

import UIKit

class LoginViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        let loginView = LoginView()
        self.view = loginView

        loginView.delegate = self
    }
}

extension LoginViewController: ViewButtonDelegate{
    func didTapButton() {
        let signupVC = SignUpViewController()
        self.navigationController?.pushViewController(signupVC, animated: true)
    }
}

위의 코드 형태에서 중요하게 볼 점은 기존에 컨트롤러에 정의되었던 UI 속성들이 모두 커스텀 UIView 클래스로 이동되었다는 것, UIView 클래스에서 addTarget 메서드를 통해 이벤트를 감지하는데 실제 동작은 델리게이트 패턴으로 컨트롤러에 전달되었다는 것이다.

# 뷰 속성의 분리

UIView 속성을 분리할때 주의할 점은 생성자 함수를 오버라이딩 해야 한다는 것이다. 물론 이는 코드베이스 뷰를 작성할 때의 이야기이다.

  1. init(frame:): 구현하려고 하는 커스텀 뷰의 중심 및 경계선을 지정해준다. 인터페이스 빌더를 사용하지 않을 때 해당 생성자 함수를 호출해야 한다.
  2. required init?(coder aDecoder: NSCoder): 스토리보드, xib와 같은 인터페이스 빌더는 코딩 없이 속성 수정이 가능하게끔 해주며 이를 unarchiving이라고 한다. init?(coder:)에 전달된 디코더를 통해 작성하는 커스텀 뷰를 모바일 기기 저장소에 serialize 하여 저장하고 deserialize하여 다시 불러오는 작업을 가능하게 해준다.

커스텀 UIView에서는 반드시 아래와 같은 생성자 함수들을 정의해줘야 한다.

override init(frame: CGRect) {
    super.init(frame: frame)
    setupUI()
}

required init?(coder aDecoder: NSCoder) {
    super.init(coder: aDecoder)
    setupUI()
}

# 뷰 이벤트 정의

뷰에서는 다양한 이벤트를 전달받을 수 있다. 버튼 클릭, 스와이프 등 수 많은 이벤트가 있으며 이러한 이벤트들에 대해 동작들을 모두 컨트롤러에 정의하는 것 역시 컨트롤러에 대한 관심사의 분리 원칙이 적용되지 못한다는 점이 있다.

뷰에서 받을 이벤트들에 대해 프로토콜 안에 리스트업을 해두고 addTarget 안에서 프로토콜 내의 함수를 호출하는 것은 delegate를 채택할 대상에게 위임한다.

이후 컨트롤러에서 이벤트에 대한 프로토콜을 채택하고 실제 함수의 동작을 정의한다. 이때 반드시 주의해야할 점은 함수 정의에 대한 책임이 자신 뷰컨에 있다는 것을 꼭 지정해줘야 한다는 것이다. 이것이 델리게이트 패턴에서 가장 중요하게 사용되는 me.delegate = self 코드이다.

뷰와 뷰 컨트롤러가 분리된 함수는 아래와 같다.

import UIKit

class LoginView: UIView {

    weak var delegate: ViewButtonDelegate?

    // UI관련 나머지 코드들은 모두 생략
    let accountButton: UIButton = {
        let btn = UIButton(type: .system)
        return btn
    }()

    func setupUI(){
        layoutViews()

        // addTarget으로 함수 이벤트 수신
        accountButton.addTarget(self, action: #selector(accountButtonTapped), for: .touchUpInside)
    }

    @objc func accountButtonTapped(){

        // 함수 호출은 뷰에서 하되, 실제 동작에 대한 정의는 델리게이트에게 위임한다.
        delegate?.didTapButton()
    }
}

// 뷰 이벤트에 대한 동작 리스트를 프로토콜로 정의
protocol ViewButtonDelegate: AnyObject{
    func didTapButton()
}

이때 프로토콜이 클래스 내에서 사용되기 위해서는 AnyObject를 상속해야 한다. 이제 컨트롤러의 코드를 봐보자.

import UIKit

class LoginViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        // 로그인 뷰 인스턴스 생성 및 컨트롤러에 할당
        let loginView = LoginView()
        self.view = loginView

        // ** 델리게이트 self로 지정 **
        loginView.delegate = self
    }
}

// 이벤트들을 리스트업 해둔 프로토콜 채택, 실제 동작을 구현한다.
extension LoginViewController: ViewButtonDelegate{
    func didTapButton() {
        let signupVC = SignUpViewController()
        self.navigationController?.pushViewController(signupVC, animated: true)
    }
}

로그인 뷰 인스턴스를 생성하고 인스턴스에 대한 델리게이트 속성을 자기 자신 뷰컨으로 지정한다.

이후 컨트롤러가 이벤트에 대한 델리게이트 프로토콜을 채택한 뒤 내부 동작을 구현한다.

사실 이렇게 까지 서치할 생각은 없었는데 UIView에서는 네비게이션 뷰 컨트롤러에 접근할 수 없다보니 위와 같이 이벤트 처리를 컨트롤러에 위임하는 방식이 있다는 것을 알고 코드를 훨씬 개선시킬 수 있었다.

# UIView에서 상위 뷰컨 찾아내기

뷰와 뷰컨트롤러를 분리하다 보니 뷰에서 각종 뷰컨 메서드들을 사용하지 못하는 문제가 발생한다. 이를테면 뷰컨 내에서 화면을 내릴때 self.dismiss(animated: true) 메서드를 호출했던 것을 UIView 커스텀 클래스에서는 호출하지 못한다는 것이다.

이럴 때에는 UIView 클래스 내에서 자신을 사용하는 상위 뷰컨트롤러를 찾아 직접 dismiss 함수를 호출해줘야 할 것이다.

이와 관련된 코드가 스택 오버플로우에 정의되어 있었다. UIView클래스를 확장하여 속성을 구현했다.

extension UIView {
    var parentViewController: UIViewController? {
        // Starts from next (As we know self is not a UIViewController).
        var parentResponder: UIResponder? = self.next
        while parentResponder != nil {
            if let viewController = parentResponder as? UIViewController {
                return viewController
            }
            parentResponder = parentResponder?.next
        }
        return nil
    }
}

UIResponder

UIResponder는 이벤트를 처리하고 응답할 수 있는 객체를 의미한다. UIView, UIViewController, UIWindow 등 여러 대상들이 이에 해당한다. UIKit에는 다양한 이벤트가 정의되어 있는데, 이는 UIResponder객체에 모두 공통적으로 적용되지는 않는다. 터치 등의 이벤트는 UIView에서 직접 처리가 가능하지만 또 다른 이벤트는 UIViewController에서만 처리가 가능한 경우도 있다.

이러한 차이로 인해 first responder라는 개념이 등장하게 된다. 이벤트 발생과 함께 UIKit에서 관리하는 이벤트 큐에 UIEvent 객체가 push되고 큐로부터 이벤트가 pop되면 해당 이벤트를 처리할 대상을 UIKit이 할당하게 된다. 이벤트 타입에 맞춰 first responder로 해당 이벤트를 전달하게 되는데, 만약 특정 이벤트가 감지된 첫 대상이 view였음에도 이를 처리하지 못하는 UIResponder객체인 경우 또 다른 UIResponder를 찾아 나서게 된다는 것이다.

아래는 리스폰더 체이닝에 관한 설명이다.

As mentioned in the beginning, UIKit handles this by dynamically managing a linked list of UIResponders. The so called first responder is simply the root element of the list, and if a responder can't handle a specific action/event, the action is recursively sent to the next responder of the list until someone can handle the action or the list ends.

종종 텍스트필드 내의 텍스트값 변경을 종료하고 싶을때 resignFirstResponder() 메서드를 호출하고는 하는데, 이러한 이유가 다 UIResponder로부터 기인하는 것이었다. 텍스트 변경 이벤트를 textField에서 처리하고 있는데, 이에 대한 리스폰더 책임을 내려놓음으로써 더 이상 이벤트 처리를 필요로 하지 않게 되고, 키보드도 내려가게 되는 것으로 이해되었다.

리스폰더 체이닝이 linked list of UIResponders로 관리되기 때문에 자연스럽게 next 속성이 필요함을 알 수 있다. 공식문서 (opens new window)에 따르면 UIResponder 객체에는 nextResponder 속성에 대해 이미 정의해두었다. 속성에 대한 실제 접근은 UIResponder객체.next 를 통해 접근이 이루어진다.

UIView 객체는 내부적으로 nextResponder를 찾는 메서드가 구현되어 있다. 뷰 객체를 관리하는 뷰 컨트롤러가 있다면 해당 뷰 컨트롤러를 반환해준다.

위의 예제코드를 살펴보면 parentResponder변수에 self.next를 할당하는 것을 볼 수 있다. 다음 UIResponder 객체를 서치한 뒤, 대상이 UIViewController로 타입캐스팅 된다면 이 객체는 부모 뷰 컨트롤러가 된다고 이해할 수 있다.

# Reference

  1. medium - iOS Development and the Wrong Kind of MVC (opens new window)
  2. stackoverflow - Business logic in MVC (opens new window)
  3. [iOS/UIKit] init(frame:)와 init(coder:) (opens new window)
  4. [Swift] 클래스만 사용 가능한 프로토콜의 선언 (opens new window)
  5. How do I push a view controller from a UIView (opens new window)
  6. iOS Responder Chain: UIResponder, UIEvent, UIControl and uses (opens new window)