Sure, Why not?

SwiftUI ) FCM 푸시 알림으로 특정 화면 이동 처리하기 본문

💻

SwiftUI ) FCM 푸시 알림으로 특정 화면 이동 처리하기

joho2022 2025. 7. 5. 22:58

푸시할 때 2

 

APNs만으로 iOS 푸시 알림을 경험했지만,

 

실제 서비스에서는

안드로이드 지원, 서버 연동, 마케팅 기능 등등 복잡한 요구가 생긴다.

 

이런 이유로 많은 팀들이 확장성과 편의성을 갖춘 Firebase Cloud Messaging을 선택하는 것 같다.

 


 

1. Xcode 프로젝트 설정

Signing & Capabilities 탭에서 아래 두 개를 추가한다.

 

Push Notifications, Background Modes

 

 

 

2. Firebase 설정

 

로그인 - Google 계정

이메일 또는 휴대전화

accounts.google.com

 

새 프로젝트를 생성한다.

 

 

iOS 앱 등록해준다. (Bundle ID 필요함)

 

그리고 안내되는 과정을 수행한다. (info.plist 이동, SDK 설치 등등 )

SDK설치 시, FCM이 목적이니 FirebaseMessaging을 선택하였다.

 

 

3. APNs 인증 설정 

 

로그인 - Apple

 

idmsa.apple.com

새로운 Key를 생성해주고, p8 인증 파일을 한 번만 저장이 가능하기 때문에,

주의해서  저장관리한다.

 

 

프로젝트 설정 들어간다.

 

 

 

 

아까 다운로드한 p8파일 업로드해준다.

키 ID는 그대로 넣어주면 되고,

 

팀 ID는 아래 주소에서 확인 가능하다.

 

로그인 - Apple

 

idmsa.apple.com

 

 

그러면 지금까지의 초기 세팅은 완료 되었다.

이제 코드작성 남았다.

 


단순한 푸시알림은 이전에 했으니,

조건에 따라 특정 뷰로 이동하거나 푸시를 무시하는 시나리오로 시작하고자 한다.

 

대표적인 시나리오는 다음과 같다.

- 채팅 탭이 아닐때  -> 채팅탭, 채팅방 이동

- 채팅 탭에 이미 있을 때  -> 푸시 무시

- 앱내, 시스템 알림권한  -> 알림 무시

 

세부적인 코드를 전부 나타내지 않고, 중요하다고 판단되는 로직만 정리하고자 한다.

 

AppDelegate

import SwiftUI
import UserNotifications
import Firebase
import FirebaseMessaging

class AppDelegate: NSObject, UIApplicationDelegate {
    
    weak var tabManager: TabSelectionManager?
    weak var pushSetting: PushSettingManager?
    
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        
        FirebaseApp.configure()
        UNUserNotificationCenter.current().delegate = self
        Messaging.messaging().delegate = self
        
        requestNotificationPermission()
        
        return true
    }
    
    func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
        print("device Token: ", token)
        Messaging.messaging().apnsToken = deviceToken
    }
    
}

extension AppDelegate {
    
    private func requestNotificationPermission() {
        UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in
            DispatchQueue.main.async {
                if granted {
                    UIApplication.shared.registerForRemoteNotifications()
                }
            }
        }
    }
    
}

extension AppDelegate: UNUserNotificationCenterDelegate {
    
    func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
        
        guard let shouldShowPush = pushSetting?.shouldShowPush,
              shouldShowPush else {
            print("앱/OS 알림꺼짐 -> 알림 무시")
            return []
        }
        
        if tabManager?.selectedTab == .chat {
            return []
        } else {
            return [.banner, .list, .badge, .sound]
        }
    }
    
    func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
        let userInfo = response.notification.request.content.userInfo
        print("받은 푸시 데이터: ", userInfo)
        
        if let chatID = userInfo["chat_id"] as? String {
            await MainActor.run {
                if tabManager?.selectedTab != .chat {
                    tabManager?.pendingChatID = chatID
                    tabManager?.selectedTab = .chat
                } else {
                    print("채팅탭에 있으므로 푸시 네비게이션 무시")
                }
            }
        }
        print("알림 제목: ", response.notification.request.content.title)
    }
    
}

extension AppDelegate: MessagingDelegate {
    
    func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
        
        // FCM토큰 서버에 전달해야함
        print("FCM 토큰: \(fcmToken ?? "없음")")
    }
    
}

앱이 실행되면 Firebase설정, 델리게이트 설정한다.

디바이스 토큰을 확인하고자 한다면, Firebase가 내부적으로 swizzling하고 있기 때문에

