Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
21 changes: 21 additions & 0 deletions Projects/Data/Tests/RecentSearchRepositoryImplTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) == ["서울역", "홍대입구역", "강남역", "잠실역"])
}
}
5 changes: 4 additions & 1 deletion Projects/Feature/Home/Sources/HomeViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?()
}
}
Expand Down
Loading