[새싹 iOS] 23주차_Push Notification

임승섭·2023년 12월 18일
1

새싹 iOS

목록 보기
39/45

1. Remote Push Notification in Apple

  • 앱에서 원격 알림을 받기 위해서는 서버device token 이 필요하다
  • 동작 구조
    1. APNs 에 앱을 등록한다
    2. device token 을 받는다
    3. device token 을 서버에 전달한다
    4. 푸시 알림이 필요한 시점에 서버는 APNs에게 메세지 데이터device token 을 보낸다
      • 이 때, APNs와 서버는 TLS 통신을 하며, 서버에는 인증서가 준비되어 있어야 한다
    5. 받은 device token을 통해 APNs는 기기를 식별하여, 알림 데이터를 푸시 알림으로 보내준다
  • APNs (Apple Push Notification Service)
    • 애플 기기로 푸시 알림을 전송하는 서비스
    • 오직 APNs만 기기에 직접적으로 푸시 알림을 보낼 수 있다
    • 고유한 device token을 통해 특정 디바이스로 알림을 보낸다
  • Device Token
    • 앱과 디바이스 모두에게 유일성을 갖는다
    • 서로 다른 앱에서 같은 device token을 사용할 수 없고,
      다른 디바이스에 설치된 같은 앱에서도 다른 device token을 사용한다
    • APNs만 해독이 가능하다
    • 로컬 저장소에 device token을 저장해두지 않는다

2. FCM Token

  • FCM (Firebase Cloud Messaging)
    • 무료로 메세지를 안정적으로 전송할 수 있는 교차 플랫폼 메시징 솔루션
    • 설정에서 APNs Auth Key 를 등록한다
    • 푸시 알림을 대신 전송하는 대리자(delegate) 역할을 수행하고,
      실질적으로는 APNs 서버에 푸시 알림에 대한 요청을 한다.
  • 공식 문서에서는 등록 토큰 이라고 하는 FCM Token은 앱 시작 시 생성된다

    • 등록 토큰을 수신하기 위한 delegate을 설정한다

      // 공식 문서에 있는 코드
      Messaging.messaging().delegate = self
    • 최초 앱 시작 시, 토큰이 업데이트되거나 무효화될 때 신규 또는 기존 토큰을 가져온다
      어떠한 경우든, 유효한 토큰이 있는 didReceiveRegistrationToken 메서드를 호출한다
      이 때, 서버에 해당 토큰을 전달하거나, NotificationCenter를 이용해 앱 전체에 알린다

      // 공식 문서에 있는 코드
      func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
        print("Firebase registration token: \(String(describing: fcmToken))")
      
        let dataDict: [String: String] = ["token": fcmToken ?? ""]
        NotificationCenter.default.post(
          name: Notification.Name("FCMToken"),
          object: nil,
          userInfo: dataDict
        )
        // TODO: If necessary send token to application server.
        // Note: This callback is fired at each app startup and whenever a new token is generated.
      }
    • .token(completion: ) 메서드를 통해 원하는 시점에 직접 토큰을 가져올 수도 있다

      // 공식 문서에 있는 코드
      Messaging.messaging().token { token, error in
        if let error = error {
          print("Error fetching FCM registration token: \(error)")
        } else if let token = token {
          print("FCM registration token: \(token)")
          self.fcmRegTokenMessage.text  = "Remote FCM registration token: \(token)"
        }
      }

3. 프로젝트 적용

Sooda

1. 세팅

  • GoogleService-Info.plist 파일 추가
  • Signing & Capabilities에 Push Notification 추가
  • 인증서 등록 및 Profile 등록

AppDelegate - didFinishLaunchingWithOptions

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

	// GoogleSerice-info.plist에 File I/O 하는 기능
    FirebaseApp.configure()
    
    // 알림 delegate 설정
    UNUserNotificationCenter.current().delegate = self
    
    // 알림 허용 확인
    UNUserNotificationCenter.current().requestAuthorization(
        options: [.alert, .sound, .badge, .providesAppNotificationSettings]) { didAllow, error  in
        print("Notification Authorization : \(didAllow)")
    }
    
    // 원격 알림에 앱 등록
    application.registerForRemoteNotifications()
    
    
    // Messaging delegate 설정
    Messaging.messaging().delegate = self
    
    
    return true
}

