From 6c6cd765c7b7d1ad492253f32772012ac3bfea0f Mon Sep 17 00:00:00 2001 From: YuGeonHui Date: Thu, 24 Sep 2026 22:36:11 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EC=9C=84=EC=9E=84=EB=A7=8C=20?= =?UTF-8?q?=ED=95=98=EB=8A=94=20UseCase=206=EA=B0=9C=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=20(Phase=20F,=2021=20=E2=86=92=2015)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 조합도 규칙도 없이 호출만 위임하는 계층을 걷어낸다. 결정표 기준으로 UseCase는 ①여러 Repository 조합을 2개 이상 화면이 쓰거나 ②화면 무관 비즈니스 규칙이 있거나 ③팀이 커서 변경 차단이 필요할 때 값을 한다. ③은 개발자 2~3명이라 전원 미달이므로 ①②로만 판정했다. ## 제거 (6개) ObserveAlarmUseCase events.updates() 1줄 ObserveAlarmChangeUseCase events.changes() 1줄 RequestAlarmSyncUseCase requesting.syncNow() 1줄 GetUserProfileUseCase userRepository.fetchMe() GetLastRouteDetailUseCase repository.lastRoute(id:) UpdateAlertFrequencyUseCase **프로덕션 사용처 0곳 — 죽은 코드** 앞의 셋을 지워도 Feature가 App을 알게 되지 않는다 — `AlarmSyncEvents` 등은 이미 Domain 프로토콜이라 ViewModel이 직접 주입받으면 된다. 같은 프로토콜에 이름만 하나 더 입힌 계층이었다. `UpdateAlertFrequencyUseCase`는 테스트조차 없었다. 알림 빈도가 레거시처럼 미노출 상태라 화면에서 쓸 일이 없었는데, 계층이 있으니 살아 있는 것처럼 보였다. ## 테스트 스텁이 오히려 단순해졌다 StubObserveAlarmUseCase → StubAlarmSyncEvents StubGetUserProfileUseCase → StubUserRepository 한 겹이 사라지면서 스텁이 실제 계약을 직접 구현한다. 검증: HomeFeature 66 / SettingsFeature 18 / Debug 빌드 ## 남은 것 `SearchPlaces`·`ReverseGeocode`·`GetCurrentLocation`·`RecentSearches` 4개도 같은 위임 계층이지만, 각각 10~13개 파일이 참조한다. 별도로 진행한다. Co-Authored-By: Claude Opus 5 --- Projects/App/Sources/AppDIContainer.swift | 14 +- .../UseCases/GetLastRouteDetailUseCase.swift | 17 --- .../UseCases/GetUserProfileUseCase.swift | 15 -- .../UseCases/ObserveAlarmChangeUseCase.swift | 17 --- .../UseCases/ObserveAlarmUseCase.swift | 17 --- .../UseCases/RequestAlarmSyncUseCase.swift | 20 --- .../UpdateAlertFrequencyUseCase.swift | 15 -- .../Feature/Home/Example/ExampleApp.swift | 27 ++-- .../Home/Sources/HomeDIContainer.swift | 32 ++-- .../Feature/Home/Sources/HomeViewModel.swift | 142 +++++++++--------- .../Home/Tests/HomeViewModelTests.swift | 27 ++-- .../Feature/Settings/Example/ExampleApp.swift | 16 +- .../Sources/SettingsDIContainer.swift | 8 +- .../Settings/Sources/SettingsViewModel.swift | 11 +- .../Settings/Tests/SettingsFeatureStubs.swift | 14 +- .../Tests/SettingsViewModelTests.swift | 2 +- 16 files changed, 155 insertions(+), 239 deletions(-) delete mode 100644 Projects/Domain/Sources/UseCases/GetLastRouteDetailUseCase.swift delete mode 100644 Projects/Domain/Sources/UseCases/GetUserProfileUseCase.swift delete mode 100644 Projects/Domain/Sources/UseCases/ObserveAlarmChangeUseCase.swift delete mode 100644 Projects/Domain/Sources/UseCases/ObserveAlarmUseCase.swift delete mode 100644 Projects/Domain/Sources/UseCases/RequestAlarmSyncUseCase.swift delete mode 100644 Projects/Domain/Sources/UseCases/UpdateAlertFrequencyUseCase.swift diff --git a/Projects/App/Sources/AppDIContainer.swift b/Projects/App/Sources/AppDIContainer.swift index 60e7b8ba..1ae93911 100644 --- a/Projects/App/Sources/AppDIContainer.swift +++ b/Projects/App/Sources/AppDIContainer.swift @@ -197,7 +197,7 @@ final class AppDIContainer { getCurrentLocationUseCase: any GetCurrentLocationUseCase ) -> any SettingsCoordinatorBuildable { SettingsDIContainer( - getUserProfileUseCase: DefaultGetUserProfileUseCase(userRepository: userRepository), + userRepository: userRepository, logoutUseCase: makeLogoutUseCase(), withdrawUseCase: makeWithdrawUseCase(), updateHomeAddressUseCase: DefaultUpdateHomeAddressUseCase( @@ -276,15 +276,13 @@ final class AppDIContainer { activityPort: liveActivityPort, sessionStore: alarmSessionStore ), - // 세션 스트림은 소유자(Store)가 제공한다 — AlarmSyncService는 사건 채널만. - observeAlarmUseCase: DefaultObserveAlarmUseCase(events: alarmSessionStore), - observeAlarmChangeUseCase: DefaultObserveAlarmChangeUseCase(events: alarmChangePresenter), + // 세션 스트림은 소유자(Store)가, 변경 사건은 Presenter가 제공한다. + alarmSyncEvents: alarmSessionStore, + alarmChangeEvents: alarmChangePresenter, // 홈 pull-to-refresh(Phase 16) — 4번째 트리거도 같은 동기화 한 곳으로 합류한다. - requestAlarmSyncUseCase: DefaultRequestAlarmSyncUseCase(requesting: alarmSyncService), + alarmSyncRequesting: alarmSyncService, // 재실행 카드 복원(Phase 14) — 기존 미사용 자산(detail 엔드포인트) 재활용. - getLastRouteDetailUseCase: DefaultGetLastRouteDetailUseCase( - repository: lastRouteRepository - ), + lastRouteRepository: lastRouteRepository, // 원탭 칩(Phase 18) — 검색 화면과 같은 인스턴스 공유(위 주석 참조). searchLastRoutesUseCase: searchLastRoutes, recentSearchesUseCase: recentSearches, diff --git a/Projects/Domain/Sources/UseCases/GetLastRouteDetailUseCase.swift b/Projects/Domain/Sources/UseCases/GetLastRouteDetailUseCase.swift deleted file mode 100644 index c964daa6..00000000 --- a/Projects/Domain/Sources/UseCases/GetLastRouteDetailUseCase.swift +++ /dev/null @@ -1,17 +0,0 @@ -/// 경로 상세 재조회 (Phase 14 카드 복원) — 재실행 후 "알람은 있는데 무슨 경로인지 -/// 모르는" 상태를 해소한다. 홈은 alarmSynced 수신 시 카드가 없으면 이걸로 복원한다. -public protocol GetLastRouteDetailUseCase: Sendable { - func execute(routeId: String) async throws -> LastRoute -} - -public struct DefaultGetLastRouteDetailUseCase: GetLastRouteDetailUseCase { - private let repository: any LastRouteRepository - - public init(repository: any LastRouteRepository) { - self.repository = repository - } - - public func execute(routeId: String) async throws -> LastRoute { - try await repository.lastRoute(id: routeId) - } -} diff --git a/Projects/Domain/Sources/UseCases/GetUserProfileUseCase.swift b/Projects/Domain/Sources/UseCases/GetUserProfileUseCase.swift deleted file mode 100644 index 5a50a617..00000000 --- a/Projects/Domain/Sources/UseCases/GetUserProfileUseCase.swift +++ /dev/null @@ -1,15 +0,0 @@ -public protocol GetUserProfileUseCase: Sendable { - func execute() async throws -> UserProfile -} - -public struct DefaultGetUserProfileUseCase: GetUserProfileUseCase { - private let userRepository: any UserRepository - - public init(userRepository: any UserRepository) { - self.userRepository = userRepository - } - - public func execute() async throws -> UserProfile { - try await userRepository.fetchMe() - } -} diff --git a/Projects/Domain/Sources/UseCases/ObserveAlarmChangeUseCase.swift b/Projects/Domain/Sources/UseCases/ObserveAlarmChangeUseCase.swift deleted file mode 100644 index 1a89f314..00000000 --- a/Projects/Domain/Sources/UseCases/ObserveAlarmChangeUseCase.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation - -public protocol ObserveAlarmChangeUseCase: Sendable { - func execute() -> AsyncStream -} - -public struct DefaultObserveAlarmChangeUseCase: ObserveAlarmChangeUseCase { - private let events: any AlarmChangeEvents - - public init(events: any AlarmChangeEvents) { - self.events = events - } - - public func execute() -> AsyncStream { - events.changes() - } -} diff --git a/Projects/Domain/Sources/UseCases/ObserveAlarmUseCase.swift b/Projects/Domain/Sources/UseCases/ObserveAlarmUseCase.swift deleted file mode 100644 index 33c0ac3f..00000000 --- a/Projects/Domain/Sources/UseCases/ObserveAlarmUseCase.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation - -public protocol ObserveAlarmUseCase: Sendable { - func execute() -> AsyncStream -} - -public struct DefaultObserveAlarmUseCase: ObserveAlarmUseCase { - private let events: any AlarmSyncEvents - - public init(events: any AlarmSyncEvents) { - self.events = events - } - - public func execute() -> AsyncStream { - events.updates() - } -} diff --git a/Projects/Domain/Sources/UseCases/RequestAlarmSyncUseCase.swift b/Projects/Domain/Sources/UseCases/RequestAlarmSyncUseCase.swift deleted file mode 100644 index ef0cdd99..00000000 --- a/Projects/Domain/Sources/UseCases/RequestAlarmSyncUseCase.swift +++ /dev/null @@ -1,20 +0,0 @@ -import Foundation - -/// 홈 pull-to-refresh의 수동 갱신 진입점(Phase 16) — Observe 계열과 같은 패턴으로 -/// App의 AlarmSyncService(AlarmSyncRequesting)를 감싼다. 완료 = 동기화 종료(성공/실패 -/// 불문)이고, 갱신된 값은 ObserveAlarmUseCase 스트림이 따로 나른다. -public protocol RequestAlarmSyncUseCase: Sendable { - func execute() async -} - -public struct DefaultRequestAlarmSyncUseCase: RequestAlarmSyncUseCase { - private let requesting: any AlarmSyncRequesting - - public init(requesting: any AlarmSyncRequesting) { - self.requesting = requesting - } - - public func execute() async { - await requesting.syncNow() - } -} diff --git a/Projects/Domain/Sources/UseCases/UpdateAlertFrequencyUseCase.swift b/Projects/Domain/Sources/UseCases/UpdateAlertFrequencyUseCase.swift deleted file mode 100644 index d7e8c713..00000000 --- a/Projects/Domain/Sources/UseCases/UpdateAlertFrequencyUseCase.swift +++ /dev/null @@ -1,15 +0,0 @@ -public protocol UpdateAlertFrequencyUseCase: Sendable { - func execute(frequencies: [Int]) async throws -} - -public struct DefaultUpdateAlertFrequencyUseCase: UpdateAlertFrequencyUseCase { - private let userRepository: any UserRepository - - public init(userRepository: any UserRepository) { - self.userRepository = userRepository - } - - public func execute(frequencies: [Int]) async throws { - try await userRepository.updateAlertFrequencies(frequencies) - } -} diff --git a/Projects/Feature/Home/Example/ExampleApp.swift b/Projects/Feature/Home/Example/ExampleApp.swift index 937acf7c..70bb8177 100644 --- a/Projects/Feature/Home/Example/ExampleApp.swift +++ b/Projects/Feature/Home/Example/ExampleApp.swift @@ -39,10 +39,10 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { reverseGeocodeUseCase: PreviewReverseGeocodeUseCase(), registerAlarmUseCase: PreviewRegisterAlarmUseCase(), cancelAlarmUseCase: PreviewCancelAlarmUseCase(), - observeAlarmUseCase: PreviewObserveAlarmUseCase(), - observeAlarmChangeUseCase: PreviewObserveAlarmChangeUseCase(), - requestAlarmSyncUseCase: PreviewRequestAlarmSyncUseCase(), - getLastRouteDetailUseCase: PreviewGetLastRouteDetailUseCase(), + alarmSyncEvents: PreviewAlarmSyncEvents(), + alarmChangeEvents: PreviewAlarmChangeEvents(), + alarmSyncRequesting: PreviewAlarmSyncRequesting(), + lastRouteRepository: PreviewLastRouteRepository(), searchLastRoutesUseCase: PreviewSearchLastRoutesUseCase(), recentSearchesUseCase: PreviewRecentSearchesUseCase(), searchCoordinatorBuildable: PreviewSearchCoordinatorBuildable() @@ -89,33 +89,36 @@ struct PreviewCancelAlarmUseCase: CancelAlarmUseCase { } /// 등록된 알람이 없는 서버 상태를 흉내 낸다 — 동기화 이벤트가 오지 않으므로 화면을 건드리지 않는다. -struct PreviewObserveAlarmUseCase: ObserveAlarmUseCase { - func execute() -> AsyncStream { +struct PreviewAlarmSyncEvents: AlarmSyncEvents { + func updates() -> AsyncStream { AsyncStream { _ in } } } /// 수동 갱신(pull-to-refresh) 스텁 — 잠깐 도는 스피너만 흉내 낸다(결과 스트림 없음). -struct PreviewRequestAlarmSyncUseCase: RequestAlarmSyncUseCase { - func execute() async { +struct PreviewAlarmSyncRequesting: AlarmSyncRequesting { + func syncNow() async { try? await Task.sleep(for: .milliseconds(600)) } } /// 막차 변경 판정이 없는 상태를 흉내 낸다 — 토스트·배너 강조는 발생하지 않는다. -struct PreviewObserveAlarmChangeUseCase: ObserveAlarmChangeUseCase { - func execute() -> AsyncStream { +struct PreviewAlarmChangeEvents: AlarmChangeEvents { + func changes() -> AsyncStream { AsyncStream { _ in } } } /// 재실행 카드 복원 경로 스텁 — 동기화 이벤트가 없어 호출되지 않지만, 호출돼도 /// canned 경로를 돌려줘 플로우가 성립한다. -struct PreviewGetLastRouteDetailUseCase: GetLastRouteDetailUseCase { - func execute(routeId: String) async throws -> LastRoute { +struct PreviewLastRouteRepository: LastRouteRepository { + func lastRoute(id: String) async throws -> LastRoute { try? await Task.sleep(for: .milliseconds(300)) return PreviewSearchCoordinator.makeCannedRoute() } + + // 홈 프리뷰는 상세 조회만 쓴다. + func searchLastRoutes(start: Coordinate, end: Coordinate) async throws -> [LastRoute] { [] } } /// 원탭 칩 재검색 스텁(Phase 18) — canned 경로 1건을 돌려줘 칩 탭 → 카드 시연이 성립한다. diff --git a/Projects/Feature/Home/Sources/HomeDIContainer.swift b/Projects/Feature/Home/Sources/HomeDIContainer.swift index 0686b17e..854d0330 100644 --- a/Projects/Feature/Home/Sources/HomeDIContainer.swift +++ b/Projects/Feature/Home/Sources/HomeDIContainer.swift @@ -13,10 +13,10 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { private let reverseGeocodeUseCase: any ReverseGeocodeUseCase private let registerAlarmUseCase: any RegisterAlarmUseCase private let cancelAlarmUseCase: any CancelAlarmUseCase - private let observeAlarmUseCase: any ObserveAlarmUseCase - private let observeAlarmChangeUseCase: any ObserveAlarmChangeUseCase - private let requestAlarmSyncUseCase: any RequestAlarmSyncUseCase - private let getLastRouteDetailUseCase: any GetLastRouteDetailUseCase + private let alarmSyncEvents: any AlarmSyncEvents + private let alarmChangeEvents: any AlarmChangeEvents + private let alarmSyncRequesting: any AlarmSyncRequesting + private let lastRouteRepository: any LastRouteRepository private let searchLastRoutesUseCase: any SearchLastRoutesUseCase private let recentSearchesUseCase: any RecentSearchesUseCase private let searchCoordinatorBuildable: any SearchCoordinatorBuildable @@ -28,10 +28,10 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { reverseGeocodeUseCase: any ReverseGeocodeUseCase, registerAlarmUseCase: any RegisterAlarmUseCase, cancelAlarmUseCase: any CancelAlarmUseCase, - observeAlarmUseCase: any ObserveAlarmUseCase, - observeAlarmChangeUseCase: any ObserveAlarmChangeUseCase, - requestAlarmSyncUseCase: any RequestAlarmSyncUseCase, - getLastRouteDetailUseCase: any GetLastRouteDetailUseCase, + alarmSyncEvents: any AlarmSyncEvents, + alarmChangeEvents: any AlarmChangeEvents, + alarmSyncRequesting: any AlarmSyncRequesting, + lastRouteRepository: any LastRouteRepository, searchLastRoutesUseCase: any SearchLastRoutesUseCase, recentSearchesUseCase: any RecentSearchesUseCase, searchCoordinatorBuildable: any SearchCoordinatorBuildable, @@ -41,10 +41,10 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { self.reverseGeocodeUseCase = reverseGeocodeUseCase self.registerAlarmUseCase = registerAlarmUseCase self.cancelAlarmUseCase = cancelAlarmUseCase - self.observeAlarmUseCase = observeAlarmUseCase - self.observeAlarmChangeUseCase = observeAlarmChangeUseCase - self.requestAlarmSyncUseCase = requestAlarmSyncUseCase - self.getLastRouteDetailUseCase = getLastRouteDetailUseCase + self.alarmSyncEvents = alarmSyncEvents + self.alarmChangeEvents = alarmChangeEvents + self.alarmSyncRequesting = alarmSyncRequesting + self.lastRouteRepository = lastRouteRepository self.searchLastRoutesUseCase = searchLastRoutesUseCase self.recentSearchesUseCase = recentSearchesUseCase self.searchCoordinatorBuildable = searchCoordinatorBuildable @@ -67,10 +67,10 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { reverseGeocodeUseCase: reverseGeocodeUseCase, registerAlarmUseCase: registerAlarmUseCase, cancelAlarmUseCase: cancelAlarmUseCase, - observeAlarmUseCase: observeAlarmUseCase, - observeAlarmChangeUseCase: observeAlarmChangeUseCase, - requestAlarmSyncUseCase: requestAlarmSyncUseCase, - getLastRouteDetailUseCase: getLastRouteDetailUseCase, + alarmSyncEvents: alarmSyncEvents, + alarmChangeEvents: alarmChangeEvents, + alarmSyncRequesting: alarmSyncRequesting, + lastRouteRepository: lastRouteRepository, searchLastRoutesUseCase: searchLastRoutesUseCase, recentSearchesUseCase: recentSearchesUseCase ) diff --git a/Projects/Feature/Home/Sources/HomeViewModel.swift b/Projects/Feature/Home/Sources/HomeViewModel.swift index 80d9bd26..7f20a34e 100644 --- a/Projects/Feature/Home/Sources/HomeViewModel.swift +++ b/Projects/Feature/Home/Sources/HomeViewModel.swift @@ -41,11 +41,37 @@ final class HomeViewModel { var routeCard: RouteCardViewData? var banner: BannerViewData? var isAlarmBusy = false - var alarmButton: AlarmButtonMode = .hidden - /// 신선도 스탬프 "HH:mm 확인 기준"(Phase 16) — 배너 보조 라인·카드 푸터 공용 - /// 단일 소스. 등록 세션과 확인 시각이 있을 때만 값이 있고, sync 무음 실패 시 - /// 낡은 시각을 그대로 유지하는 것이 실패의 정직한 표면이다(원칙 3). - var freshnessText: String? + + // MARK: 파생의 근거 — 아래 계산 프로퍼티가 읽는다. + + /// 화면에 떠 있는 카드의 경로 id. + var selectedRouteId: String? + /// 서버에 알람이 등록된 경로 id. + var registeredRouteId: String? + /// 세션이 마지막으로 서버로 확인된 시각. 스트림의 checkedAt·등록 성공 시각만이 + /// 원천이다(수신 시각으로 찍지 않는다 — 시딩 복원값의 둔갑 방지). + var syncedAt: Date? + + // MARK: 파생 — 저장하지 않는다. + // + // 이전에는 둘 다 저장 필드였고 `refreshAlarmButton()`·`refreshFreshness()`를 + // 상태가 바뀌는 자리마다 손으로 불러야 했다(호출 6곳). 하나라도 빠뜨리면 + // 버튼과 스탬프가 실제 상태와 어긋난다. + + var alarmButton: AlarmButtonMode { + HomeViewModel.alarmButtonMode( + selectedRouteId: selectedRouteId, registeredRouteId: registeredRouteId + ) + } + + /// 신선도 스탬프 "HH:mm 확인 기준" — 배너 보조 라인·카드 푸터 공용 단일 소스. + /// 등록 세션과 확인 시각이 있을 때만 값이 있고, sync 무음 실패 시 낡은 시각을 + /// 그대로 유지하는 것이 실패의 정직한 표면이다(원칙 3). + var freshnessText: String? { + HomeViewModel.freshnessText( + checkedAt: syncedAt, isRegistered: registeredRouteId != nil + ) + } } /// 재방출되면 안 되는 원샷 안내 — 상태와 분리한다. @@ -102,22 +128,24 @@ final class HomeViewModel { private let reverseGeocodeUseCase: any ReverseGeocodeUseCase private let registerAlarmUseCase: any RegisterAlarmUseCase private let cancelAlarmUseCase: any CancelAlarmUseCase - private let observeAlarmUseCase: any ObserveAlarmUseCase - private let observeAlarmChangeUseCase: any ObserveAlarmChangeUseCase - private let requestAlarmSyncUseCase: any RequestAlarmSyncUseCase - private let getLastRouteDetailUseCase: any GetLastRouteDetailUseCase + // Domain 포트를 직접 받는다. 이전에는 같은 프로토콜을 한 겹 더 감싼 UseCase를 + // 주입받았는데, 조합도 규칙도 없이 호출만 위임하는 계층이라 이름만 늘렸다. + private let alarmSyncEvents: any AlarmSyncEvents + private let alarmChangeEvents: any AlarmChangeEvents + private let alarmSyncRequesting: any AlarmSyncRequesting + private let lastRouteRepository: any LastRouteRepository private let searchLastRoutesUseCase: any SearchLastRoutesUseCase private let recentSearchesUseCase: any RecentSearchesUseCase private let now: @Sendable () -> Date - private var selectedRoute: LastRoute? + /// 화면에 떠 있는 경로. id는 State가 파생(알람 버튼)에 쓰므로 **함께 갱신한다** — + /// 손으로 맞추면 언젠가 빠뜨린다. + private var selectedRoute: LastRoute? { + didSet { state.selectedRouteId = selectedRoute?.id } + } /// 칩의 원본 도착지(Phase 18) — 표시는 문자열(State), 재검색은 이 Place가 한다. private var chipPlace: Place? /// 서버에 알람이 등록된 경로 id — 해제 버튼·동기화 복원의 기준. - private var registeredRouteId: String? - /// 세션 값이 마지막으로 서버로 확인된 시각(Phase 16) — 스트림의 checkedAt·등록 - /// 성공 시각만이 원천이다(수신 시각으로 찍지 않는다 — 시딩 복원값의 둔갑 방지). - private var lastCheckedAt: Date? private var locationTask: Task? private var alarmTask: Task? private var observeTask: Task? @@ -134,10 +162,10 @@ final class HomeViewModel { reverseGeocodeUseCase: any ReverseGeocodeUseCase, registerAlarmUseCase: any RegisterAlarmUseCase, cancelAlarmUseCase: any CancelAlarmUseCase, - observeAlarmUseCase: any ObserveAlarmUseCase, - observeAlarmChangeUseCase: any ObserveAlarmChangeUseCase, - requestAlarmSyncUseCase: any RequestAlarmSyncUseCase, - getLastRouteDetailUseCase: any GetLastRouteDetailUseCase, + alarmSyncEvents: any AlarmSyncEvents, + alarmChangeEvents: any AlarmChangeEvents, + alarmSyncRequesting: any AlarmSyncRequesting, + lastRouteRepository: any LastRouteRepository, searchLastRoutesUseCase: any SearchLastRoutesUseCase, recentSearchesUseCase: any RecentSearchesUseCase, now: @escaping @Sendable () -> Date = { Date() } @@ -146,10 +174,10 @@ final class HomeViewModel { self.reverseGeocodeUseCase = reverseGeocodeUseCase self.registerAlarmUseCase = registerAlarmUseCase self.cancelAlarmUseCase = cancelAlarmUseCase - self.observeAlarmUseCase = observeAlarmUseCase - self.observeAlarmChangeUseCase = observeAlarmChangeUseCase - self.requestAlarmSyncUseCase = requestAlarmSyncUseCase - self.getLastRouteDetailUseCase = getLastRouteDetailUseCase + self.alarmSyncEvents = alarmSyncEvents + self.alarmChangeEvents = alarmChangeEvents + self.alarmSyncRequesting = alarmSyncRequesting + self.lastRouteRepository = lastRouteRepository self.searchLastRoutesUseCase = searchLastRoutesUseCase self.recentSearchesUseCase = recentSearchesUseCase self.now = now @@ -210,8 +238,8 @@ final class HomeViewModel { // 영원히 non-nil로 남아 진입 가드에 영구히 걸리고(당김 새로고침 영구 잠김) // onManualSyncFinished도 불리지 않아 스피너가 멈추지 않는다. defer { self?.refreshTask = nil } - guard let useCase = self?.requestAlarmSyncUseCase else { return } - await useCase.execute() + guard let requesting = self?.alarmSyncRequesting else { return } + await requesting.syncNow() guard let self, !Task.isCancelled else { return } self.onManualSyncFinished?() } @@ -239,10 +267,6 @@ final class HomeViewModel { newState.recentRouteChipText = "→ \(arrival.name)" // 새 경로 선택 = 기존 배너는 더 이상 유효하지 않다 (재등록 전까지 숨김). newState.banner = nil - newState.alarmButton = Self.alarmButtonMode( - selectedRouteId: route.id, - registeredRouteId: registeredRouteId - ) state = newState promoteChipDestination(arrival) } @@ -314,12 +338,10 @@ final class HomeViewModel { do { let followUp = try await useCase.execute(route: route) guard !Task.isCancelled, let self else { return } - self.registeredRouteId = route.id + self.state.registeredRouteId = route.id // 등록 성공 = 서버가 방금 이 값을 확인해줬다 — 스탬프 시작점(Phase 16). - self.lastCheckedAt = self.now() + self.state.syncedAt = self.now() self.state.isAlarmBusy = false - self.refreshAlarmButton() - self.refreshFreshness() self.renderBanner( departure: route.departureTime, firstWalkSeconds: route.firstWalkSectionSeconds @@ -346,7 +368,7 @@ final class HomeViewModel { } func cancelAlarmTapped() { - guard let routeId = registeredRouteId, !state.isAlarmBusy else { return } + guard let routeId = state.registeredRouteId, !state.isAlarmBusy else { return } alarmTask?.cancel() state.isAlarmBusy = true alarmTask = Task { [weak self] in @@ -354,16 +376,11 @@ final class HomeViewModel { do { try await useCase.execute(lastRouteId: routeId) guard !Task.isCancelled, let self else { return } - self.registeredRouteId = nil - self.lastCheckedAt = nil + self.state.registeredRouteId = nil + self.state.syncedAt = nil var newState = self.state newState.banner = nil newState.isAlarmBusy = false - newState.alarmButton = Self.alarmButtonMode( - selectedRouteId: self.selectedRoute?.id, - registeredRouteId: nil - ) - newState.freshnessText = nil self.state = newState } catch { guard !Task.isCancelled else { return } @@ -383,7 +400,7 @@ final class HomeViewModel { private func observeAlarmUpdates() { observeTask?.cancel() observeTask = Task { [weak self] in - guard let stream = self?.observeAlarmUseCase.execute() else { return } + guard let stream = self?.alarmSyncEvents.updates() else { return } for await update in stream { guard !Task.isCancelled else { return } self?.alarmSynced(update) @@ -393,12 +410,10 @@ final class HomeViewModel { private func alarmSynced(_ update: AlarmSyncUpdate) { let info = update.info - registeredRouteId = info.lastRouteId + state.registeredRouteId = info.lastRouteId // 확인 시각은 스트림이 준 값만 쓴다(Phase 16) — 시딩 복원이면 직전 세션의 마지막 // 확인 시각이고, 그것도 없으면 nil(스탬프 없음). 수신 시각으로 찍지 않는다. - lastCheckedAt = update.checkedAt - refreshAlarmButton() - refreshFreshness() + state.syncedAt = update.checkedAt // 유예(출발+60초)가 지난 시각으로는 배너를 (재)시작하지 않는다 — 지난 막차의 // 복원은 오정보이고, 못 탐(actionable=false) 판정이 고정한 실패 배너를 후속 // 동기화가 덮어쓰는 일도 이 가드가 막는다. 유예 안이면 시작한다 — 발화~유예 @@ -422,20 +437,16 @@ final class HomeViewModel { guard state.routeCard == nil, restoreCardTask == nil else { return } restoreCardTask = Task { [weak self] in defer { self?.restoreCardTask = nil } - guard let useCase = self?.getLastRouteDetailUseCase else { return } - guard let route = try? await useCase.execute(routeId: info.lastRouteId) else { return } + guard let repository = self?.lastRouteRepository else { return } + guard let route = try? await repository.lastRoute(id: info.lastRouteId) else { return } guard !Task.isCancelled, let self else { return } // 복원 도중 상태가 변했으면(새 경로 선택·세션 종료) 낡은 복원을 버린다. - guard self.registeredRouteId == info.lastRouteId, + guard self.state.registeredRouteId == info.lastRouteId, self.state.routeCard == nil else { return } self.selectedRoute = route var newState = self.state // 복원 경로는 도착지 명칭 원천이 없다 — arrivalText는 placeholder 유지(Phase 17 수용). newState.routeCard = RouteCardViewData(entity: route, now: self.now()) - newState.alarmButton = Self.alarmButtonMode( - selectedRouteId: route.id, - registeredRouteId: self.registeredRouteId - ) self.state = newState // 배너를 도보 반영 기준으로 다시 그린다(등록/refresh/LA와 같은 값 — 이중 시각 금지). if let departure = info.departureTime, @@ -454,7 +465,7 @@ final class HomeViewModel { private func observeAlarmChanges() { changeTask?.cancel() changeTask = Task { [weak self] in - guard let stream = self?.observeAlarmChangeUseCase.execute() else { return } + guard let stream = self?.alarmChangeEvents.changes() else { return } for await verdict in stream { guard !Task.isCancelled else { return } self?.alarmChanged(verdict) @@ -479,15 +490,10 @@ final class HomeViewModel { // 운행 종료·경로 소멸 — 알람 세션이 사라졌으므로 배너·버튼·등록 기록을 전부 // 정리한다. 직전 info 이벤트(alarmSynced)가 남긴 죽은 registeredRouteId도 // 여기서 지워진다. LA final state 종료·알람 취소는 App/Domain 경로의 몫. - registeredRouteId = nil - lastCheckedAt = nil + state.registeredRouteId = nil + state.syncedAt = nil var newState = state newState.banner = nil - newState.alarmButton = Self.alarmButtonMode( - selectedRouteId: selectedRoute?.id, - registeredRouteId: nil - ) - newState.freshnessText = nil // 출발 시각이 이미 지났으면 "지난 막차"로 전환한다 — 유예 경과로 인한 로컬 // 만료가 이 경로로 온다(이전에는 홈의 배너 타이머가 직접 전환했다). // 서버발 운행 종료(출발 전 경로 소멸)는 카드를 그대로 둔다 — 지나간 것이 @@ -496,8 +502,10 @@ final class HomeViewModel { AlarmTiming.isSessionExpired(departureTime: departure, now: now()) { newState.routeCard = newState.routeCard?.asPastTrain(departure: departure) // 지난 막차에는 등록 버튼을 두지 않는다 — 누르면 tooLate로 실패할 뿐이다. - newState.alarmButton = .hidden + // 버튼은 selectedRouteId에서 파생되므로 **지역 사본에 함께 반영**한다. + // (`selectedRoute = nil`의 didSet은 아래 `state = newState`에 덮인다.) selectedRoute = nil + newState.selectedRouteId = nil } state = newState onToast?(.lastTrainServiceEnded) @@ -568,20 +576,8 @@ final class HomeViewModel { } } - private func refreshAlarmButton() { - state.alarmButton = Self.alarmButtonMode( - selectedRouteId: selectedRoute?.id, - registeredRouteId: registeredRouteId - ) - } /// 스탬프 재계산(Phase 16) — 등록 세션 존재 ∧ 확인 시각 존재일 때만 값이 있다. - private func refreshFreshness() { - state.freshnessText = Self.freshnessText( - checkedAt: lastCheckedAt, - isRegistered: registeredRouteId != nil - ) - } /// 스트림이 세션을 방출할 때마다 배너를 다시 그린다. /// diff --git a/Projects/Feature/Home/Tests/HomeViewModelTests.swift b/Projects/Feature/Home/Tests/HomeViewModelTests.swift index 17c46c07..1a493805 100644 --- a/Projects/Feature/Home/Tests/HomeViewModelTests.swift +++ b/Projects/Feature/Home/Tests/HomeViewModelTests.swift @@ -33,24 +33,27 @@ private struct StubCancelAlarmUseCase: CancelAlarmUseCase { func execute(lastRouteId: String) async throws { try await handler(lastRouteId) } } -private struct StubObserveAlarmUseCase: ObserveAlarmUseCase { +// Domain 포트를 직접 스텁한다 — 위임만 하던 UseCase 계층이 사라졌다. +private struct StubAlarmSyncEvents: AlarmSyncEvents { let handler: @Sendable () -> AsyncStream - func execute() -> AsyncStream { handler() } + func updates() -> AsyncStream { handler() } } -private struct StubRequestAlarmSyncUseCase: RequestAlarmSyncUseCase { +private struct StubAlarmSyncRequesting: AlarmSyncRequesting { let handler: @Sendable () async -> Void - func execute() async { await handler() } + func syncNow() async { await handler() } } -private struct StubObserveAlarmChangeUseCase: ObserveAlarmChangeUseCase { +private struct StubAlarmChangeEvents: AlarmChangeEvents { let handler: @Sendable () -> AsyncStream - func execute() -> AsyncStream { handler() } + func changes() -> AsyncStream { handler() } } -private struct StubGetLastRouteDetailUseCase: GetLastRouteDetailUseCase { +private struct StubLastRouteRepository: LastRouteRepository { let handler: @Sendable (String) async throws -> LastRoute - func execute(routeId: String) async throws -> LastRoute { try await handler(routeId) } + func lastRoute(id: String) async throws -> LastRoute { try await handler(id) } + // 홈은 상세 조회만 쓴다 — 검색은 SearchLastRoutesUseCase가 맡는다. + func searchLastRoutes(start: Coordinate, end: Coordinate) async throws -> [LastRoute] { [] } } private struct StubSearchLastRoutesUseCase: SearchLastRoutesUseCase { @@ -202,10 +205,10 @@ private func makeSUT( handler: register, authorizationOutcome: registerOutcome ), cancelAlarmUseCase: StubCancelAlarmUseCase(handler: cancel), - observeAlarmUseCase: StubObserveAlarmUseCase(handler: alarmUpdates), - observeAlarmChangeUseCase: StubObserveAlarmChangeUseCase(handler: alarmChanges), - requestAlarmSyncUseCase: StubRequestAlarmSyncUseCase(handler: requestSync), - getLastRouteDetailUseCase: StubGetLastRouteDetailUseCase(handler: routeDetail), + alarmSyncEvents: StubAlarmSyncEvents(handler: alarmUpdates), + alarmChangeEvents: StubAlarmChangeEvents(handler: alarmChanges), + alarmSyncRequesting: StubAlarmSyncRequesting(handler: requestSync), + lastRouteRepository: StubLastRouteRepository(handler: routeDetail), searchLastRoutesUseCase: StubSearchLastRoutesUseCase(handler: searchRoutes), recentSearchesUseCase: StubRecentSearchesUseCase( fetchHandler: recentFetch, saveHandler: recentSave diff --git a/Projects/Feature/Settings/Example/ExampleApp.swift b/Projects/Feature/Settings/Example/ExampleApp.swift index d9ca767a..3667ae2b 100644 --- a/Projects/Feature/Settings/Example/ExampleApp.swift +++ b/Projects/Feature/Settings/Example/ExampleApp.swift @@ -46,7 +46,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { private func startSettingsFlow() { let container = SettingsDIContainer( - getUserProfileUseCase: PreviewGetUserProfileUseCase(), + userRepository: PreviewUserRepository(), logoutUseCase: PreviewLogoutUseCase(), withdrawUseCase: PreviewWithdrawUseCase(), updateHomeAddressUseCase: PreviewUpdateHomeAddressUseCase(), @@ -95,11 +95,19 @@ private final class LauncherViewController: UIViewController { private struct PreviewError: Error {} -private struct PreviewGetUserProfileUseCase: GetUserProfileUseCase { - func execute() async throws -> UserProfile { +private struct PreviewUserRepository: UserRepository { + func fetchMe() async throws -> UserProfile { try? await Task.sleep(for: .milliseconds(500)) - return UserProfile(userID: 1, providerID: nil, nickname: nil, address: "서울 중구 세종대로 110", coordinate: nil, appVersion: nil) + return UserProfile( + userID: 1, providerID: nil, nickname: nil, + address: "서울 중구 세종대로 110", coordinate: nil, appVersion: nil + ) } + + // 설정 프리뷰는 조회만 쓴다 — 나머지는 no-op. + func updateHomeAddress(address: String?, coordinate: Coordinate?) async throws {} + func updateAlertFrequencies(_ frequencies: [Int]) async throws {} + func withdraw(reason: String?) async throws {} } private struct PreviewLogoutUseCase: LogoutUseCase { diff --git a/Projects/Feature/Settings/Sources/SettingsDIContainer.swift b/Projects/Feature/Settings/Sources/SettingsDIContainer.swift index e44e71fc..d3e2079a 100644 --- a/Projects/Feature/Settings/Sources/SettingsDIContainer.swift +++ b/Projects/Feature/Settings/Sources/SettingsDIContainer.swift @@ -7,7 +7,7 @@ import UIKit /// screen/ViewModel assembly stays here so adding screens never bloats /// coordinator initializers. public final class SettingsDIContainer: SettingsCoordinatorBuildable { - private let getUserProfileUseCase: any GetUserProfileUseCase + private let userRepository: any UserRepository private let logoutUseCase: any LogoutUseCase private let withdrawUseCase: any WithdrawUseCase private let updateHomeAddressUseCase: any UpdateHomeAddressUseCase @@ -19,7 +19,7 @@ public final class SettingsDIContainer: SettingsCoordinatorBuildable { private let appStoreURL: URL? public init( - getUserProfileUseCase: any GetUserProfileUseCase, + userRepository: any UserRepository, logoutUseCase: any LogoutUseCase, withdrawUseCase: any WithdrawUseCase, updateHomeAddressUseCase: any UpdateHomeAddressUseCase, @@ -30,7 +30,7 @@ public final class SettingsDIContainer: SettingsCoordinatorBuildable { currentVersion: String, appStoreURL: URL? = nil ) { - self.getUserProfileUseCase = getUserProfileUseCase + self.userRepository = userRepository self.logoutUseCase = logoutUseCase self.withdrawUseCase = withdrawUseCase self.updateHomeAddressUseCase = updateHomeAddressUseCase @@ -48,7 +48,7 @@ public final class SettingsDIContainer: SettingsCoordinatorBuildable { func makeSettingsScreen() -> (UIViewController, SettingsViewModel) { let viewModel = SettingsViewModel( - getUserProfileUseCase: getUserProfileUseCase, + userRepository: userRepository, logoutUseCase: logoutUseCase, checkAppUpdateUseCase: checkAppUpdateUseCase, currentVersion: currentVersion, diff --git a/Projects/Feature/Settings/Sources/SettingsViewModel.swift b/Projects/Feature/Settings/Sources/SettingsViewModel.swift index 373ea5d4..e504ee78 100644 --- a/Projects/Feature/Settings/Sources/SettingsViewModel.swift +++ b/Projects/Feature/Settings/Sources/SettingsViewModel.swift @@ -47,7 +47,8 @@ final class SettingsViewModel { /// 상태에서 파생되는 렌더 모델 — 저장하지 않는다. var sections: [Section] { Self.sections(from: state, currentVersion: currentVersion) } - private let getUserProfileUseCase: any GetUserProfileUseCase + /// 위임만 하던 UseCase 대신 Repository를 직접 받는다 — 조합도 규칙도 없는 계층이었다. + private let userRepository: any UserRepository private let logoutUseCase: any LogoutUseCase private let checkAppUpdateUseCase: (any CheckAppUpdateUseCase)? private let currentVersion: String @@ -58,13 +59,13 @@ final class SettingsViewModel { private var logoutTask: Task? init( - getUserProfileUseCase: any GetUserProfileUseCase, + userRepository: any UserRepository, logoutUseCase: any LogoutUseCase, checkAppUpdateUseCase: (any CheckAppUpdateUseCase)? = nil, currentVersion: String, appStoreURL: URL? = nil ) { - self.getUserProfileUseCase = getUserProfileUseCase + self.userRepository = userRepository self.logoutUseCase = logoutUseCase self.checkAppUpdateUseCase = checkAppUpdateUseCase self.currentVersion = currentVersion @@ -115,10 +116,10 @@ final class SettingsViewModel { private func loadProfile() { profileTask?.cancel() profileTask = Task { [weak self] in - guard let useCase = self?.getUserProfileUseCase else { return } + guard let repository = self?.userRepository else { return } let text: String do { - let profile = try await useCase.execute() + let profile = try await repository.fetchMe() let address = profile.address?.trimmingCharacters(in: .whitespaces) ?? "" text = address.isEmpty ? "집 주소를 등록해 주세요" : address } catch { diff --git a/Projects/Feature/Settings/Tests/SettingsFeatureStubs.swift b/Projects/Feature/Settings/Tests/SettingsFeatureStubs.swift index 7afb7026..cfb57578 100644 --- a/Projects/Feature/Settings/Tests/SettingsFeatureStubs.swift +++ b/Projects/Feature/Settings/Tests/SettingsFeatureStubs.swift @@ -12,7 +12,7 @@ func waitUntil(_ predicate: () -> Bool) async { } } -final class StubGetUserProfileUseCase: GetUserProfileUseCase { +final class StubUserRepository: UserRepository { let address: String? let fails: Bool @@ -21,10 +21,18 @@ final class StubGetUserProfileUseCase: GetUserProfileUseCase { self.fails = fails } - func execute() async throws -> UserProfile { + func fetchMe() async throws -> UserProfile { if fails { throw StubFailure() } - return UserProfile(userID: 1, providerID: nil, nickname: nil, address: address, coordinate: nil, appVersion: nil) + return UserProfile( + userID: 1, providerID: nil, nickname: nil, + address: address, coordinate: nil, appVersion: nil + ) } + + // 설정 테스트는 조회만 쓴다 — 나머지는 no-op. + func updateHomeAddress(address: String?, coordinate: Coordinate?) async throws {} + func updateAlertFrequencies(_ frequencies: [Int]) async throws {} + func withdraw(reason: String?) async throws {} } final class SpyLogoutUseCase: LogoutUseCase { diff --git a/Projects/Feature/Settings/Tests/SettingsViewModelTests.swift b/Projects/Feature/Settings/Tests/SettingsViewModelTests.swift index 929b9583..92516a5f 100644 --- a/Projects/Feature/Settings/Tests/SettingsViewModelTests.swift +++ b/Projects/Feature/Settings/Tests/SettingsViewModelTests.swift @@ -12,7 +12,7 @@ struct SettingsViewModelTests { logout: SpyLogoutUseCase = SpyLogoutUseCase() ) -> SettingsViewModel { SettingsViewModel( - getUserProfileUseCase: StubGetUserProfileUseCase(address: address, fails: profileFails), + userRepository: StubUserRepository(address: address, fails: profileFails), logoutUseCase: logout, checkAppUpdateUseCase: StubCheckAppUpdateUseCase(status: update), currentVersion: "2.0.0",