 Apple Lover Developer & Artist

영속적인 디자인에 현대의 공감을 채워넣는 공방입니다

 Apple/iOS Dev Challenges

[Challenge] 🛠️ iOS 앱 설계 퓨전 레시피 8부 - 데이터 바인딩

singularis7 2023. 3. 1. 22:02
반응형

Overview

MVC 아키텍처에서 Model의 데이터를 View에 바인딩시키며 생산성 향상을 위해 활용할 수 있는 테크닉을 생각해 본다.

Controller

MVC 패러다임에서 Controller는 Model과 View의 상호 작용을 중재해 주는 역할을 했다. 모델의 데이터를 View에 불러오는 것과 View에서 발생한 다양한 종류의 이벤트를 해석해서 모델을 조작하는 것 모두 상호 작용의 예이다.

ViewController

UIKit에서는 ViewController가 핵심적인 역할을 한다. MVC에서 Controller 역할을 하면서도 View를 핸들링하는데 초점을 두고 있기 때문이다. Controller와 View 역할이 결합되었기 때문에 코드 관리에 신경을 써줘야 한다.

이번의 경우 ViewController가 View를 관리하며 사용자에게 데이터를 보여주는 논리에 집중하도록 역할을 정했다. 데이터를 조작하는 논리는 모델 컨트롤러를 활용하여 ViewController에서 분리해야 한다.

Pattern

앱 인터페이스 개발 과정에서 생산성 향상에 활용 가능한 패턴을 생각해 보았다.

Method

configure 메서드가 필요하다. View가 화면에 보이 전 View의 초기값, 바인딩, 계층을 알맞게 설정해 줄 필요가 있기 때문이다.

updateUI 메서드가 필요하다. ViewController가 모델에서 불러온 데이터를 사용자 인터페이스에 적절한 방식으로 반영시킬 필요가 있기 때문이다.

displayError 메서드가 필요하다. Model 과의 상호작용 과정에서 문제가 발행했을 때 사용자 인터페이스를 활용해 에러를 핸들링할 필요가 있을 수 있기 때문이다.

TableView

접근

TableView를 활용하기 위한 접근법은 크게 두 가지로 분류할 수 있다.

첫 번째는 일반 ViewController를 사용하여 수동으로 View 계층에 TableView를 추가하고 delegate 프로토콜을 채택해 주는 방식이다. 많은 부분의 관리책임이 개발자에게 있으나 그만큼 자유로운 구현을 할 수 있다.

두 번째는 TableViewController를 활용하는 것이다. 한 페이지를 전부 TableView로 채우는 경우 유용하며 수동 설정 코드를 추상화 시킬 수 있다. 덤으로 키보드 스크롤 핸들링, 테이블뷰 편집 모드 관리 기능을 손쉽게 얻을 수 있다. (참조: UITableviewController)

한 페이지 전체에 데이터를 노출하는 경우가 다수라 TableViewController를 활용하는 두번째 방식을 활용할 것이다.

기본 활용

Table View에 데이터를 보여주기 위해 우선 데이터의 정보와 이를 보여줄 방법을 설명해줘야 한다. 보통 Delegate 패턴을 통해 TableView의 View 이벤트를 핸들링하고 데이터 소스를 활용해 데이터의 컬렉션을 제공하는 방식을 사용한다.

DataSource Delegate의 numberOfSectionsnumberOfRowInSection을 통해 섹션과 행의 개수 정보를 그리고 TableView Delegate의 cellForRowAt을 통해 각 행에 담길 셀을 생성하고 데이터를 설정해 줄 수 있다. 이때 cell을 설정해 주는 작업은 configureCell 메서드로 추려서 cellForRowAt 위임 메서드와 구분해 관리한다. (참조: Filling a table with data - Apple Developer)

Concurrency

Model은 네트워크 통신을 사용함에 따라 비동기적으로 동작하도록 설계된 코드가 있다. ViewController의 동기적 환경에서 비동기 코드를 사용할 수 있어야 한다.

Swift Concurrency를 활용한 비동기 코드의 경우 각종 정의부에 async-await 키워드를 포함하고 있다. (참조: Concurrency - swift.org)

