티스토리 뷰

1. 개요

원격 푸시, 알림, Push Notification 등으로 불리는 푸시알림은 Product가 앱으로 개발되어야 하는 중요한 이유 중 하나이다.
 
Apple 디바이스에 전송되는 푸시알림은 APNs(Apple Push Notification Service)를 통해 전송된다.
 
보통의 경우 Firebase나 kakao등의 푸시 서버를 사용하는 편인데 클라이언트에서는 서버의 요청에 따라 유저를 특정할 수 있는 값과 APNs 토큰을 전달하고, 서버에서는 해당 토큰과 값으로 등록 등의 처리를 한다. (사용하고자 하는  방식에 따라 다름)
 
푸시알림은 사용자에게 단순히 알림을 주는 것 뿐 아니라, 알림을 클릭했을 때 특정 화면으로 이동하게 한다.
이번 포스팅에서는 푸시 데이터를 수신하고, 데이터에 따라 특정 뷰로 이동하는 내용을 정리했다.
 

2. 앱 실행 구조

앱이 실행될 때 SceneDelegate의 scene willConnectTo 메소드에서는 window에서 root를 선언해줄 수 있다. 
앱마다 다르긴 하지만, 내가 구현한 앱에서는 탭바 컨트롤러가 root가 되는 경우도 있고, 네비게이션 컨트롤러를 root로 선언하기도 한다.
이때, root는 앱의 가장 하단에서 항상 실행중인 것을 가정하고 있을 때, root뷰에 화면 전환 로직을 만들어주면 큰 무리가 없다.
 
하지만 그렇게 되면 root가 푸시알림의 처리라는 중요한 로직까지 책임지게 되며, 탭바 컨트롤러 혹은 네비게이션 컨트롤러의 본질적인 역할에서 다소 벗어날 수 있겠다는 판단이 들었다.
 
따라서, 코디네이터 패턴에서 참고한 AppCoordinator 를 만들어서 root의 역할을 분배해주었다.
자연스레 AppCoordinator는 첫 앱실행시, 푸시 알림 처리에 대한 글로벌한 로직을 담당하게 되었다.

  • LaunchScreen을 띄움
  • Auth - JWT Token 인증(유효성 처리, 재발급 등)
  • 앱 내 기타 필요한 정보 로드, fetch
  • 온보딩 or 로그인 or 메인
  • 푸시 알림 처리

이번 포스팅에서는 푸시 알림 처리에 대해서만 정리를 한다.

var appCoordinator: AppCoordinator?

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    guard let scene = (scene as? UIWindowScene) else { return }
    window = UIWindow(windowScene: scene)

    let baseTabBarController = BaseTabBarController()

    window?.rootViewController = baseTabBarController
    window?.makeKeyAndVisible()

    appCoordinator = AppCoordinator(baseTabBarController)
    self.appCoordinator?.start()
}

scene willConnectTo 에서 AppCoordinator를 구현했고, BaseTabBarController를 가진 app coordinator에서 start() 를 실행한다
start 함수에서는 AppCoordinator가 맡은 여러 로직을 처리하여 다음 뷰로 전환시킨다.
 
푸시알림은 AppDelegate와 SceneDelegate에서 SceneDelegate의 클래스 변수로 선언된 AppCoordinator로 전달되며, 
AppCoordinator는 푸시알림이 가지고 있는 데이터에 따른 뷰 전환 로직을 실행한다.
 

3. 푸시 권한 요청, 토큰 등록

AppStore Connect에서 Remote Notification Push 등록 처리를 완료하고, AppDelegate에서 푸시 토큰을 얻고, 우리 서버에 토큰을 보낸다.

#AppDelegate

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

    // 1. 푸시 권한 요청
    UNUserNotificationCenter.current().delegate = self
    UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in
        print(granted)
    }
    // 2. device 토큰 획득
    application.registerForRemoteNotifications()
    return true
}

func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
    // 3. 우리 서버에 토큰 보내기
    TokenManager.saveUserAPNSToken(token: tokenString)
}

