 Apple Lover Developer & Artist

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

 Apple/iOS Dev Challenges

[Challenge] 🛠️ iOS 앱 설계 퓨전 레시피 11부 - 주문서 개발

singularis7 2023. 3. 26. 17:21
반응형

Overview

사용자가 레스토랑에 음식을 주문할 수 있도록 기능을 개발한다. Notification, Timer 등의 비동기 처리를 구현할 때 Combine이 제공하는 통합된 API를 사용해본다.

Prerequirement

콤바인 API의 개념과 Swift Concurrency의 사전 지식이 요구된다. 다음의 공식 문서 정리 포스팅을 참조하면 좋을 것이다.

주문서 기능

사용자는 레스토랑에서 판매하는 음식 메뉴를 볼 수 있다. 앞선 포스팅 처럼 카테고리별로 분류된 화면을 전환하며 자신이 원하는 음식 메뉴를 찾을 수 있다. 원하는 음식 메뉴를 찾으면 해당 아이템을 선택하여 세부 정보를 확인할 수 있다.

사용자는 선택한 음식 메뉴 아이템을 기준으로 필요한 조작을 할 수 있다. 이번에 개발하는 기능은 주문서 기능으로 상용 쇼핑앱에서의 장바구니 기능과 유사하다. 사용자는 주문서에 담은 음식 아이템 종류를 사용자 인터페이스의 Order 화면에서 확인할 수 있다.

사용자가 음식 메뉴 아이템을 주문서에 담는 조작을 할 때마다 화면의 인터페이스는 갱신되어야 한다. 대표적으로 탭바와 Order 화면의 테이블 뷰 명단이 있다. 주문서에 음식 메뉴 아이템을 담으면 탭바의 Order 탭에 벳지를 붙여준다. 이때 화면에 보여지는 값은 주문서에 담긴 음식 메뉴 아이템의 갯수이다. 그리고 Order 화면의 테이블 뷰 명단에 사용자가 추가한 음식 메뉴 아이템 정보를 반영 시켜준다.

위와 같은 사용자 경험 설계에 대한 이유가 있다. 애플의 HIG를 찾아보았다. 탭바의 경우 badge를 사용할 수 있는데 해당 화면의 새로운 정보를 사용자의 주의를 끌지 않는 선에서 허용하고 있었다.

구현

앞서 개발한 Model 중 Restaurant controller가 있었다. 지금까지는 주로 서버 API와 통신하는데 사용되는 메서드가 연동되었다. 음식 메뉴 아이템을 담을 수 있는 주문서 기능과 서버에 주문을 넣어주는 기능을 여기서 구현해볼 것이다.

로컬 저장소

사용자가 선택한 음식 메뉴 아이템을 담아둘 임시 창고가 필요하다. 컴퓨터에서는 RAM과 같은 메모리를 생각해볼 수 있다. 프로그램적으로는 변수를 활용해볼 수 있다.

화면을 이동할 때 주문서 저장 공간이 파괴되면 안된다. 사용자의 입장에서는 보고있는 화면과 관계없이 담아둔 주문서 명단이 유지될 것이라고 생각하는게 자연스럽다. 여러 해결법 중 하나로 Restaurant Controller를 어플리케이션 내 공유 자원으로 생각하여 문제를 해결해볼 수 있다.

싱글턴 패턴

Swift의 클래스는 참조 타입이다. 클래스를 사용하는 여러 목적이 있겠으나 이번의 경우 인스턴스의 identity를 식별할 수 있기 때문이다. 메모리상 동일한 인스턴스인지 아닌지를 비교할 수 있다. 또한 컴퓨터의 메모리 모델에서 Heap 영역에 인스턴스가 할당된다. 동적으로 메모리를 할당하여 생성된 클래스 인스턴스는 동일한 OS 프로세스 내에서 언제든지 접근할 수 있다. 싱글턴 코드 패턴은 컴퓨터의 특징을 활용하기 위한 전략으로도 볼 수 있다고 생각한다.

특정 클래스에 싱글턴 패턴을 적용하는 것은 어렵지 않다. static 변수를 통해 타입 프로퍼티를 생성하는데 여기에 타입 자신의 기본 인스턴스를 생성해준다. 만약 하나의 인스턴스만 사용해야 하는 경우 접근 지정자를 통해 외부에 생성자를 감추는 방식으로 인스턴스의 추가 생성을 막을 수도 있다.

