diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift
index d74f566..7c28719 100644
--- a/Projects/App/Project.swift
+++ b/Projects/App/Project.swift
@@ -12,6 +12,8 @@ let appTarget = Target.target(
"UILaunchScreen": [:],
"NSAlarmKitUsageDescription": "막차 시간에 맞춰 알람을 울리기 위해 권한이 필요합니다.",
"NSLocationWhenInUseUsageDescription": "현재 위치를 출발지로 사용하기 위해 위치 정보 접근 권한이 필요합니다.",
+ // 사일런트 푸시(content-available=1) 수신용 — 사용자 알림 권한과 무관.
+ "UIBackgroundModes": ["remote-notification"],
"UIApplicationSceneManifest": [
"UIApplicationSupportsMultipleScenes": false,
"UISceneConfigurations": [
@@ -28,6 +30,11 @@ let appTarget = Target.target(
]),
sources: ["Sources/**"],
resources: ["Resources/**"],
+ // APNs 등록에 필수 (없으면 didFailToRegister: "aps-environment 없음").
+ // 배포 서명 시 프로비저닝이 production으로 치환한다.
+ entitlements: .dictionary([
+ "aps-environment": "development",
+ ]),
dependencies: [
.project(target: "HomeFeature", path: "../Feature/Home"),
.project(target: "HomeFeatureInterface", path: "../Feature/Home"),
diff --git a/Projects/App/Resources/GoogleService-Info.plist b/Projects/App/Resources/GoogleService-Info.plist
new file mode 100644
index 0000000..210bdc7
--- /dev/null
+++ b/Projects/App/Resources/GoogleService-Info.plist
@@ -0,0 +1,30 @@
+
+
+
+
+ API_KEY
+ AIzaSyDPexsKmlRAz9ghUGrGKIvrAvSaH5mMz9o
+ GCM_SENDER_ID
+ 104855490976
+ PLIST_VERSION
+ 1
+ BUNDLE_ID
+ com.atcha.iOS
+ PROJECT_ID
+ atcha-3b0e4
+ STORAGE_BUCKET
+ atcha-3b0e4.firebasestorage.app
+ IS_ADS_ENABLED
+
+ IS_ANALYTICS_ENABLED
+
+ IS_APPINVITE_ENABLED
+
+ IS_GCM_ENABLED
+
+ IS_SIGNIN_ENABLED
+
+ GOOGLE_APP_ID
+ 1:104855490976:ios:e93a557692dabde3fdd4c5
+
+
\ No newline at end of file
diff --git a/Projects/App/Sources/AlarmSyncService.swift b/Projects/App/Sources/AlarmSyncService.swift
new file mode 100644
index 0000000..67296cc
--- /dev/null
+++ b/Projects/App/Sources/AlarmSyncService.swift
@@ -0,0 +1,97 @@
+import Domain
+import Foundation
+import UIKit
+import os
+
+/// 서버발 알람 시각 갱신의 단일 진입점. 앱 시작(인증 부트스트랩 직후)·포그라운드
+/// 복귀·사일런트 푸시 3경로가 전부 여기의 `RefreshAlarmUseCase` 호출 한 곳으로
+/// 모인다 — 시각 변경 시 재스케줄은 UseCase 내부 정책이고, 성공 결과는
+/// `AlarmSyncEvents` 스트림으로 구독자(HomeViewModel)에게 전파돼 배너를 갱신한다.
+// Sendable 프로토콜(AlarmSyncEvents) 채택이 기본 MainActor 격리를 nonisolated로
+// 추론시키므로 명시한다 — 상태(subscribers 등)는 전부 메인 액터에서만 만진다.
+@MainActor
+final class AlarmSyncService: AlarmSyncEvents {
+ private let refreshAlarmUseCase: any RefreshAlarmUseCase
+ private static let logger = Logger(subsystem: "com.atcha.iOS.v2", category: "AlarmSync")
+
+ private var subscribers: [UUID: AsyncStream.Continuation] = [:]
+ /// 구독 전에 끝난 동기화를 놓치지 않기 위한 replay-1. 홈은 앱 시작 동기화와
+ /// 거의 동시에 구독하므로 순서에 기대지 않는다.
+ private var lastInfo: AlarmInfo?
+ /// 진행 중 동기화 — 트리거가 겹치면(예: 앱 시작 직후 포그라운드 노티) 합류한다.
+ private var inFlight: Task?
+ private var foregroundObserver: (any NSObjectProtocol)?
+
+ // 앱 수명 객체(조합 루트 소유) — 해제 경로가 없어 관찰 해지/태스크 취소 정리가 없다.
+ init(refreshAlarmUseCase: any RefreshAlarmUseCase) {
+ self.refreshAlarmUseCase = refreshAlarmUseCase
+ }
+
+ /// 인증 부트스트랩 완료 후 1회 호출: 즉시 동기화(앱 시작 경로) + 포그라운드
+ /// 관찰 시작. 그 전의 포그라운드 전환은 무시된다 — 세션 없이 refresh를 쏘지 않는다.
+ func activate() {
+ guard foregroundObserver == nil else { return }
+ foregroundObserver = NotificationCenter.default.addObserver(
+ forName: UIScene.willEnterForegroundNotification,
+ object: nil,
+ queue: .main
+ ) { _ in
+ // Sendable 클로저 → 메인 액터 복귀. 서비스는 앱과 수명이 같아 강한 캡처로 충분하다.
+ Task { @MainActor in _ = await self.sync() }
+ }
+ Task { _ = await sync() }
+ }
+
+ /// 사일런트 푸시(content-available=1) 경로 — 백그라운드 fetch 결과 매핑까지 담당.
+ func syncFromPush() async -> UIBackgroundFetchResult {
+ await sync() != nil ? .newData : .failed
+ }
+
+ /// 3경로 공용 동기화. 실패는 스트림에 흘리지 않는다 — 구독자는 상태를 유지하고,
+ /// 다음 트리거(포그라운드·푸시)가 자연 재시도가 된다.
+ @discardableResult
+ private func sync() async -> AlarmInfo? {
+ if let inFlight {
+ return await inFlight.value
+ }
+ Self.logger.info("알람 동기화 시작")
+ let task = Task { [refreshAlarmUseCase] () -> AlarmInfo? in
+ do {
+ return try await refreshAlarmUseCase.execute()
+ } catch {
+ Self.logger.info("알람 동기화 실패(상태 유지): \(error)")
+ return nil
+ }
+ }
+ inFlight = task
+ let info = await task.value
+ inFlight = nil
+ if let info {
+ Self.logger.info("알람 동기화 성공: route=\(info.lastRouteId, privacy: .public)")
+ lastInfo = info
+ for continuation in subscribers.values {
+ continuation.yield(info)
+ }
+ }
+ return info
+ }
+
+ // MARK: - AlarmSyncEvents
+
+ nonisolated func updates() -> AsyncStream {
+ AsyncStream { continuation in
+ let id = UUID()
+ Task { @MainActor in
+ if let last = self.lastInfo {
+ continuation.yield(last)
+ }
+ self.subscribers[id] = continuation
+ }
+ continuation.onTermination = { _ in
+ Task { @MainActor in
+ self.subscribers.removeValue(forKey: id)
+ }
+ }
+ }
+ }
+}
diff --git a/Projects/App/Sources/AppCoordinator.swift b/Projects/App/Sources/AppCoordinator.swift
index 3284cac..a8ddddc 100644
--- a/Projects/App/Sources/AppCoordinator.swift
+++ b/Projects/App/Sources/AppCoordinator.swift
@@ -38,6 +38,8 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate {
try await self.container.authSessionManager.bootstrap()
guard !Task.isCancelled else { return }
self.startHome()
+ // 앱 시작 동기화 + 포그라운드 관찰 시작 — 세션이 준비된 뒤에만.
+ self.container.alarmSyncService.activate()
} catch {
guard !Task.isCancelled else { return }
self.splashViewController?.showRetry()
diff --git a/Projects/App/Sources/AppDIContainer.swift b/Projects/App/Sources/AppDIContainer.swift
index 33deca3..3319dfe 100644
--- a/Projects/App/Sources/AppDIContainer.swift
+++ b/Projects/App/Sources/AppDIContainer.swift
@@ -16,6 +16,14 @@ final class AppDIContainer {
private let networkClient: any NetworkClient
let authSessionManager: AuthSessionManager
+ // 알람 스택은 1회 생성해 공유한다 — AlarmSyncService(갱신 일원화)와
+ // 홈의 등록/해제 UseCase가 같은 리포지토리·스케줄러를 봐야 한다.
+ private let alarmRepository: any AlarmRepository
+ private let placeRepository: any PlaceRepository
+ private let lastRouteRepository: any LastRouteRepository
+ private let alarmScheduler: any AlarmScheduler
+ let alarmSyncService: AlarmSyncService
+
init() {
#if DEV
// 검수용 임시 우회: 실서버(미확정 #1·#2)가 죽어 있어도 기본 60초 타임아웃 대기로
@@ -42,37 +50,46 @@ final class AppDIContainer {
issuer: UnconfiguredAnonymousSessionIssuer()
)
self.authSessionManager = sessionManager
- self.networkClient = AuthenticatedNetworkClient(
+ let networkClient = AuthenticatedNetworkClient(
base: baseClient,
sessionManager: sessionManager
)
- }
+ self.networkClient = networkClient
- func makeHomeDIContainer() -> any HomeCoordinatorBuildable {
#if DEV
// 검수용 임시 우회 — 실서버(미확정 #1·#2) 부재 시에만 데모 데이터로 폴백.
// 실서버 확정 시 이 블록과 DevDemoFallbacks.swift를 제거한다.
- let placeRepository: any PlaceRepository = DevDemoFallbackPlaceRepository(
+ self.placeRepository = DevDemoFallbackPlaceRepository(
base: PlaceRepositoryImpl(networkClient: networkClient)
)
- let lastRouteRepository: any LastRouteRepository = DevDemoFallbackLastRouteRepository(
+ self.lastRouteRepository = DevDemoFallbackLastRouteRepository(
base: LastRouteRepositoryImpl(networkClient: networkClient)
)
- let alarmRepository: any AlarmRepository = DevDemoTolerantAlarmRepository(
+ self.alarmRepository = DevDemoTolerantAlarmRepository(
base: AlarmRepositoryImpl(networkClient: networkClient)
)
#else
- let placeRepository: any PlaceRepository = PlaceRepositoryImpl(networkClient: networkClient)
- let lastRouteRepository: any LastRouteRepository = LastRouteRepositoryImpl(networkClient: networkClient)
- let alarmRepository: any AlarmRepository = AlarmRepositoryImpl(networkClient: networkClient)
+ self.placeRepository = PlaceRepositoryImpl(networkClient: networkClient)
+ self.lastRouteRepository = LastRouteRepositoryImpl(networkClient: networkClient)
+ self.alarmRepository = AlarmRepositoryImpl(networkClient: networkClient)
#endif
- let recentSearchRepository = RecentSearchRepositoryImpl(store: UserDefaultsKeyValueStore())
// 디바이스 포트 어댑터 — CoreLocation/AlarmKit을 아는 곳은 App의 어댑터뿐.
+ let alarmScheduler = CoreAlarmSchedulerAdapter()
+ self.alarmScheduler = alarmScheduler
+ self.alarmSyncService = AlarmSyncService(
+ refreshAlarmUseCase: DefaultRefreshAlarmUseCase(
+ repository: alarmRepository,
+ scheduler: alarmScheduler
+ )
+ )
+ }
+
+ func makeHomeDIContainer() -> any HomeCoordinatorBuildable {
+ let recentSearchRepository = RecentSearchRepositoryImpl(store: UserDefaultsKeyValueStore())
let locationService = CoreLocationServiceAdapter()
let getCurrentLocation: any GetCurrentLocationUseCase =
DefaultGetCurrentLocationUseCase(locationService: locationService)
- let alarmScheduler = CoreAlarmSchedulerAdapter()
let searchContainer = SearchDIContainer(
searchPlacesUseCase: DefaultSearchPlacesUseCase(repository: placeRepository),
@@ -92,10 +109,7 @@ final class AppDIContainer {
repository: alarmRepository,
scheduler: alarmScheduler
),
- refreshAlarmUseCase: DefaultRefreshAlarmUseCase(
- repository: alarmRepository,
- scheduler: alarmScheduler
- ),
+ observeAlarmUseCase: DefaultObserveAlarmUseCase(events: alarmSyncService),
searchCoordinatorBuildable: searchContainer
)
}
diff --git a/Projects/App/Sources/AppDelegate.swift b/Projects/App/Sources/AppDelegate.swift
index f72936b..034e602 100644
--- a/Projects/App/Sources/AppDelegate.swift
+++ b/Projects/App/Sources/AppDelegate.swift
@@ -1,13 +1,31 @@
import FirebaseCore
+import FirebaseMessaging
import UIKit
+import os
@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
+ // 조합 루트는 AppDelegate가 소유한다 — 사일런트 푸시가 scene 없이 백그라운드로
+ // 앱을 깨워도 AlarmSyncService에 닿을 수 있어야 한다.
+ let container = AppDIContainer()
+
+ /// plist 부재 = FCM 경로 완전 비활성 (등록·델리게이트·수신 전부 dead-path).
+ /// 갱신은 폴링(앱 시작·포그라운드 복귀)만으로 성립한다.
+ private var isFirebaseEnabled = false
+
+ private static let fcmLogger = Logger(subsystem: "com.atcha.iOS.v2", category: "FCM")
+
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
configureFirebaseIfAvailable()
+ if isFirebaseEnabled {
+ Messaging.messaging().delegate = self
+ // 사일런트 푸시는 사용자 알림 권한이 필요 없다 —
+ // UNUserNotificationCenter.requestAuthorization을 호출하지 않는다.
+ application.registerForRemoteNotifications()
+ }
return true
}
@@ -26,5 +44,42 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
return
}
FirebaseApp.configure()
+ isFirebaseEnabled = true
+ }
+
+ // MARK: - 원격 알림 (사일런트 푸시)
+
+ func application(
+ _ application: UIApplication,
+ didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
+ ) {
+ guard isFirebaseEnabled else { return }
+ Messaging.messaging().apnsToken = deviceToken
+ }
+
+ func application(
+ _ application: UIApplication,
+ didFailToRegisterForRemoteNotificationsWithError error: any Error
+ ) {
+ Self.fcmLogger.error("APNs 등록 실패: \(error.localizedDescription, privacy: .public)")
+ }
+
+ /// content-available=1 수신 → 갱신 일원화 지점(AlarmSyncService)으로 위임.
+ /// 서버는 레거시와 같은 payload(`type: REFRESH`)를 보낸다 — 그 외 타입은 무시.
+ func application(
+ _ application: UIApplication,
+ didReceiveRemoteNotification userInfo: [AnyHashable: Any]
+ ) async -> UIBackgroundFetchResult {
+ guard isFirebaseEnabled else { return .noData }
+ guard userInfo["type"] as? String == "REFRESH" else { return .noData }
+ Self.fcmLogger.info("사일런트 푸시 수신(REFRESH) → 알람 동기화 시작")
+ return await container.alarmSyncService.syncFromPush()
+ }
+}
+
+extension AppDelegate: MessagingDelegate {
+ nonisolated func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
+ // TODO: [미확정 #5] 익명 체계에서의 FCM 토큰 서버 전달 방식 확정 전까지 로깅만 한다.
+ Self.fcmLogger.info("FCM 토큰 수신(서버 전달 보류): \(fcmToken ?? "nil", privacy: .private)")
}
}
diff --git a/Projects/App/Sources/SceneDelegate.swift b/Projects/App/Sources/SceneDelegate.swift
index 997b901..181fe50 100644
--- a/Projects/App/Sources/SceneDelegate.swift
+++ b/Projects/App/Sources/SceneDelegate.swift
@@ -9,11 +9,14 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
- guard let windowScene = scene as? UIWindowScene else { return }
+ guard let windowScene = scene as? UIWindowScene,
+ // 조합 루트는 AppDelegate 소유 — 푸시 경로(scene 없음)와 공유한다.
+ let container = (UIApplication.shared.delegate as? AppDelegate)?.container
+ else { return }
let navigationController = UINavigationController()
let coordinator = AppCoordinator(
navigationController: navigationController,
- container: AppDIContainer()
+ container: container
)
let window = UIWindow(windowScene: windowScene)
diff --git a/Projects/Domain/Sources/Interfaces/AlarmSyncEvents.swift b/Projects/Domain/Sources/Interfaces/AlarmSyncEvents.swift
new file mode 100644
index 0000000..3d6b02f
--- /dev/null
+++ b/Projects/Domain/Sources/Interfaces/AlarmSyncEvents.swift
@@ -0,0 +1,10 @@
+import Foundation
+
+/// 알람 동기화 결과 포트 — 발행 주체는 App의 AlarmSyncService(앱 시작·포그라운드
+/// 복귀·사일런트 푸시 3경로를 RefreshAlarmUseCase 한 곳으로 모으는 유일한 호출자).
+/// 실패는 흘리지 않는다 — 구독자는 성공 결과만 받고, 실패 시 기존 상태를 유지한다.
+public protocol AlarmSyncEvents: Sendable {
+ /// 구독자마다 독립 스트림. 구독 시 마지막 동기화 결과가 있으면 즉시 방출한다
+ /// (구독 전에 끝난 앱 시작 동기화를 놓치지 않도록).
+ func updates() -> AsyncStream
+}
diff --git a/Projects/Domain/Sources/UseCases/ObserveAlarmUseCase.swift b/Projects/Domain/Sources/UseCases/ObserveAlarmUseCase.swift
new file mode 100644
index 0000000..6778c4b
--- /dev/null
+++ b/Projects/Domain/Sources/UseCases/ObserveAlarmUseCase.swift
@@ -0,0 +1,17 @@
+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/Tests/DefaultObserveAlarmUseCaseTests.swift b/Projects/Domain/Tests/DefaultObserveAlarmUseCaseTests.swift
new file mode 100644
index 0000000..a31c532
--- /dev/null
+++ b/Projects/Domain/Tests/DefaultObserveAlarmUseCaseTests.swift
@@ -0,0 +1,35 @@
+@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 info = AlarmInfo(
+ lastRouteId: "r1",
+ departureTime: Date(timeIntervalSince1970: 1_755_800_000),
+ updatedAt: nil,
+ isReal: true
+ )
+ let sut = DefaultObserveAlarmUseCase(
+ events: StubAlarmSyncEvents {
+ AsyncStream { continuation in
+ continuation.yield(info)
+ continuation.finish()
+ }
+ }
+ )
+
+ var received: [AlarmInfo] = []
+ for await update in sut.execute() {
+ received.append(update)
+ }
+
+ #expect(received == [info])
+ }
+}
diff --git a/Projects/Feature/Home/Example/ExampleApp.swift b/Projects/Feature/Home/Example/ExampleApp.swift
index ab40aa4..afdd7e0 100644
--- a/Projects/Feature/Home/Example/ExampleApp.swift
+++ b/Projects/Feature/Home/Example/ExampleApp.swift
@@ -39,7 +39,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
reverseGeocodeUseCase: PreviewReverseGeocodeUseCase(),
registerAlarmUseCase: PreviewRegisterAlarmUseCase(),
cancelAlarmUseCase: PreviewCancelAlarmUseCase(),
- refreshAlarmUseCase: PreviewRefreshAlarmUseCase(),
+ observeAlarmUseCase: PreviewObserveAlarmUseCase(),
searchCoordinatorBuildable: PreviewSearchCoordinatorBuildable()
)
let coordinator = container.makeHomeCoordinator(navigationController: navigationController)
@@ -81,11 +81,10 @@ struct PreviewCancelAlarmUseCase: CancelAlarmUseCase {
}
}
-/// 등록된 알람이 없는 서버 상태를 흉내 낸다 — 포그라운드 복귀가 화면을 건드리지 않는다.
-struct PreviewRefreshAlarmUseCase: RefreshAlarmUseCase {
- struct NoRegisteredAlarm: Error {}
- func execute() async throws -> AlarmInfo {
- throw NoRegisteredAlarm()
+/// 등록된 알람이 없는 서버 상태를 흉내 낸다 — 동기화 이벤트가 오지 않으므로 화면을 건드리지 않는다.
+struct PreviewObserveAlarmUseCase: ObserveAlarmUseCase {
+ func execute() -> AsyncStream {
+ AsyncStream { _ in }
}
}
diff --git a/Projects/Feature/Home/Sources/HomeDIContainer.swift b/Projects/Feature/Home/Sources/HomeDIContainer.swift
index 6509ff8..1bfb2e1 100644
--- a/Projects/Feature/Home/Sources/HomeDIContainer.swift
+++ b/Projects/Feature/Home/Sources/HomeDIContainer.swift
@@ -12,7 +12,7 @@ public final class HomeDIContainer: HomeCoordinatorBuildable {
private let reverseGeocodeUseCase: any ReverseGeocodeUseCase
private let registerAlarmUseCase: any RegisterAlarmUseCase
private let cancelAlarmUseCase: any CancelAlarmUseCase
- private let refreshAlarmUseCase: any RefreshAlarmUseCase
+ private let observeAlarmUseCase: any ObserveAlarmUseCase
private let searchCoordinatorBuildable: any SearchCoordinatorBuildable
public init(
@@ -20,14 +20,14 @@ public final class HomeDIContainer: HomeCoordinatorBuildable {
reverseGeocodeUseCase: any ReverseGeocodeUseCase,
registerAlarmUseCase: any RegisterAlarmUseCase,
cancelAlarmUseCase: any CancelAlarmUseCase,
- refreshAlarmUseCase: any RefreshAlarmUseCase,
+ observeAlarmUseCase: any ObserveAlarmUseCase,
searchCoordinatorBuildable: any SearchCoordinatorBuildable
) {
self.getCurrentLocationUseCase = getCurrentLocationUseCase
self.reverseGeocodeUseCase = reverseGeocodeUseCase
self.registerAlarmUseCase = registerAlarmUseCase
self.cancelAlarmUseCase = cancelAlarmUseCase
- self.refreshAlarmUseCase = refreshAlarmUseCase
+ self.observeAlarmUseCase = observeAlarmUseCase
self.searchCoordinatorBuildable = searchCoordinatorBuildable
}
@@ -43,7 +43,7 @@ public final class HomeDIContainer: HomeCoordinatorBuildable {
reverseGeocodeUseCase: reverseGeocodeUseCase,
registerAlarmUseCase: registerAlarmUseCase,
cancelAlarmUseCase: cancelAlarmUseCase,
- refreshAlarmUseCase: refreshAlarmUseCase
+ observeAlarmUseCase: observeAlarmUseCase
)
viewModel.onSearchRequested = onSearchRequested
return HomeViewController(viewModel: viewModel)
diff --git a/Projects/Feature/Home/Sources/HomeViewController.swift b/Projects/Feature/Home/Sources/HomeViewController.swift
index 94b8b67..2b128d5 100644
--- a/Projects/Feature/Home/Sources/HomeViewController.swift
+++ b/Projects/Feature/Home/Sources/HomeViewController.swift
@@ -46,18 +46,6 @@ final class HomeViewController: UIViewController {
configureUI()
bind()
viewModel.viewDidLoad()
- // "SceneDelegate → 알림 경유": scene 포그라운드 전환마다 시스템이 게시하는
- // 노티를 관찰한다 (최초 진입 포함 — 알람 상태 복원을 겸한다).
- NotificationCenter.default.addObserver(
- self,
- selector: #selector(sceneWillEnterForeground),
- name: UIScene.willEnterForegroundNotification,
- object: nil
- )
- }
-
- @objc private func sceneWillEnterForeground() {
- viewModel.appWillEnterForeground()
}
override func viewWillAppear(_ animated: Bool) {
diff --git a/Projects/Feature/Home/Sources/HomeViewModel.swift b/Projects/Feature/Home/Sources/HomeViewModel.swift
index 147b2e3..902906b 100644
--- a/Projects/Feature/Home/Sources/HomeViewModel.swift
+++ b/Projects/Feature/Home/Sources/HomeViewModel.swift
@@ -54,16 +54,16 @@ final class HomeViewModel {
private let reverseGeocodeUseCase: any ReverseGeocodeUseCase
private let registerAlarmUseCase: any RegisterAlarmUseCase
private let cancelAlarmUseCase: any CancelAlarmUseCase
- private let refreshAlarmUseCase: any RefreshAlarmUseCase
+ private let observeAlarmUseCase: any ObserveAlarmUseCase
private let now: @Sendable () -> Date
private let bannerTickInterval: Duration
private var selectedRoute: LastRoute?
- /// 서버에 알람이 등록된 경로 id — 해제 버튼·포그라운드 복원의 기준.
+ /// 서버에 알람이 등록된 경로 id — 해제 버튼·동기화 복원의 기준.
private var registeredRouteId: String?
private var locationTask: Task?
private var alarmTask: Task?
- private var refreshTask: Task?
+ private var observeTask: Task?
private var bannerTask: Task?
init(
@@ -71,7 +71,7 @@ final class HomeViewModel {
reverseGeocodeUseCase: any ReverseGeocodeUseCase,
registerAlarmUseCase: any RegisterAlarmUseCase,
cancelAlarmUseCase: any CancelAlarmUseCase,
- refreshAlarmUseCase: any RefreshAlarmUseCase,
+ observeAlarmUseCase: any ObserveAlarmUseCase,
now: @escaping @Sendable () -> Date = { Date() },
bannerTickInterval: Duration = .seconds(60)
) {
@@ -79,7 +79,7 @@ final class HomeViewModel {
self.reverseGeocodeUseCase = reverseGeocodeUseCase
self.registerAlarmUseCase = registerAlarmUseCase
self.cancelAlarmUseCase = cancelAlarmUseCase
- self.refreshAlarmUseCase = refreshAlarmUseCase
+ self.observeAlarmUseCase = observeAlarmUseCase
self.now = now
self.bannerTickInterval = bannerTickInterval
}
@@ -87,7 +87,7 @@ final class HomeViewModel {
deinit {
locationTask?.cancel()
alarmTask?.cancel()
- refreshTask?.cancel()
+ observeTask?.cancel()
bannerTask?.cancel()
}
@@ -95,6 +95,7 @@ final class HomeViewModel {
func viewDidLoad() {
loadCurrentLocation()
+ observeAlarmUpdates()
}
/// 출발지/도착지 어느 필드를 탭해도 동일하게 검색 플로우로 진입한다.
@@ -171,31 +172,31 @@ final class HomeViewModel {
}
}
- /// 포그라운드 복귀(최초 진입 포함) 시 서버 알람을 재조회한다. 시각 변경 재스케줄은
- /// RefreshAlarmUseCase 내부 정책이고, 여기서는 배너·버튼 상태만 갱신한다.
- /// Phase 8에서 앱 시작·푸시 수신 경로와 함께 AlarmSyncService로 일원화될 예정이라
- /// App이 아닌 ViewModel에 최소 구현으로 둔다.
- func appWillEnterForeground() {
- refreshTask?.cancel()
- refreshTask = Task { [weak self] in
- guard let useCase = self?.refreshAlarmUseCase else { return }
- do {
- let info = try await useCase.execute()
+ // MARK: - 내부 전이
+
+ /// 서버 알람 동기화(재조회·재스케줄)는 App의 AlarmSyncService가 앱 시작·포그라운드
+ /// 복귀·푸시 수신 3경로를 일원화해 수행한다. 여기서는 성공 결과 스트림만 구독해
+ /// 배너·버튼 상태를 갱신한다. 실패는 스트림에 흐르지 않는다 — 상태 유지.
+ /// TODO: [미확정] 서버의 "등록된 알람 없음" 표현이 확정되면 그 경우에만
+ /// 배너·버튼을 정리하는 이벤트를 추가한다.
+ private func observeAlarmUpdates() {
+ observeTask?.cancel()
+ observeTask = Task { [weak self] in
+ guard let stream = self?.observeAlarmUseCase.execute() else { return }
+ for await info in stream {
guard !Task.isCancelled else { return }
- self?.registeredRouteId = info.lastRouteId
- self?.refreshAlarmButton()
- if let departure = info.departureTime {
- self?.startBannerTimer(departure: departure)
- }
- } catch {
- // TODO: [미확정] 서버의 "등록된 알람 없음" 표현이 확정되면 그 경우에만
- // 배너·버튼을 정리한다. 지금은 네트워크 오류와 구분할 수 없어
- // 상태를 유지한다 (Phase 8 하드닝에서 세분화).
+ self?.alarmSynced(info)
}
}
}
- // MARK: - 내부 전이
+ private func alarmSynced(_ info: AlarmInfo) {
+ registeredRouteId = info.lastRouteId
+ refreshAlarmButton()
+ if let departure = info.departureTime {
+ startBannerTimer(departure: departure)
+ }
+ }
private func loadCurrentLocation() {
locationTask?.cancel()
diff --git a/Projects/Feature/Home/Tests/HomeViewModelTests.swift b/Projects/Feature/Home/Tests/HomeViewModelTests.swift
index 8a738c6..a4d44e1 100644
--- a/Projects/Feature/Home/Tests/HomeViewModelTests.swift
+++ b/Projects/Feature/Home/Tests/HomeViewModelTests.swift
@@ -25,9 +25,9 @@ private struct StubCancelAlarmUseCase: CancelAlarmUseCase {
func execute(lastRouteId: String) async throws { try await handler(lastRouteId) }
}
-private struct StubRefreshAlarmUseCase: RefreshAlarmUseCase {
- let handler: @Sendable () async throws -> AlarmInfo
- func execute() async throws -> AlarmInfo { try await handler() }
+private struct StubObserveAlarmUseCase: ObserveAlarmUseCase {
+ let handler: @Sendable () -> AsyncStream
+ func execute() -> AsyncStream { handler() }
}
/// 테스트 도중 `now()`를 전진시키기 위한 가변 시계.
@@ -110,7 +110,9 @@ private func makeSUT(
},
register: @escaping @Sendable (LastRoute) async throws -> Void = { _ in },
cancel: @escaping @Sendable (String) async throws -> Void = { _ in },
- refresh: @escaping @Sendable () async throws -> AlarmInfo = { throw StubError() },
+ alarmUpdates: @escaping @Sendable () -> AsyncStream = {
+ AsyncStream { $0.finish() }
+ },
now: @escaping @Sendable () -> Date = { fixedNow },
bannerTickInterval: Duration = .seconds(60)
) -> HomeViewModel {
@@ -119,7 +121,7 @@ private func makeSUT(
reverseGeocodeUseCase: StubReverseGeocodeUseCase(handler: geocode),
registerAlarmUseCase: StubRegisterAlarmUseCase(handler: register),
cancelAlarmUseCase: StubCancelAlarmUseCase(handler: cancel),
- refreshAlarmUseCase: StubRefreshAlarmUseCase(handler: refresh),
+ observeAlarmUseCase: StubObserveAlarmUseCase(handler: alarmUpdates),
now: now,
bannerTickInterval: bannerTickInterval
)
@@ -327,16 +329,19 @@ struct HomeViewModelTests {
}
@Test
- func foregroundRefresh_restoresBannerAndCancelButtonWithoutCard() async {
- // 앱 재실행 복원 시나리오: 카드 없이 서버 알람만 있는 상태.
+ func alarmSync_restoresBannerAndCancelButtonWithoutCard() async {
+ // 앱 재실행 복원 시나리오: 카드 없이 서버 알람만 있는 상태 — 앱 시작 동기화가
+ // 스트림으로 도착한다.
let departure = fixedNow.addingTimeInterval(30 * 60)
- let sut = makeSUT(refresh: {
- AlarmInfo(lastRouteId: "r1", departureTime: departure, updatedAt: nil, isReal: true)
- })
+ let (stream, continuation) = AsyncStream.makeStream()
+ let sut = makeSUT(alarmUpdates: { stream })
let recorder = StateRecorder()
recorder.attach(to: sut)
- sut.appWillEnterForeground()
+ sut.viewDidLoad()
+ continuation.yield(
+ AlarmInfo(lastRouteId: "r1", departureTime: departure, updatedAt: nil, isReal: true)
+ )
await recorder.waitUntilLast { $0.banner != nil }
#expect(sut.state.banner == .init(text: "막차 출발까지 30분", isUrgent: false))
@@ -345,37 +350,39 @@ struct HomeViewModelTests {
}
@Test
- func foregroundRefresh_updatesBannerToNewDeparture() async {
+ func alarmSync_updatesBannerToNewDeparture() async {
let route = makeRoute(id: "r1", departure: fixedNow.addingTimeInterval(42 * 60))
- // 서버가 15분 당긴 시각을 돌려준다.
- let sut = makeSUT(refresh: {
- AlarmInfo(
- lastRouteId: "r1",
- departureTime: fixedNow.addingTimeInterval(27 * 60),
- updatedAt: nil,
- isReal: true
- )
- })
+ let (stream, continuation) = AsyncStream.makeStream()
+ let sut = makeSUT(alarmUpdates: { stream })
let recorder = StateRecorder()
recorder.attach(to: sut)
+ sut.viewDidLoad()
sut.routeSelected(route)
sut.registerAlarmTapped()
await recorder.waitUntilLast { $0.banner?.text == "막차 출발까지 42분" }
- sut.appWillEnterForeground()
+ // 서버가 15분 당긴 시각을 돌려준다 (포그라운드 복귀·푸시 동기화 공용 경로).
+ continuation.yield(
+ AlarmInfo(
+ lastRouteId: "r1",
+ departureTime: fixedNow.addingTimeInterval(27 * 60),
+ updatedAt: nil,
+ isReal: true
+ )
+ )
await recorder.waitUntilLast { $0.banner?.text == "막차 출발까지 27분" }
#expect(sut.state.alarmButton == .cancel)
}
@Test
- func foregroundRefresh_failure_keepsState() async {
- let sut = makeSUT(refresh: { throw StubError() })
+ func alarmSync_noEvent_keepsState() async {
+ // 동기화 실패는 스트림에 흐르지 않는다 — 이벤트 없음 = 상태 유지.
+ let sut = makeSUT(alarmUpdates: { AsyncStream { $0.finish() } })
let recorder = StateRecorder()
recorder.attach(to: sut)
- sut.appWillEnterForeground()
- // 실패는 상태를 건드리지 않는다 — 드레인만 하고 확인한다.
+ sut.viewDidLoad()
for _ in 0..<20 { await Task.yield() }
#expect(sut.state.banner == nil)