diff --git a/Projects/Data/Sources/Repositories/RecentSearchRepositoryImpl.swift b/Projects/Data/Sources/Repositories/RecentSearchRepositoryImpl.swift index 02c8379e..5b24591a 100644 --- a/Projects/Data/Sources/Repositories/RecentSearchRepositoryImpl.swift +++ b/Projects/Data/Sources/Repositories/RecentSearchRepositoryImpl.swift @@ -1,7 +1,11 @@ import CoreStorage import Domain -public struct RecentSearchRepositoryImpl: RecentSearchRepository { +/// `actor`인 이유: save/remove가 전부 read-modify-write다. 값 타입이던 시절에는 +/// 홈의 목적지 승격 저장과 검색 화면의 저장이 겹치면 나중 쓰기가 먼저 쓰기를 +/// 통째로 덮어써 최근 검색 1건이 조용히 사라졌다(lost update). 단일 writer를 +/// 타입으로 강제해 막는다. +public actor RecentSearchRepositoryImpl: RecentSearchRepository { private let store: any KeyValueStore private let maxCount: Int private let storageKey = "recentSearches" diff --git a/Projects/Data/Tests/RecentSearchRepositoryImplTests.swift b/Projects/Data/Tests/RecentSearchRepositoryImplTests.swift index cd89dd53..9c49e5f2 100644 --- a/Projects/Data/Tests/RecentSearchRepositoryImplTests.swift +++ b/Projects/Data/Tests/RecentSearchRepositoryImplTests.swift @@ -101,4 +101,25 @@ struct RecentSearchRepositoryImplTests { let other = RecentSearchRepositoryImpl(store: store) #expect(try await other.recentSearches() == [.fixture()]) } + + /// 회귀: save가 read-modify-write라 값 타입이던 시절에는 동시 저장 시 나중 쓰기가 + /// 먼저 쓰기를 통째로 덮어써 1건이 사라졌다(홈의 목적지 승격 저장 ↔ 검색 화면 저장). + /// + /// **반드시 단일 인스턴스로 검증한다** — actor 격리는 인스턴스 단위라 `sut`(매번 새 + /// 인스턴스를 만드는 computed property)로는 직렬화가 성립하지 않는다. 실제 앱도 + /// `AppDIContainer`가 repository를 1회 생성해 홈·검색이 공유하므로 이쪽이 실조건이다. + @Test + func save_concurrentWrites_keepsBoth() async throws { + let shared = RecentSearchRepositoryImpl(store: store) + + await withTaskGroup(of: Void.self) { group in + for name in ["서울역", "홍대입구역", "강남역", "잠실역"] { + group.addTask { try? await shared.save(.fixture(name: name)) } + } + } + + let names = try await shared.recentSearches().map(\.name) + #expect(names.count == 4) + #expect(Set(names) == ["서울역", "홍대입구역", "강남역", "잠실역"]) + } } diff --git a/Projects/Feature/Home/Sources/HomeViewModel.swift b/Projects/Feature/Home/Sources/HomeViewModel.swift index 70623fd9..c7f2ec45 100644 --- a/Projects/Feature/Home/Sources/HomeViewModel.swift +++ b/Projects/Feature/Home/Sources/HomeViewModel.swift @@ -211,10 +211,13 @@ final class HomeViewModel { func refreshPulled() { guard refreshTask == nil else { return } refreshTask = Task { [weak self] in + // defer로 비워야 한다 — 아래 취소 가드에서 early return하면 refreshTask가 + // 영원히 non-nil로 남아 진입 가드에 영구히 걸리고(당김 새로고침 영구 잠김) + // onManualSyncFinished도 불리지 않아 스피너가 멈추지 않는다. + defer { self?.refreshTask = nil } guard let useCase = self?.requestAlarmSyncUseCase else { return } await useCase.execute() guard let self, !Task.isCancelled else { return } - self.refreshTask = nil self.onManualSyncFinished?() } }