Task를 활용해 ViewController의 동기적 실행 환경에서 비동기 코드를 실행시킨다. UIKit의 View를 관리하는 코드는 효율성 등의 사유로 메인 스레드에서만 실행돼야 한다. 모든 ViewController는 MainActor에서만 동작하도록 선언되어 있으나 이를 상속받은 하위 클래스에서도 편의상 명시적으로 @MainActor 키워드를 붙여주었다.

Pass data between views

TableView에서 선택한 특정 아이템에 관한 세부 정보를 보여주도록 구현할 수 있을 것이다. ViewController 간 데이터를 넘겨주는 기본 방법은 prepare을 활용하는 것이다. UIKit이 자동으로 ViewController를 생성하고 사용자는 화면이 전환되기 전 prepare 메서드를 통해 다음 화면의 필수 설정 작업을 해줄 수 있었다. (참조: Customizing the behavior of segue-based presentations)

지난 2019년 애플은 @IBSegueAction을 공개하였다. iOS13 이상에서만 사용할 수 있다. prepare과 주요한 차이점은 새로운 ViewController의 생성을 개발자가 책임지도록 한 것이다.

ViewController의 생성자를 재정의하여 화면 생성에 필요한 설정 과정을 캡슐화할 수 있게 되었고 segueIdentifier string 값을 통해 구분할 필요가 없어지는 등 더욱 깨끗한 코드를 작성할 수 있도록 도와준다. (참조: Improving Storyboard Segues With IBSegueAction - Kodeco)

사용법은 간단하다. 스토리보드의 Segue를 선택하여 컨트롤 + 드래그하는 방식으로 @IBSegueAction을 생성할 수 있다. 액션 함수에서는 전환할 화면을 지칭하는 ViewConroller 객체를 반환하면 된다.

ViewController의 설정 과정에서 더 나은 캡슐화를 위해 생성자를 재설정할 수 있다. 스토리보드, nib 등의 인터페이스 빌더를 활용해 View를 정의한 경우 NSCoder를 반드시 받아야 한다. 필수적인 항목을 받는 생성자를 만들고 required init를 함께 재정의하면 된다.

위와 같은 패턴을 전문적인 표현으로 dependency injection라고 부르며 쉽게 테스트할 수 있는 코드를 만드는데 도움주기도 한다.

Implement

CategoryTableViewController

앞서 소개한 생각을 바탕으로 ViewController를 구현하면 된다. 코드를 보자!

@MainActor
final class CategoryTableViewController: UITableViewController {
    private let restaurantController = RestaurantController.shared
    private var categories: [String] = []

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

    private func configureUI() {
        Task {
            do {
                let categories = try await restaurantController.fetchCategories()
                updateUI(with: categories)
            } catch {
                displayError(error, title: "Failed to Fetch Categories")
            }
        }
    }

    private func updateUI(with categories: [String]) {
        self.categories = categories
        tableView.reloadData()
    }
}

정보를 보여주는데 필요한 메서드가 구현되었다. view가 사용자에게 보이기 전 화면 구성을 마치기 위해 viewDidLoad 이벤트를 받아 configure 메서드를 호출하고 있다. 동기적 실행 환경에서 비동기 메서드를 호출하기 위해 Task를 활용하였다. 받아온 데이터를 인터페이스 컴포넌트에 반영하기 위해 updateUI를 활용하고 있다.

데이터 처리 중 에러가 발생하면 사용자에게 alert를 띄어주는 방식으로 처리해 볼 것이다. 패턴화 된 기능을 모든 ViewController에서 활용할 수 있도록 extension을 통해 구현하였다.

extension UIViewController {

    var isOnScreen: Bool {
        viewIfLoaded?.window != nil
    }

    func displayError(_ error: Error, title: String) {
        displayAlert(
            title: title,
            message: error.localizedDescription,
            actionTitle: "Dismiss"
        )
    }

    func displayAlert(title: String, message: String, actionTitle: String) {
        guard self.isOnScreen else { return }
        let alertController = UIAlertController(
            title: title,
            message: message,
            preferredStyle: .alert
        )
        alertController.addAction(UIAlertAction(title: actionTitle, style: .default))
        present(alertController, animated: true)
    }

}