끄면 확인할 수 있다.

 

Info.plist에 아래와 같이 설정하면 됨.

<key>FirebaseAppDelegateProxyEnabled</key>
<false/>

 

 

스위즐링 = 런타임에 기존 메서드의 동작을 가로채서 다른 코드로 바꾸는 기능

 

앱이 시작하자마자 알림권한을 받고 있는데,

requestNotificationPermission를 특정뷰에서 호출하면, 의도하는 뷰에서 권한알림을 할 수 있다.

 

willPresent(notification:)

앱이 포그라운드 상태에서 수신할 때 설정하는데,

알림 설정 체크와 현재 탭 상태를 비교하여 푸시를 표시할 지, 무시할 지 결정하도록 하였다.

 

didReceive(response:)

푸시 알림을 탭했을 때 호출되는 메서드인데,

나는 푸시 데이터에서 chat_id를 추출해서 채팅 탭으로 전환하고 채팅방으로 이동까지 하도록 하였다.

 

 

 

TabSelectionManager

선택된 탭과 채팅 이동 정보를 관리하는 객체로,

푸시 알림 등으로 받은 chatID를 바탕으로 chatPath를 설정해 네비게이션 흐름을 제어한다.

import Foundation

enum Tab: Hashable {
    case home, chat, myPage
}

final class TabSelectionManager: ObservableObject {
    @Published var selectedTab: Tab = .home
    @Published var pendingChatID: String? = nil
    @Published var chatPath: [String] = []
    
    func handlePendingNavigation() {
        if let chatID = pendingChatID {
            if !chatPath.contains(chatID) {
                chatPath = [chatID]
            }
            pendingChatID = nil
            
            print("chatPath: ", chatPath)
        }
    }
}

 

 

 

 

PushSettingManager

앱 내부 푸시 설정과 iOS 시스템 푸시 권한 상태를 관리하는 객체

import Foundation
import UserNotifications

final class PushSettingManager: ObservableObject {
    @Published var isAppPushEnabled: Bool = true
    @Published var isOSPushEnabled: Bool = true

    func checkOSPushStatus() {
        UNUserNotificationCenter.current().getNotificationSettings { settings in
            Task { @MainActor in
                self.isOSPushEnabled = (settings.authorizationStatus == .authorized)
            }
        }
    }
    
    var shouldShowPush: Bool {
        isAppPushEnabled && isOSPushEnabled
    }
}

 

 


 

RootView

import SwiftUI

struct RootView: View {
    @EnvironmentObject var tabManager: TabSelectionManager
    @EnvironmentObject var pushSetting: PushSettingManager

    var body: some View {
        TabView(selection: $tabManager.selectedTab) {
            // 홈 탭
            NavigationStack {
                VStack {
                    Text("메인 화면")
                        .font(.largeTitle)
                        .padding()
                }
            }
            .tabItem { Label("홈", systemImage: "house.fill") }
            .tag(Tab.home)
            
            NavigationStack(path: $tabManager.chatPath) {
                ChatListView()
                    .onAppear {
                        tabManager.handlePendingNavigation()
                    }
                    .onChange(of: tabManager.pendingChatID) { _, _ in
                        tabManager.handlePendingNavigation()
                    }
                    .navigationDestination(for: String.self) { chatID in
                        ChatView(chatID: chatID)
                    }
            }
            .tabItem { Label("채팅", systemImage: "message.fill") }
            .tag(Tab.chat)
            
            // 마이페이지 탭
            NavigationStack {
                VStack {
                    Text("마이페이지")
                        .font(.largeTitle)
                    
                    NavigationLink("알림 설정") {
                        PushSettingView(pushSetting: pushSetting)
                    }
                    .padding()
                }
            }
            .tabItem { Label("마이페이지", systemImage: "person.fill") }
            .tag(Tab.myPage)
        }
    }
}

임의 채팅리스트 뷰에서 

onAppear는 채팅 탭에 진입할 때 pendingChatID가 있다면 즉시 이동하게 하고,

onChange는 푸시 클릭 등으로 pendingChatID가 바뀌는 경우에도 대응하도록 하였다.

 


결과

채팅탭일때는 푸시가 무시되는 것을 확인할 수 있다. / FCM 테스트 전송화면

 

 

 

'💻' 카테고리의 다른 글

async let vs TaskGroup vs 연속 await  (2) 2025.08.28
Core Bluetooth  (3) 2025.07.21
SwiftUI) Push Notification  (0) 2025.06.20
API 동시 호출 시에도 안전하게 토큰 재발급하기  (0) 2025.05.07
통합 로깅 시스템 os Logger  (1) 2025.04.30