From ff7eb44838698c82c43744edee869518dfa6254d Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 13 Mar 2026 10:26:30 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EB=B0=B1=EA=B7=B8=EB=9D=BC?= =?UTF-8?q?=EC=9A=B4=EB=93=9C=20=ED=98=B9=EC=9D=80=20=EC=95=B1=EC=9D=B4=20?= =?UTF-8?q?=EC=A2=85=EB=A3=8C=EB=90=98=EC=97=88=EC=9D=84=20=EB=95=8C?= =?UTF-8?q?=EC=9D=98=20=ED=91=B8=EC=8B=9C=20=EB=B0=B0=EC=A7=80=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Firebase/functions/src/fcm/notification.ts | 23 +++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/Firebase/functions/src/fcm/notification.ts b/Firebase/functions/src/fcm/notification.ts index 228dea09..16d3c9ac 100644 --- a/Firebase/functions/src/fcm/notification.ts +++ b/Firebase/functions/src/fcm/notification.ts @@ -97,9 +97,19 @@ export const sendPushNotification = onTaskDispatched({ }; await notificationDocRef.set(notificationData, { merge: true }); - // 1. 사용자 FCM 토큰 가져오기 - const tokenDoc = await admin.firestore().doc(`users/${userId}/userData/tokens`).get(); + // 1. 사용자 FCM 토큰과 읽지 않은 알림 수 가져오기 + const unreadCountPromise = admin.firestore() + .collection(`users/${userId}/notifications`) + .where("isRead", "==", false) + .count() + .get(); + const tokenDocPromise = admin.firestore().doc(`users/${userId}/userData/tokens`).get(); + const [tokenDoc, unreadCountSnapshot] = await Promise.all([ + tokenDocPromise, + unreadCountPromise + ]); const fcmToken = tokenDoc.data()?.fcmToken; + const unreadNotificationCount = unreadCountSnapshot.data().count; if (!fcmToken) { logger.warn(`사용자 ${userId}의 fcmToken이 없어 푸시 발송은 건너뜁니다. Firestore에는 기록했습니다.`); @@ -113,7 +123,14 @@ export const sendPushNotification = onTaskDispatched({ todoId: todoId, todoKind: todoKind }, - apns: { payload: { aps: { sound: "default" } } }, + apns: { + payload: { + aps: { + sound: "default", + badge: unreadNotificationCount + } + } + }, token: fcmToken, }; try { From a9fd340138ea9e00603a3511bf7253cb1dd0a15f Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 13 Mar 2026 10:27:24 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=EC=98=A8=EA=B7=B8=EB=9D=BC?= =?UTF-8?q?=EC=9A=B4=EB=93=9C=20=EC=83=81=ED=83=9C=EC=97=90=EC=84=9C?= =?UTF-8?q?=EC=9D=98=20=ED=91=B8=EC=8B=9C=20=EB=B0=B0=EC=A7=80=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/App/Delegate/AppDelegate.swift | 125 +++++++++++++++++++++++++- 1 file changed, 124 insertions(+), 1 deletion(-) diff --git a/DevLog/App/Delegate/AppDelegate.swift b/DevLog/App/Delegate/AppDelegate.swift index 2ee5924b..163361fd 100644 --- a/DevLog/App/Delegate/AppDelegate.swift +++ b/DevLog/App/Delegate/AppDelegate.swift @@ -11,10 +11,15 @@ import FirebaseAuth import FirebaseFirestore import FirebaseMessaging import GoogleSignIn +import Combine import UserNotifications class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate { private let logger = Logger(category: "AppDelegate") + private var store: Firestore { Firestore.firestore() } + private var authStateListenerHandle: AuthStateDidChangeListenerHandle? + private var cancellable: AnyCancellable? + func application( _ app: UIApplication, open url: URL, @@ -47,6 +52,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate { // Firebase Messaging 설정 Messaging.messaging().delegate = self + observeAuthState() // 앱이 완전 종료되어도, 알림을 통해 앱이 시작된 경우 처리 if let remoteNotification = launchOptions?[.remoteNotification] as? [AnyHashable: Any] { @@ -73,6 +79,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate { ) { logger.error("Failed to register APNs token", error: error) } + + func applicationDidBecomeActive(_ application: UIApplication) { + syncBadgeCount() + } // FCMToken 갱신 func messaging( @@ -83,8 +93,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate { NotificationCenter.default.post(name: .fcmToken, object: nil, userInfo: ["fcmToken": fcmToken]) } } +} - private func updateUserTimeZone() { +private extension AppDelegate { + func updateUserTimeZone() { Task { do { guard let uid = Auth.auth().currentUser?.uid else { return } @@ -96,6 +108,117 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate { } } } + + func observeAuthState() { + authStateListenerHandle = Auth.auth().addStateDidChangeListener { [weak self] _, user in + guard let self else { return } + + self.cancellable?.cancel() + + guard user != nil else { + self.updateBadgeCount(0) + return + } + + self.startObservingBadgeCount() + self.syncBadgeCount() + } + } + + func startObservingBadgeCount() { + cancellable = try? observeUnreadNotificationCount() + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { [weak self] completion in + guard let self else { return } + + if case .failure(let error) = completion { + self.logger.error("Failed to observe unread notification count", error: error) + } + }, + receiveValue: { [weak self] count in + self?.updateBadgeCount(count) + } + ) + } + + func fetchUnreadNotificationCount() async throws -> Int { + logger.info("Fetching unread notification count") + + guard let uid = Auth.auth().currentUser?.uid else { + logger.error("User not authenticated") + throw AuthError.notAuthenticated + } + + do { + let snapshot = try await store.collection("users/\(uid)/notifications") + .whereField("isRead", isEqualTo: false) + .getDocuments() + + let unreadNotificationCount = snapshot.documents.count + logger.info("Unread notification count: \(unreadNotificationCount)") + return unreadNotificationCount + } catch { + logger.error("Failed to fetch unread notification count", error: error) + throw error + } + } + + func observeUnreadNotificationCount() throws -> AnyPublisher { + logger.info("Observing unread notification count") + + guard let uid = Auth.auth().currentUser?.uid else { + logger.error("User not authenticated") + throw AuthError.notAuthenticated + } + + let subject = PassthroughSubject() + let listener = store.collection("users/\(uid)/notifications") + .whereField("isRead", isEqualTo: false) + .addSnapshotListener { [weak self] snapshot, error in + if let error { + self?.logger.error("Failed to observe unread notification count", error: error) + subject.send(completion: .failure(error)) + return + } + + guard let snapshot else { return } + + let unreadNotificationCount = snapshot.documents.count + self?.logger.info("Observed unread notification count: \(unreadNotificationCount)") + subject.send(unreadNotificationCount) + } + + return subject + .handleEvents(receiveCancel: { listener.remove() }) + .eraseToAnyPublisher() + } + + func syncBadgeCount() { + Task { @MainActor [weak self] in + guard let self else { return } + guard Auth.auth().currentUser != nil else { + self.updateBadgeCount(0) + return + } + + do { + let unreadNotificationCount = try await self.fetchUnreadNotificationCount() + self.updateBadgeCount(unreadNotificationCount) + } catch { + self.logger.error("Failed to fetch unread notification count", error: error) + } + } + } + + @MainActor + private func updateBadgeCount(_ count: Int) { + UNUserNotificationCenter.current().setBadgeCount(count) { [weak self] error in + if let error { + self?.logger.error("Failed to update badge count", error: error) + } + } + } } extension AppDelegate: UNUserNotificationCenterDelegate { From abd6c00c5fc6a6432dea22a1355dd245d8c5f381 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 13 Mar 2026 10:34:27 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20Logger=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/App/Delegate/AppDelegate.swift | 70 ++++++++++++++------------- 1 file changed, 37 insertions(+), 33 deletions(-) diff --git a/DevLog/App/Delegate/AppDelegate.swift b/DevLog/App/Delegate/AppDelegate.swift index 163361fd..e5255f81 100644 --- a/DevLog/App/Delegate/AppDelegate.swift +++ b/DevLog/App/Delegate/AppDelegate.swift @@ -125,24 +125,45 @@ private extension AppDelegate { } } - func startObservingBadgeCount() { - cancellable = try? observeUnreadNotificationCount() - .receive(on: DispatchQueue.main) - .sink( - receiveCompletion: { [weak self] completion in - guard let self else { return } - - if case .failure(let error) = completion { - self.logger.error("Failed to observe unread notification count", error: error) + func syncBadgeCount() { + Task { @MainActor [weak self] in + guard let self else { return } + guard Auth.auth().currentUser != nil else { + self.updateBadgeCount(0) + return + } + + do { + let unreadNotificationCount = try await self.fetchUnreadNotificationCount() + self.updateBadgeCount(unreadNotificationCount) + } catch { + self.logger.error("Failed to fetch unread notification count", error: error) + } + } + } + + private func startObservingBadgeCount() { + do { + cancellable = try observeUnreadNotificationCount() + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { [weak self] completion in + guard let self else { return } + + if case .failure(let error) = completion { + self.logger.error("Failed to observe unread notification count", error: error) + } + }, + receiveValue: { [weak self] count in + self?.updateBadgeCount(count) } - }, - receiveValue: { [weak self] count in - self?.updateBadgeCount(count) - } - ) + ) + } catch { + logger.error("Failed to start observing badge count", error: error) + } } - func fetchUnreadNotificationCount() async throws -> Int { + private func fetchUnreadNotificationCount() async throws -> Int { logger.info("Fetching unread notification count") guard let uid = Auth.auth().currentUser?.uid else { @@ -164,7 +185,7 @@ private extension AppDelegate { } } - func observeUnreadNotificationCount() throws -> AnyPublisher { + private func observeUnreadNotificationCount() throws -> AnyPublisher { logger.info("Observing unread notification count") guard let uid = Auth.auth().currentUser?.uid else { @@ -194,23 +215,6 @@ private extension AppDelegate { .eraseToAnyPublisher() } - func syncBadgeCount() { - Task { @MainActor [weak self] in - guard let self else { return } - guard Auth.auth().currentUser != nil else { - self.updateBadgeCount(0) - return - } - - do { - let unreadNotificationCount = try await self.fetchUnreadNotificationCount() - self.updateBadgeCount(unreadNotificationCount) - } catch { - self.logger.error("Failed to fetch unread notification count", error: error) - } - } - } - @MainActor private func updateBadgeCount(_ count: Int) { UNUserNotificationCenter.current().setBadgeCount(count) { [weak self] error in From a22fcdef42a5142f2b9d98ab4cacf677fe99364c Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 13 Mar 2026 10:37:01 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20self=20guard=20=EB=AC=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/App/Delegate/AppDelegate.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/DevLog/App/Delegate/AppDelegate.swift b/DevLog/App/Delegate/AppDelegate.swift index e5255f81..af8c6185 100644 --- a/DevLog/App/Delegate/AppDelegate.swift +++ b/DevLog/App/Delegate/AppDelegate.swift @@ -197,8 +197,9 @@ private extension AppDelegate { let listener = store.collection("users/\(uid)/notifications") .whereField("isRead", isEqualTo: false) .addSnapshotListener { [weak self] snapshot, error in + guard let self else { return } if let error { - self?.logger.error("Failed to observe unread notification count", error: error) + self.logger.error("Failed to observe unread notification count", error: error) subject.send(completion: .failure(error)) return } @@ -206,7 +207,7 @@ private extension AppDelegate { guard let snapshot else { return } let unreadNotificationCount = snapshot.documents.count - self?.logger.info("Observed unread notification count: \(unreadNotificationCount)") + self.logger.info("Observed unread notification count: \(unreadNotificationCount)") subject.send(unreadNotificationCount) }