From bbc651c8c0bca0eccb6d3f17507231ace6e37910 Mon Sep 17 00:00:00 2001 From: YuGeonHui Date: Thu, 24 Sep 2026 22:54:49 +0900 Subject: [PATCH 1/6] =?UTF-8?q?refactor:=20=EC=9C=84=EC=9E=84=EB=A7=8C=20?= =?UTF-8?q?=ED=95=98=EB=8D=98=20UseCase=204=EA=B0=9C=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=20(Phase=20F=20=EC=99=84=EB=A3=8C,=2015=20=E2=86=92=2011)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetCurrentLocation / ReverseGeocode / SearchPlaces / RecentSearches 를 지우고 호출처가 Domain 포트(LocationService·PlaceRepository· RecentSearchRepository)를 직접 주입받는다. 넷 다 단일 Repository 메서드를 한 줄 위임하던 계층이라, 같은 프로토콜에 이름만 하나 더 입히고 있었다. Presentation → Domain 경계는 그대로다 — 피처가 받는 건 여전히 Domain 프로토콜이고 구체 Data 타입은 AppDIContainer만 본다. 부수 정리: - 위임 여부만 검증하던 Domain 테스트 5개 삭제 (Observe/ObserveChange/ RequestAlarmSync/GetLastRouteDetail/ReverseGeocode). 앞 커밋에서 UseCase를 지울 때 테스트 파일이 남아 DomainTests가 컴파일되지 않고 있었다. - Example/Tests 스텁이 좁은 UseCase 대신 PlaceRepository 전체를 채택하게 되어 쓰지 않는 메서드에 기본 구현을 달았다(의도된 Tests/Example 중복 규약). - RecentSearchesUseCase.fetch() → RecentSearchRepository.recentSearches()로 호출명이 바뀐다. 검증: Debug/Stage/Release 3구성 빌드 + Domain 112 · AtchaData 53 · AtchaV2 39 · HomeFeature 66 · SearchFeature 26 · SettingsFeature 18 전부 통과. Co-Authored-By: Claude Opus 5 --- Projects/.DS_Store | Bin 0 -> 6148 bytes Projects/App/Sources/AppDIContainer.swift | 28 ++++---- .../UseCases/GetCurrentLocationUseCase.swift | 15 ----- .../UseCases/RecentSearchesUseCase.swift | 25 -------- .../UseCases/ReverseGeocodeUseCase.swift | 15 ----- .../UseCases/SearchPlacesUseCase.swift | 15 ----- ...efaultGetLastRouteDetailUseCaseTests.swift | 40 ------------ ...efaultObserveAlarmChangeUseCaseTests.swift | 30 --------- .../DefaultObserveAlarmUseCaseTests.swift | 39 ------------ .../DefaultRequestAlarmSyncUseCaseTests.swift | 22 ------- .../DefaultReverseGeocodeUseCaseTests.swift | 60 ------------------ .../Feature/Home/Example/ExampleApp.swift | 22 ++++--- .../Home/Sources/HomeDIContainer.swift | 24 +++---- .../Feature/Home/Sources/HomeViewModel.swift | 44 ++++++------- .../Home/Tests/HomeViewModelTests.swift | 22 ++++--- .../Feature/Search/Example/ExampleApp.swift | 19 ++++-- .../Search/Sources/SearchDIContainer.swift | 24 +++---- .../Search/Sources/SearchViewModel.swift | 38 +++++------ .../Search/Tests/SearchCoordinatorTests.swift | 18 ++++-- .../Search/Tests/SearchViewModelTests.swift | 41 ++++++++---- .../Feature/Settings/Example/ExampleApp.swift | 31 ++++----- .../Sources/HomeAddressViewModel.swift | 27 ++++---- .../Sources/SettingsDIContainer.swift | 20 +++--- .../Tests/HomeAddressViewModelTests.swift | 5 +- .../Settings/Tests/SettingsFeatureStubs.swift | 23 ++++--- 25 files changed, 205 insertions(+), 442 deletions(-) create mode 100644 Projects/.DS_Store delete mode 100644 Projects/Domain/Sources/UseCases/GetCurrentLocationUseCase.swift delete mode 100644 Projects/Domain/Sources/UseCases/RecentSearchesUseCase.swift delete mode 100644 Projects/Domain/Sources/UseCases/ReverseGeocodeUseCase.swift delete mode 100644 Projects/Domain/Sources/UseCases/SearchPlacesUseCase.swift delete mode 100644 Projects/Domain/Tests/DefaultGetLastRouteDetailUseCaseTests.swift delete mode 100644 Projects/Domain/Tests/DefaultObserveAlarmChangeUseCaseTests.swift delete mode 100644 Projects/Domain/Tests/DefaultObserveAlarmUseCaseTests.swift delete mode 100644 Projects/Domain/Tests/DefaultRequestAlarmSyncUseCaseTests.swift delete mode 100644 Projects/Domain/Tests/DefaultReverseGeocodeUseCaseTests.swift diff --git a/Projects/.DS_Store b/Projects/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..df6ae3b4d3b44de4a11b54a8398ca5ff49f23f16 GIT binary patch literal 6148 zcmeHK%T5$Q6g@SxFghfRJBTj&7w`vchL#n(`2mMVMiQEyC@jW>X&34a`2h$!RwgF? zfJ>I_C6PV+fUxo0s+#F4dYYJ!7-Q-tRj2yiTXoNze$*5I)!Exv1ljU-JQY`1{iGj-S_u+SVpouP1m;bHF}z@ zU)Mk0Jv&`;qZ@Zb2d|t}A0KcUA}k}vYixQI)$@)Ub8D`Ax*63R&&~3(>ZG_=w{c>3ExB`hm8T~9dO{|*GF&dLqsE9!C9AxX^;sUDnYPhom3;bSw2q;t3!Z$_x;Sx{ z%l_4v%QU>4l1W{T#{;qOxUX@~7HhT)hCV0|6bK586_D>k%qEyvEDY*T2RnTPAVzd+ z<5+(qiLp3{iN(SoJv3#e63tX)j~L2Kr$4rNiN(U8nGU5Za~)e**%OM=)#;CQI#gmX z^g)53fUm%mxope+|DyiL4lyaf2Dwm(r((}PqKS!<;Tfh8?(J+6O+8cpq|3c iZpV5cTk$-bHpXHZ5EF}qL3(K6kARjTbU}f?s=#mJTj|39 literal 0 HcmV?d00001 diff --git a/Projects/App/Sources/AppDIContainer.swift b/Projects/App/Sources/AppDIContainer.swift index 1ae93911..fb975e36 100644 --- a/Projects/App/Sources/AppDIContainer.swift +++ b/Projects/App/Sources/AppDIContainer.swift @@ -194,7 +194,7 @@ final class AppDIContainer { } func makeSettingsDIContainer( - getCurrentLocationUseCase: any GetCurrentLocationUseCase + locationService: any LocationService ) -> any SettingsCoordinatorBuildable { SettingsDIContainer( userRepository: userRepository, @@ -204,9 +204,8 @@ final class AppDIContainer { userRepository: userRepository, placeRepository: placeRepository ), - searchPlacesUseCase: DefaultSearchPlacesUseCase(repository: placeRepository), - getCurrentLocationUseCase: getCurrentLocationUseCase, - reverseGeocodeUseCase: DefaultReverseGeocodeUseCase(repository: placeRepository), + placeRepository: placeRepository, + locationService: locationService, checkAppUpdateUseCase: DefaultCheckAppUpdateUseCase( repository: AppVersionRepositoryImpl(networkClient: networkClient) ), @@ -241,26 +240,21 @@ final class AppDIContainer { func makeHomeDIContainer() -> any HomeCoordinatorBuildable { let recentSearchRepository = RecentSearchRepositoryImpl(store: UserDefaultsKeyValueStore()) - let locationService = CoreLocationServiceAdapter() - let getCurrentLocation: any GetCurrentLocationUseCase = - DefaultGetCurrentLocationUseCase(locationService: locationService) + let locationService: any LocationService = CoreLocationServiceAdapter() // 원탭 칩(Phase 18) — 검색과 홈이 같은 인스턴스를 봐야 검색의 저장·삭제가 // 칩에 그대로 비친다 (recentSearchRepository 1회 생성과 같은 이유). let searchLastRoutes: any SearchLastRoutesUseCase = DefaultSearchLastRoutesUseCase(repository: lastRouteRepository) - let recentSearches: any RecentSearchesUseCase = - DefaultRecentSearchesUseCase(repository: recentSearchRepository) - let searchContainer = SearchDIContainer( - searchPlacesUseCase: DefaultSearchPlacesUseCase(repository: placeRepository), + placeRepository: placeRepository, searchLastRoutesUseCase: searchLastRoutes, - recentSearchesUseCase: recentSearches, - getCurrentLocationUseCase: getCurrentLocation + recentSearchRepository: recentSearchRepository, + locationService: locationService ) return HomeDIContainer( - getCurrentLocationUseCase: getCurrentLocation, - reverseGeocodeUseCase: DefaultReverseGeocodeUseCase(repository: placeRepository), + locationService: locationService, + placeRepository: placeRepository, registerAlarmUseCase: DefaultRegisterAlarmUseCase( repository: alarmRepository, scheduler: alarmScheduler, @@ -285,10 +279,10 @@ final class AppDIContainer { lastRouteRepository: lastRouteRepository, // 원탭 칩(Phase 18) — 검색 화면과 같은 인스턴스 공유(위 주석 참조). searchLastRoutesUseCase: searchLastRoutes, - recentSearchesUseCase: recentSearches, + recentSearchRepository: recentSearchRepository, searchCoordinatorBuildable: searchContainer, settingsCoordinatorBuildable: makeSettingsDIContainer( - getCurrentLocationUseCase: getCurrentLocation + locationService: locationService ) ) } diff --git a/Projects/Domain/Sources/UseCases/GetCurrentLocationUseCase.swift b/Projects/Domain/Sources/UseCases/GetCurrentLocationUseCase.swift deleted file mode 100644 index 80cac688..00000000 --- a/Projects/Domain/Sources/UseCases/GetCurrentLocationUseCase.swift +++ /dev/null @@ -1,15 +0,0 @@ -public protocol GetCurrentLocationUseCase: Sendable { - func execute() async throws -> Coordinate -} - -public struct DefaultGetCurrentLocationUseCase: GetCurrentLocationUseCase { - private let locationService: any LocationService - - public init(locationService: any LocationService) { - self.locationService = locationService - } - - public func execute() async throws -> Coordinate { - try await locationService.currentLocation() - } -} diff --git a/Projects/Domain/Sources/UseCases/RecentSearchesUseCase.swift b/Projects/Domain/Sources/UseCases/RecentSearchesUseCase.swift deleted file mode 100644 index 59497f38..00000000 --- a/Projects/Domain/Sources/UseCases/RecentSearchesUseCase.swift +++ /dev/null @@ -1,25 +0,0 @@ -public protocol RecentSearchesUseCase: Sendable { - func fetch() async throws -> [Place] - func save(_ place: Place) async throws - func remove(_ place: Place) async throws -} - -public struct DefaultRecentSearchesUseCase: RecentSearchesUseCase { - private let repository: any RecentSearchRepository - - public init(repository: any RecentSearchRepository) { - self.repository = repository - } - - public func fetch() async throws -> [Place] { - try await repository.recentSearches() - } - - public func save(_ place: Place) async throws { - try await repository.save(place) - } - - public func remove(_ place: Place) async throws { - try await repository.remove(place) - } -} diff --git a/Projects/Domain/Sources/UseCases/ReverseGeocodeUseCase.swift b/Projects/Domain/Sources/UseCases/ReverseGeocodeUseCase.swift deleted file mode 100644 index 6eb73e60..00000000 --- a/Projects/Domain/Sources/UseCases/ReverseGeocodeUseCase.swift +++ /dev/null @@ -1,15 +0,0 @@ -public protocol ReverseGeocodeUseCase: Sendable { - func execute(coordinate: Coordinate) async throws -> Place -} - -public struct DefaultReverseGeocodeUseCase: ReverseGeocodeUseCase { - private let repository: any PlaceRepository - - public init(repository: any PlaceRepository) { - self.repository = repository - } - - public func execute(coordinate: Coordinate) async throws -> Place { - try await repository.reverseGeocode(coordinate) - } -} diff --git a/Projects/Domain/Sources/UseCases/SearchPlacesUseCase.swift b/Projects/Domain/Sources/UseCases/SearchPlacesUseCase.swift deleted file mode 100644 index bef88198..00000000 --- a/Projects/Domain/Sources/UseCases/SearchPlacesUseCase.swift +++ /dev/null @@ -1,15 +0,0 @@ -public protocol SearchPlacesUseCase: Sendable { - func execute(keyword: String, near coordinate: Coordinate?) async throws -> [Place] -} - -public struct DefaultSearchPlacesUseCase: SearchPlacesUseCase { - private let repository: any PlaceRepository - - public init(repository: any PlaceRepository) { - self.repository = repository - } - - public func execute(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { - try await repository.searchPlaces(keyword: keyword, near: coordinate) - } -} diff --git a/Projects/Domain/Tests/DefaultGetLastRouteDetailUseCaseTests.swift b/Projects/Domain/Tests/DefaultGetLastRouteDetailUseCaseTests.swift deleted file mode 100644 index ba4007c9..00000000 --- a/Projects/Domain/Tests/DefaultGetLastRouteDetailUseCaseTests.swift +++ /dev/null @@ -1,40 +0,0 @@ -@testable import Domain -import Foundation -import Testing - -private struct StubError: Error {} - -private struct StubLastRouteRepository: LastRouteRepository { - var result: Result - - func searchLastRoutes(start: Coordinate, end: Coordinate) async throws -> [LastRoute] { [] } - - func lastRoute(id: String) async throws -> LastRoute { - try result.get() - } -} - -private let fixture = LastRoute( - id: "route-1", departureTime: Date(timeIntervalSince1970: 1_000), totalTime: 0, - totalWalkTime: 0, transferCount: 0, totalDistance: 0, totalWalkDistance: 0, legs: [] -) - -struct DefaultGetLastRouteDetailUseCaseTests { - @Test - func execute_returnsRepositoryDetail() async throws { - let sut = DefaultGetLastRouteDetailUseCase( - repository: StubLastRouteRepository(result: .success(fixture)) - ) - #expect(try await sut.execute(routeId: "route-1") == fixture) - } - - @Test - func execute_repositoryFails_rethrows() async { - let sut = DefaultGetLastRouteDetailUseCase( - repository: StubLastRouteRepository(result: .failure(StubError())) - ) - await #expect(throws: StubError.self) { - _ = try await sut.execute(routeId: "route-1") - } - } -} diff --git a/Projects/Domain/Tests/DefaultObserveAlarmChangeUseCaseTests.swift b/Projects/Domain/Tests/DefaultObserveAlarmChangeUseCaseTests.swift deleted file mode 100644 index ebf36cd2..00000000 --- a/Projects/Domain/Tests/DefaultObserveAlarmChangeUseCaseTests.swift +++ /dev/null @@ -1,30 +0,0 @@ -@testable import Domain -import Foundation -import Testing - -private struct StubAlarmChangeEvents: AlarmChangeEvents { - let handler: @Sendable () -> AsyncStream - func changes() -> AsyncStream { handler() } -} - -struct DefaultObserveAlarmChangeUseCaseTests { - @Test - func execute_forwardsPortStream() async { - let sut = DefaultObserveAlarmChangeUseCase( - events: StubAlarmChangeEvents { - AsyncStream { continuation in - continuation.yield(.delayed(by: 600)) - continuation.yield(.sessionEnded) - continuation.finish() - } - } - ) - - var received: [AlarmChangeVerdict] = [] - for await verdict in sut.execute() { - received.append(verdict) - } - - #expect(received == [.delayed(by: 600), .sessionEnded]) - } -} diff --git a/Projects/Domain/Tests/DefaultObserveAlarmUseCaseTests.swift b/Projects/Domain/Tests/DefaultObserveAlarmUseCaseTests.swift deleted file mode 100644 index 274f4799..00000000 --- a/Projects/Domain/Tests/DefaultObserveAlarmUseCaseTests.swift +++ /dev/null @@ -1,39 +0,0 @@ -@testable import Domain -import Foundation -import Testing - -private struct StubAlarmSyncEvents: AlarmSyncEvents { - let handler: @Sendable () -> AsyncStream - func updates() -> AsyncStream { handler() } -} - -struct DefaultObserveAlarmUseCaseTests { - @Test - func execute_forwardsPortStream() async { - let update = AlarmSyncUpdate( - info: AlarmInfo( - lastRouteId: "r1", - departureTime: Date(timeIntervalSince1970: 1_755_800_000), - updatedAt: nil, - isReal: true - ), - // 확인 시각이 함께 흘러야 한다 — 신선도 스탬프의 원천(Phase 16). - checkedAt: Date(timeIntervalSince1970: 1_755_790_000) - ) - let sut = DefaultObserveAlarmUseCase( - events: StubAlarmSyncEvents { - AsyncStream { continuation in - continuation.yield(update) - continuation.finish() - } - } - ) - - var received: [AlarmSyncUpdate] = [] - for await value in sut.execute() { - received.append(value) - } - - #expect(received == [update]) - } -} diff --git a/Projects/Domain/Tests/DefaultRequestAlarmSyncUseCaseTests.swift b/Projects/Domain/Tests/DefaultRequestAlarmSyncUseCaseTests.swift deleted file mode 100644 index 7d8c4e54..00000000 --- a/Projects/Domain/Tests/DefaultRequestAlarmSyncUseCaseTests.swift +++ /dev/null @@ -1,22 +0,0 @@ -@testable import Domain -import Foundation -import Testing - -private actor SpyAlarmSyncRequesting: AlarmSyncRequesting { - private(set) var syncNowCount = 0 - func syncNow() async { syncNowCount += 1 } -} - -struct DefaultRequestAlarmSyncUseCaseTests { - @Test - func execute_forwardsToPortOncePerCall() async { - let spy = SpyAlarmSyncRequesting() - let sut = DefaultRequestAlarmSyncUseCase(requesting: spy) - - await sut.execute() - await sut.execute() - - // 합류·중복 억제는 포트 구현(AlarmSyncService inFlight)의 몫 — UseCase는 위임만. - #expect(await spy.syncNowCount == 2) - } -} diff --git a/Projects/Domain/Tests/DefaultReverseGeocodeUseCaseTests.swift b/Projects/Domain/Tests/DefaultReverseGeocodeUseCaseTests.swift deleted file mode 100644 index 81f50d13..00000000 --- a/Projects/Domain/Tests/DefaultReverseGeocodeUseCaseTests.swift +++ /dev/null @@ -1,60 +0,0 @@ -@testable import Domain -import Foundation -import Testing - -private struct StubError: Error {} - -private actor CallLog { - private(set) var coordinates: [Coordinate] = [] - func append(_ coordinate: Coordinate) { coordinates.append(coordinate) } -} - -private struct SpyPlaceRepository: PlaceRepository { - let log: CallLog - var place: Place? = nil - - func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { - [] - } - - func reverseGeocode(_ coordinate: Coordinate) async throws -> Place { - await log.append(coordinate) - guard let place else { throw StubError() } - return place - } - - func isServiceRegion(_ coordinate: Coordinate) async throws -> Bool { true } -} - -struct DefaultReverseGeocodeUseCaseTests { - @Test - func execute_forwardsCoordinateAndReturnsPlace() async throws { - let log = CallLog() - let expected = Place( - name: "강남역", - address: "서울 강남구 강남대로 396", - coordinate: Coordinate(latitude: 37.4979, longitude: 127.0276) - ) - let sut = DefaultReverseGeocodeUseCase( - repository: SpyPlaceRepository(log: log, place: expected) - ) - - let place = try await sut.execute( - coordinate: Coordinate(latitude: 37.4979, longitude: 127.0276) - ) - - #expect(place == expected) - #expect(await log.coordinates == [Coordinate(latitude: 37.4979, longitude: 127.0276)]) - } - - @Test - func execute_propagatesRepositoryError() async { - let sut = DefaultReverseGeocodeUseCase( - repository: SpyPlaceRepository(log: CallLog()) - ) - - await #expect(throws: StubError.self) { - _ = try await sut.execute(coordinate: Coordinate(latitude: 0, longitude: 0)) - } - } -} diff --git a/Projects/Feature/Home/Example/ExampleApp.swift b/Projects/Feature/Home/Example/ExampleApp.swift index 70bb8177..d2fe625f 100644 --- a/Projects/Feature/Home/Example/ExampleApp.swift +++ b/Projects/Feature/Home/Example/ExampleApp.swift @@ -35,8 +35,8 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { guard let windowScene = scene as? UIWindowScene else { return } let navigationController = UINavigationController() let container = HomeDIContainer( - getCurrentLocationUseCase: PreviewGetCurrentLocationUseCase(), - reverseGeocodeUseCase: PreviewReverseGeocodeUseCase(), + locationService: PreviewLocationService(), + placeRepository: PreviewPlaceRepository(), registerAlarmUseCase: PreviewRegisterAlarmUseCase(), cancelAlarmUseCase: PreviewCancelAlarmUseCase(), alarmSyncEvents: PreviewAlarmSyncEvents(), @@ -44,7 +44,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { alarmSyncRequesting: PreviewAlarmSyncRequesting(), lastRouteRepository: PreviewLastRouteRepository(), searchLastRoutesUseCase: PreviewSearchLastRoutesUseCase(), - recentSearchesUseCase: PreviewRecentSearchesUseCase(), + recentSearchRepository: PreviewRecentSearchRepository(), searchCoordinatorBuildable: PreviewSearchCoordinatorBuildable() ) let coordinator = container.makeHomeCoordinator(navigationController: navigationController) @@ -60,18 +60,22 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { // Example apps wire stub use cases — no Data/network dependency. -struct PreviewGetCurrentLocationUseCase: GetCurrentLocationUseCase { - func execute() async throws -> Coordinate { +struct PreviewLocationService: LocationService { + func currentLocation() async throws -> Coordinate { try? await Task.sleep(for: .milliseconds(400)) return Coordinate(latitude: 37.4979, longitude: 127.0276) } } -struct PreviewReverseGeocodeUseCase: ReverseGeocodeUseCase { - func execute(coordinate: Coordinate) async throws -> Place { +struct PreviewPlaceRepository: PlaceRepository { + func reverseGeocode(_ coordinate: Coordinate) async throws -> Place { try? await Task.sleep(for: .milliseconds(200)) return Place(name: "강남역", address: "서울 강남구 강남대로 396", coordinate: coordinate) } + + // 홈은 역지오코딩만 쓴다 — 나머지는 프리뷰에서 호출되지 않는다. + func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { [] } + func isServiceRegion(_ coordinate: Coordinate) async throws -> Bool { true } } struct PreviewRegisterAlarmUseCase: RegisterAlarmUseCase { @@ -130,8 +134,8 @@ struct PreviewSearchLastRoutesUseCase: SearchLastRoutesUseCase { } /// 최근 검색 스텁(Phase 18) — canned 1건으로 칩이 즉시 표출된다. save/remove는 no-op. -struct PreviewRecentSearchesUseCase: RecentSearchesUseCase { - func fetch() async throws -> [Place] { +struct PreviewRecentSearchRepository: RecentSearchRepository { + func recentSearches() async throws -> [Place] { [PreviewSearchCoordinator.makeCannedArrival()] } diff --git a/Projects/Feature/Home/Sources/HomeDIContainer.swift b/Projects/Feature/Home/Sources/HomeDIContainer.swift index 854d0330..c5846fbd 100644 --- a/Projects/Feature/Home/Sources/HomeDIContainer.swift +++ b/Projects/Feature/Home/Sources/HomeDIContainer.swift @@ -9,8 +9,8 @@ import UIKit /// screen/ViewModel assembly stays here so adding screens never bloats /// coordinator initializers. public final class HomeDIContainer: HomeCoordinatorBuildable { - private let getCurrentLocationUseCase: any GetCurrentLocationUseCase - private let reverseGeocodeUseCase: any ReverseGeocodeUseCase + private let locationService: any LocationService + private let placeRepository: any PlaceRepository private let registerAlarmUseCase: any RegisterAlarmUseCase private let cancelAlarmUseCase: any CancelAlarmUseCase private let alarmSyncEvents: any AlarmSyncEvents @@ -18,14 +18,14 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { private let alarmSyncRequesting: any AlarmSyncRequesting private let lastRouteRepository: any LastRouteRepository private let searchLastRoutesUseCase: any SearchLastRoutesUseCase - private let recentSearchesUseCase: any RecentSearchesUseCase + private let recentSearchRepository: any RecentSearchRepository private let searchCoordinatorBuildable: any SearchCoordinatorBuildable /// nil이면 톱니바퀴가 아무 일도 하지 않는다(Example 구성 호환). private let settingsCoordinatorBuildable: (any SettingsCoordinatorBuildable)? public init( - getCurrentLocationUseCase: any GetCurrentLocationUseCase, - reverseGeocodeUseCase: any ReverseGeocodeUseCase, + locationService: any LocationService, + placeRepository: any PlaceRepository, registerAlarmUseCase: any RegisterAlarmUseCase, cancelAlarmUseCase: any CancelAlarmUseCase, alarmSyncEvents: any AlarmSyncEvents, @@ -33,12 +33,12 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { alarmSyncRequesting: any AlarmSyncRequesting, lastRouteRepository: any LastRouteRepository, searchLastRoutesUseCase: any SearchLastRoutesUseCase, - recentSearchesUseCase: any RecentSearchesUseCase, + recentSearchRepository: any RecentSearchRepository, searchCoordinatorBuildable: any SearchCoordinatorBuildable, settingsCoordinatorBuildable: (any SettingsCoordinatorBuildable)? = nil ) { - self.getCurrentLocationUseCase = getCurrentLocationUseCase - self.reverseGeocodeUseCase = reverseGeocodeUseCase + self.locationService = locationService + self.placeRepository = placeRepository self.registerAlarmUseCase = registerAlarmUseCase self.cancelAlarmUseCase = cancelAlarmUseCase self.alarmSyncEvents = alarmSyncEvents @@ -46,7 +46,7 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { self.alarmSyncRequesting = alarmSyncRequesting self.lastRouteRepository = lastRouteRepository self.searchLastRoutesUseCase = searchLastRoutesUseCase - self.recentSearchesUseCase = recentSearchesUseCase + self.recentSearchRepository = recentSearchRepository self.searchCoordinatorBuildable = searchCoordinatorBuildable self.settingsCoordinatorBuildable = settingsCoordinatorBuildable } @@ -63,8 +63,8 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { onSettingsRequested: @escaping () -> Void ) -> UIViewController { let viewModel = HomeViewModel( - getCurrentLocationUseCase: getCurrentLocationUseCase, - reverseGeocodeUseCase: reverseGeocodeUseCase, + locationService: locationService, + placeRepository: placeRepository, registerAlarmUseCase: registerAlarmUseCase, cancelAlarmUseCase: cancelAlarmUseCase, alarmSyncEvents: alarmSyncEvents, @@ -72,7 +72,7 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { alarmSyncRequesting: alarmSyncRequesting, lastRouteRepository: lastRouteRepository, searchLastRoutesUseCase: searchLastRoutesUseCase, - recentSearchesUseCase: recentSearchesUseCase + recentSearchRepository: recentSearchRepository ) viewModel.onSearchRequested = onSearchRequested viewModel.onSettingsRequested = onSettingsRequested diff --git a/Projects/Feature/Home/Sources/HomeViewModel.swift b/Projects/Feature/Home/Sources/HomeViewModel.swift index 7f20a34e..27c570b5 100644 --- a/Projects/Feature/Home/Sources/HomeViewModel.swift +++ b/Projects/Feature/Home/Sources/HomeViewModel.swift @@ -124,8 +124,8 @@ final class HomeViewModel { didSet { if state != oldValue { onStateChange?(state) } } } - private let getCurrentLocationUseCase: any GetCurrentLocationUseCase - private let reverseGeocodeUseCase: any ReverseGeocodeUseCase + private let locationService: any LocationService + private let placeRepository: any PlaceRepository private let registerAlarmUseCase: any RegisterAlarmUseCase private let cancelAlarmUseCase: any CancelAlarmUseCase // Domain 포트를 직접 받는다. 이전에는 같은 프로토콜을 한 겹 더 감싼 UseCase를 @@ -135,7 +135,7 @@ final class HomeViewModel { private let alarmSyncRequesting: any AlarmSyncRequesting private let lastRouteRepository: any LastRouteRepository private let searchLastRoutesUseCase: any SearchLastRoutesUseCase - private let recentSearchesUseCase: any RecentSearchesUseCase + private let recentSearchRepository: any RecentSearchRepository private let now: @Sendable () -> Date /// 화면에 떠 있는 경로. id는 State가 파생(알람 버튼)에 쓰므로 **함께 갱신한다** — @@ -158,8 +158,8 @@ final class HomeViewModel { private var chipSearchTask: Task? init( - getCurrentLocationUseCase: any GetCurrentLocationUseCase, - reverseGeocodeUseCase: any ReverseGeocodeUseCase, + locationService: any LocationService, + placeRepository: any PlaceRepository, registerAlarmUseCase: any RegisterAlarmUseCase, cancelAlarmUseCase: any CancelAlarmUseCase, alarmSyncEvents: any AlarmSyncEvents, @@ -167,11 +167,11 @@ final class HomeViewModel { alarmSyncRequesting: any AlarmSyncRequesting, lastRouteRepository: any LastRouteRepository, searchLastRoutesUseCase: any SearchLastRoutesUseCase, - recentSearchesUseCase: any RecentSearchesUseCase, + recentSearchRepository: any RecentSearchRepository, now: @escaping @Sendable () -> Date = { Date() } ) { - self.getCurrentLocationUseCase = getCurrentLocationUseCase - self.reverseGeocodeUseCase = reverseGeocodeUseCase + self.locationService = locationService + self.placeRepository = placeRepository self.registerAlarmUseCase = registerAlarmUseCase self.cancelAlarmUseCase = cancelAlarmUseCase self.alarmSyncEvents = alarmSyncEvents @@ -179,7 +179,7 @@ final class HomeViewModel { self.alarmSyncRequesting = alarmSyncRequesting self.lastRouteRepository = lastRouteRepository self.searchLastRoutesUseCase = searchLastRoutesUseCase - self.recentSearchesUseCase = recentSearchesUseCase + self.recentSearchRepository = recentSearchRepository self.now = now } @@ -218,11 +218,11 @@ final class HomeViewModel { guard case .needsSearch = state.departure else { return } locationTask?.cancel() locationTask = Task { [weak self] in - guard let locationUseCase = self?.getCurrentLocationUseCase, - let coordinate = try? await locationUseCase.execute() else { return } + guard let locationUseCase = self?.locationService, + let coordinate = try? await locationUseCase.currentLocation() else { return } guard !Task.isCancelled, - let geocodeUseCase = self?.reverseGeocodeUseCase, - let place = try? await geocodeUseCase.execute(coordinate: coordinate) + let geocodeUseCase = self?.placeRepository, + let place = try? await geocodeUseCase.reverseGeocode(coordinate) else { return } guard !Task.isCancelled else { return } self?.state.departure = .current(name: place.name) @@ -283,8 +283,8 @@ final class HomeViewModel { let start: Coordinate do { - guard let locationUseCase = self?.getCurrentLocationUseCase else { return } - start = try await locationUseCase.execute() + guard let locationUseCase = self?.locationService else { return } + start = try await locationUseCase.currentLocation() } catch let error as LocationError { guard !Task.isCancelled else { return } // 사유별 안내는 기존 3분기 이벤트 재사용(Phase 17 문구·액션 그대로). @@ -522,9 +522,9 @@ final class HomeViewModel { guard chipSaveTask == nil else { return } chipTask?.cancel() chipTask = Task { [weak self] in - guard let useCase = self?.recentSearchesUseCase else { return } + guard let repository = self?.recentSearchRepository else { return } // 로드 실패는 칩 없음으로 무해화한다 (검색 화면의 최근 목록과 같은 취급). - let latest = ((try? await useCase.fetch()) ?? []).first + let latest = ((try? await repository.recentSearches()) ?? []).first guard !Task.isCancelled, let self else { return } self.chipPlace = latest self.state.recentRouteChipText = latest.map { "→ \($0.name)" } @@ -538,7 +538,7 @@ final class HomeViewModel { private func promoteChipDestination(_ place: Place) { chipSaveTask?.cancel() chipSaveTask = Task { [weak self] in - guard let useCase = self?.recentSearchesUseCase else { return } + guard let useCase = self?.recentSearchRepository else { return } try? await useCase.save(place) guard !Task.isCancelled else { return } self?.chipSaveTask = nil @@ -550,11 +550,11 @@ final class HomeViewModel { state.departure = .loading locationTask = Task { [weak self] in do { - guard let locationUseCase = self?.getCurrentLocationUseCase else { return } - let coordinate = try await locationUseCase.execute() + guard let locationUseCase = self?.locationService else { return } + let coordinate = try await locationUseCase.currentLocation() guard !Task.isCancelled, - let geocodeUseCase = self?.reverseGeocodeUseCase else { return } - let place = try await geocodeUseCase.execute(coordinate: coordinate) + let geocodeUseCase = self?.placeRepository else { return } + let place = try await geocodeUseCase.reverseGeocode(coordinate) guard !Task.isCancelled else { return } self?.state.departure = .current(name: place.name) } catch let error as LocationError { diff --git a/Projects/Feature/Home/Tests/HomeViewModelTests.swift b/Projects/Feature/Home/Tests/HomeViewModelTests.swift index 1a493805..0f534741 100644 --- a/Projects/Feature/Home/Tests/HomeViewModelTests.swift +++ b/Projects/Feature/Home/Tests/HomeViewModelTests.swift @@ -6,14 +6,18 @@ import Testing private struct StubError: Error {} -private struct StubGetCurrentLocationUseCase: GetCurrentLocationUseCase { +private struct StubLocationService: LocationService { let handler: @Sendable () async throws -> Coordinate - func execute() async throws -> Coordinate { try await handler() } + func currentLocation() async throws -> Coordinate { try await handler() } } -private struct StubReverseGeocodeUseCase: ReverseGeocodeUseCase { +private struct StubPlaceRepository: PlaceRepository { let handler: @Sendable (Coordinate) async throws -> Place - func execute(coordinate: Coordinate) async throws -> Place { try await handler(coordinate) } + func reverseGeocode(_ coordinate: Coordinate) async throws -> Place { try await handler(coordinate) } + + // 홈은 역지오코딩만 쓴다 — 나머지는 호출되지 않는다. + func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { [] } + func isServiceRegion(_ coordinate: Coordinate) async throws -> Bool { true } } private struct StubRegisterAlarmUseCase: RegisterAlarmUseCase { @@ -63,10 +67,10 @@ private struct StubSearchLastRoutesUseCase: SearchLastRoutesUseCase { } } -private struct StubRecentSearchesUseCase: RecentSearchesUseCase { +private struct StubRecentSearchRepository: RecentSearchRepository { let fetchHandler: @Sendable () async throws -> [Place] let saveHandler: @Sendable (Place) async throws -> Void - func fetch() async throws -> [Place] { try await fetchHandler() } + func recentSearches() async throws -> [Place] { try await fetchHandler() } func save(_ place: Place) async throws { try await saveHandler(place) } func remove(_ place: Place) async throws {} } @@ -199,8 +203,8 @@ private func makeSUT( now: @escaping @Sendable () -> Date = { fixedNow } ) -> HomeViewModel { HomeViewModel( - getCurrentLocationUseCase: StubGetCurrentLocationUseCase(handler: location), - reverseGeocodeUseCase: StubReverseGeocodeUseCase(handler: geocode), + locationService: StubLocationService(handler: location), + placeRepository: StubPlaceRepository(handler: geocode), registerAlarmUseCase: StubRegisterAlarmUseCase( handler: register, authorizationOutcome: registerOutcome ), @@ -210,7 +214,7 @@ private func makeSUT( alarmSyncRequesting: StubAlarmSyncRequesting(handler: requestSync), lastRouteRepository: StubLastRouteRepository(handler: routeDetail), searchLastRoutesUseCase: StubSearchLastRoutesUseCase(handler: searchRoutes), - recentSearchesUseCase: StubRecentSearchesUseCase( + recentSearchRepository: StubRecentSearchRepository( fetchHandler: recentFetch, saveHandler: recentSave ), now: now diff --git a/Projects/Feature/Search/Example/ExampleApp.swift b/Projects/Feature/Search/Example/ExampleApp.swift index 29e78c0e..18c8e91d 100644 --- a/Projects/Feature/Search/Example/ExampleApp.swift +++ b/Projects/Feature/Search/Example/ExampleApp.swift @@ -46,9 +46,9 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { private func startSearchFlow() { let container = SearchDIContainer( - searchPlacesUseCase: PreviewSearchPlacesUseCase(), + placeRepository: PreviewPlaceRepository(), searchLastRoutesUseCase: PreviewSearchLastRoutesUseCase(), - recentSearchesUseCase: PreviewRecentSearchesUseCase() + recentSearchRepository: PreviewRecentSearchRepository() ) let coordinator = container.makeSearchCoordinator( navigationController: navigationController, @@ -122,13 +122,20 @@ nonisolated enum PreviewScenario { ] } -struct PreviewSearchPlacesUseCase: SearchPlacesUseCase { - func execute(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { +struct PreviewPlaceRepository: PlaceRepository { + func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { // 디바운스가 체감되도록 실서버 지연을 흉내낸다. try? await Task.sleep(for: .milliseconds(300)) let matches = PreviewScenario.catalog.filter { $0.name.localizedStandardContains(keyword) } return matches.isEmpty ? PreviewScenario.catalog : matches } + + // 검색은 장소 검색만 쓴다 — 나머지는 프리뷰에서 호출되지 않는다. + func reverseGeocode(_ coordinate: Coordinate) async throws -> Place { + Place(name: "강남역", address: "서울 강남구 강남대로 396", coordinate: coordinate) + } + + func isServiceRegion(_ coordinate: Coordinate) async throws -> Bool { true } } struct PreviewSearchLastRoutesUseCase: SearchLastRoutesUseCase { @@ -212,7 +219,7 @@ actor PreviewRecentSearchesStore { } } -struct PreviewRecentSearchesUseCase: RecentSearchesUseCase { +struct PreviewRecentSearchRepository: RecentSearchRepository { // 데모 장소를 최근 검색에 심어 3가지 상태를 바로 시연할 수 있게 한다. private let store = PreviewRecentSearchesStore(seed: [ PreviewScenario.catalog[0], @@ -220,7 +227,7 @@ struct PreviewRecentSearchesUseCase: RecentSearchesUseCase { PreviewScenario.catalog[5], ]) - func fetch() async throws -> [Place] { await store.fetch() } + func recentSearches() async throws -> [Place] { await store.fetch() } func save(_ place: Place) async throws { await store.save(place) } func remove(_ place: Place) async throws { await store.remove(place) } } diff --git a/Projects/Feature/Search/Sources/SearchDIContainer.swift b/Projects/Feature/Search/Sources/SearchDIContainer.swift index 35cea9c5..431efc70 100644 --- a/Projects/Feature/Search/Sources/SearchDIContainer.swift +++ b/Projects/Feature/Search/Sources/SearchDIContainer.swift @@ -7,21 +7,21 @@ import UIKit /// screen/ViewModel assembly stays here so adding screens never bloats /// coordinator initializers. public final class SearchDIContainer: SearchCoordinatorBuildable { - private let searchPlacesUseCase: any SearchPlacesUseCase + private let placeRepository: any PlaceRepository private let searchLastRoutesUseCase: any SearchLastRoutesUseCase - private let recentSearchesUseCase: any RecentSearchesUseCase - private let getCurrentLocationUseCase: (any GetCurrentLocationUseCase)? + private let recentSearchRepository: any RecentSearchRepository + private let locationService: (any LocationService)? public init( - searchPlacesUseCase: any SearchPlacesUseCase, + placeRepository: any PlaceRepository, searchLastRoutesUseCase: any SearchLastRoutesUseCase, - recentSearchesUseCase: any RecentSearchesUseCase, - getCurrentLocationUseCase: (any GetCurrentLocationUseCase)? = nil + recentSearchRepository: any RecentSearchRepository, + locationService: (any LocationService)? = nil ) { - self.searchPlacesUseCase = searchPlacesUseCase + self.placeRepository = placeRepository self.searchLastRoutesUseCase = searchLastRoutesUseCase - self.recentSearchesUseCase = recentSearchesUseCase - self.getCurrentLocationUseCase = getCurrentLocationUseCase + self.recentSearchRepository = recentSearchRepository + self.locationService = locationService } public func makeSearchCoordinator( @@ -43,10 +43,10 @@ public final class SearchDIContainer: SearchCoordinatorBuildable { onBack: @escaping () -> Void ) -> UIViewController { let viewModel = SearchViewModel( - searchPlacesUseCase: searchPlacesUseCase, + placeRepository: placeRepository, searchLastRoutesUseCase: searchLastRoutesUseCase, - recentSearchesUseCase: recentSearchesUseCase, - getCurrentLocationUseCase: getCurrentLocationUseCase, + recentSearchRepository: recentSearchRepository, + locationService: locationService, initialField: initialField ) viewModel.onRouteChosen = onRouteChosen diff --git a/Projects/Feature/Search/Sources/SearchViewModel.swift b/Projects/Feature/Search/Sources/SearchViewModel.swift index 2f35fd78..4669e7e3 100644 --- a/Projects/Feature/Search/Sources/SearchViewModel.swift +++ b/Projects/Feature/Search/Sources/SearchViewModel.swift @@ -66,11 +66,11 @@ final class SearchViewModel { private var availableRoutes: [LastRoute] = [] private var isExpanded = false - private let searchPlacesUseCase: any SearchPlacesUseCase + private let placeRepository: any PlaceRepository private let searchLastRoutesUseCase: any SearchLastRoutesUseCase - private let recentSearchesUseCase: any RecentSearchesUseCase + private let recentSearchRepository: any RecentSearchRepository // nil이면(예: Example 스텁 구성) 프리필·near 바이어스 없이 동작한다. - private let getCurrentLocationUseCase: (any GetCurrentLocationUseCase)? + private let locationService: (any LocationService)? // 오늘/내일 라벨 판정의 기준 시각(Phase 17) — 실 Date() 직접 호출 대신 주입(기존 VM 관례). private let now: @Sendable () -> Date private let debounceInterval: Duration @@ -83,18 +83,18 @@ final class SearchViewModel { private var saveTask: Task? init( - searchPlacesUseCase: any SearchPlacesUseCase, + placeRepository: any PlaceRepository, searchLastRoutesUseCase: any SearchLastRoutesUseCase, - recentSearchesUseCase: any RecentSearchesUseCase, - getCurrentLocationUseCase: (any GetCurrentLocationUseCase)? = nil, + recentSearchRepository: any RecentSearchRepository, + locationService: (any LocationService)? = nil, initialField: SearchEntryField = .departure, now: @escaping @Sendable () -> Date = { Date() }, debounceInterval: Duration = .milliseconds(300) ) { - self.searchPlacesUseCase = searchPlacesUseCase + self.placeRepository = placeRepository self.searchLastRoutesUseCase = searchLastRoutesUseCase - self.recentSearchesUseCase = recentSearchesUseCase - self.getCurrentLocationUseCase = getCurrentLocationUseCase + self.recentSearchRepository = recentSearchRepository + self.locationService = locationService self.now = now self.debounceInterval = debounceInterval // 탭한 필드로 진입한다(Phase 17) — 초기 활성 슬롯만 정하고 프리필 정책은 불변. @@ -153,14 +153,14 @@ final class SearchViewModel { // [weak self]: the in-flight task must not keep the ViewModel alive. searchTask = Task { [weak self] in guard let interval = self?.debounceInterval, - let useCase = self?.searchPlacesUseCase else { return } + let useCase = self?.placeRepository else { return } try? await Task.sleep(for: interval) guard !Task.isCancelled else { return } // 디바운스 통과 = 이 키워드로 실제 요청한다 — 로딩은 여기서부터(Phase 17). self?.state.content = .loadingPlaces do { // 현재 위치가 확보된 경우에만 근처 우선 정렬 바이어스를 건다. - let places = try await useCase.execute(keyword: trimmed, near: self?.currentCoordinate) + let places = try await useCase.searchPlaces(keyword: trimmed, near: self?.currentCoordinate) guard !Task.isCancelled else { return } self?.listedPlaces = places self?.state.content = .places(places.map(PlaceViewData.init(entity:))) @@ -187,9 +187,9 @@ final class SearchViewModel { let place = listedPlaces[index] recentTask?.cancel() recentTask = Task { [weak self] in - guard let useCase = self?.recentSearchesUseCase else { return } - try? await useCase.remove(place) - let places = (try? await useCase.fetch()) ?? [] + guard let repository = self?.recentSearchRepository else { return } + try? await repository.remove(place) + let places = (try? await repository.recentSearches()) ?? [] guard !Task.isCancelled else { return } self?.listedPlaces = places self?.state.content = .recent(places.map(PlaceViewData.init(entity:))) @@ -239,9 +239,9 @@ final class SearchViewModel { /// 출발지 기본값 = 현재 위치. 실패·권한 거부는 조용히 무시한다 (권한 안내 UX는 홈 담당). private func prefillDepartureWithCurrentLocation() { - guard let useCase = getCurrentLocationUseCase else { return } + guard let locationService else { return } locationTask = Task { [weak self] in - guard let coordinate = try? await useCase.execute() else { return } + guard let coordinate = try? await locationService.currentLocation() else { return } guard !Task.isCancelled, let self else { return } self.currentCoordinate = coordinate // 사용자가 이미 출발지를 만졌다면 덮어쓰지 않는다. @@ -259,7 +259,7 @@ final class SearchViewModel { private func confirm(_ place: Place, in field: Field) { searchTask?.cancel() saveTask = Task { [weak self] in - guard let useCase = self?.recentSearchesUseCase else { return } + guard let useCase = self?.recentSearchRepository else { return } try? await useCase.save(place) } @@ -320,9 +320,9 @@ final class SearchViewModel { private func showRecent() { recentTask?.cancel() recentTask = Task { [weak self] in - guard let useCase = self?.recentSearchesUseCase else { return } + guard let repository = self?.recentSearchRepository else { return } // 최근 검색 로드 실패는 빈 목록으로 무해화한다. - let places = (try? await useCase.fetch()) ?? [] + let places = (try? await repository.recentSearches()) ?? [] guard !Task.isCancelled else { return } self?.listedPlaces = places self?.state.content = .recent(places.map(PlaceViewData.init(entity:))) diff --git a/Projects/Feature/Search/Tests/SearchCoordinatorTests.swift b/Projects/Feature/Search/Tests/SearchCoordinatorTests.swift index e618c709..1f2d8aec 100644 --- a/Projects/Feature/Search/Tests/SearchCoordinatorTests.swift +++ b/Projects/Feature/Search/Tests/SearchCoordinatorTests.swift @@ -6,8 +6,14 @@ import Testing import UIKit // 코디네이터 조립용 최소 스텁 — Tests/Example 중복은 의도된 트레이드오프(기존 규약). -private struct StubSearchPlacesUseCase: SearchPlacesUseCase { - func execute(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { [] } +private struct StubPlaceRepository: PlaceRepository { + func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { [] } + + func reverseGeocode(_ coordinate: Coordinate) async throws -> Place { + Place(name: "강남역", address: "서울 강남구 강남대로 396", coordinate: coordinate) + } + + func isServiceRegion(_ coordinate: Coordinate) async throws -> Bool { true } } private struct StubSearchLastRoutesUseCase: SearchLastRoutesUseCase { @@ -16,8 +22,8 @@ private struct StubSearchLastRoutesUseCase: SearchLastRoutesUseCase { } } -private struct StubRecentSearchesUseCase: RecentSearchesUseCase { - func fetch() async throws -> [Place] { [] } +private struct StubRecentSearchRepository: RecentSearchRepository { + func recentSearches() async throws -> [Place] { [] } func save(_ place: Place) async throws {} func remove(_ place: Place) async throws {} } @@ -36,9 +42,9 @@ private func makeCoordinator(navigationController: UINavigationController) -> Se SearchCoordinator( navigationController: navigationController, container: SearchDIContainer( - searchPlacesUseCase: StubSearchPlacesUseCase(), + placeRepository: StubPlaceRepository(), searchLastRoutesUseCase: StubSearchLastRoutesUseCase(), - recentSearchesUseCase: StubRecentSearchesUseCase() + recentSearchRepository: StubRecentSearchRepository() ), initialField: .departure, onRouteSelected: { _, _ in } diff --git a/Projects/Feature/Search/Tests/SearchViewModelTests.swift b/Projects/Feature/Search/Tests/SearchViewModelTests.swift index df336fb0..7e4a0235 100644 --- a/Projects/Feature/Search/Tests/SearchViewModelTests.swift +++ b/Projects/Feature/Search/Tests/SearchViewModelTests.swift @@ -4,11 +4,18 @@ import Foundation import SearchFeatureInterface import Testing -private struct StubSearchPlacesUseCase: SearchPlacesUseCase { +private struct StubPlaceRepository: PlaceRepository { let handler: @Sendable (String) async throws -> [Place] - func execute(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { + func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { try await handler(keyword) } + + // 검색은 장소 검색만 쓴다 — 나머지는 호출되지 않는다. + func reverseGeocode(_ coordinate: Coordinate) async throws -> Place { + Place(name: "강남역", address: "서울 강남구 강남대로 396", coordinate: coordinate) + } + + func isServiceRegion(_ coordinate: Coordinate) async throws -> Bool { true } } private struct StubSearchLastRoutesUseCase: SearchLastRoutesUseCase { @@ -18,9 +25,9 @@ private struct StubSearchLastRoutesUseCase: SearchLastRoutesUseCase { } } -private struct StubGetCurrentLocationUseCase: GetCurrentLocationUseCase { +private struct StubLocationService: LocationService { let handler: @Sendable () async throws -> Coordinate - func execute() async throws -> Coordinate { try await handler() } + func currentLocation() async throws -> Coordinate { try await handler() } } // 키워드 검색이 받은 near 좌표를 기록한다. @@ -29,14 +36,20 @@ private actor NearLog { func append(_ coordinate: Coordinate?) { coordinates.append(coordinate) } } -private struct NearRecordingSearchPlacesUseCase: SearchPlacesUseCase { +private struct NearRecordingPlaceRepository: PlaceRepository { let log: NearLog let places: [Place] - func execute(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { + func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { await log.append(coordinate) return places } + + func reverseGeocode(_ coordinate: Coordinate) async throws -> Place { + Place(name: "강남역", address: "서울 강남구 강남대로 396", coordinate: coordinate) + } + + func isServiceRegion(_ coordinate: Coordinate) async throws -> Bool { true } } // save/remove 호출 기록 + fetch 응답을 한곳에서 관리. @@ -63,9 +76,9 @@ private actor RecentStore { func fetch() -> [Place] { places } } -private struct StubRecentSearchesUseCase: RecentSearchesUseCase { +private struct StubRecentSearchRepository: RecentSearchRepository { let store: RecentStore - func fetch() async throws -> [Place] { await store.fetch() } + func recentSearches() async throws -> [Place] { await store.fetch() } func save(_ place: Place) async throws { await store.save(place) } func remove(_ place: Place) async throws { await store.remove(place) } } @@ -139,10 +152,10 @@ private func makeSUT( initialField: SearchEntryField = .departure ) -> SearchViewModel { SearchViewModel( - searchPlacesUseCase: StubSearchPlacesUseCase(handler: placesHandler), + placeRepository: StubPlaceRepository(handler: placesHandler), searchLastRoutesUseCase: StubSearchLastRoutesUseCase(handler: routesHandler), - recentSearchesUseCase: StubRecentSearchesUseCase(store: store), - getCurrentLocationUseCase: location.map(StubGetCurrentLocationUseCase.init(handler:)), + recentSearchRepository: StubRecentSearchRepository(store: store), + locationService: location.map(StubLocationService.init(handler:)), initialField: initialField, now: { fixedNow }, debounceInterval: .zero @@ -390,10 +403,10 @@ struct SearchViewModelTests { let coordinate = Coordinate(latitude: 37.49, longitude: 127.02) let log = NearLog() let sut = SearchViewModel( - searchPlacesUseCase: NearRecordingSearchPlacesUseCase(log: log, places: [makePlace("회사")]), + placeRepository: NearRecordingPlaceRepository(log: log, places: [makePlace("회사")]), searchLastRoutesUseCase: StubSearchLastRoutesUseCase(handler: { .available([]) }), - recentSearchesUseCase: StubRecentSearchesUseCase(store: RecentStore()), - getCurrentLocationUseCase: StubGetCurrentLocationUseCase(handler: { coordinate }), + recentSearchRepository: StubRecentSearchRepository(store: RecentStore()), + locationService: StubLocationService(handler: { coordinate }), debounceInterval: .zero ) let recorder = StateRecorder() diff --git a/Projects/Feature/Settings/Example/ExampleApp.swift b/Projects/Feature/Settings/Example/ExampleApp.swift index 3667ae2b..6c6ce2d9 100644 --- a/Projects/Feature/Settings/Example/ExampleApp.swift +++ b/Projects/Feature/Settings/Example/ExampleApp.swift @@ -50,9 +50,8 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { logoutUseCase: PreviewLogoutUseCase(), withdrawUseCase: PreviewWithdrawUseCase(), updateHomeAddressUseCase: PreviewUpdateHomeAddressUseCase(), - searchPlacesUseCase: PreviewSearchPlacesUseCase(), - getCurrentLocationUseCase: PreviewGetCurrentLocationUseCase(), - reverseGeocodeUseCase: PreviewReverseGeocodeUseCase(), + placeRepository: PreviewPlaceRepository(), + locationService: PreviewLocationService(), checkAppUpdateUseCase: PreviewCheckAppUpdateUseCase(), currentVersion: "2.0.0", appStoreURL: URL(string: "https://apps.apple.com") @@ -128,27 +127,29 @@ private struct PreviewUpdateHomeAddressUseCase: UpdateHomeAddressUseCase { } } -private struct PreviewSearchPlacesUseCase: SearchPlacesUseCase { - func execute(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { - [ - Place(name: "\(keyword) 서울점", address: "서울 중구 세종대로 110", coordinate: Coordinate(latitude: 37.5665, longitude: 126.9780)), - Place(name: "\(keyword) 부산점", address: "부산 해운대구 해운대해변로 264", coordinate: Coordinate(latitude: 35.1587, longitude: 129.1604)), +private struct PreviewPlaceRepository: PlaceRepository { + func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { + try? await Task.sleep(for: .milliseconds(300)) + return [ + Place(name: "서울시청", address: "서울 중구 세종대로 110", + coordinate: Coordinate(latitude: 37.5663, longitude: 126.9779)), ] } -} -private struct PreviewGetCurrentLocationUseCase: GetCurrentLocationUseCase { - func execute() async throws -> Coordinate { - Coordinate(latitude: 37.5665, longitude: 126.9780) + func reverseGeocode(_ coordinate: Coordinate) async throws -> Place { + Place(name: "서울시청", address: "서울 중구 세종대로 110", coordinate: coordinate) } + + func isServiceRegion(_ coordinate: Coordinate) async throws -> Bool { true } } -private struct PreviewReverseGeocodeUseCase: ReverseGeocodeUseCase { - func execute(coordinate: Coordinate) async throws -> Place { - Place(name: "서울시청", address: "서울 중구 세종대로 110", coordinate: coordinate) +private struct PreviewLocationService: LocationService { + func currentLocation() async throws -> Coordinate { + Coordinate(latitude: 37.5665, longitude: 126.9780) } } + private struct PreviewCheckAppUpdateUseCase: CheckAppUpdateUseCase { func execute(currentVersion: String) async throws -> AppUpdateStatus { .recommended(latest: "2.1.0") diff --git a/Projects/Feature/Settings/Sources/HomeAddressViewModel.swift b/Projects/Feature/Settings/Sources/HomeAddressViewModel.swift index 5136246a..7b576a75 100644 --- a/Projects/Feature/Settings/Sources/HomeAddressViewModel.swift +++ b/Projects/Feature/Settings/Sources/HomeAddressViewModel.swift @@ -36,9 +36,8 @@ final class HomeAddressViewModel { } private var listedPlaces: [Place] = [] - private let searchPlacesUseCase: any SearchPlacesUseCase - private let getCurrentLocationUseCase: any GetCurrentLocationUseCase - private let reverseGeocodeUseCase: any ReverseGeocodeUseCase + private let placeRepository: any PlaceRepository + private let locationService: any LocationService private let updateHomeAddressUseCase: any UpdateHomeAddressUseCase private let debounceInterval: Duration @@ -46,15 +45,13 @@ final class HomeAddressViewModel { private var saveTask: Task? init( - searchPlacesUseCase: any SearchPlacesUseCase, - getCurrentLocationUseCase: any GetCurrentLocationUseCase, - reverseGeocodeUseCase: any ReverseGeocodeUseCase, + placeRepository: any PlaceRepository, + locationService: any LocationService, updateHomeAddressUseCase: any UpdateHomeAddressUseCase, debounceInterval: Duration = .milliseconds(300) ) { - self.searchPlacesUseCase = searchPlacesUseCase - self.getCurrentLocationUseCase = getCurrentLocationUseCase - self.reverseGeocodeUseCase = reverseGeocodeUseCase + self.placeRepository = placeRepository + self.locationService = locationService self.updateHomeAddressUseCase = updateHomeAddressUseCase self.debounceInterval = debounceInterval } @@ -75,12 +72,12 @@ final class HomeAddressViewModel { // [weak self]: the in-flight task must not keep the ViewModel alive. searchTask = Task { [weak self] in guard let interval = self?.debounceInterval, - let useCase = self?.searchPlacesUseCase else { return } + let useCase = self?.placeRepository else { return } try? await Task.sleep(for: interval) guard !Task.isCancelled else { return } self?.state.content = .loading do { - let places = try await useCase.execute(keyword: trimmed, near: nil) + let places = try await useCase.searchPlaces(keyword: trimmed, near: nil) guard !Task.isCancelled else { return } self?.listedPlaces = places self?.state.content = places.isEmpty @@ -103,11 +100,11 @@ final class HomeAddressViewModel { guard !state.isSaving else { return } state.isSaving = true saveTask = Task { [weak self] in - guard let locate = self?.getCurrentLocationUseCase, - let geocode = self?.reverseGeocodeUseCase else { return } + guard let locate = self?.locationService, + let geocode = self?.placeRepository else { return } do { - let coordinate = try await locate.execute() - let place = try await geocode.execute(coordinate: coordinate) + let coordinate = try await locate.currentLocation() + let place = try await geocode.reverseGeocode(coordinate) guard !Task.isCancelled else { return } self?.state.isSaving = false self?.save(place) diff --git a/Projects/Feature/Settings/Sources/SettingsDIContainer.swift b/Projects/Feature/Settings/Sources/SettingsDIContainer.swift index d3e2079a..6e0828e4 100644 --- a/Projects/Feature/Settings/Sources/SettingsDIContainer.swift +++ b/Projects/Feature/Settings/Sources/SettingsDIContainer.swift @@ -11,9 +11,8 @@ public final class SettingsDIContainer: SettingsCoordinatorBuildable { private let logoutUseCase: any LogoutUseCase private let withdrawUseCase: any WithdrawUseCase private let updateHomeAddressUseCase: any UpdateHomeAddressUseCase - private let searchPlacesUseCase: any SearchPlacesUseCase - private let getCurrentLocationUseCase: any GetCurrentLocationUseCase - private let reverseGeocodeUseCase: any ReverseGeocodeUseCase + private let placeRepository: any PlaceRepository + private let locationService: any LocationService private let checkAppUpdateUseCase: (any CheckAppUpdateUseCase)? private let currentVersion: String private let appStoreURL: URL? @@ -23,9 +22,8 @@ public final class SettingsDIContainer: SettingsCoordinatorBuildable { logoutUseCase: any LogoutUseCase, withdrawUseCase: any WithdrawUseCase, updateHomeAddressUseCase: any UpdateHomeAddressUseCase, - searchPlacesUseCase: any SearchPlacesUseCase, - getCurrentLocationUseCase: any GetCurrentLocationUseCase, - reverseGeocodeUseCase: any ReverseGeocodeUseCase, + placeRepository: any PlaceRepository, + locationService: any LocationService, checkAppUpdateUseCase: (any CheckAppUpdateUseCase)? = nil, currentVersion: String, appStoreURL: URL? = nil @@ -34,9 +32,8 @@ public final class SettingsDIContainer: SettingsCoordinatorBuildable { self.logoutUseCase = logoutUseCase self.withdrawUseCase = withdrawUseCase self.updateHomeAddressUseCase = updateHomeAddressUseCase - self.searchPlacesUseCase = searchPlacesUseCase - self.getCurrentLocationUseCase = getCurrentLocationUseCase - self.reverseGeocodeUseCase = reverseGeocodeUseCase + self.placeRepository = placeRepository + self.locationService = locationService self.checkAppUpdateUseCase = checkAppUpdateUseCase self.currentVersion = currentVersion self.appStoreURL = appStoreURL @@ -63,9 +60,8 @@ public final class SettingsDIContainer: SettingsCoordinatorBuildable { func makeHomeAddressViewController(onSaved: @escaping () -> Void) -> UIViewController { let viewModel = HomeAddressViewModel( - searchPlacesUseCase: searchPlacesUseCase, - getCurrentLocationUseCase: getCurrentLocationUseCase, - reverseGeocodeUseCase: reverseGeocodeUseCase, + placeRepository: placeRepository, + locationService: locationService, updateHomeAddressUseCase: updateHomeAddressUseCase ) viewModel.onSaved = onSaved diff --git a/Projects/Feature/Settings/Tests/HomeAddressViewModelTests.swift b/Projects/Feature/Settings/Tests/HomeAddressViewModelTests.swift index 39cba0d7..32735f9a 100644 --- a/Projects/Feature/Settings/Tests/HomeAddressViewModelTests.swift +++ b/Projects/Feature/Settings/Tests/HomeAddressViewModelTests.swift @@ -14,9 +14,8 @@ struct HomeAddressViewModelTests { locationFails: Bool = false ) -> HomeAddressViewModel { HomeAddressViewModel( - searchPlacesUseCase: StubSearchPlacesUseCase(places: [place]), - getCurrentLocationUseCase: StubGetCurrentLocationUseCase(fails: locationFails), - reverseGeocodeUseCase: StubReverseGeocodeUseCase(), + placeRepository: StubPlaceRepository(places: [place]), + locationService: StubLocationService(fails: locationFails), updateHomeAddressUseCase: update, debounceInterval: .zero ) diff --git a/Projects/Feature/Settings/Tests/SettingsFeatureStubs.swift b/Projects/Feature/Settings/Tests/SettingsFeatureStubs.swift index cfb57578..0e438bd2 100644 --- a/Projects/Feature/Settings/Tests/SettingsFeatureStubs.swift +++ b/Projects/Feature/Settings/Tests/SettingsFeatureStubs.swift @@ -61,25 +61,28 @@ struct StubCheckAppUpdateUseCase: CheckAppUpdateUseCase { func execute(currentVersion: String) async throws -> AppUpdateStatus { status } } -struct StubSearchPlacesUseCase: SearchPlacesUseCase { +struct StubPlaceRepository: PlaceRepository { var places: [Place] = [] - func execute(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { places } + + func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { + places + } + + func reverseGeocode(_ coordinate: Coordinate) async throws -> Place { + Place(name: "서울시청", address: "서울 중구 세종대로 110", coordinate: coordinate) + } + + func isServiceRegion(_ coordinate: Coordinate) async throws -> Bool { true } } -struct StubGetCurrentLocationUseCase: GetCurrentLocationUseCase { +struct StubLocationService: LocationService { var fails = false - func execute() async throws -> Coordinate { + func currentLocation() async throws -> Coordinate { if fails { throw StubFailure() } return Coordinate(latitude: 37.5665, longitude: 126.9780) } } -struct StubReverseGeocodeUseCase: ReverseGeocodeUseCase { - func execute(coordinate: Coordinate) async throws -> Place { - Place(name: "서울시청", address: "서울 중구 세종대로 110", coordinate: coordinate) - } -} - final class SpyUpdateHomeAddressUseCase: UpdateHomeAddressUseCase { private let log = Mutex<[String?]>([]) private let stored = Mutex<(any Error)?>(nil) From 6464171fae6bc2d874a3f120fadd500e5514c8e9 Mon Sep 17 00:00:00 2001 From: YuGeonHui Date: Thu, 24 Sep 2026 23:02:34 +0900 Subject: [PATCH 2/6] =?UTF-8?q?perf:=20=EC=9E=A5=EC=86=8C=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=9D=91=EB=8B=B5=20=EC=BA=90=EC=8B=9C=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85=20(=EC=97=AD=EC=A7=80=EC=98=A4=EC=BD=94=EB=94=A9=207?= =?UTF-8?q?=EC=9D=BC=20=C2=B7=20=EA=B2=80=EC=83=89=201=EC=8B=9C=EA=B0=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 검색 화면은 진입할 때마다 위치 획득 + GET /locations/rgeo를 다시 돌았고, 같은 키워드를 다시 쳐도 매번 서버로 갔다. 코드베이스 전체에 응답 캐시가 0건이었다. CoreStorage에 ExpiringCache를 추가하고, Data 레이어에 CachingPlaceRepository 데코레이터로 감쌌다. 피처는 여전히 Domain 프로토콜만 보므로 호출처는 한 줄도 바뀌지 않는다. 캐시 대상은 "틀렸을 때의 피해"로 골랐다: - 역지오코딩 7일 — 좌표→주소는 행정구역 개편 수준에서만 바뀐다 - 장소 검색 1시간 — POI는 생기고 없어진다 - 서비스 지역 판정 캐시 안 함 — 틀리면 되는 지역을 막는다. 호출도 집 주소 저장 시 1회뿐이라 아낄 왕복이 없다 좌표는 키로 쓰기 전에 반올림한다(역지오코딩 4자리≈11m, 검색 편향 3자리≈110m). GPS는 가만히 있어도 미터 단위로 떨려서, 원시 좌표를 키로 쓰면 적중률이 사실상 0이 된다. 만료에는 ±10% 지터를 넣는다. 같은 TTL로 한꺼번에 채워진 항목은 한꺼번에 만료되는데, 이 앱의 실제 피크는 막차 시간대에 수천 기기가 동시에 앱을 여는 순간이라 만료가 몰리면 그 순간 요청이 서버에 몰린다. 실패는 캐시하지 않는다 — 지하철에서 한 번 실패한 키워드가 지상에 나와도 계속 실패로 답하면 재시도 버튼이 의미를 잃는다. 저장은 FileKeyValueStore(.cache) — 백업에서 제외된다. UserDefaults는 첫 접근에 plist 전체를 올리므로 이 크기(≈20KB)를 담을 자리가 아니다. 부수: RecentSearchRecordDTO → PlaceRecordDTO. 최근 검색과 캐시가 같은 표현을 쓰게 되어 이름이 용도를 좁게 말하고 있었다. 검증: 3구성 빌드 + CoreStorage 44(+11) · AtchaData 64(+11) 등 전 스킴 통과. Co-Authored-By: Claude Opus 5 --- Projects/App/Sources/AppDIContainer.swift | 16 +- .../Core/Storage/Sources/ExpiringCache.swift | 93 ++++++++ .../Storage/Tests/ExpiringCacheTests.swift | 157 +++++++++++++ ...chRecordDTO.swift => PlaceRecordDTO.swift} | 5 +- .../Repositories/CachingPlaceRepository.swift | 83 +++++++ .../RecentSearchRepositoryImpl.swift | 6 +- .../Tests/CachingPlaceRepositoryTests.swift | 213 ++++++++++++++++++ ...OTests.swift => PlaceRecordDTOTests.swift} | 8 +- 8 files changed, 571 insertions(+), 10 deletions(-) create mode 100644 Projects/Core/Storage/Sources/ExpiringCache.swift create mode 100644 Projects/Core/Storage/Tests/ExpiringCacheTests.swift rename Projects/Data/Sources/DTO/{RecentSearchRecordDTO.swift => PlaceRecordDTO.swift} (65%) create mode 100644 Projects/Data/Sources/Repositories/CachingPlaceRepository.swift create mode 100644 Projects/Data/Tests/CachingPlaceRepositoryTests.swift rename Projects/Data/Tests/{RecentSearchRecordDTOTests.swift => PlaceRecordDTOTests.swift} (74%) diff --git a/Projects/App/Sources/AppDIContainer.swift b/Projects/App/Sources/AppDIContainer.swift index fb975e36..e9ca1b30 100644 --- a/Projects/App/Sources/AppDIContainer.swift +++ b/Projects/App/Sources/AppDIContainer.swift @@ -90,7 +90,21 @@ final class AppDIContainer { ) self.networkClient = networkClient - self.placeRepository = PlaceRepositoryImpl(networkClient: networkClient) + // 장소 조회는 캐시를 통과시킨다. 검색 화면은 진입할 때마다 위치 획득 + 역지오코딩을 + // 다시 돌았고, 같은 키워드를 다시 쳐도 매번 왕복했다. 캐시 인스턴스는 여기서 1회만 + // 만든다 — ExpiringCache의 격리는 인스턴스 단위라 나눠 만들면 직렬화가 깨진다. + let placeCacheStore = FileKeyValueStore(namespace: .cache("PlaceCache")) + self.placeRepository = CachingPlaceRepository( + upstream: PlaceRepositoryImpl(networkClient: networkClient), + // 7일: 좌표→주소는 행정구역 개편 수준에서만 바뀐다. + geocodeCache: ExpiringCache( + store: placeCacheStore, key: "reverseGeocode", ttl: .seconds(7 * 24 * 60 * 60), limit: 50 + ), + // 1시간: POI는 생기고 없어진다. 한 세션의 반복 입력을 흡수하는 게 목적이다. + searchCache: ExpiringCache( + store: placeCacheStore, key: "search", ttl: .seconds(60 * 60), limit: 30 + ) + ) self.lastRouteRepository = LastRouteRepositoryImpl(networkClient: networkClient) #if DEV // 막차 "변경"(앞당김/늦춤/운행종료)은 실서버가 임의로 재현해줄 수 없다 — diff --git a/Projects/Core/Storage/Sources/ExpiringCache.swift b/Projects/Core/Storage/Sources/ExpiringCache.swift new file mode 100644 index 00000000..d696f4c8 --- /dev/null +++ b/Projects/Core/Storage/Sources/ExpiringCache.swift @@ -0,0 +1,93 @@ +import Foundation + +/// 유효기간이 있는 키-값 캐시. **서버에서 다시 받을 수 있는 응답 전용**이다 — +/// 디코딩 실패도 캐시 부재로 강등하므로, 잃으면 안 되는 값은 여기 담지 않는다 +/// (그쪽은 `DocumentStore`/`CollectionStore`). +/// +/// `actor`인 이유는 `CollectionStore`와 같다. 조회·저장이 전부 read-modify-write라 +/// 값 타입으로 두면 동시 저장이 서로를 덮어쓴다. 격리는 **인스턴스 단위**이므로 +/// 조합 루트에서 1회 생성해 공유해야 한다. +/// +/// **만료에 지터를 넣는 이유.** 같은 TTL로 한꺼번에 채워진 항목은 한꺼번에 만료된다. +/// 이 앱의 실제 피크는 막차 시간대(23:30~00:30)에 수천 기기가 동시에 앱을 여는 +/// 순간이라, 만료가 몰리면 그 순간 캐시가 통째로 무력해지고 요청이 서버에 몰린다. +/// 항목마다 ±`jitterRatio`를 흩어 그 동시성을 깬다. +public actor ExpiringCache { + private struct Entry: Codable, Sendable { + let key: String + let value: Value + let expiresAt: Date + } + + private let store: any KeyValueStore + private let storageKey: String + private let ttlSeconds: Double + private let limit: Int + private let jitterRatio: Double + private let now: @Sendable () -> Date + /// 지터 배수를 주입으로 받는 이유: 난수를 타입 안에서 뽑으면 만료 시각이 + /// 테스트에서 결정적이지 않다(`now` 주입과 같은 이유). + private let jitter: @Sendable (ClosedRange) -> Double + + private var cached: [Entry]? + + /// - Parameters: + /// - ttl: 기준 유효기간. 실제 만료는 여기에 ±`jitterRatio`가 곱해진다. + /// - limit: 최신순 상한. 초과분은 저장 시 잘린다. + public init( + store: any KeyValueStore, + key: String, + ttl: Duration, + limit: Int, + jitterRatio: Double = 0.1, + now: @escaping @Sendable () -> Date = Date.init, + jitter: @escaping @Sendable (ClosedRange) -> Double = { Double.random(in: $0) } + ) { + self.store = store + storageKey = key + ttlSeconds = Double(ttl.components.seconds) + Double(ttl.components.attoseconds) / 1e18 + self.limit = limit + self.jitterRatio = jitterRatio + self.now = now + self.jitter = jitter + } + + /// 만료된 항목은 부재로 답한다. 만료분 청소는 저장 시점에 한 번에 하므로 + /// 조회는 디스크를 건드리지 않는다. + public func value(forKey key: String) -> Value? { + let moment = now() + return entries().first { $0.key == key && $0.expiresAt > moment }?.value + } + + /// 같은 키의 기존 항목을 걷어내고 맨 앞에 넣는다 — 재저장이 멱등이고, + /// 최신순 상한이 LRU처럼 동작한다. + public func setValue(_ value: Value, forKey key: String) throws { + let moment = now() + let factor = 1 + jitter(-jitterRatio...jitterRatio) + let entry = Entry(key: key, value: value, expiresAt: moment.addingTimeInterval(ttlSeconds * factor)) + + var items = entries() + items.removeAll { $0.key == key || $0.expiresAt <= moment } + items.insert(entry, at: 0) + try persist(Array(items.prefix(limit))) + } + + public func removeAll() throws { + try store.removeValue(forKey: storageKey) + cached = [] + } + + private func entries() -> [Entry] { + if let cached { return cached } + let value = (try? store.value([Entry].self, forKey: storageKey)) ?? [] + cached = value + return value + } + + /// 디스크와 메모리 캐시를 같은 지점에서 갱신한다 — 한쪽만 바꾸면 살아 있는 + /// 인스턴스가 낡은 값을 답한다. + private func persist(_ items: [Entry]) throws { + try store.setValue(items, forKey: storageKey) + cached = items + } +} diff --git a/Projects/Core/Storage/Tests/ExpiringCacheTests.swift b/Projects/Core/Storage/Tests/ExpiringCacheTests.swift new file mode 100644 index 00000000..4a62a0d3 --- /dev/null +++ b/Projects/Core/Storage/Tests/ExpiringCacheTests.swift @@ -0,0 +1,157 @@ +@testable import CoreStorage +import Foundation +import Synchronization +import Testing + +private final class InMemoryKeyValueStore: KeyValueStore { + private let storage = Mutex<[String: Data]>([:]) + + func data(forKey key: String) throws -> Data? { storage.withLock { $0[key] } } + func set(_ data: Data, forKey key: String) throws { storage.withLock { $0[key] = data } } + func removeValue(forKey key: String) throws { storage.withLock { $0[key] = nil } } +} + +/// 시계를 손으로 돌린다 — 만료 검증에 실제 대기를 쓰면 테스트가 느려지고 흔들린다. +private final class Clock: @unchecked Sendable { + private let value: Mutex + + init(_ start: Date) { value = Mutex(start) } + // Mutex는 non-copyable이라 캡처 목록에 넣을 수 없다 — 클래스 참조(self)를 캡처한다. + var now: @Sendable () -> Date { { self.value.withLock { $0 } } } + func advance(_ seconds: TimeInterval) { value.withLock { $0 = $0.addingTimeInterval(seconds) } } +} + +struct ExpiringCacheTests { + private let store = InMemoryKeyValueStore() + private let clock = Clock(Date(timeIntervalSince1970: 1_000_000)) + + /// 지터 0 고정이 기본이다 — 만료 시각이 결정적이어야 경계 검증이 성립한다. + private func makeSUT( + ttl: Duration = .seconds(60), + limit: Int = 10, + jitter: @escaping @Sendable (ClosedRange) -> Double = { _ in 0 } + ) -> ExpiringCache { + ExpiringCache( + store: store, key: "cache", ttl: ttl, limit: limit, + now: clock.now, jitter: jitter + ) + } + + @Test + func value_missingKey_returnsNil() async { + #expect(await makeSUT().value(forKey: "a") == nil) + } + + @Test + func setValue_thenRead_returnsStoredValue() async throws { + let sut = makeSUT() + + try await sut.setValue("서울역", forKey: "a") + + #expect(await sut.value(forKey: "a") == "서울역") + } + + @Test + func value_beforeTTL_stillHits() async throws { + let sut = makeSUT(ttl: .seconds(60)) + try await sut.setValue("서울역", forKey: "a") + + clock.advance(59) + + #expect(await sut.value(forKey: "a") == "서울역") + } + + @Test + func value_afterTTL_missesAsIfAbsent() async throws { + let sut = makeSUT(ttl: .seconds(60)) + try await sut.setValue("서울역", forKey: "a") + + clock.advance(61) + + #expect(await sut.value(forKey: "a") == nil) + } + + /// 재저장은 멱등이다 — 같은 키가 둘로 갈라지면 상한이 실제보다 빨리 찬다. + @Test + func setValue_sameKeyTwice_keepsOneEntryWithLatestValue() async throws { + let sut = makeSUT() + + try await sut.setValue("옛값", forKey: "a") + try await sut.setValue("새값", forKey: "a") + try await sut.setValue("b값", forKey: "b") + + #expect(await sut.value(forKey: "a") == "새값") + #expect(await sut.value(forKey: "b") == "b값") + } + + /// 상한은 최신순으로 자른다 — 가장 오래 안 쓴 항목이 먼저 나간다. + @Test + func setValue_overLimit_dropsOldest() async throws { + let sut = makeSUT(limit: 2) + + try await sut.setValue("1", forKey: "a") + try await sut.setValue("2", forKey: "b") + try await sut.setValue("3", forKey: "c") + + #expect(await sut.value(forKey: "a") == nil) + #expect(await sut.value(forKey: "b") == "2") + #expect(await sut.value(forKey: "c") == "3") + } + + /// 만료분 청소는 저장 시점에 일어난다 — 조회가 디스크를 건드리지 않게 하기 위해서다. + @Test + func setValue_prunesExpiredEntries() async throws { + let sut = makeSUT(ttl: .seconds(60), limit: 10) + try await sut.setValue("낡음", forKey: "a") + + clock.advance(61) + try await sut.setValue("새것", forKey: "b") + + // 만료 항목이 남아 있었다면 상한 1짜리 캐시에서 b가 밀려났을 것이다. + #expect(await sut.value(forKey: "a") == nil) + #expect(await sut.value(forKey: "b") == "새것") + } + + /// 지터는 만료를 흩는다. 최대 지터(+10%)가 걸리면 기준 TTL을 넘겨도 아직 산다 — + /// 막차 시간대에 수천 기기의 만료가 한 점에 몰리지 않는 이유가 이것이다. + @Test + func setValue_positiveJitter_extendsExpiryBeyondBaseTTL() async throws { + let sut = makeSUT(ttl: .seconds(100), jitter: { $0.upperBound }) + try await sut.setValue("서울역", forKey: "a") + + clock.advance(105) + + #expect(await sut.value(forKey: "a") == "서울역") + } + + @Test + func setValue_negativeJitter_expiresBeforeBaseTTL() async throws { + let sut = makeSUT(ttl: .seconds(100), jitter: { $0.lowerBound }) + try await sut.setValue("서울역", forKey: "a") + + clock.advance(95) + + #expect(await sut.value(forKey: "a") == nil) + } + + /// 손상된 저장 내용은 캐시 부재로 강등한다 — 다음 저장이 덮어써 자가 치유한다. + @Test + func value_corruptedStore_treatedAsEmpty() async throws { + try store.set(Data("깨진 JSON".utf8), forKey: "cache") + let sut = makeSUT() + + #expect(await sut.value(forKey: "a") == nil) + try await sut.setValue("복구", forKey: "a") + #expect(await sut.value(forKey: "a") == "복구") + } + + @Test + func removeAll_dropsEverything() async throws { + let sut = makeSUT() + try await sut.setValue("서울역", forKey: "a") + + try await sut.removeAll() + + #expect(await sut.value(forKey: "a") == nil) + } +} diff --git a/Projects/Data/Sources/DTO/RecentSearchRecordDTO.swift b/Projects/Data/Sources/DTO/PlaceRecordDTO.swift similarity index 65% rename from Projects/Data/Sources/DTO/RecentSearchRecordDTO.swift rename to Projects/Data/Sources/DTO/PlaceRecordDTO.swift index bc6b2317..c13e0f5e 100644 --- a/Projects/Data/Sources/DTO/RecentSearchRecordDTO.swift +++ b/Projects/Data/Sources/DTO/PlaceRecordDTO.swift @@ -1,7 +1,8 @@ import Domain -/// 최근 검색 로컬 저장용 레코드 — Domain 엔티티는 Codable을 채택하지 않으므로 여기서 변환한다. -public struct RecentSearchRecordDTO: Codable, Equatable, Sendable { +/// 로컬 저장용 장소 레코드 — Domain 엔티티는 Codable을 채택하지 않으므로 여기서 변환한다. +/// 최근 검색과 장소 응답 캐시가 같은 표현을 쓴다. +public struct PlaceRecordDTO: Codable, Equatable, Sendable { public let name: String public let address: String public let latitude: Double diff --git a/Projects/Data/Sources/Repositories/CachingPlaceRepository.swift b/Projects/Data/Sources/Repositories/CachingPlaceRepository.swift new file mode 100644 index 00000000..b73903a3 --- /dev/null +++ b/Projects/Data/Sources/Repositories/CachingPlaceRepository.swift @@ -0,0 +1,83 @@ +import CoreStorage +import Domain +import Foundation + +/// 장소 조회 응답 캐시. `PlaceRepository`를 감싸 **호출처를 전혀 바꾸지 않고** 왕복을 줄인다 +/// (피처는 여전히 Domain 프로토콜만 본다). +/// +/// 캐시를 붙일 대상을 고른 기준은 **틀렸을 때의 피해**다. +/// +/// | 대상 | 틀리면 | 결정 | +/// |---|---|---| +/// | 역지오코딩 | 주소 표기가 조금 낡는다 | 캐시. 좌표→주소는 행정구역 개편 수준에서만 바뀌므로 7일 | +/// | 장소 검색 | 새로 생긴 가게가 안 보인다 | 캐시. POI는 생기고 없어지므로 1시간 | +/// | 서비스 지역 판정 | **서비스가 되는데 안 된다고 막는다** | **캐시하지 않음** — 지역 확장이 즉시 반영돼야 하고, 호출 빈도도 집 주소 저장 시 1회뿐이라 아낄 게 없다 | +/// +/// 실패는 캐시하지 않는다. 지하철에서 한 번 실패한 키워드가 지상에 나와도 계속 +/// 실패로 답하는 게 더 나쁘다. +public struct CachingPlaceRepository: PlaceRepository { + private let upstream: any PlaceRepository + private let geocodeCache: ExpiringCache + private let searchCache: ExpiringCache<[PlaceRecordDTO]> + + public init( + upstream: any PlaceRepository, + geocodeCache: ExpiringCache, + searchCache: ExpiringCache<[PlaceRecordDTO]> + ) { + self.upstream = upstream + self.geocodeCache = geocodeCache + self.searchCache = searchCache + } + + public func reverseGeocode(_ coordinate: Coordinate) async throws -> Place { + let key = Self.geocodeKey(coordinate) + if let cached = await geocodeCache.value(forKey: key) { + return cached.toEntity() + } + let place = try await upstream.reverseGeocode(coordinate) + try? await geocodeCache.setValue(PlaceRecordDTO(place), forKey: key) + return place + } + + public func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { + let key = Self.searchKey(keyword: keyword, near: coordinate) + if let cached = await searchCache.value(forKey: key) { + return cached.map { $0.toEntity() } + } + let places = try await upstream.searchPlaces(keyword: keyword, near: coordinate) + try? await searchCache.setValue(places.map(PlaceRecordDTO.init), forKey: key) + return places + } + + public func isServiceRegion(_ coordinate: Coordinate) async throws -> Bool { + try await upstream.isServiceRegion(coordinate) + } + + // MARK: - 키 + + /// 좌표를 소수 4자리(≈11m)로 반올림한다. GPS가 가만히 있어도 미터 단위로 떨리므로 + /// 원시 좌표를 키로 쓰면 **적중률이 사실상 0**이 된다. 11m 안의 두 점이 같은 주소를 + /// 갖는다는 가정은 역지오코딩에서 안전하다. + static func geocodeKey(_ coordinate: Coordinate) -> String { + "\(rounded(coordinate.latitude, digits: 4)),\(rounded(coordinate.longitude, digits: 4))" + } + + /// 검색의 좌표는 **정렬 편향(near)** 으로만 쓰이므로 3자리(≈110m)면 충분하다 — + /// 더 잘게 쪼개면 걸어가는 동안 같은 키워드가 매번 새 키가 된다. + static func searchKey(keyword: String, near coordinate: Coordinate?) -> String { + let normalized = keyword + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let bias = coordinate.map { + "\(rounded($0.latitude, digits: 3)),\(rounded($0.longitude, digits: 3))" + } ?? "-" + return "\(normalized)|\(bias)" + } + + /// 문자열 보간의 기본 표기에 맡기지 않는다 — 부동소수 오차가 그대로 키에 드러나 + /// 같은 좌표가 두 키로 갈라질 수 있다. + private static func rounded(_ value: Double, digits: Int) -> String { + String(format: "%.\(digits)f", value) + } +} diff --git a/Projects/Data/Sources/Repositories/RecentSearchRepositoryImpl.swift b/Projects/Data/Sources/Repositories/RecentSearchRepositoryImpl.swift index 5b24591a..965a6b16 100644 --- a/Projects/Data/Sources/Repositories/RecentSearchRepositoryImpl.swift +++ b/Projects/Data/Sources/Repositories/RecentSearchRepositoryImpl.swift @@ -22,7 +22,7 @@ public actor RecentSearchRepositoryImpl: RecentSearchRepository { public func save(_ place: Place) async throws { var records = loadRecords() records.removeAll { $0.toEntity() == place } - records.insert(RecentSearchRecordDTO(place), at: 0) + records.insert(PlaceRecordDTO(place), at: 0) try store.setValue(Array(records.prefix(maxCount)), forKey: storageKey) } @@ -33,7 +33,7 @@ public actor RecentSearchRepositoryImpl: RecentSearchRepository { } /// 부재·손상 데이터는 빈 목록으로 — 일회성 캐시라 다음 save가 덮어써 자가 치유한다. - private func loadRecords() -> [RecentSearchRecordDTO] { - (try? store.value([RecentSearchRecordDTO].self, forKey: storageKey)) ?? [] + private func loadRecords() -> [PlaceRecordDTO] { + (try? store.value([PlaceRecordDTO].self, forKey: storageKey)) ?? [] } } diff --git a/Projects/Data/Tests/CachingPlaceRepositoryTests.swift b/Projects/Data/Tests/CachingPlaceRepositoryTests.swift new file mode 100644 index 00000000..65583331 --- /dev/null +++ b/Projects/Data/Tests/CachingPlaceRepositoryTests.swift @@ -0,0 +1,213 @@ +@testable import AtchaData +import CoreStorage +import Domain +import Foundation +import Synchronization +import Testing + +private struct UpstreamError: Error {} + +private final class InMemoryKeyValueStore: KeyValueStore { + private let storage = Mutex<[String: Data]>([:]) + + func data(forKey key: String) throws -> Data? { storage.withLock { $0[key] } } + func set(_ data: Data, forKey key: String) throws { storage.withLock { $0[key] = data } } + func removeValue(forKey key: String) throws { storage.withLock { $0[key] = nil } } +} + +/// 상류 호출을 세는 스파이 — 캐시 적중의 관찰 가능한 정의가 "왕복이 안 일어남"이다. +private final class SpyPlaceRepository: PlaceRepository, @unchecked Sendable { + private let geocodeCalls = Mutex(0) + private let searchCalls = Mutex(0) + private let serviceRegionCalls = Mutex(0) + private let shouldFail = Mutex(false) + + var geocodeCount: Int { geocodeCalls.withLock { $0 } } + var searchCount: Int { searchCalls.withLock { $0 } } + var serviceRegionCount: Int { serviceRegionCalls.withLock { $0 } } + var fails: Bool { + get { shouldFail.withLock { $0 } } + set { shouldFail.withLock { $0 = newValue } } + } + + func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { + searchCalls.withLock { $0 += 1 } + if fails { throw UpstreamError() } + return [makePlace("\(keyword) 결과")] + } + + func reverseGeocode(_ coordinate: Coordinate) async throws -> Place { + geocodeCalls.withLock { $0 += 1 } + if fails { throw UpstreamError() } + return makePlace("현위치") + } + + func isServiceRegion(_ coordinate: Coordinate) async throws -> Bool { + serviceRegionCalls.withLock { $0 += 1 } + return true + } +} + +private func makePlace(_ name: String) -> Place { + Place(name: name, address: "\(name) 주소", coordinate: Coordinate(latitude: 37.5, longitude: 127.0)) +} + +struct CachingPlaceRepositoryTests { + private let upstream = SpyPlaceRepository() + private let store = InMemoryKeyValueStore() + + private func makeSUT(ttl: Duration = .seconds(3600)) -> CachingPlaceRepository { + CachingPlaceRepository( + upstream: upstream, + geocodeCache: ExpiringCache( + store: store, key: "rgeo", ttl: ttl, limit: 10, jitter: { _ in 0 } + ), + searchCache: ExpiringCache( + store: store, key: "search", ttl: ttl, limit: 10, jitter: { _ in 0 } + ) + ) + } + + // MARK: - 적중 + + @Test + func reverseGeocode_secondCallSameCoordinate_doesNotHitUpstream() async throws { + let sut = makeSUT() + let coordinate = Coordinate(latitude: 37.5665, longitude: 126.9780) + + let first = try await sut.reverseGeocode(coordinate) + let second = try await sut.reverseGeocode(coordinate) + + #expect(upstream.geocodeCount == 1) + #expect(first == second) + } + + /// GPS는 가만히 있어도 미터 단위로 떨린다. 반올림이 없으면 적중률이 사실상 0이 되고, + /// 검색 화면에 들어갈 때마다 역지오코딩이 다시 돈다 — 이 캐시를 만든 이유 그 자체다. + @Test + func reverseGeocode_coordinateJitterWithinRounding_hitsCache() async throws { + let sut = makeSUT() + + _ = try await sut.reverseGeocode(Coordinate(latitude: 37.56650, longitude: 126.97800)) + _ = try await sut.reverseGeocode(Coordinate(latitude: 37.566504, longitude: 126.978001)) + + #expect(upstream.geocodeCount == 1) + } + + @Test + func reverseGeocode_differentCoordinate_goesToUpstream() async throws { + let sut = makeSUT() + + _ = try await sut.reverseGeocode(Coordinate(latitude: 37.5665, longitude: 126.9780)) + _ = try await sut.reverseGeocode(Coordinate(latitude: 37.4979, longitude: 127.0276)) + + #expect(upstream.geocodeCount == 2) + } + + @Test + func searchPlaces_sameKeywordAndBias_doesNotHitUpstream() async throws { + let sut = makeSUT() + let near = Coordinate(latitude: 37.5665, longitude: 126.9780) + + let first = try await sut.searchPlaces(keyword: "강남", near: near) + let second = try await sut.searchPlaces(keyword: "강남", near: near) + + #expect(upstream.searchCount == 1) + #expect(first == second) + } + + /// 키워드는 공백·대소문자를 정규화한다 — 같은 검색이 두 키로 갈라지면 캐시가 논다. + @Test + func searchPlaces_keywordDiffersOnlyByWhitespaceOrCase_hitsCache() async throws { + let sut = makeSUT() + + _ = try await sut.searchPlaces(keyword: "Gangnam", near: nil) + _ = try await sut.searchPlaces(keyword: " gangnam ", near: nil) + + #expect(upstream.searchCount == 1) + } + + @Test + func searchPlaces_differentKeyword_goesToUpstream() async throws { + let sut = makeSUT() + + _ = try await sut.searchPlaces(keyword: "강남", near: nil) + _ = try await sut.searchPlaces(keyword: "홍대", near: nil) + + #expect(upstream.searchCount == 2) + } + + /// near는 정렬 편향으로만 쓰이므로 110m 단위로 뭉갠다 — 더 잘게 쪼개면 걸어가는 동안 + /// 같은 키워드가 매번 새 키가 된다. + /// + /// 버킷 경계를 피한 좌표를 쓴다. 반올림 캐시는 경계를 사이에 둔 두 점이 갈라지는 게 + /// 정상 동작이라, 경계값으로 검증하면 테스트가 구현이 아니라 우연을 본다. + @Test + func searchPlaces_nearBiasWithinRounding_hitsCache() async throws { + let sut = makeSUT() + + _ = try await sut.searchPlaces(keyword: "강남", near: Coordinate(latitude: 37.5660, longitude: 126.9780)) + _ = try await sut.searchPlaces(keyword: "강남", near: Coordinate(latitude: 37.56604, longitude: 126.97803)) + + #expect(upstream.searchCount == 1) + } + + /// near 유무는 다른 질의다 — 편향이 없으면 결과 순서가 달라진다. + @Test + func searchPlaces_nilBiasVersusCoordinate_areDifferentKeys() async throws { + let sut = makeSUT() + + _ = try await sut.searchPlaces(keyword: "강남", near: nil) + _ = try await sut.searchPlaces(keyword: "강남", near: Coordinate(latitude: 37.5665, longitude: 126.9780)) + + #expect(upstream.searchCount == 2) + } + + // MARK: - 캐시하지 않는 것 + + /// 실패는 캐시하지 않는다. 지하철에서 한 번 실패한 키워드가 지상에 나와도 계속 + /// 실패로 답하면, 재시도 버튼이 의미를 잃는다. + @Test + func searchPlaces_upstreamFailure_isNotCached() async throws { + let sut = makeSUT() + upstream.fails = true + + await #expect(throws: UpstreamError.self) { + _ = try await sut.searchPlaces(keyword: "강남", near: nil) + } + + upstream.fails = false + let recovered = try await sut.searchPlaces(keyword: "강남", near: nil) + + #expect(upstream.searchCount == 2) + #expect(!recovered.isEmpty) + } + + @Test + func reverseGeocode_upstreamFailure_isNotCached() async throws { + let sut = makeSUT() + upstream.fails = true + + await #expect(throws: UpstreamError.self) { + _ = try await sut.reverseGeocode(Coordinate(latitude: 37.5, longitude: 127.0)) + } + + upstream.fails = false + _ = try await sut.reverseGeocode(Coordinate(latitude: 37.5, longitude: 127.0)) + + #expect(upstream.geocodeCount == 2) + } + + /// 서비스 지역 판정은 캐시하지 않는다 — 틀리면 **되는 지역을 막는다**. 호출도 + /// 집 주소 저장 시 1회뿐이라 아낄 왕복이 없다. + @Test + func isServiceRegion_alwaysGoesToUpstream() async throws { + let sut = makeSUT() + let coordinate = Coordinate(latitude: 37.5665, longitude: 126.9780) + + _ = try await sut.isServiceRegion(coordinate) + _ = try await sut.isServiceRegion(coordinate) + + #expect(upstream.serviceRegionCount == 2) + } +} diff --git a/Projects/Data/Tests/RecentSearchRecordDTOTests.swift b/Projects/Data/Tests/PlaceRecordDTOTests.swift similarity index 74% rename from Projects/Data/Tests/RecentSearchRecordDTOTests.swift rename to Projects/Data/Tests/PlaceRecordDTOTests.swift index f6d8e925..d2bd863b 100644 --- a/Projects/Data/Tests/RecentSearchRecordDTOTests.swift +++ b/Projects/Data/Tests/PlaceRecordDTOTests.swift @@ -3,13 +3,13 @@ import Domain import Foundation import Testing -struct RecentSearchRecordDTOTests { +struct PlaceRecordDTOTests { @Test func decode_inlineJSON_mapsFields() throws { let json = Data( #"{"name":"홍대입구역","address":"서울 마포구","latitude":37.556748,"longitude":126.923643}"#.utf8 ) - let dto = try JSONDecoder().decode(RecentSearchRecordDTO.self, from: json) + let dto = try JSONDecoder().decode(PlaceRecordDTO.self, from: json) #expect(dto.toEntity() == Place( name: "홍대입구역", address: "서울 마포구", @@ -24,8 +24,8 @@ struct RecentSearchRecordDTOTests { address: "서울 용산구", coordinate: Coordinate(latitude: 37.554722, longitude: 126.970833) ) - let encoded = try JSONEncoder().encode(RecentSearchRecordDTO(place)) - let decoded = try JSONDecoder().decode(RecentSearchRecordDTO.self, from: encoded) + let encoded = try JSONEncoder().encode(PlaceRecordDTO(place)) + let decoded = try JSONDecoder().decode(PlaceRecordDTO.self, from: encoded) #expect(decoded.toEntity() == place) } } From f42a39e18596f5dd782c2aa7c05d80607a850584 Mon Sep 17 00:00:00 2001 From: YuGeonHui Date: Thu, 24 Sep 2026 23:04:00 +0900 Subject: [PATCH 3/6] =?UTF-8?q?docs:=20UseCase=C2=B7=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=B1=84=EB=84=90=C2=B7=EC=A0=80=EC=9E=A5/=EC=BA=90=EC=8B=9C?= =?UTF-8?q?=20=EA=B7=9C=EC=95=BD=EC=9D=84=20CLAUDE.md=EC=97=90=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리팩토링으로 확정된 규칙이 커밋 메시지에만 남아 있었다. 다음 작업자가 같은 판단을 다시 내리지 않도록 규약으로 적는다. - UseCase는 기본이 아니다(포트 2개 조합 또는 비즈니스 규칙일 때만) - 화면 상태 채널은 정확히 2개, != oldValue 게이트 유지 - 영속·캐시는 CoreStorage의 actor 3종으로만, 매체는 값 크기로 가름 - 응답 캐시는 Data 데코레이터로, 대상은 "틀렸을 때의 피해"로 선정 - 막차 경로 캐시를 왜 아직 안 했는지와 도입 조건 2가지 Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index a7d1a376..32b69666 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,8 +81,12 @@ AtchaV2(앱, 조합 루트: 어댑터·스플래시·AlarmSyncService) ─► Ho - 다른 모듈에 노출하는 진입점은 Interface 타겟의 프로토콜(`HomeCoordinatorBuildable` 패턴)로만. - 테스트는 **Swift Testing**(`@Test`/`#expect`). Example 앱은 스텁 UseCase로 피처 단독 실행(Data 무의존). 스텁이 Tests/Example에 중복되는 것은 의도된 트레이드오프. - catch-all `Shared`/`Common` 모듈을 만들지 않는다. 로깅·캐싱 등이 필요해지면 목적별 단일 모듈(`Logger`, `Storage`)을 새로 판다. +- 영속·캐시는 `CoreStorage`의 타입으로만 한다. `KeyValueStore`(Data 단위 추상) 위에 `DocumentStore`(단일 문서) / `CollectionStore`(상한 있는 목록) / `ExpiringCache`(TTL+지터) 셋을 올린다. **전부 actor다** — 목록·문서 갱신은 read-modify-write라 값 타입으로 두면 동시 쓰기가 서로를 덮어쓴다(실제로 최근 검색 1건이 조용히 사라지는 버그였다). 격리는 **인스턴스 단위**라 조합 루트에서 1회 생성해 공유해야 한다. 저장 매체는 값 크기로 가른다 — 단일 값 4KB 초과면 `FileKeyValueStore`(UserDefaults는 첫 접근에 plist 전체를 올린다), 서버에서 다시 받을 수 있는 값은 `.cache` 네임스페이스로 백업 제외. +- 응답 캐시는 Data 레이어 **데코레이터**로 붙인다(`CachingPlaceRepository`) — 피처·Domain은 한 줄도 모른다. 대상 선정 기준은 "틀렸을 때의 피해"다: 역지오코딩 7일 · 장소 검색 1시간 · **서비스 지역 판정은 캐시 금지**(틀리면 되는 지역을 막는다). 좌표는 키로 쓰기 전에 반올림한다(GPS가 떨려서 원시 좌표는 적중률이 0). 만료에는 ±10% 지터 — 막차 시간대에 수천 기기의 만료가 한 점에 몰리면 안 된다. **실패는 캐시하지 않는다.** - 모듈명 `Data`는 금지(Foundation.Data 섀도잉) — Data 레이어 모듈명은 `AtchaData`(디렉터리는 `Projects/Data`). - UI는 UIKit 코드 기반(스토리보드 없음) + SnapKit + DesignSystem 토큰(`DSColor`/`DSFont`/`DSSpacing`). +- **UseCase는 기본이 아니다.** 단일 Repository 메서드를 한 줄 위임하는 UseCase는 만들지 않는다 — 같은 프로토콜에 이름만 하나 더 입히는 계층이다. 새로 만들 조건은 ① 포트 2개 이상을 조합하거나 ② 비즈니스 규칙(순서·판정·폴백)을 담을 때. 해당 없으면 ViewModel이 Domain Repository/Service 프로토콜을 직접 주입받는다(경계는 유지된다 — 피처가 보는 건 여전히 Domain 프로토콜뿐). 2026-09-24 전면 재검토로 21 → 11개. 개발자가 5명을 넘거나 한 Repository 메서드를 4화면 초과가 쓰면 재검토. +- **화면 상태 채널은 정확히 2개**: `onStateChange`(상태) + `onToast`(사건). `private(set) var state`는 `didSet`에서 `!= oldValue`일 때만 방출한다 — 같은 값 재방출은 배너 틱마다 셀을 리로드시킨다. State 밖에 상태를 따로 들지 않고, 두 번째 상태 콜백을 만들지 않는다(키 입력 diff는 계약이 아니라 VC의 일). ### 미완 상태 (작업 시 참고) @@ -92,5 +96,6 @@ AtchaV2(앱, 조합 루트: 어댑터·스플래시·AlarmSyncService) ─► Ho - **네트워크 진단**: `URLSessionNetworkClient`가 DEBUG에서 `com.atcha.network` 서브시스템에 `메서드 · URL · 상태코드/에러 · 소요시간`을 남긴다(헤더·본문은 토큰이 실리므로 절대 로깅 금지). `NetworkError.debugDescription`이 URLError 코드를 노출해 타임아웃(-1001)·연결 불가(-1004)·TLS 실패(-1200)를 가른다 — 이전에는 전부 `.transport`로 뭉개져 원인 구분이 불가능했다. 부트스트랩 실패는 `BootstrapFailureMessage`를 거쳐 스플래시의 `showRetry`로 표면화된다(게스트 전환으로 부트스트랩이 비동기가 되면서 비로소 쓰이는 경로). - `Projects/App/Resources/GoogleService-Info.plist`는 **레거시 번들 ID(`com.atcha.iOS`)용 파일**이라 존재 가드만 통과할 뿐 V2(`com.atcha.iOS.v2`)로의 사일런트 푸시가 성립하지 않는다 — V2용 재발급·교체 필요. FCM 토큰은 로그인/가입 파라미터로만 서버에 가고, 갱신 전달은 `SyncPushTokenUseCase`까지 배선됐지만 서버 API 미확정이라 `UnconfirmedPushTokenRepository`(no-op)가 주입돼 있다. 그래서 갱신 채널은 현재 폴링(앱 시작·포그라운드 복귀)과 홈 pull-to-refresh(수동)뿐. - AtchaV2는 iOS 26 전용. AlarmKit(CoreAlarm)·Live Activity(CoreLiveActivity + AtchaWidget 익스텐션)는 Phase 9~12에서 구축 완료. +- **막차 경로(`/routes/last-routes`) 응답 캐시는 의도적으로 미도입**이다. 장소·역지오코딩과 달리 틀렸을 때의 피해가 "막차를 놓친다"라서, 도입하려면 두 가지가 함께 와야 한다: ① 화면에 **"HH:mm 기준" 스탬프 필수**(스탬프 없이 표시 금지), ② **알람 등록 근거로는 캐시 사용 금지** — 지금 `RegisterAlarmUseCase`는 사용자가 고른 `LastRoute`를 그대로 받으므로, 이 규칙을 강제하려면 등록 시점 재조회가 필요해 시그니처가 바뀐다. TTL은 쓰지 않는다(막차의 유효 시간은 벽시계가 아니라 `departureTime` 자체다 — `RouteCardViewData.asPastTrain`이 이미 그 전환을 한다). - Phase 12 이후의 갭 분석·후속 로드맵: `docs/planning/atcha-v2-post12-roadmap.md` / Phase 13·14(알람 이후 + 재실행 정합성) 구현 프롬프트: `docs/prompts/atcha-v2-session-lifecycle-prompt.md`. - Phase 검수는 사람 검수 대신 **자동 검수 규약**(`docs/prompts/atcha-v2-auto-verification.md`)을 따른다 — 에이전트가 computer use로 시뮬레이터 검수를 직접 수행·증적 보고하고, 실기기 잔여 항목만 사용자에게 이관. From ebf8d8f2afa985a1eb99d4e825c35d17e26125ad Mon Sep 17 00:00:00 2001 From: YuGeonHui Date: Thu, 24 Sep 2026 23:12:01 +0900 Subject: [PATCH 4/6] =?UTF-8?q?refactor:=20=EC=9D=BD=EB=8A=94=20=EA=B3=B3?= =?UTF-8?q?=EC=9D=B4=20=EC=97=86=EB=8A=94=20isAcknowledged=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AlarmSessionLifecycleService.isAcknowledged는 쓰기 2곳, 읽기 0곳이었다. "스냅샷 영속화의 인메모리 미러"였는데 정본(AlarmSessionStore)이 세션 수명을 들게 되면서 미러를 볼 이유가 사라졌다. 남겨 두면 두 곳이 어긋날 수 있다는 인상만 준다. Co-Authored-By: Claude Opus 5 --- Projects/App/Sources/AlarmSessionLifecycleService.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Projects/App/Sources/AlarmSessionLifecycleService.swift b/Projects/App/Sources/AlarmSessionLifecycleService.swift index 3afc3f0e..198680d3 100644 --- a/Projects/App/Sources/AlarmSessionLifecycleService.swift +++ b/Projects/App/Sources/AlarmSessionLifecycleService.swift @@ -16,9 +16,6 @@ final class AlarmSessionLifecycleService { subsystem: "com.atcha.iOS.v2", category: "SessionLifecycle" ) - /// stopIntent 확인 기록 — 스냅샷 영속화의 인메모리 미러. - private(set) var isAcknowledged = false - init( liveActivity: any LastTrainDepartureEnding, sessionStore: AlarmSessionStore @@ -34,7 +31,6 @@ final class AlarmSessionLifecycleService { func alarmAcknowledged() async { // 로그는 자동 검수 ②(강제 종료 후 인텐트 실행 여부 판정)의 증적 채널이다. Self.logger.info("알람 확인(stopIntent) 수신 — departed 전환 + 자동 소멸 예약") - isAcknowledged = true // 세션 스냅샷이 있을 때만 기록한다 — 스냅샷 없는 확인(이론상 경합)은 남길 곳이 없고, // 그 경우의 정리는 wake 시점 리컨실이 맡는다. await sessionStore.acknowledge() From 791ec6608153aa0e734409bb8195e8332c7eb59d Mon Sep 17 00:00:00 2001 From: YuGeonHui Date: Thu, 24 Sep 2026 23:15:05 +0900 Subject: [PATCH 5/6] =?UTF-8?q?refactor:=20=EB=85=B8=EC=84=A0=EB=AA=85=20?= =?UTF-8?q?=ED=8C=8C=EC=8B=B1=EC=9D=84=20DesignSystem=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=AA=A8=EC=9C=BC=EA=B3=A0=20=EB=B3=B5=EC=A0=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TransportBadgeMapper가 Home·Search에 **바이트 단위로 동일하게** 복제돼 있었다. 지하철 노선 하나를 추가하면 두 파일을 같이 고쳐야 했고, 한쪽만 고쳐도 빌드는 통과한다. 노선명 문자열 파싱(버스 종류·지하철 노선 테이블)을 DSTransportBadge.Kind의 팩토리로 옮겼다. 이 테이블은 팔레트와 짝이라 색이 추가되면 같이 바뀌므로 DesignSystem이 들고 있는 게 맞다. DesignSystem이 Domain을 import하지는 않는다. DSTransportBadge.Kind는 의도적으로 Domain 타입이 아니고(원래 주석), 디자인 시스템이 TransportLeg을 알게 되면 비즈니스 엔티티에 묶인다. 경계를 문자열에 두면 둘 다 지킨다 — 피처에는 leg.mode switch 약 10줄만 남는다(각 91줄 → 26줄). 테스트 15개 신설. 복제돼 있던 동안 이 테이블에는 테스트가 하나도 없었다. 특히 "인천 1호선이 1호선으로 떨어지면 안 된다"는 테이블 순서 규칙이 이제 깨지면 잡힌다. 검증: 3구성 빌드 + DesignSystem 73(+15) · HomeFeature 66 · SearchFeature 26. Co-Authored-By: Claude Opus 5 --- .../DSTransportBadge+RouteName.swift | 74 +++++++++++++++++ .../DSTransportBadgeRouteNameTests.swift | 80 +++++++++++++++++++ .../Home/Sources/TransportBadgeMapper.swift | 69 ++-------------- .../Search/Sources/TransportBadgeMapper.swift | 69 ++-------------- 4 files changed, 168 insertions(+), 124 deletions(-) create mode 100644 Projects/DesignSystem/Sources/Components/DSTransportBadge+RouteName.swift create mode 100644 Projects/DesignSystem/Tests/DSTransportBadgeRouteNameTests.swift diff --git a/Projects/DesignSystem/Sources/Components/DSTransportBadge+RouteName.swift b/Projects/DesignSystem/Sources/Components/DSTransportBadge+RouteName.swift new file mode 100644 index 00000000..9538ee67 --- /dev/null +++ b/Projects/DesignSystem/Sources/Components/DSTransportBadge+RouteName.swift @@ -0,0 +1,74 @@ +import Foundation + +/// 서버 노선명 문자열 → 배지 종류. **문자열 파싱만** 여기 산다. +/// +/// 이 파일이 DesignSystem에 있는 이유와, 그럼에도 Domain을 import하지 않는 이유가 +/// 같은 하나다. 노선 테이블은 **팔레트와 짝**이라 색이 추가되면 여기도 같이 바뀐다 — +/// 그래서 DesignSystem에 있어야 한다. 반대로 `DSTransportBadge.Kind`는 의도적으로 +/// Domain 타입이 아니므로(같은 파일 상단 주석), DesignSystem이 `TransportLeg`을 알게 +/// 되면 디자인 시스템이 비즈니스 엔티티에 묶인다. 경계를 문자열에 두면 둘 다 지킨다. +/// +/// 피처는 `leg.mode` switch(약 10줄)만 들고 이 팩토리를 부른다. 이전에는 이 테이블 +/// 전체가 Home·Search에 **바이트 단위로 복제**돼 있었고, 노선 하나를 추가하면 두 곳을 +/// 같이 고쳐야 했다. +public extension DSTransportBadge.Kind { + /// 레거시 서버 형식: `"간선:472"` (종류:번호). 형식이 아니면 원문을 그대로 쓴다. + static func bus(legacyRouteName routeName: String?) -> DSTransportBadge.Kind { + guard let routeName, let colonIndex = routeName.firstIndex(of: ":") else { + return .bus(.general, text: routeName ?? "버스") + } + let type = String(routeName[.. DSTransportBadge.Kind { + guard let routeName else { return .subway(.line1, text: "지하철") } + for entry in subwayLineTable where routeName.contains(entry.keyword) { + return .subway(entry.line, text: entry.badge) + } + // TODO: 실서버 노선명 실측 후 테이블 확장. + return .subway(.line1, text: routeName) + } + + /// **순서가 규칙이다.** 구별되는 이름이 맨숭맨숭한 "N호선"보다 먼저 와야 한다 — + /// "인천 1호선"이 "1호선"으로 떨어지면 안 된다. 테이블에 줄을 추가할 때는 위치가 + /// 곧 우선순위다. + private static let subwayLineTable: [(keyword: String, line: DSSubwayLine, badge: String)] = [ + ("수인분당", .suinBundang, "수인분당"), + ("신분당", .shinbundang, "신분당"), + ("경의중앙", .gyeonguiJungang, "경의중앙"), + ("경춘", .gyeongchun, "경춘"), + ("경강", .gyeonggang, "경강"), + ("공항", .airport, "공항"), + ("GTX", .gtxA, "GTX-A"), + ("인천 1", .incheon1, "인천1"), + ("인천1", .incheon1, "인천1"), + ("인천 2", .incheon2, "인천2"), + ("인천2", .incheon2, "인천2"), + ("서해", .seohae, "서해"), + ("신림", .sillim, "신림"), + ("우이신설", .uiSinseol, "우이신설"), + ("의정부", .uijeongbu, "의정부"), + ("에버라인", .everline, "에버"), + ("김포", .gimpo, "김포"), + ("1호선", .line1, "1"), + ("2호선", .line2, "2"), + ("3호선", .line3, "3"), + ("4호선", .line4, "4"), + ("5호선", .line5, "5"), + ("6호선", .line6, "6"), + ("7호선", .line7, "7"), + ("8호선", .line8, "8"), + ("9호선", .line9, "9"), + ] +} diff --git a/Projects/DesignSystem/Tests/DSTransportBadgeRouteNameTests.swift b/Projects/DesignSystem/Tests/DSTransportBadgeRouteNameTests.swift new file mode 100644 index 00000000..fb44ad8a --- /dev/null +++ b/Projects/DesignSystem/Tests/DSTransportBadgeRouteNameTests.swift @@ -0,0 +1,80 @@ +@testable import DesignSystem +import Testing + +/// 이 테이블은 Home·Search에 복제돼 있던 동안 테스트가 하나도 없었다. +/// DesignSystem으로 모으면서 규칙을 고정한다. +struct DSTransportBadgeRouteNameTests { + // MARK: - 버스 + + @Test(arguments: [ + ("간선:472", DSBusType.mainline, "472"), + ("지선:6642", DSBusType.regular, "6642"), + ("마을:서초08", DSBusType.town, "서초08"), + ("광역:9401", DSBusType.widearea, "9401"), + ("직행:1005", DSBusType.widearea, "1005"), + ]) + func bus_legacyFormat_mapsTypeAndNumber(routeName: String, type: DSBusType, text: String) { + #expect(DSTransportBadge.Kind.bus(legacyRouteName: routeName) == .bus(type, text: text)) + } + + /// 모르는 종류는 색만 기본값으로 떨어뜨리고 번호는 지킨다. + @Test + func bus_unknownType_keepsNumberWithGeneralColor() { + #expect(DSTransportBadge.Kind.bus(legacyRouteName: "심야:N26") == .bus(.general, text: "N26")) + } + + /// 콜론이 없으면 원문 전체가 배지 텍스트다 — 이름을 잃는 것보다 낫다. + @Test + func bus_withoutColon_usesRawNameAsText() { + #expect(DSTransportBadge.Kind.bus(legacyRouteName: "472") == .bus(.general, text: "472")) + } + + @Test + func bus_nilRouteName_fallsBackToLabel() { + #expect(DSTransportBadge.Kind.bus(legacyRouteName: nil) == .bus(.general, text: "버스")) + } + + // MARK: - 지하철 + + @Test(arguments: [ + ("1호선", DSSubwayLine.line1, "1"), + ("9호선", DSSubwayLine.line9, "9"), + ("수인분당선", DSSubwayLine.suinBundang, "수인분당"), + ("신분당선", DSSubwayLine.shinbundang, "신분당"), + ("경의중앙선", DSSubwayLine.gyeonguiJungang, "경의중앙"), + ("공항철도", DSSubwayLine.airport, "공항"), + ("GTX-A", DSSubwayLine.gtxA, "GTX-A"), + ("우이신설선", DSSubwayLine.uiSinseol, "우이신설"), + ]) + func subway_knownLine_mapsToPaletteAndBadgeText( + routeName: String, line: DSSubwayLine, text: String + ) { + #expect(DSTransportBadge.Kind.subway(routeName: routeName) == .subway(line, text: text)) + } + + /// **테이블 순서가 규칙이다.** 구별되는 이름이 "N호선"보다 먼저 와야 한다 — + /// 이 테스트가 깨지면 테이블에 줄을 잘못된 위치에 넣은 것이다. + @Test(arguments: [ + ("인천 1호선", DSSubwayLine.incheon1, "인천1"), + ("인천1호선", DSSubwayLine.incheon1, "인천1"), + ("인천 2호선", DSSubwayLine.incheon2, "인천2"), + ("의정부경전철", DSSubwayLine.uijeongbu, "의정부"), + ("김포골드라인", DSSubwayLine.gimpo, "김포"), + ]) + func subway_distinctiveNames_winOverBareLineNumbers( + routeName: String, line: DSSubwayLine, text: String + ) { + #expect(DSTransportBadge.Kind.subway(routeName: routeName) == .subway(line, text: text)) + } + + /// 미매핑 노선은 원문을 유지한다 — 색이 틀리는 편이 이름이 사라지는 것보다 낫다. + @Test + func subway_unmappedLine_keepsRawNameWithBestEffortColor() { + #expect(DSTransportBadge.Kind.subway(routeName: "동해선") == .subway(.line1, text: "동해선")) + } + + @Test + func subway_nilRouteName_fallsBackToLabel() { + #expect(DSTransportBadge.Kind.subway(routeName: nil) == .subway(.line1, text: "지하철")) + } +} diff --git a/Projects/Feature/Home/Sources/TransportBadgeMapper.swift b/Projects/Feature/Home/Sources/TransportBadgeMapper.swift index 2c24ba83..841b2396 100644 --- a/Projects/Feature/Home/Sources/TransportBadgeMapper.swift +++ b/Projects/Feature/Home/Sources/TransportBadgeMapper.swift @@ -1,9 +1,11 @@ import DesignSystem import Domain -/// Maps Domain transit legs onto the DesignSystem's badge vocabulary. -/// DSTransportBadge.Kind is deliberately not a Domain type, so this -/// translation lives in the feature. +/// Domain 이동수단 → DesignSystem 배지 어휘. `DSTransportBadge.Kind`는 의도적으로 +/// Domain 타입이 아니라, 이 번역은 피처에 산다. +/// +/// 노선명 문자열 파싱(버스 종류·지하철 노선 테이블)은 팔레트와 짝이라 DesignSystem이 +/// 들고 있다 — 여기 남은 건 `leg.mode` 분기뿐이다. enum TransportBadgeMapper { static func kinds(for legs: [TransportLeg]) -> [DSTransportBadge.Kind] { legs.map(kind(for:)) @@ -14,68 +16,11 @@ enum TransportBadgeMapper { case .walk: .walk case .bus: - busKind(routeName: leg.routeName) + .bus(legacyRouteName: leg.routeName) case .subway: - subwayKind(routeName: leg.routeName) + .subway(routeName: leg.routeName) case .unknown: .bus(.general, text: leg.routeName ?? "이동") } } - - // Legacy server format: "간선:472" (type:number). - private static func busKind(routeName: String?) -> DSTransportBadge.Kind { - guard let routeName, let colonIndex = routeName.firstIndex(of: ":") else { - return .bus(.general, text: routeName ?? "버스") - } - let type = String(routeName[.. DSTransportBadge.Kind { - guard let routeName else { return .subway(.line1, text: "지하철") } - for entry in subwayLineTable where routeName.contains(entry.keyword) { - return .subway(entry.line, text: entry.badge) - } - // TODO: 실서버 노선명 실측 후 테이블 확장 — 미매핑 노선은 원문 텍스트 유지, 색상은 best-effort. - return .subway(.line1, text: routeName) - } } diff --git a/Projects/Feature/Search/Sources/TransportBadgeMapper.swift b/Projects/Feature/Search/Sources/TransportBadgeMapper.swift index 2c24ba83..841b2396 100644 --- a/Projects/Feature/Search/Sources/TransportBadgeMapper.swift +++ b/Projects/Feature/Search/Sources/TransportBadgeMapper.swift @@ -1,9 +1,11 @@ import DesignSystem import Domain -/// Maps Domain transit legs onto the DesignSystem's badge vocabulary. -/// DSTransportBadge.Kind is deliberately not a Domain type, so this -/// translation lives in the feature. +/// Domain 이동수단 → DesignSystem 배지 어휘. `DSTransportBadge.Kind`는 의도적으로 +/// Domain 타입이 아니라, 이 번역은 피처에 산다. +/// +/// 노선명 문자열 파싱(버스 종류·지하철 노선 테이블)은 팔레트와 짝이라 DesignSystem이 +/// 들고 있다 — 여기 남은 건 `leg.mode` 분기뿐이다. enum TransportBadgeMapper { static func kinds(for legs: [TransportLeg]) -> [DSTransportBadge.Kind] { legs.map(kind(for:)) @@ -14,68 +16,11 @@ enum TransportBadgeMapper { case .walk: .walk case .bus: - busKind(routeName: leg.routeName) + .bus(legacyRouteName: leg.routeName) case .subway: - subwayKind(routeName: leg.routeName) + .subway(routeName: leg.routeName) case .unknown: .bus(.general, text: leg.routeName ?? "이동") } } - - // Legacy server format: "간선:472" (type:number). - private static func busKind(routeName: String?) -> DSTransportBadge.Kind { - guard let routeName, let colonIndex = routeName.firstIndex(of: ":") else { - return .bus(.general, text: routeName ?? "버스") - } - let type = String(routeName[.. DSTransportBadge.Kind { - guard let routeName else { return .subway(.line1, text: "지하철") } - for entry in subwayLineTable where routeName.contains(entry.keyword) { - return .subway(entry.line, text: entry.badge) - } - // TODO: 실서버 노선명 실측 후 테이블 확장 — 미매핑 노선은 원문 텍스트 유지, 색상은 best-effort. - return .subway(.line1, text: routeName) - } } From e87f06bfebeea4355e9c49c6d2706b989536001e Mon Sep 17 00:00:00 2001 From: YuGeonHui Date: Thu, 24 Sep 2026 23:25:39 +0900 Subject: [PATCH 6/6] =?UTF-8?q?chore:=20.DS=5FStore=EB=A5=BC=20=EC=A0=84?= =?UTF-8?q?=EC=97=AD=EC=9C=BC=EB=A1=9C=20=EB=AC=B4=EC=8B=9C=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=EC=B6=94=EC=A0=81=20=EC=A4=91=EC=9D=B8=203?= =?UTF-8?q?=EA=B0=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gitignore에 개별 경로 규칙 하나만 있어서 다른 디렉터리의 .DS_Store는 그대로 커밋될 수 있었고, 실제로 #371에서 Projects/.DS_Store가 딸려 들어갔다. 전역 규칙으로 바꾸고 추적 중이던 3개를 인덱스에서 뺀다. Co-Authored-By: Claude Opus 5 --- .gitignore | 4 +++- .../AtchaImage/Icon.xcassets/.DS_Store | Bin 6148 -> 0 bytes .../Icon.xcassets/Onboarding/.DS_Store | Bin 6148 -> 0 bytes Projects/.DS_Store | Bin 6148 -> 0 bytes 4 files changed, 3 insertions(+), 1 deletion(-) delete mode 100644 Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/.DS_Store delete mode 100644 Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Onboarding/.DS_Store delete mode 100644 Projects/.DS_Store diff --git a/.gitignore b/.gitignore index 8a2926e2..bbac01f2 100644 --- a/.gitignore +++ b/.gitignore @@ -116,7 +116,9 @@ BaseConfig.xcconfig .claude -Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Onboarding/.DS_Store +# Finder 메타데이터. 전역 규칙이 없어 개별 경로만 막고 있었고, 그래서 Projects/.DS_Store가 +# 실수로 커밋된 적이 있다(#371). +.DS_Store # --- Tuist (generated artifacts only; legacy Atcha-iOS.xcodeproj stays tracked) --- /Atcha.xcworkspace/ diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/.DS_Store b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/.DS_Store deleted file mode 100644 index 9d0497417d75732a73ecb78f5f6e6ac1c5f3d5d5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKPfrs;6n_H+wji=Vi%2xt*oz4yR%j%~7}kPO^iX68mH^h>b|@>`nPzuOMMBcE zCVl`vfS-Xc;L)oGFMa_Jo;>?zXBt|nSL2_3$(!GsH}B2#{dQ-k0{|r7sN4k50YF0) zm>9<9CnCm$_DG-d3?h|Oo+qL$2(+cgd5#()uTl6+K_nfwEJDVPHD~g<{Cg zQD36l2L_ic!NeqMQ-^}eUZq=c12%wy{8ks_#|`dwT&5iyih>NgoT!KKHye9=M^)+15^gG_QuVw#xQ6u6T$P7wTdt*ra}d z*ppM-vVGrXRhmEA^=yZF1(xSw$ZH}}%!Y-m`Bcmlt!>rqvdgNOrEikQjJeh}u1iCQ zj5Gi;W>%V7s_jxpHke(b$z;ZStUMab!aaBd7VJY4-oiV052x?}KEqe|P7-8_TqCn2 zNA8j(@{l|s>!eKfiA!F{{J!>MzS_MK39#Ssm=7(Vd7M?zD>4S7SGoZ{1VB3urN8;5 z?yBiry~9ud1`cjv8*8qBi@oZ(m~H|UQ3%>d&q(Zu8HgG9-x&}eQUg_>sjwwcxP(8t z0w5+tUkKXbE+NoRXew+;#1RxCQxRnC&j&(ftZ1RiUE>Xu~za}k~&*^iW6t8f%+U( sh=^-Rj8d>s$8ltcqj(xM5*#yxfoLjhNkk7y{6`?7A#P#@ek%h%08DxfEC2ui diff --git a/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Onboarding/.DS_Store b/Atcha-iOS/DesignSource/AtchaImage/Icon.xcassets/Onboarding/.DS_Store deleted file mode 100644 index 469961f86af88008eaeb6d4ecc94d2aad09fd5d2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK%}T>S5T32orijpkf?j07Yti~wEnY&cFJMFuDlxI82GguGsXdfJ?)pN$h|lB9 z?pBoQNzfuQW%iq$o!L#kEjw8N5Y0h*8=wLJ4k}@xjLjE9zM(k?3De=+Gw<2Z&c;>;qJk>Do2f-T2=1t*2m+rv$C;ubn4&7 zy+l13<^}#qwJcejz%v>@BH_{NC$UPpIL~B_$=r<005iZ0%pe2ysB#u(@Y(p0m;q+s zZyBKTL82167ITC8=)i_PA8EWuNP;%qB?zTO*J5rEM^J=GMKr0xUNMA8N58ajuEpG- zNe7`<#(C_@!roAXULE~Xhl6kpa?1=b1D_d~Gu;Z+|C8_U|DTh%#|$t7|B3-oYX&34a`2h$!RwgF? zfJ>I_C6PV+fUxo0s+#F4dYYJ!7-Q-tRj2yiTXoNze$*5I)!Exv1ljU-JQY`1{iGj-S_u+SVpouP1m;bHF}z@ zU)Mk0Jv&`;qZ@Zb2d|t}A0KcUA}k}vYixQI)$@)Ub8D`Ax*63R&&~3(>ZG_=w{c>3ExB`hm8T~9dO{|*GF&dLqsE9!C9AxX^;sUDnYPhom3;bSw2q;t3!Z$_x;Sx{ z%l_4v%QU>4l1W{T#{;qOxUX@~7HhT)hCV0|6bK586_D>k%qEyvEDY*T2RnTPAVzd+ z<5+(qiLp3{iN(SoJv3#e63tX)j~L2Kr$4rNiN(U8nGU5Za~)e**%OM=)#;CQI#gmX z^g)53fUm%mxope+|DyiL4lyaf2Dwm(r((}PqKS!<;Tfh8?(J+6O+8cpq|3c iZpV5cTk$-bHpXHZ5EF}qL3(K6kARjTbU}f?s=#mJTj|39