AppDelegate - didRegisterForRemoteNotificationsWithDeviceToken

func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
	// deviceToken (APNs 토큰)을 가져와서 Messaging의 apnsToken 설정
    Messaging.messaging().apnsToken = deviceToken
}

func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
    print(error)
}
  • application.registerForRemoteNotifications()이 성공적으로 실행되면 위 메서드를 통해 deviceToken을 받는다.
  • 만약 실패 시 didFailToRegisterForRemoteNotificationsWithError 메서드가 실행된다.
  • 무조건 이 둘 중에 하나가 실행되어야 하는데, 정말 가끔 둘 다 실행이 안되어서 deviceToken을 받지 못하는 경우가 꽤 있었다.... 이것저것 해도 실행이 계속 안되다가 또 갑자기 되고,,, 아직 이유는 모르겠는데, Xcode 껐켰 후 실행된 경험이 있다....

AppDelegate - didReceiveRegistrationToken

func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
      let firebaseToken = fcmToken ?? "No Token" 
    
      KeychainStorage.shared.fcmToken = firebaseToken
}
  • firebase에서 fcm token을 받고, 이를 키체인에 저장한다.
  • 만약 여기 말고 앱 내의 다른 곳에서 fcm Token을 받고 싶다면, 아래 코드를 실행한다
    Messaging.messaging().token { token, error in
      if let error = error {
        print("Error fetching FCM registration token: \(error)")
      } else if let token = token {
        print("FCM registration token: \(token)")
      }
    }

2. 서버 통신

  • FirebaseMessaging에서 받은 fcm token을 서버에 전달해준다
  • 서버 DB에는 서버에 전달한 계정과 fcm token이 함께 저장되고,
    해당 계정에 push notification이 필요한 경우, 저장된 fcm token의 기기로 알림을 보내준다.
  • 현재 프로젝트에서 deviceToken을 서버에 전달하는 API
    • 회원가입 /v1/users/join
    • 로그인(이메일, 카카오, 애플) /v1/users/login
    • FCM deviceToken 저장 /v1/users/deviceToken

2.5 deviceToken 업데이트 시점에 대한 고민 지점

  • 로그인 또는 회원가입 시 deviceToken 전송
  • token이 업데이트되었을 때 deviceToken 전송
  • 로그아웃 시 deviceToken 정보 삭제
  • 만약 여러 기기에서 로그인을 시도한다면,
    • 서버 DB 테이블에 계정 당 deviceToken을 하나만 저장할 수 있다면
      가장 최신에 로그인한 계정으로 push notification이 보내진다.
    • 하지만 실질적으로 사용자가 자주 사용하는 기기는
      가장 최신에 로그인한 기기가 아닐 수 있다.
    • 이런 경우, 앱 내의 특정 화면을 정해서
      그 화면에 사용자가 접속했을 때 해당 계정의 deviceToken을 업데이트 해주는 것도 좋은 방법인 것 같다.

3. Push Notification 설정

  • 이번 프로젝트에서는 채팅 알림을 받도록 구현이 되어있다.

  • push notification 수신 시 구현 내용

    채팅에 해당하는 채팅방에 들어가면
    푸시 알림이 오지 않는다
    푸시 알림을 클릭했을 때,
    해당 채팅방으로 바로 화면 전환

1. 채팅에 해당하는 채팅방에 들어가면 푸시 알림이 오지 않는다

// AppDelegate
// 포그라운드에서 알림 받기
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {

    guard let userInfo = notification.request.content.userInfo as? [String: Any] else { return }
    
    // 1. 채널 채팅인 경우
    if let channelChatInfo: PushChannelChattingDTO = self.decodingData(userInfo: userInfo) {
    	// userInfo 디코딩해서 푸시 알림 내용 확인
        
        // 현재 보고 있는 채팅방이 아닌 경우만 푸시 알림
        // (UserDefaults 이용해서 현재 보고 있는 채팅방 여부 확인)
        if !self.checkCurrentChannel(chatInfo: channelChatInfo) {
            completionHandler([.list, .badge, .sound, .banner])
        }
    }
    
    // 2. 디엠 채팅인 경우 - 생략
}

2. 푸시 알림을 클릭했을 때, 해당 채팅방으로 바로 화면 전환