싱글턴 패턴은 iOS 개발 기본 패턴으로 활용된다. Foundation에서 많은 활용 예시를 찾아볼 수 있는데 대표적으로 URLSession을 생각해볼 수 있다. 이전 포스팅에서 사용해본 것처럼 shared 인스턴스를 활용해 네트워킹 작업을 구현하며 싱글턴 패턴을 간접적으로 사용했다. iOS에서 자주 사용되는 패턴을 코코아 패턴으로 부르는데 좀더 찾아 보고 싶다면 다음의 공식 문서를 참조하면 좋다.

필자는 Restaurant controller에 싱글턴 패턴을 적용하기 위해 다음과 같이 적용하였다. 생성자를 감추는 코드는 싱글턴 패턴을 구현하기 위해 반드시 필요한 것은 아니다. 필자는 하나의 인스턴스만을 사용하려는 의도로 감추었다.

class RestaurantController {    
    static let shared = RestaurantController()

    private init() {}
}

주문서 창고

메모리 상 식별할 수 있는 인스턴스를 만들었으나 주문서 저장 공간을 만들진 않았다. 주문서를 의미하며 음식 메뉴 아이템의 CRUD를 담당해줄 Order 객체를 만들 것이다. 참조 타입의 Restaurant controller가 값 타입의 주문서 컬렉션을 갖도록 하여 데이터 변화를 감지할 수 있도록 struct를 활용해 구현하였다. 코드는 다음과 같다.

struct Order: Codable {

    private(set) var menuItems: [MenuItem] = []

    var totalAmount: Double {
        menuItems.reduce(0.0) { $0 + $1.price }
    }

    mutating func addOrder(with menuItem: MenuItem) {
        menuItems.append(menuItem)
    }

    mutating func deleteOrder(on index: Int) {
        menuItems.remove(at: index)
    }

    mutating func deleteAllOrder() {
        menuItems.removeAll()
    }

}

order 타입의 인스턴스를 Restaurant controller에 연동해주면 1차적으로 저장 창고 만들기가 완성된다.

var order = Order()

데이터 바인딩

위에서 언급한 기능 명세에 따라 사용자 인터페이스에 데이터를 바인딩 시켜야 한다. 먼저 사용자가 주문서에 음식 메뉴 아이템을 추가할 때 탭바의 뱃지에 카운트를 표시하는 기능을 구현할 것이다.

이를 위해 Order의 변경을 감지할 수 있어야 한다. iOS SDK와 Swift에서는 데이터의 변경을 감지할 수 있는 다양한 도구를 제공한다. 이전 세대에서는 KVO를 사용하기도 했으나 이번엔 Property Observer를 사용해볼 것이다. 값 타입 변수에 didset 클로저를 붙여주어 값이 변화할 때 수행할 행동을 지정해줄 수 있는 좋은 방법이다.

값이 변화할 때마다 뱃지 카운트를 갱신할 수 있는 이벤트를 생성해야한다. Notification Center를 활용하면 Notification name에 이벤트의 의도를 담아 iOS 앱 세상에 방송할 수 있다. 주문서 정보가 갱신되었다는 의도를 담은 Notification Name을 생성하는 것으로 부터 바인딩 구현 작업이 시작된다.

extension Notification.Name {

    static let orderUpdateNotification = Notification.Name("RestaurantController.orderUpdated")

}

Notification.Name 타입을 확장하여 주문서가 업데이트 되었다는 알림을 추가하였다. 이런 방식을 사용한 이유는 네임 스페이스를 활용하기 위함이다. 네임 스페이스를 통해 코드의 사용자는 닷 노테이션(.)을 사용해 order Update Notification에 접근할 수 있게 된다. 사용하기 쉽다.

이제 Restaurant controller에서 프로퍼티 옵저버를 사용해 order이 변경될 때마다 order update notification을 방송(post)시켜준다. 코드는 다음과 같다.

private(set) var order = Order() {
    didSet {
        NotificationCenter.default.post(
            name: .orderUpdateNotification,
            object: nil
        )
    }
}

탭바 뱃지 연동

이제 주문서 명단 정보가 변경될 때마다 이벤트를 얻을 수 있게되었다. 이벤트를 기준으로 탭바를 갱신할 수 있도록 연동해본다. 고민해볼 지점이 있다. 과연 탭바를 갱신하는 코드는 어디에 담겨야 하는? 정리하자면, 누가 뱃지를 갱신하는 책임을 지녀야하는가?