여기까지 진행하여 빌드까지 하고 서버에 푸시 알림 테스트를 요청하여 서버에서 푸시를 보냈다면 ! 푸시가 안올수도 있다.
우리는 APNs에 대해 이해를 할 필요가 있는데 APNs는 두개의 서버가 있고 토큰은 분리가 되어있다.

  1. 개발서버: sandbox
  2. 상용서버: production

그러면 우리가 얻은 토큰은 개발일까? 상용일까?

  • 실기기에 debug 모드로 빌드한 앱은 sandbox 토큰을 얻는다.
  • 실기기에 release 모드로 빌드한 앱은 sandbox 토큰을 얻는다. (production 이라는 stackoverflow 얘기도 있지만, 직접 테스트 해본 결과 sandbox 토큰을 얻었다.)
  • TestFilight와 앱스토어에서 다운받은 앱은 Production 토큰을 얻는다.

그래서 디바이스 빌드로 테스트하는 경우 sandbox 서버로 보내달라고 요청해야 한다.
(어떤 서비스에서는 아예 토큰을 서버로 보낼때 debug 모드인지 release 모드인지까지 같이 보내기도 했었다.)
 

4. 푸시 알림 수신, 탭 이벤트

푸시 알림이 수신되고, 푸시를 탭 했을때는 앱이 실행중일 때와 실행중이지 않을 때의 처리 방법이 다르다.
 

A. 앱이 실행중이지 않을 때

먼저 앱이 실행중이지 않을 때는 푸시로 앱을 실행했다는 내용이 SceneDelegate의 willConnectTo의 options 파라미터로 들어온다.
이 경우 나는 이런 방법으로 푸시알림을 처리한다.

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    guard let scene = (scene as? UIWindowScene) else { return }
    window = UIWindow(windowScene: scene)

    let baseTabBarController = BaseTabBarController()

    window?.rootViewController = baseTabBarController
    window?.makeKeyAndVisible()

    appCoordinator = AppCoordinator(baseTabBarController)
    self.appCoordinator?.start()

    // 🔔 푸시로 앱이 실행된 경우 !
    guard let notificationResponse = connectionOptions.notificationResponse else { return }
    let userInfo = notificationResponse.notification.request.content.userInfo
    appCoordinator?.userInfoByPush = userInfo
}

 
option으로 들어온 notification userInfo를 appDelegate의 클래스변수에 넣는다.

class AppCoordinator {
    //MARK: - Properties
    let tabBarController: BaseTabBarController
    // 푸시로 앱을 실행시킨 경우 데이터가 들어옴
    var userInfoByPush: [AnyHashable: Any]?
    
    ...
    
    // 들어온 푸시 데이터가 있다면?
    if let pushData = self.userInfoByPush {
        self.userInfoByPush = nil
        self.configurePushNotification(userInfo: pushData)
    }
}

start() 함수에서는 비동기작업 이후 메인 뷰를 호출한다. 그 이후 AppCoordinator는 userInfoByPush 변수를 확인하여 데이터가 있을 경우 탭바가 해당 뷰를 전환하도록 구현했다. (해당 변수는 다시 nil을 할당해준다)

이렇게 한 이유는 다음과 같다.
Auth 인증을 비롯한 앱의 start처리, 그리고 메인 뷰의 로드 등을 완료한 상태에서 푸시로 들어온 뷰를 전환해주어야
개발자가 예상하지 못한 이슈를 방지할 수 있다고 생각했다. (해당 뷰를 닫은 뒤 다른 뷰를 호출할때도 마찬가지)

 

B. 앱이 실행중일 때 (백그라운드 포함)

앱이 실행중이거나 백그라운드일 때의 푸시 처리는 내가 느끼기에 복잡하지 않았다.
앱의 window에 떠있는 뷰가 있으며, root도 설정이 되어있는 상태다. 
이때의 알림 탭 이벤트는 AppDelegate의 UNUserNotificationCenterDelegate에서 수신한다.