AppDelegate
  • 푸시 알림 클릭 시, NotificationCenter를 통해 SceneDelegate에게 알림 내용을 보낸다.

    // 푸시 알림을 클릭
    func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
    
        guard let userInfo = response.notification.request.content.userInfo as? [String: Any] else { return }
    	
        // 1. 채널 채팅인 경우
        if let channelChatInfo: PushChannelChattingDTO = self.decodingData(userInfo: userInfo) {
            // userInfo 디코딩해서 푸시 알림 내용 확인
    
            if let channelId = Int(channelChatInfo.channel_id),
               let workspaceId = Int(channelChatInfo.workspace_id) {
    
                let userInfo: [String: Any] = [
                    "channelId": channelId,
                    "workspaceId": workspaceId
                ]
    
                // Notification Post -> SceneDelegate에 observer
                NotificationCenter.default.post(
                    name: Notification.Name("channelChattingPushNotification"),
                    object: nil,
                    userInfo: userInfo
                )
            }
        }
    
        // 2. 디엠 채팅인 경우 - 생략
    	
    	
        completionHandler()
    }

SceneDelegate
  • NotificationCenter를 통해 푸시 알림 클릭에 대한 노티를 받으면,
    AppCoordinator 메서드 실행
    (모든 화면 초기화 후 해당되는 채팅방으로 화면 전환)

    class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    
        var appCoordinator: AppCoordinator?
    
        func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    
            /* ... */
    
            setNotification()	// 노티 등록
        }
    }
    	
    	
    extension SceneDelegate {
    
        private func setNotification() {
            // 1. 채널 채팅
            NotificationCenter.default.addObserver(
                self,
                selector: #selector(channelChatPushClicked),
                name: Notification.Name("channelChattingPushNotification"),
                object: nil
            )
        }	
    	
    	
        // 1. 채널 채팅
        @objc
        private func channelChatPushClicked(_ notification: Notification) {
    
            if let userInfo = notification.userInfo,
                let channelId = userInfo["channelId"] as? Int ,
                let workspaceId = userInfo["workspaceId"] as? Int {
    
                    appCoordinator?.showDirectChannelChattingView(
                        workSpaceId: workspaceId,
                        channelId: channelId,
                        channelName: nil
                    )
            }
        }

AppCoordinator
  • showDirectChannelChattingView : 현재 쌓여있는 뷰를 모두 초기화시키고, 곧바로 채팅 화면으로 전환한다.

  • 코디네이터 구조

    class AppCoordinator: AppCoordinatorProtocol {
    
        /* ... */
    
        func showDirectChannelChattingView(
            workSpaceId: Int,
            channelId: Int,
            channelName: String?
        ) {
            /*
            1. AppCoordinator child removeAll
    
            2. AppCoordinator showTabbarFlow(workspaceId: Int)
    
            3. TabbarCoordinator prepareTabBarController(selectedItem = 0)
    
            // channel
            4 - 1. HomeDefaultCoordinator showChannelChatting
    
            //dm
            4 - 2. HomeDefaultCoordinator showDMChatting
            */
    	
    	
            // 1. child coordinator removeAll
            childCoordinators.removeAll()
            navigationController.viewControllers.removeAll()
    		
    		
            // 2. show tabBar flow
            let tabBarCoordinator = TabBarCoordinator(navigationController)
            tabBarCoordinator.finishDelegate = self
            tabBarCoordinator.workSpaceId = workSpaceId
            childCoordinators.append(tabBarCoordinator)
            tabBarCoordinator.start()
            // (-> homeDefaultCoordinator start)
    	
    	
            // 3. HomeDefaultCoordinator show ChannelChatting
            for i in 0...3 {
                if let homeDefaultCoordinator =  tabBarCoordinator.childCoordinators[i] as? HomeDefaultSceneCoordinatorProtocol {
    	
                 	   homeDefaultCoordinator.showChannelChattingView(
                        workSpaceId: workSpaceId,
                        channelId: channelId,
                        channelName: channelName
                    )
                }
            }
      }

레퍼런스

Apple Developer - Registering your app with APNs
Firebase - Cloud Messaging
개발자 소들이 - APNs :: Push Notification 동작 방식
FCM을 도입할 때 고려할 것들
Jihyun Kim - IOS에서 이미지가 있는 푸시알림 구현하기
웅쓰 - iOS 앱 Push 알림 이해하기

0개의 댓글