 Apple Lover Developer & Artist

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

 Apple/iOS Dev Challenges

[Challenge] 🛠️ iOS 앱 설계 퓨전 레시피 13부 - User Notification

singularis7 2023. 4. 2. 16:06
반응형

Overview

음식점 서버에 주문을 넣은 후 음식의 준비가 완료되면 사용자에게 푸시 알림을 보내는 기능을 개발한다. User Notification 중 Local Notification을 활용하며 푸시 알림 이벤트에 따른 액션을 처리해 본다. 프로토콜 지향 프로그래밍을 활용해 Notification 정보를 체계적으로 관리해 본다.

Local Notification

UIKit에서는 시각적인 상호작용을 위해 UIView를 활용한다. 앱이 켜져 있을 때는 사용자 인터페이스를 제공하여 사용자와 상호작용할 수 있으나 앱이 종료되었을 때에는 이를 활용하기 어려워 보인다.

User Notification은 중요한 이벤트에 대하여 사용자가 응답할 수 있는 수단을 제공한다. 예를 들어 휴대폰을 잠가둔 상태로 메시지를 받을 때 알림이 오는 것을 생각해 볼 수 있다. 사용자는 문자 알림을 보고 메시지를 확인하거나 즉석으로 답장 보낼 수 있다.

개요

User Notification은 두 가지 방식으로 구현될 수 있다. 우선 Remote Notification이다. 카카오톡과 같은 SNS 서비스를 사용하면 메시지가 올 때마다 푸시 알림이 온다. 때로는 앱의 서비스를 프로모션 하는 푸시 알림을 수신하는 경우도 있다. 모두 Remote 방식의 Notification의 사례이다. 다음으로 Local Notification이다. 아침에 일어나기 위해 알람을 설정하거나 음식의 조리를 위해 타이머를 설정하거나 할 일을 리마인더에 정리하여 알림을 받는 것 모두 Local 방식의 Notification 사례이다.

지식

User Notification을 구현하기 위해 알아두어야 할 내용이 있다. 이번 포스팅에서 구현해 볼 내용은 Local Notification이기에 이를 기준으로 설명한다. 먼저, 사용자가 받아야 할 Notification에 관한 정보를 configure 할 수 있어야 한다. 구성 내용에는 알림의 내용 및 사용자가 취할 수 있는 액션을 담을 수 있다. 다음으로 사용자가 원하는 시점에 알림을 확인할 수 있도록 Local Notification을 시스템에 스케줄해야 한다. 여러 종류의 트리거를 제공하기에 푸시 알림을 스케줄하는 방식은 여러가지가 존재하지만 이번의 경우 시간을 기준으로 설정해볼 것이다. 마지막으로 Local Notification의 액션에 사용자가 응답한 경우 앱이 실행 중이지 않은 상태를 포함해 대응할 수 있도록 구현해볼 것이다.

디자인

User Notification에 관한 정보를 관리해 주어 생산성을 향상할 수 있도록 프로토콜 지향 프로그래밍 기법을 활용해 API를 디자인해 준다. 프로토콜과 구조체를 활용해 알림과 관련된 정보를 한 곳에서 관리하도록 디자인하여 소프트웨어의 응집도를 개선시켜 준다.

Best Practices

애플 문서에 소개된 User Notification에 관한 좋은 관습을 살펴보았다. 필자가 아이폰을 사용하면서 경험해 본 내용과 일맥상통하는 생각들이 담겨있어 함께 소개한다.

Notification을 보여야 할 때는 해당 알림의 유용성(usefulness)을 고려하며 중요한 이벤트를 위해 알림을 제한해야 한다. 이상적인 알림의 경우 사용자가 특정 작업을 완료할 수 있도록 돕는 일종의 정보 모음으로 생각할 수 있다. 효과적인 알림 메시지의 경우 다음의 가이드라인을 따른다.

  • 간결한 정보를 보여줌 - 알림은 가치 있는 정보를 제공하도록 빠르게 갱신되어야 한다.
  • 사용자를 속이지 않음 - 유사한 주제로 여러 개의 알림을 보내지 않으며 사용자가 응답하지 않는 경우도 포함된다.
  • 앱의 이름과 아이콘을 보임 - 알림은 앱의 이름과 아이콘을 배너에 보여줘야 한다. 알림 정보가 중복되지 않기 위함이다.
  • 적정한 소리를 포함해야 함 - 소리는 사람들의 주의를 끌 수 있다. 앱은 커스텀 혹은 기본 알림 사운드를 사용할 수 있다. 이때 커스텀 알림 소리는 짧고 명확하고 전문적으로 생성된 소리를 사용한다.