필자는 경로를 기준으로 판단해보기로 했다. 뷰는 계층적인 구조로 메모리에 올라가있다. 특정 뷰에 접근하기 위한 방법으로 절대 경로와 상대 경로를 생각해볼 수 있다. 절대 경로는 계층 구조의 뿌리에서 찾고자 하는 대상을 찾아 내려가는 사고 방식이다. 상태 경로는 계층 구조의 특정 지점에서 시작해 계층을 타고 오르거나 내려가는 작업을 조합해 대상을 찾아나가는 사고 방식이다. 후자의 경우, 뷰 계층의 변화에 대응하기 어렵다는 단점을 지니기에 권장하지 않는다. 필자는 변화의 영향성 관점에서 절대 경로 접근이 안전하다고 판단하여 절대 경로 사고 방식을 개발에 접목시켰다.

main 스토리보드를 기준으로 탭바 컨트롤러는 뷰 계층의 최상위에 위치한다. 탭바 컨트롤러는 스토리보드의 세그웨이를 활용해 뷰를 연동되었기에 기본 탭바 컨트롤러 클래스로 생성되었다. 사용자 지정 코드를 넣기에 적합하지 않다. UIKIt의 구조상 Scene 아래 UIWindow가 붙고 UIWindow는 root view controller로 부터 시작되는 뷰계층을 붙잡고 있다. 절대적 경로 사고법을 통해 뷰 계층을 핸들링하기 좋은 지점으로 Scene Delegate를 생각하게 되었다.

Scene Delegate는 각종 UI 생명주기 이벤트를 받는다. 각종 위임 메서드를 통해 라이프사이클에 따라 실행할 코드를 구현할 수 있다. 이 관점에서 Scene Delegate에 UI 핸들링 코드를 직접 구현하면 Massive Scene Delegate가 되기 쉽다고 생각하였다. 따라서 Scene Delegate에서 UI 계층만 전문적으로 관리하는 위임 객체를 통해 구현을 분리시켜주기로 마음 먹었다. 코드는 다음과 같다.

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    private var sceneHirarchyController = SceneHierarchyController()

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let _ = (scene as? UIWindowScene) else {return}
        sceneHirarchyController.configure(with: self)
}

sceneHirarchyController는 다음과 같은 delegate 프로토콜을 통해 위임 패턴으로 연동 되었다. 위임 메서드의 역할은 뷰 계층에 접근할 수 있도록 UIWindow 를 제공해주는 것이다.

protocol SceneHierarchyControllerDelegate: AnyObject {

    func loadUIHirarchy() -> UIWindow?

}

Scene Delegate는 위 프로토콜을 채택하여 자신이 쥐고 있는 UIWindow를 SceneHirarchyController에 제공한다.

// MARK: RootViewController Handling Code
extension SceneDelegate: SceneHierarchyControllerDelegate {

    func loadUIHirarchy() -> UIWindow? {
        return self.window
    }

}

이제 Scene Delegate와 SceneHierarchyControllerDelegate가 성공적으로 연결되었기 때문에 후자 객체를 통해 뷰 계층을 핸들링 해줄 수 있게 되었다. 뱃지를 갱신하는 코드는 아래와 같이 구현하였다.

import UIKit
import Combine

final class SceneHierarchyController {

    weak var delegate: SceneHierarchyControllerDelegate?

    private weak var window: UIWindow?

    private var orderUpdateSubscribe: Cancellable?
    private weak var orderTabBarItem: UITabBarItem?

    func configure(with delegate: SceneHierarchyControllerDelegate) {
        self.delegate = delegate
        window = self.delegate?.loadUIHirarchy()
        configureTabBarUI()
        subscribe()
    }

    private func configureTabBarUI() {
        guard let rootTabBarController = window?.rootViewController as? UITabBarController,
              let orderTabBarItem = rootTabBarController.viewControllers?[1].tabBarItem else {
            return
        }

        self.orderTabBarItem = orderTabBarItem
    }

    private func subscribe() {
        orderUpdateSubscribe = NotificationCenter.default.publisher(
            for: .orderUpdateNotification,
            object: nil
        ).sink(receiveValue: { [weak self] _ in
            let badgeValue = RestaurantController.shared.order.menuItems.count
            self?.orderTabBarItem?.badgeValue = (badgeValue == 0) ? nil : String(badgeValue)
        })
    }

    private func unsubscribe() {
        orderUpdateSubscribe?.cancel()
    }

    deinit {
        unsubscribe()
    }

}

