From 0f90c8887f30db90f96dbecff150cbc9d9687d58 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 4 Jul 2026 01:50:23 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20timeZone=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 | 3 -- .../App/Handler/UserTimeZoneSyncHandler.swift | 11 ++++- .../UserTimeZoneSyncHandlerTests.swift | 49 ++++++++++++++++++- 3 files changed, 58 insertions(+), 5 deletions(-) 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..32754c59 100644 --- a/Application/App/Sources/App/Handler/UserTimeZoneSyncHandler.swift +++ b/Application/App/Sources/App/Handler/UserTimeZoneSyncHandler.swift @@ -11,7 +11,7 @@ import Data import Foundation final class UserTimeZoneSyncHandler { - private struct SyncKey: Equatable { + private struct SyncKey: Hashable { let uid: String let timeZoneIdentifier: String } @@ -20,6 +20,7 @@ final class UserTimeZoneSyncHandler { private let userService: UserService private let logger = Logger(category: "UserTimeZoneSyncHandler") private var lastSyncedKey: SyncKey? + private var syncingKeys = Set() private var cancellables = Set() init( @@ -31,6 +32,7 @@ final class UserTimeZoneSyncHandler { self.userService = userService authService.observeSignedIn() + .removeDuplicates() .sink { [weak self] isSignedIn in self?.handleSessionUpdate(isSignedIn: isSignedIn) } @@ -48,6 +50,7 @@ private extension UserTimeZoneSyncHandler { func handleSessionUpdate(isSignedIn: Bool) { guard isSignedIn else { lastSyncedKey = nil + syncingKeys.removeAll() return } @@ -75,8 +78,14 @@ private extension UserTimeZoneSyncHandler { logger.info("Skipping timeZone update because the current user timeZone is already synced") return } + guard !syncingKeys.contains(key) else { + logger.info("Skipping timeZone update because the current user timeZone is already syncing") + return + } do { + syncingKeys.insert(key) + defer { syncingKeys.remove(key) } try await userService.updateUserTimeZone() lastSyncedKey = key } catch { diff --git a/Application/App/Tests/UserTimeZone/UserTimeZoneSyncHandlerTests.swift b/Application/App/Tests/UserTimeZone/UserTimeZoneSyncHandlerTests.swift index 874816ce..310a6638 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,6 +91,28 @@ struct UserTimeZoneSyncHandlerTests { _ = handler } + @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() @@ -142,10 +184,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 +199,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 } From 8c3df238af39b985dc584e376328d03ec1eac387 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:56:10 +0900 Subject: [PATCH 2/3] =?UTF-8?q?test:=20UserTimeZone=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=A0=84=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserTimeZone/UserTimeZoneSyncHandlerTests.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Application/App/Tests/UserTimeZone/UserTimeZoneSyncHandlerTests.swift b/Application/App/Tests/UserTimeZone/UserTimeZoneSyncHandlerTests.swift index 310a6638..33105332 100644 --- a/Application/App/Tests/UserTimeZone/UserTimeZoneSyncHandlerTests.swift +++ b/Application/App/Tests/UserTimeZone/UserTimeZoneSyncHandlerTests.swift @@ -113,8 +113,8 @@ struct UserTimeZoneSyncHandlerTests { _ = handler } - @Test("같은 timeZone이어도 사용자가 바뀌면 다시 저장한다") - func 같은_timeZone이어도_사용자가_바뀌면_다시_저장한다() async throws { + @Test("로그아웃 후 다른 사용자로 로그인하면 같은 timeZone이어도 다시 저장한다") + func 로그아웃_후_다른_사용자로_로그인하면_같은_timeZone이어도_다시_저장한다() async throws { let userService = UserServiceSpy() let authService = AuthServiceSpy(uid: "first-user") let handler = UserTimeZoneSyncHandler( @@ -127,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 @@ -161,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") @@ -174,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 } From e22e44c4c390d0888d47515e2834c657933281bc Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 4 Jul 2026 11:57:37 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20UserTimeZone=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EC=83=81=ED=83=9C=20=EC=A0=91=EA=B7=BC=20=EB=B3=B4?= =?UTF-8?q?=ED=98=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../App/Handler/UserTimeZoneSyncHandler.swift | 56 ++++++++++++++++--- 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/Application/App/Sources/App/Handler/UserTimeZoneSyncHandler.swift b/Application/App/Sources/App/Handler/UserTimeZoneSyncHandler.swift index 32754c59..d1f2cd03 100644 --- a/Application/App/Sources/App/Handler/UserTimeZoneSyncHandler.swift +++ b/Application/App/Sources/App/Handler/UserTimeZoneSyncHandler.swift @@ -16,9 +16,16 @@ final class UserTimeZoneSyncHandler { 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() @@ -49,8 +56,7 @@ final class UserTimeZoneSyncHandler { private extension UserTimeZoneSyncHandler { func handleSessionUpdate(isSignedIn: Bool) { guard isSignedIn else { - lastSyncedKey = nil - syncingKeys.removeAll() + resetSyncState() return } @@ -74,22 +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 - } - guard !syncingKeys.contains(key) else { + case .alreadySyncing: logger.info("Skipping timeZone update because the current user timeZone is already syncing") return } do { - syncingKeys.insert(key) - defer { syncingKeys.remove(key) } 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 + } + } }