Implement

User Notification을 사용하려면 iOS를 통해 사용자의 허락을 받아야 한다. 권한의 획득을 포함해 알림에 필요한 설정값과 전송 API 코드에 관한 응집도를 높일 수 있도록 controller 객체를 생성해 주었다. 이름은 UserNotificationCenterController이다.

import Foundation
import UserNotifications

final class UserNotificationCenterController {

  static let shared = UserNotificationCenterController()

  private init() {}

  private let center = UNUserNotificationCenter.current()

  private var usedAuthorizationOptions: UNAuthorizationOptions {
      [.alert, .badge, .sound]
  }

  func configure(with object: UNUserNotificationCenterDelegate) {
      center.delegate = object
      authorizeIfNeeded()
  }

}

User Notification을 사용하기 위해 해당 프레임워크를 import 해주었다. 앱의 Notification 설정 및 조작은 UNUserNotificationCenter.current()의 싱글턴 인스턴스를 통해 제어할 수 있다. controller 객체의 center 프로퍼티에 담아두어 이를 기준으로 모든 메서드를 구현한다.

UserNotificationCenterController 객체는 싱글톤 패턴을 활용한 공유 자원으로 설정해 주었다. 알림을 관리하는 유틸리티 객체로써 어디서든지 쉽게 불러서 쓸 수 있도록 하기 위함이다. 예를 들어 App Delegate의 위임 메서드를 활용해 알림 권한을 획득할 수 있어야 하고 앱의 특정 맥락에서 사용자 알림을 시스템에 스케줄 할 수 있어야 한다.

권한 요청

당신은 아이폰의 주인이다. 아이폰이 당신의 허가를 받지 않고 스팸성 알림을 보내서 소중한 시간을 뺏는다고 생각하면 어떨까? 휴대폰은 들고 다니는 광고판으로 전락해버리고 말 것이다. iOS 운영체제는 사용자의 허가를 받을 때만 앱이 알림을 보낼 수 있도록 제어하여 앞선 상황을 예방하고 있다.

프로그래밍적으로 권한을 획득하는 방법이 있다. center가 제공하는 requestAuthorization 메서드를 통해 권한을 얻을 수 있다. 이 메서드는 두 종류의 파라미터를 받는다. options 파라미터는 앱이 사용할 수 있는 알림의 방식을 설정해 줄 수 있다. 대표적으로 다음의 방식이 존재한다.

. alert,. carPlay,. announcement,. criticalAlert,. badge

이 중 조금 특이한 친구가 있다.. provisional을 사용하면 사용자의 허가 없이도 알림을 보낼 수 있다. 영단어를 해석해 보면 임시적인이라는 의미의 temporary와 유사한 의미를 지닌다. 이 옵션을 사용하면 iOS의 알림 센터에 알림과 권한 획득창이 동시에 보인다. 알림은 사용자의 작업을 방해하지 않도록 조용히 전달된다. 또한 후행 클로저를 전달하여 에러를 핸들링하거나 권한을 획득하거나 실패하는 경우에 응답하는 코드를 작성할 수 있다.

사용자에게 푸시 알림을 보내는 것을 허가할지 선택권을 제공하는 알림 창을 띄울 수 있다. Best Practice를 참조하여 사용자 경험 중 푸시 알림 기능이 필요한 시점에 보여주는 것이 좋다. 사용자가 알림 기능을 허가하지 않아도 추후 변경할 수 있다. 아이폰의 설정 앱을 통해 사용 중인 앱에 관해 푸시 알림을 포함하여 각종 권한 설정을 변경할 수 있다.

사용자 경험을 해치지 않도록 이번 프로젝트에서 사용할 권한 관리 코드를 구상해 보았다. 사용자가 이미 푸시 알림 기능을 허가한 경우 권한 설정 알림 창을 띄우지 않는다. 사용자가 푸시 알림 권한 설정을 요청하지 않으면 알림창을 띄워서 권한을 얻겠다는 의미이다. 사용자가 푸시 알림 권한을 주지 않은 경우 후행 클로저를 통해 사용자가 거절했다는 정보를 넘기고 상황에 알맞은 액션을 취할 수 있도록 코드를 설계한다. 코드 구현은 다음과 같다.