extension AppDelegate: UNUserNotificationCenterDelegate {
    // 실행중에도 알림을 수신하도록 처리
    func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void){
        completionHandler([.banner, .badge, .sound])
    }
    
    // 푸시 알림을 탭 했을 때
    func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
        let userInfo = response.notification.request.content.userInfo
        configureNotification(userInfo: userInfo)
    }
    
    private func configureNotification(userInfo: [AnyHashable: Any]) {
        print("🔔푸시: \(userInfo)")
        sceneDelegate?.appCoordinator?.configurePushNotification(userInfo: userInfo)
    }
}

수신한 푸시 데이터를 sceneDelegate의 appCoordinator에 전달하며,
해당 메소드에서는 BaseTabBarController가 뷰를 전환하도록 한다. 로직 자체는 복잡하지 않다
 
다만, 개발중인 앱의 특성에 따라 고려해야할 부분이 있다. 
BaseTabBarController 위에 present된 뷰가 있는데, push로 처리를 해야 하는 뷰가 푸시로 들어온다면 어떻게 할까?
 
정답은 없겠지만 나는 두가지 경우로 추려보았다.

  방법 장점 단점
1 현재 뷰 위에 present 혹은 push 유저가 보고있던 뷰 스택 유지 가능 스택이 복잡해질 수 있음.
유저가 스택을 빠져나가는데 헤멜 가능성.
2 탭바 위에 뷰 제거 / rootView로 만든 뒤
present 혹은 push
깔끔한 뷰 스택 유지 가능 유저가 보던 뷰가 사라짐

내가 개발한 앱은 기획자와 팀원들간의 충분한 상의를 거쳐 2번째 방법으로 푸시알림을 처리하기로 했다.
(앱 사용중 푸시알림이 와도 유저들이 다른 뷰로 이동하고자 할 때 탭을 한다고 판단)
(다른 좋은 방법도 있는지 더 고민해봐야 겠다.)
 
이에 탭바 컨트롤러는 푸시알림을 처리하기 전 다음 메소드를 호출 하고 진행한다.
UIViewController에 구현되어 있는 presentedViewController 를 활용해 상단에 있는 뷰를 dismiss 하는 기능이다.

extension UIViewController {
    /// 상단에 present 된 뷰들을 모두 dismiss 합니다
    func dismissAllPresentedViewControllers(completion: @escaping () -> Void) {
        if let presentedVC = presentedViewController {
            presentedVC.dismissAllPresentedViewControllers {
                self.dismiss(animated: false, completion: {
                    completion()
                })
            }
        } else {
            completion()
        }
    }
}

이후 해당 뷰를 전환해주면 원활하게 동작하는 것을 확인할 수 있다.

푸시 알림을 통해 들어온 뷰를 특정하는 것에도 여러 방법이 있겠지만 나는 열거형을 사용해 뷰 컨트롤러를 리턴받는다.
enum을 좋아하기도 하고🔥 간결한 코드 작성도 가능하다. 
enum에 대해서는 따로 포스팅을 해서 정리 해야겠다.

 

5. 결론

이렇게 푸시 알림을 처리하는 방법과 고민을 정리해보았다. 최근 업무에서 푸시 처리 부분의 코드를 정리했었다.
 
푸시알림과 관련된 비즈니스 로직을 처리 하는 것은 백엔드에서 처리해주지만,
클라이언트에서는 결국 푸시알림을 받았을 때 어떻게 화면을 처리해줄지? 에 대한 고민이 필요했다.
 
그나저나 최근에는 지금까지 iOS에서 지원하지 않았던 Web Push를 iOS 16.4 부터 지원하기 시작했고,
이와 함께 웹으로 개발한 PWA(Progressive Web App)앱을 간편하게 받을 수 있기도 하다.
 
퍼포먼스, 푸시 알림 등의 확실한 차이가 있었던 앱과 웹의 경계가 많이 허물어지고 있다.
앞으로 앱으로써의 프로덕트는 웹과 비교해 더욱 확실한 차별점을 가지고 있어야 하겠다는 생각이 든다.

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/12   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30 31
글 보관함