diff --git a/Application/App/Sources/App/Delegate/AppDelegate.swift b/Application/App/Sources/App/Delegate/AppDelegate.swift index 26c26601..8dbd2565 100644 --- a/Application/App/Sources/App/Delegate/AppDelegate.swift +++ b/Application/App/Sources/App/Delegate/AppDelegate.swift @@ -62,9 +62,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } - // 앱이 온그라운드로 되었을 때, 로그인 세션이 존재한다면 현재 유저의 timeZone 저장 - NotificationCenter.default.post(name: .didRequestUserTimeZoneSync, object: nil) - // Firebase Messaging 설정 container.resolve(PushMessagingService.self).setDelegate(self) diff --git a/Application/App/Sources/App/Handler/UserTimeZoneSyncHandler.swift b/Application/App/Sources/App/Handler/UserTimeZoneSyncHandler.swift index c92e6fbb..d1f2cd03 100644 --- a/Application/App/Sources/App/Handler/UserTimeZoneSyncHandler.swift +++ b/Application/App/Sources/App/Handler/UserTimeZoneSyncHandler.swift @@ -11,15 +11,23 @@ import Data import Foundation final class UserTimeZoneSyncHandler { - private struct SyncKey: Equatable { + private struct SyncKey: Hashable { let uid: String let timeZoneIdentifier: String } + private enum SyncStartResult { + case started + case alreadySynced + case alreadySyncing + } + private let authService: AuthService private let userService: UserService private let logger = Logger(category: "UserTimeZoneSyncHandler") + private let lock = NSLock() private var lastSyncedKey: SyncKey? + private var syncingKeys = Set() private var cancellables = Set() init( @@ -31,6 +39,7 @@ final class UserTimeZoneSyncHandler { self.userService = userService authService.observeSignedIn() + .removeDuplicates() .sink { [weak self] isSignedIn in self?.handleSessionUpdate(isSignedIn: isSignedIn) } @@ -47,7 +56,7 @@ final class UserTimeZoneSyncHandler { private extension UserTimeZoneSyncHandler { func handleSessionUpdate(isSignedIn: Bool) { guard isSignedIn else { - lastSyncedKey = nil + resetSyncState() return } @@ -71,16 +80,56 @@ private extension UserTimeZoneSyncHandler { timeZoneIdentifier: TimeZone.autoupdatingCurrent.identifier ) - guard lastSyncedKey != key else { + switch beginSync(for: key) { + case .started: + break + case .alreadySynced: logger.info("Skipping timeZone update because the current user timeZone is already synced") return + case .alreadySyncing: + logger.info("Skipping timeZone update because the current user timeZone is already syncing") + return } do { try await userService.updateUserTimeZone() - lastSyncedKey = key + finishSync(for: key, didSucceed: true) } catch { + finishSync(for: key, didSucceed: false) logger.error("Failed to sync user timeZone", error: error) } } + + private func resetSyncState() { + lock.lock() + defer { lock.unlock() } + + lastSyncedKey = nil + syncingKeys.removeAll() + } + + private func beginSync(for key: SyncKey) -> SyncStartResult { + lock.lock() + defer { lock.unlock() } + + if lastSyncedKey == key { + return .alreadySynced + } + if syncingKeys.contains(key) { + return .alreadySyncing + } + + syncingKeys.insert(key) + return .started + } + + private func finishSync(for key: SyncKey, didSucceed: Bool) { + lock.lock() + defer { lock.unlock() } + + syncingKeys.remove(key) + if didSucceed { + lastSyncedKey = key + } + } } diff --git a/Application/App/Tests/UserTimeZone/UserTimeZoneSyncHandlerTests.swift b/Application/App/Tests/UserTimeZone/UserTimeZoneSyncHandlerTests.swift index 874816ce..33105332 100644 --- a/Application/App/Tests/UserTimeZone/UserTimeZoneSyncHandlerTests.swift +++ b/Application/App/Tests/UserTimeZone/UserTimeZoneSyncHandlerTests.swift @@ -49,6 +49,26 @@ struct UserTimeZoneSyncHandlerTests { _ = handler } + @Test("같은 로그인 상태가 연속 방출되면 현재 timeZone을 한 번만 저장한다") + func 같은_로그인_상태가_연속_방출되면_현재_timeZone을_한_번만_저장한다() async throws { + let userService = UserServiceSpy(updateDelay: .milliseconds(100)) + let authService = AuthServiceSpy(uid: "user-id") + let handler = UserTimeZoneSyncHandler( + authService: authService, + userService: userService + ) + + authService.updateSession(uid: "user-id") + authService.updateSession(uid: "user-id") + + try await waitUntil { + await userService.updateUserTimeZoneCallCount == 1 + } + try await Task.sleep(for: .milliseconds(150)) + #expect(await userService.updateUserTimeZoneCallCount == 1) + _ = handler + } + @Test("foreground 복귀 시 현재 timeZone을 요청하되 같은 사용자와 같은 timeZone은 중복 저장하지 않는다") func foreground_복귀_시_현재_timeZone을_요청하되_같은_사용자와_같은_timeZone은_중복_저장하지_않는다() async throws { let notificationCenter = NotificationCenter() @@ -71,8 +91,30 @@ struct UserTimeZoneSyncHandlerTests { _ = handler } - @Test("같은 timeZone이어도 사용자가 바뀌면 다시 저장한다") - func 같은_timeZone이어도_사용자가_바뀌면_다시_저장한다() async throws { + @Test("현재 timeZone 저장 중 foreground 요청이 들어오면 중복 저장하지 않는다") + func 현재_timeZone_저장_중_foreground_요청이_들어오면_중복_저장하지_않는다() async throws { + let notificationCenter = NotificationCenter() + let userService = UserServiceSpy(updateDelay: .milliseconds(100)) + let authService = AuthServiceSpy(uid: "user-id") + let handler = UserTimeZoneSyncHandler( + authService: authService, + userService: userService, + notificationCenter: notificationCenter + ) + + authService.updateSession(uid: "user-id") + try await waitUntil { + await userService.updateUserTimeZoneCallCount == 1 + } + + notificationCenter.post(name: .didRequestUserTimeZoneSync, object: nil) + try await Task.sleep(for: .milliseconds(150)) + #expect(await userService.updateUserTimeZoneCallCount == 1) + _ = handler + } + + @Test("로그아웃 후 다른 사용자로 로그인하면 같은 timeZone이어도 다시 저장한다") + func 로그아웃_후_다른_사용자로_로그인하면_같은_timeZone이어도_다시_저장한다() async throws { let userService = UserServiceSpy() let authService = AuthServiceSpy(uid: "first-user") let handler = UserTimeZoneSyncHandler( @@ -85,6 +127,7 @@ struct UserTimeZoneSyncHandlerTests { await userService.updateUserTimeZoneCallCount == 1 } + authService.updateSession(uid: nil) authService.updateSession(uid: "second-user") try await waitUntil { await userService.updateUserTimeZoneCallCount == 2 @@ -119,11 +162,13 @@ struct UserTimeZoneSyncHandlerTests { @Test("timeZone 저장 실패 시 캐시를 갱신하지 않는다") func timeZone_저장_실패_시_캐시를_갱신하지_않는다() async throws { + let notificationCenter = NotificationCenter() let userService = UserServiceSpy(updateError: TestError.updateFailed) let authService = AuthServiceSpy(uid: "user-id") let handler = UserTimeZoneSyncHandler( authService: authService, - userService: userService + userService: userService, + notificationCenter: notificationCenter ) authService.updateSession(uid: "user-id") @@ -132,7 +177,7 @@ struct UserTimeZoneSyncHandlerTests { } await userService.setUpdateError(nil) - authService.updateSession(uid: "user-id") + notificationCenter.post(name: .didRequestUserTimeZoneSync, object: nil) try await waitUntil { await userService.updateUserTimeZoneCallCount == 2 } @@ -142,10 +187,12 @@ struct UserTimeZoneSyncHandlerTests { private actor UserServiceSpy: UserService { private var updateError: Error? + private let updateDelay: Duration? private(set) var updateUserTimeZoneCallCount = 0 - init(updateError: Error? = nil) { + init(updateError: Error? = nil, updateDelay: Duration? = nil) { self.updateError = updateError + self.updateDelay = updateDelay } func upsertUser(_ response: AuthDataResponse) async throws { } @@ -155,6 +202,9 @@ private actor UserServiceSpy: UserService { func updateUserTimeZone() async throws { updateUserTimeZoneCallCount += 1 + if let updateDelay { + try await Task.sleep(for: updateDelay) + } if let updateError { throw updateError }