private func authorizeIfNeeded(completion: ((Bool) -> ())? = nil) {
  center.getNotificationSettings { settings in
      switch settings.authorizationStatus {
      case .authorized:
          completion?(true)
      case .notDetermined:
          self.center.requestAuthorization(options: self.usedAuthorizationOptions) { granted, _ in
              completion?(granted)
          }
      case .denied, .provisional, .ephemeral:
          completion?(false)
      default:
          completion?(false)
      }
  }
}

Local Notification Scheduling

새로운 Local Notification을 사용자에게 보여주려면 스케줄링해야 한다. 과정으로 알림의 내용에 해당되는 콘텐츠와 알림을 시스템에 스케줄링하는 방식에 해당되는 트리거가 필요하다. UserNotificationCenter의 싱글톤 인스턴스를 통해 시스템에 예약하게 되는데 앞서 소개한 내용물과 트리거를 Notification Request라는 개념으로 묶어서 전달해야 한다. 이 과정을 구현하여 사용자에게 Local Notification을 전송할 수 있다.

Modeling

프로그래밍에 있어서 반복되는 논리를 추상화하지 않는 것에 찜찜함을 느낀다. 위 과정도 프로젝트가 확장되며 같은 논리를 재사용해야 하는 경우가 발생될 수 있다고 생각한다. 이전 네트워크 통신 코드에 관한 API를 디자인할 때 프로토콜 지향 프로그래밍을 사용해 본 것처럼 이번에도 구조체와 프로토콜을 활용해 같은 논리를 아름답게 재사용해볼 것이다.

Notification Request

생각을 정리해 본다. 추려내야 하는 정보는 Notification Request라고 생각한다. 하나의 요청 정보에는 알림 콘텐츠와 트리거가 담겨있다. 이들을 Request로 묶어서 싱글톤 센터 인스턴 스을 통해 시스템에 스케줄링해줘야 한다. 다음과 같이 코드로 구현해 보았다.

import Foundation
import UserNotifications

protocol NotificationRequest {

    var id: String { get }
    var category: NotificationCategory? { get }
    var content: UNNotificationContent { get }
    var trigger: UNNotificationTrigger { get }
    var request: UNNotificationRequest { get }

}

extension NotificationRequest {

    var request: UNNotificationRequest {
        UNNotificationRequest(
            identifier: id,
            content: content,
            trigger: trigger
        )
    }

}

id는 Local Notification에 관한 식별자를 의미한다. 시스템에 예약된 특정 알림을 식별하는 용도로도 사용할 수 있다. content와 trigger는 각각 알림의 내용물과 스케줄링 방식을 의미한다. request는 UserNotification의 싱글톤 세션을 통해 시스템에 예약할 때 요청 정보를 포장해 주는 인스턴스를 의미한다. 프로토콜에 정의된 프로퍼티를 사용하여 프로토콜을 충족하는 객체들이 모두 제공할 수 있는 정보이다. 따라서 extension을 통한 프로토콜 기본 구현을 통해 코드를 공유하게 되었다.

프로토콜에 정의된 프로퍼티를 살펴보면 이들을 이루는 타입을 관찰할 수 있다. 접두사 UNNotification을 지닌 이름을 갖고 있다. 모두 공통 행위를 담은 추상 클래스처럼 생각할 수 있다. 예를 들어 트리거에 대한 구상 클래스는 여러 개가 존재하는데 이들을 일반화시킨 타입을 프로퍼티로 사용해 폴리모피즘을 지원하기 위함이다.

부가적으로 category라는 프로퍼티도 보인다. 사용자가 알림을 받았을 때 어떤 액션을 취할 수 있는지를 정의할 수 있는 코드를 담는다. 이 부분도 약간의 추상화가 담겨있어 맥락만 언급하고 자세한 설명을 잠시 뒤로 미루겠다.

OrderCompleteNotificationRequest

추상화를 실전에 적용하면 어떤 모습일까? 네트워크 모델을 통해 보았던 것처럼 Notification에 관한 정보도 하나의 struct에 담긴다. 연관된 정보를 캡슐화하여 들고 다닐 수 있다.

