From 1f60b5fc595d691d56ceb6611b00092fe462c05d Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 4 Jul 2026 01:00:58 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20FCM=20token=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EC=A4=91=EB=B3=B5=20=EC=A0=80=EC=9E=A5=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/App/Delegate/AppDelegate.swift | 8 +- .../App/Handler/FCMTokenSyncHandler.swift | 20 +++- .../FCMTokenSyncHandlerTests.swift | 91 +++++++++++++++++-- 3 files changed, 110 insertions(+), 9 deletions(-) diff --git a/Application/App/Sources/App/Delegate/AppDelegate.swift b/Application/App/Sources/App/Delegate/AppDelegate.swift index 57f4d279..26c26601 100644 --- a/Application/App/Sources/App/Delegate/AppDelegate.swift +++ b/Application/App/Sources/App/Delegate/AppDelegate.swift @@ -44,6 +44,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { name: UIApplication.willEnterForegroundNotification, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(requestAPNsRegistration), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) // 알림 권한 요청 UNUserNotificationCenter.current().delegate = self @@ -79,7 +85,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } - func applicationWillEnterForeground(_ application: UIApplication) { + @objc private func requestAPNsRegistration() { NotificationCenter.default.post(name: .didRequestAPNsRegistration, object: nil) } diff --git a/Application/App/Sources/App/Handler/FCMTokenSyncHandler.swift b/Application/App/Sources/App/Handler/FCMTokenSyncHandler.swift index 2696e326..b16d2c28 100644 --- a/Application/App/Sources/App/Handler/FCMTokenSyncHandler.swift +++ b/Application/App/Sources/App/Handler/FCMTokenSyncHandler.swift @@ -11,12 +11,18 @@ import Data import Foundation final class FCMTokenSyncHandler { + private struct SyncKey: Equatable { + let uid: String + let fcmToken: String + } + private let authService: AuthService private let messagingService: PushMessagingService private let userService: UserService private let notificationCenter: NotificationCenter private let logger = Logger(category: "FCMTokenSyncHandler") private var cancellables = Set() + private var lastSyncedKey: SyncKey? init( authService: AuthService, @@ -68,7 +74,10 @@ final class FCMTokenSyncHandler { private extension FCMTokenSyncHandler { func handleSessionUpdate(isSignedIn: Bool) { - guard isSignedIn else { return } + guard isSignedIn else { + lastSyncedKey = nil + return + } requestFCMTokenSync() } @@ -114,11 +123,18 @@ private extension FCMTokenSyncHandler { } func syncFCMTokenIfNeeded(_ fcmToken: String) async throws { - guard authService.uid != nil else { + guard let uid = authService.uid else { logger.info("Skipping FCM token update because no authenticated user exists") return } + let key = SyncKey(uid: uid, fcmToken: fcmToken) + guard lastSyncedKey != key else { + logger.info("Skipping FCM token update because the token was already synced") + return + } + try await userService.updateFCMToken(fcmToken) + lastSyncedKey = key } } diff --git a/Application/App/Tests/PushNotification/FCMTokenSyncHandlerTests.swift b/Application/App/Tests/PushNotification/FCMTokenSyncHandlerTests.swift index 2ae9a84a..6c156b93 100644 --- a/Application/App/Tests/PushNotification/FCMTokenSyncHandlerTests.swift +++ b/Application/App/Tests/PushNotification/FCMTokenSyncHandlerTests.swift @@ -137,8 +137,8 @@ struct FCMTokenSyncHandlerTests { _ = handler } - @Test("같은 사용자와 같은 FCM token도 매번 저장한다") - func 같은_사용자와_같은_FCM_token도_매번_저장한다() async throws { + @Test("같은 사용자와 같은 FCM token은 한 번만 저장한다") + func 같은_사용자와_같은_FCM_token은_한_번만_저장한다() async throws { let notificationCenter = NotificationCenter() let messagingService = PushMessagingServiceSpy(currentFCMToken: "current-token") let userService = UserServiceSpy() @@ -156,9 +156,8 @@ struct FCMTokenSyncHandlerTests { } notificationCenter.post(name: .didRequestFCMTokenSync, object: nil) - try await waitUntil { - await userService.updatedFCMTokens == ["current-token", "current-token"] - } + try await Task.sleep(for: .milliseconds(100)) + #expect(await userService.updatedFCMTokens == ["current-token"]) _ = handler } @@ -189,6 +188,72 @@ struct FCMTokenSyncHandlerTests { _ = handler } + @Test("FCM token이 바뀌면 같은 사용자도 다시 저장한다") + func FCM_token이_바뀌면_같은_사용자도_다시_저장한다() async throws { + let notificationCenter = NotificationCenter() + let messagingService = PushMessagingServiceSpy(currentFCMToken: "first-token") + let userService = UserServiceSpy() + let authService = AuthServiceSpy() + let handler = FCMTokenSyncHandler( + authService: authService, + messagingService: messagingService, + userService: userService, + notificationCenter: notificationCenter + ) + + notificationCenter.post(name: .didRequestFCMTokenSync, object: nil) + try await waitUntil { await userService.updatedFCMTokens == ["first-token"] } + + messagingService.currentFCMToken = "second-token" + notificationCenter.post(name: .didRequestFCMTokenSync, object: nil) + try await waitUntil { await userService.updatedFCMTokens == ["first-token", "second-token"] } + _ = handler + } + + @Test("로그아웃 후 같은 사용자로 다시 로그인하면 같은 FCM token도 다시 저장한다") + func 로그아웃_후_같은_사용자로_다시_로그인하면_같은_FCM_token도_다시_저장한다() async throws { + let notificationCenter = NotificationCenter() + let messagingService = PushMessagingServiceSpy(currentFCMToken: "current-token") + let userService = UserServiceSpy() + let authService = AuthServiceSpy(uid: "user-id") + let handler = FCMTokenSyncHandler( + authService: authService, + messagingService: messagingService, + userService: userService, + notificationCenter: notificationCenter + ) + + notificationCenter.post(name: .didRequestFCMTokenSync, object: nil) + try await waitUntil { await userService.updatedFCMTokens == ["current-token"] } + + authService.updateSession(uid: nil) + authService.updateSession(uid: "user-id") + try await waitUntil { await userService.updatedFCMTokens == ["current-token", "current-token"] } + _ = handler + } + + @Test("FCM token 저장에 실패하면 같은 요청을 다시 저장한다") + func FCM_token_저장에_실패하면_같은_요청을_다시_저장한다() async throws { + let notificationCenter = NotificationCenter() + let messagingService = PushMessagingServiceSpy(currentFCMToken: "current-token") + let userService = UserServiceSpy(updateError: FCMTokenSyncTestError()) + let authService = AuthServiceSpy() + let handler = FCMTokenSyncHandler( + authService: authService, + messagingService: messagingService, + userService: userService, + notificationCenter: notificationCenter + ) + + notificationCenter.post(name: .didRequestFCMTokenSync, object: nil) + try await waitUntil { await userService.updatedFCMTokens == ["current-token"] } + + await userService.setUpdateError(nil) + notificationCenter.post(name: .didRequestFCMTokenSync, object: nil) + try await waitUntil { await userService.updatedFCMTokens == ["current-token", "current-token"] } + _ = handler + } + @Test("로그인 세션 전이 시 현재 FCM token을 저장한다") func 로그인_세션_전이_시_현재_FCM_token을_저장한다() async throws { let notificationCenter = NotificationCenter() @@ -214,6 +279,11 @@ struct FCMTokenSyncHandlerTests { private actor UserServiceSpy: UserService { private(set) var updatedFCMTokens = [String]() + private var updateError: Error? + + init(updateError: Error? = nil) { + self.updateError = updateError + } func upsertUser(_ response: AuthDataResponse) async throws { } func fetchUserProfile() async throws -> UserProfileResponse { fatalError() } @@ -221,9 +291,16 @@ private actor UserServiceSpy: UserService { func updateFCMToken(_ fcmToken: String) async throws { updatedFCMTokens.append(fcmToken) + if let updateError { + throw updateError + } } func updateUserTimeZone() async throws { } + + func setUpdateError(_ error: Error?) { + updateError = error + } } private final class AuthServiceSpy: AuthService { @@ -255,7 +332,7 @@ private final class AuthServiceSpy: AuthService { } private final class PushMessagingServiceSpy: PushMessagingService { - private let currentFCMToken: String? + var currentFCMToken: String? private(set) var apnsTokens = [Data]() init(currentFCMToken: String?) { @@ -273,6 +350,8 @@ private final class PushMessagingServiceSpy: PushMessagingService { } } +private struct FCMTokenSyncTestError: Error { } + private final class NotificationObserver { private(set) var didReceiveNotification = false private var token: NSObjectProtocol?