SceneHierarchyController 객체가 생성될 때 위임 관계의 등록 작업을 수행한다. 그리고 뷰 계층의 최상위에 있는 탭바 컨트롤러에 접근하여 탭바 아이템 객체를 불러오고 있다. 탭바 아이템이 의미하는 것은 Order 탭바 아이템이다. 이어서 Combine을 통해 구독 관계를 등록하고 있다. default notification center가 제공하는 combine publisher를 통해 orderUpdateNotification을 제공하는 퍼블리셔를 불러왔으며 sink를 메서드를 체이닝 하여 Cancellable한 Subscriber를 생성했다. receive Value 클로저를 통해 데이터가 넘어올 때마다 뱃지의 값을 갱신하는 코드를 넘겨주었다.

Sink가 제공한 Cancellable subscriber는 orderUpdateSubscribe 인스턴스 프로퍼티에 담아두었다. 앱이 종료될 때 구독 관계를 정리하여 메모리 관리를 하기 위함이다. 이 부분의 구현은 unsubscribe 메서드와 deinit을 통해 확인해볼 수 있다.

여기서 주의 깊게 살펴본 점은 sink 메서드에 클로져를 넘겨줄 때 클로저 캡처 리스트를 통해 self 인스턴스를 넘겨줄 때 weak 를 사용한 점이다. 쉽게 말하자면, 비동기 처리 과정 중에 메모리 관리 관점에서 강한 참조 사이클 때문에 발생하는 메모리 누수를 예방하기 위함이다. 이에 관하여 궁금하다면 다음의 문서를 참조해보길 권장한다.

Order 테이블 뷰 연동

이번에는 order 변경 이벤트를 수신할 때마다 Order 테이블 뷰를 갱신해보도록 한다. 앞서 사용한 방식과 유사하게 콤바인 API를 활용해 구독 관계를 만들어준다.

@MainActor
final class OrderTableViewController: UITableViewController {
  // ...
    override func viewDidLoad() {
        super.viewDidLoad()
        configureUI()
    }

    private func configureUI() {
        navigationItem.leftBarButtonItem = editButtonItem

        orderUpdateSubscribe = NotificationCenter.default.publisher(
            for: .orderUpdateNotification,
            object: nil
        ).sink(receiveValue: { [weak self] _ in
            self?.updateUI()
        })
    }

    private func updateUI() {
        tableView.reloadData()
    }
  // ...
}

주문서 명단이 변경될 때마다 TableView의 명단을 갱신해주어야 한다. updateUI를 통해 해당 기능이 구현되었으며 sink의 receiveValue 클로져에 담겨 데이터를 수신할 때마다 실행된다.

이건 일종의 팁일 수 있는데, UIKit의 ViewController는 editButton이라는 UIBarButtonItem을 제공한다. 내부적으로 isEditting 정보를 가져서 사용자가 수정 모드에 있는지 아닌지를 추적하고 있는데 이 모드를 변경할 수 있도록 돕는 버튼이다. 사용자가 음식 메뉴 아이템 명단을 조작할 수 있도록 이 버튼을 네비게이션 바에 추가해주었다. 비슷한 논리로 tableview의 위임 메서드를 활용해 CRUD UI조작이 가능해지도록 설정해주었다.

override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
    return true
}

override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
    if editingStyle == .delete {
        restaurantController.deleteOrder(with: indexPath.row)
    }
}

TableView는 DataSource를 통해 view에 보여줄 데이터를 불러온다. restaurant controller에 각종 주문서 명단 제공 및 CRUD 조작 메서드를 만들어준 후 데이터 소스 위임 메서드를 구현해주면 주문서를 갖고 음식점에 주문 넣는 조작을 제외한 나머지 조작이 가능해진다. 구현 코드는 다음과 같다.

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

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

override func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
    imageLoadTasks[indexPath]?.cancel()
}

private func configure(_ cell: UITableViewCell, forItemAt indexPath: IndexPath) {
    guard let cell = cell as? MenuItemTableViewCell else {
        return
    }

    let menuItem = restaurantController.order.menuItems[indexPath.row]

    cell.itemName = menuItem.name
    cell.price = menuItem.price
    cell.image = nil

    imageLoadTasks[indexPath] = Task {
        if let data = try? await restaurantController.fetchImage(from: menuItem.imageURL),
           let image = UIImage(data: data),
           let currentIndexPath = self.tableView.indexPath(for: cell) {
            cell.image = (currentIndexPath == indexPath) ? image:nil
        } else {
            cell.image = nil
        }

        imageLoadTasks[indexPath] = nil
    }
}

마무리

비동기 이벤트를 활용해 UI를 갱신하는 작업을 구현해보았다. 다음 시간에는 음식점에 주문을 넣는 기능을 포함한 네트워크 통신과 알림기능 그리고 타이머를 활용한 실시간 UI 갱신 기능을 구현해 볼 것이다. 😆😀

반응형