import Foundation
import UserNotifications

struct OrderCompleteNotificationRequest: NotificationRequest {

    let minutesToPrepare: Int
    let title: String
    let body: String

    private let secondPerMinute = 1.0

    var secondsToPrepare: TimeInterval {
        TimeInterval(minutesToPrepare) * secondPerMinute
    }

    var id: String {
        UUID().uuidString
    }

    var category: NotificationCategory? {
        OrderCompleteNotificationCategory()
    }

    var content: UNNotificationContent {
        let content = UNMutableNotificationContent()

        content.sound = .default
        content.title = title
        content.body = body

        if let categoryId = category?.id {
            content.categoryIdentifier = categoryId
        }

        return content
    }

    var trigger: UNNotificationTrigger {
        UNTimeIntervalNotificationTrigger(
            timeInterval: secondsToPrepare,
            repeats: false)
    }

}

Notification Request 프로토콜을 채택하였다. 프로토콜 요구사항을 구현하고 기본 구현을 얻는다. 구조체의 기본 생성자를 통해 해당 코드의 사용자는 알림 콘텐츠에 들어갈 컨텐츠를 설정할 수 있다. 고조체 저장 프로퍼티에 담긴 커스텀 설정값을 활용해 content 연산 프로퍼티를 계산한다. 내부에는 컨텐츠 인스턴스를 받아 설정값을 세팅해 주고 반환하는 구조를 보인다. 트리거 설정부도 보인다. 예를 들어 주문앱의 기능 중 음식점 서버에 음식 주문을 넣고 대기 시간을 계산해 주는 것이 있었다. 여러 종류의 트리거 중 TimeInterval 트리거를 사용하면 특정 시간이 지나고 사용자에게 알림을 발송시키도록 구현할 수 있다.

이 객체를 사용해 실제 알림을 구성하고 시스템에 예약하여 사용자에게 발송해 주는 코드를 살펴보자! OrderConfirmationViewModel에 구현해 두었다.

private func scheduleNotification() {
  let request = OrderCompleteNotificationRequest(
      minutesToPrepare: minutesToPrepare,
      title: "Order Complete",
      body: "Your Order is Completed"
  )
  UserNotificationCenterController.shared.send(request: request)
}

앞서 우리는 UserNotification의 싱글톤 인스턴스와 직접 상호작용하기보다 controller를 통해 유틸리티 기능을 구현하고 이를 사용하는 방식을 택했다. 새롭게 정의한 구조체 인스턴스를 시스템에 예약할 수 있도록 돕는 send 함수도 request 프로토콜을 활용해 다음과 같이 구현해 보았다.

func send<Request: NotificationRequest>(request: Request, completionHandler: ((Error?) -> Void)? = nil) {
  authorizeIfNeeded { [weak self] granted in
      guard granted else {
          DispatchQueue.main.async {
              completionHandler?(NotificationRequestError.unauthorizedNotification)
          }
          return
      }
      self?.center.add(request.request, withCompletionHandler: completionHandler)
  }
}

알림 설정의 스케줄링 과정에서 발생될 수 있는 예외 사항에 대한 처리를 위해 다음과 같이 에러 처리를 하고 main 스레드가 이를 받아 화면을 갱신할 수 있도록 dispatch 프레임워크를 통해 작업을 메인 스레드로 던져주었다. 푸시 알림이 필요한 맥락에서 권한 요청할 수 있도록 authorizeIfNeeded를 사용하였다. 특이사항이 없다면 center의 add 메서드를 통해 Notification Request 프로토콜의 reqeust 기본 구현을 받아서 시스템에 예약해 준다. 여기까지 잘 구현했다면 푸시 알림을 띄우는 것쯤은 식은 죽 먹기이다.

마무리

음식점 서버에 주문을 넣은 후 음식의 준비가 완료되면 사용자에게 푸시 알림을 보내는 기능을 개발해 보았다. 프로토콜 지향 프로그래밍을 활용해 Notification 정보를 체계적으로 관리해 보았다.

다음시간에는 User Notification 중 Local Notification을 활용하며 푸시 알림 이벤트에 따른 액션을 처리해 볼 것이다. 😁😁😁

반응형