From fa35f5df8fba6e18d7da271396fdd52a87548bce Mon Sep 17 00:00:00 2001 From: YuGeonHui Date: Thu, 24 Sep 2026 18:39:24 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=B5=9C=EA=B7=BC=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=20=EB=8F=99=EC=8B=9C=20=EC=A0=80=EC=9E=A5=20=EC=9C=A0=EC=8B=A4?= =?UTF-8?q?=20=E2=80=94=20Repository=EB=A5=BC=20actor=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 실제 버그: 최근 검색 유실 (재현 확인) `RecentSearchRepositoryImpl.save`/`remove`가 read-modify-write인데 값 타입이라 단일 writer가 보장되지 않았다. `AppDIContainer:234`가 repository를 1회 생성해 홈(`promoteChipDestination`)과 검색(`saveTask`)이 공유하므로 동시 쓰기 경로가 실재한다. 단일 인스턴스에 4건을 동시 저장하는 테스트로 재현: struct (수정 전): ["서울역", "홍대입구역"] 2건 유실 actor (수정 후): 4건 전부 보존 `actor`로 전환해 단일 writer를 타입으로 강제한다. `RecentSearchRepository` 프로토콜이 이미 `async throws`라 호출처 변경은 없다. 회귀 테스트는 **반드시 단일 인스턴스로** 검증한다 — actor 격리는 인스턴스 단위라, 매번 새 인스턴스를 만드는 computed property `sut`를 쓰면 수정 후에도 실패한다(실제로 그렇게 헛짚었다). 실제 앱의 공유 조건과 일치시켜야 한다. ## 잠재 버그: refreshPulled의 defer 누락 `Task.isCancelled` 경로에서 early return하면 `refreshTask`가 영구히 non-nil로 남아 진입 가드에 영원히 걸리고(당김 새로고침 영구 잠김) `onManualSyncFinished`도 불리지 않아 스피너가 멈추지 않는 구조다. 다만 `refreshTask?.cancel()`이 `deinit`에서만 호출되므로 취소 시점엔 VM이 해제 중이라 `[weak self]`가 먼저 nil이 되어 **현재 코드 경로에서는 발현되지 않는다.** 방어적 수정이자 같은 파일 `chipTapped`(:261)와의 일관성 확보다. ## 오진이었던 것 `SceneDelegate:108-109`의 UserDefaults 직접 읽기는 버그가 아니다. 어댑터 `:341-343`이 `devDismissedDefaultsKey`를 `nonisolated static`으로 의도적으로 노출하며 "MainActor(UI)에서 동기로 읽어도 어긋나지 않는다"고 명시한다. 경고 주석(`:346-347`)은 쓰기에 대한 것이고 여기는 읽기이며, `#if DEV` 한정이다. 검증: Domain 92 / AtchaData 53 / AtchaV2 20 / Home 66 / Search 26 / Settings 12 Co-Authored-By: Claude Opus 5 --- .../RecentSearchRepositoryImpl.swift | 6 +++++- .../RecentSearchRepositoryImplTests.swift | 21 +++++++++++++++++++ .../Feature/Home/Sources/HomeViewModel.swift | 5 ++++- 3 files changed, 30 insertions(+), 2 deletions(-) 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?() } }