alert 기능은 system view controller 중 alert controller를 활용하였다. 표준 alert 표시 방식과 에러 표시 메서드를 분리하여 alert를 띄우는 코드의 재사용 성을 개선하였다. displayError는 에러 정보를 파라미터를 받도록 구현해 관심사의 분리를 진행할 수 있도록 구현했다.

사용자가 에러가 호출된 화면 맥락에서 벗어났을 때는 경고창이 뜨지 않도록 구현하여 사용성을 개선시켰다. 이를 구현하기 위해 현재 ViewController가 메모리에 올라와 있고 ViewController가 관리 중인 UIView가 window에 표시되고 있는지 확인하는 방식을 활용하였다. Managing content in your app’s windows - Apple Developer를 참조하여 View 계층을 관리하는 원리를 이해하면 도움 된다.

// MARK: TableView & DataSource Handling Code
extension CategoryTableViewController {

    override func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return categories.count
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Category", for: indexPath)
        configureCell(cell, forCategoryAt: indexPath)
        return cell
    }

    private func configureCell(_ cell: UITableViewCell, forCategoryAt indexPath: IndexPath) {
        let category = categories[indexPath.row]
        var contentConfiguration = cell.defaultContentConfiguration()
        contentConfiguration.text = category.capitalized
        cell.contentConfiguration = contentConfiguration
    }

}

TableView를 설정하는 부분의 코드이다. 모델에서 비동기적으로 불러온 데이터의 수를 데이터 소스를 통해 테이블 뷰에 넘겨주고 있다. 테이블 뷰의 각 셀을 구성하기 위해 테이블 뷰 delegate 메서드를 활용하고 있다.

테이블 뷰의 셀은 메모리를 효율적으로 관리하기 위한 재사용 메커니즘을 적용하는 것이 중요하다. dequeueReusableCell을 통해 재사용 메커니즘을 활용하고 있다.

셀에 데이터를 반영할 때 전통적인 방식으로는 textLabel 등의 프로퍼티에 직접 접근하여 값을 설정해 주었다. iOS13 이후 Modern Cell Configuration을 통해 기존 셀의 상태를 걱정하지 않고도 효율적으로 셀의 상태를 갱신할 수 있는 방법이 등장했다. contentConfiguration을 사용하는 방식이 이에 해당된다. 사용자 인터페이스를 정의하는 쪽과 상태를 설정하는 코드의 관심의 분리시키는데 도움주기도 한다.

@IBSegueAction private func showMenu(_ coder: NSCoder, sender: Any?) -> MenuTableViewController? {
  guard let cell = sender as? UITableViewCell,
              let indexPath = self.tableView.indexPath(for: cell) else {
          return nil
        }
  let category = categories[indexPath.row]
  return MenuTableViewController(coder: coder, category: category)
}

테이블 뷰에서 특정 카테고리를 선택하면 해당 카테고리의 음식 아이템 명단을 확인할 수 있는 MenuTableViewController를 보여줘야 한다. @IBSegueAction을 활용해 선택한 카테고리 정보를 새로운 ViewController 인스턴스에 주입시켜 준다. 카테고리 정보는 테이블 뷰 선택된 셀의 좌표값인 indexPath를 활용해 불러올 수 있다.

MenuTableViewController에서 의존성 주입을 활용하기 위해 카테고리 정보를 받을 수 있는 생성자를 만들어줘야 한다.

@MainActor
final class MenuTableViewController: UITableViewController {
  let category: String

  init?(coder: NSCoder, category: String) {
    self.category = category
    super.init(coder: coder)
  }

  required init?(coder: NSCoder) {
    // 클래스가 생성될 때는 모든 프로퍼티가 초기화 되어야 하기에 사용할 수 없다.
    fatalError("init(coder:) has not been implemented")
  }
}

위와 같은 패턴을 활용하여 직관적으로 앱 페이지를 확장할 수 있게 되었다.

마무리

이제까지 MVC 아키텍처에서 Model의 데이터를 View에 바인딩시키는 작업을 진행해 보았다. 생산성의 증대를 위해 활용할 수 있는 테크닉을 알아보았다.

다음 시간에는 인터페이스 빌더와 오토레이아웃을 활용해 사용자 정의 뷰를 정의해 볼 것이다 🥳

반응형