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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,9 @@ xcodebuild -workspace Atcha.xcworkspace -scheme Atcha-Dev -configuration Debug \
## 아키텍처 (AtchaV2 — uFeatures + 클린아키텍처)

```
AtchaV2(앱, 조합 루트: 어댑터·스플래시·AlarmSyncService) ─► HomeFeature ─► {HomeFeatureInterface, SearchFeatureInterface, Domain, DesignSystem, CoreCoordinator, SnapKit}
AtchaV2(앱, 조합 루트: 어댑터·스플래시·AlarmSyncService) ─► HomeFeature ─► {HomeFeatureInterface, SearchFeatureInterface, SettingsFeatureInterface, Domain, DesignSystem, CoreCoordinator, SnapKit}
├─► SearchFeature ─► {SearchFeatureInterface, Domain, DesignSystem, CoreCoordinator, SnapKit}
├─► SettingsFeature ─► {SettingsFeatureInterface, Domain, DesignSystem, CoreCoordinator, SnapKit}
├─► AtchaData ─► {Domain, CoreNetwork, CoreStorage}
├─► CoreAuth ─► {CoreNetwork, CoreStorage}
├─► CoreAlarm (무의존 — AlarmKit 유일 import 지점, App만 import)
Expand Down Expand Up @@ -79,9 +80,9 @@ AtchaV2(앱, 조합 루트: 어댑터·스플래시·AlarmSyncService) ─► Ho

### 미완 상태 (작업 시 참고)

- **인증은 소셜 로그인(카카오·애플), 가입까지 완결** — 강제 로그인(게스트 없음)으로 `AuthFeature`가 최초 토큰을 발급받는다(`GET /auth/check`·`/auth/login`·`POST /auth/sign-up`, 소셜 토큰을 Bearer로 — plain client 전용). 미가입 계정은 `SignInUseCase`가 온보딩 화면 없이 **최소 가입**(현재 위치+역지오코딩 주소, 실패 시 레거시 실측 폴백 `""`·(0,0), 알림 빈도 [1,10])까지 자동 수행한다. 로그아웃(`POST /auth/logout` — refresh 토큰을 Bearer로 싣는 특례, CoreAuth `AuthSessionManager.signOut()`)·회원탈퇴(`DELETE /members/me`)·유저 정보(`GET /members/me`, 홈주소/알림빈도 PATCH — `UserRepository`)는 UseCase까지 구현됐고 **설정 화면은 미구현**(로그아웃 진입점은 DEV 플로팅 메뉴뿐). 카카오 키는 `TUIST_KAKAO_APP_KEY` env로 주입(미주입 시 카카오 버튼만 런타임 비활성). 잔여: 카카오/Apple 콘솔에 V2 번들 등록, 서버 audience 허용 확인. `DevDemoFallbacks`(실패 은폐 데모 폴백)와 "DEV 건너뛰기"는 제거됨 — DEV에 남은 것은 막차 변경 주입용 `DevChangeSimulator`(refresh 가로채기 전용, 에러 은폐 없음)뿐이라 서버 실패가 DEV에서도 그대로 표면화된다.
- **인증은 소셜 로그인(카카오·애플), 가입까지 완결** — 강제 로그인(게스트 없음)으로 `AuthFeature`가 최초 토큰을 발급받는다(`GET /auth/check`·`/auth/login`·`POST /auth/sign-up`, 소셜 토큰을 Bearer로 — plain client 전용). 미가입 계정은 `SignInUseCase`가 온보딩 화면 없이 **최소 가입**(현재 위치+역지오코딩 주소, 실패 시 레거시 실측 폴백 `""`·(0,0), 알림 빈도 [1,10])까지 자동 수행한다. 로그아웃(`POST /auth/logout` — refresh 토큰을 Bearer로 싣는 특례, CoreAuth `AuthSessionManager.signOut()`)·회원탈퇴(`DELETE /members/me`)·유저 정보(`GET /members/me`, 홈주소/알림빈도 PATCH — `UserRepository`)은 `SettingsFeature`(홈 상단 톱니바퀴 진입: 우리집 설정·약관·피드백·버전·로그아웃·계정 탈퇴)로 연결됐다. 알림 빈도는 레거시처럼 미노출. 로그아웃·탈퇴·강제 만료는 전부 `AppCoordinator.handleSessionExpiry`에 합류하고 거기서 `AlarmSessionTeardown`이 로컬 알람·LA·스냅샷·동기화 상태를 비운다(로그아웃은 토큰이 살아 있을 때 서버 알람 취소를 먼저 시도). 홈주소 변경은 `GET /locations/is-service-region` 통과 후 PATCH. 앱 버전은 `GET /app/version`(무토큰, 1.5초 예산·실패 시 통과)으로 권장 업데이트 팝업만. 카카오 키는 `TUIST_KAKAO_APP_KEY` env로 주입(미주입 시 카카오 버튼만 런타임 비활성). 잔여: 카카오/Apple 콘솔에 V2 번들 등록, 서버 audience 허용 확인. `DevDemoFallbacks`(실패 은폐 데모 폴백)와 "DEV 건너뛰기"는 제거됨 — DEV에 남은 것은 막차 변경 주입용 `DevChangeSimulator`(refresh 가로채기 전용, 에러 은폐 없음)뿐이라 서버 실패가 DEV에서도 그대로 표면화된다.
- `AppEnvironment`의 base URL은 dev/live 실주소 반영 완료. **Stage는 dev 호스트를 공유 중** — 전용 호스트만 미정.
- `Projects/App/Resources/GoogleService-Info.plist`는 **레거시 번들 ID(`com.atcha.iOS`)용 파일**이라 존재 가드만 통과할 뿐 V2(`com.atcha.iOS.v2`)로의 사일런트 푸시가 성립하지 않는다 — V2용 재발급·교체 필요. FCM 토큰 서버 전달도 미구현(로깅만)이라 갱신 채널은 현재 폴링(앱 시작·포그라운드 복귀)과 홈 pull-to-refresh(수동)뿐.
- `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에서 구축 완료.
- 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로 시뮬레이터 검수를 직접 수행·증적 보고하고, 실기기 잔여 항목만 사용자에게 이관.
2 changes: 2 additions & 0 deletions Projects/App/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ let appTarget = Target.target(
.project(target: "HomeFeatureInterface", path: "../Feature/Home"),
.project(target: "SearchFeature", path: "../Feature/Search"),
.project(target: "SearchFeatureInterface", path: "../Feature/Search"),
.project(target: "SettingsFeature", path: "../Feature/Settings"),
.project(target: "SettingsFeatureInterface", path: "../Feature/Settings"),
.project(target: "Domain", path: "../Domain"),
.project(target: "AtchaData", path: "../Data"),
.project(target: "CoreNetwork", path: "../Core/Network"),
Expand Down
46 changes: 46 additions & 0 deletions Projects/App/Sources/Adapters/AlarmSessionTeardownAdapter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import Domain
import Foundation
import os

/// 계정 세션 종료 시 알람 세션 정리 — 조합 루트가 공유 인스턴스(스케줄러·LA·스냅샷·동기화)를
/// 그대로 넘긴다. 등록/취소 UseCase와 다른 인스턴스를 보면 정리가 헛돈다.
struct AlarmSessionTeardownAdapter: AlarmSessionTeardown {
let alarmRepository: any AlarmRepository
let scheduler: any AlarmScheduler
let activityPort: any LastTrainActivityPort
let snapshotStore: any AlarmSessionSnapshotStore
let syncService: AlarmSyncService
private static let logger = Logger(subsystem: "com.atcha.iOS.v2", category: "AlarmTeardown")

func tearDown(cancelOnServer: Bool) async {
if cancelOnServer, let routeId = await activeRouteId() {
// 베스트 에포트 — 오프라인이어도 로그아웃을 막지 않는다. 서버에 남은 세션은
// 다음 로그인의 refresh가 정본으로 되돌려 준다(같은 계정일 때만 의미가 있다).
do {
try await alarmRepository.cancel(lastRouteId: routeId)
} catch {
Self.logger.error("로그아웃 서버 알람 취소 실패(무시): \(error.localizedDescription, privacy: .public)")
}
}
// 더는 울리면 안 되는 것이 핵심 — 표출 정리(LA)보다 알람 취소를 먼저 한다.
await scheduler.cancelAlarm()
await snapshotStore.clear()
let now = Date()
await activityPort.end(final: LastTrainActivityState(
departureTime: now,
alarmTime: now,
urgency: .relaxed,
changeBadgeExpiry: nil,
phase: .active
))
await syncService.resetForSignOut()
}

/// 만료 톰스톤은 이미 죽은 세션이라 서버 취소 대상이 아니다.
private func activeRouteId() async -> String? {
if let snapshot = await snapshotStore.load(), !snapshot.expired {
return snapshot.info.lastRouteId
}
return await syncService.currentSession?.lastRouteId
}
}
12 changes: 12 additions & 0 deletions Projects/App/Sources/Adapters/UnconfirmedPushTokenRepository.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import Domain
import os

/// FCM 토큰 갱신 API가 서버에서 확정되기 전(미확정 #5)의 자리표시 — 호출 지점과 중복 방지는
/// 지금 배선해 두고, 경로가 정해지면 AtchaData의 실구현으로 이 타입만 교체한다.
struct UnconfirmedPushTokenRepository: PushTokenRepository {
private static let logger = Logger(subsystem: "com.atcha.iOS.v2", category: "FCM")

func register(token: String) async throws {
Self.logger.info("FCM 토큰 서버 전달 보류(API 미확정): \(token, privacy: .private)")
}
}
18 changes: 18 additions & 0 deletions Projects/App/Sources/AlarmSyncService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,24 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents, AlarmSyncReque
await sync() != nil ? .newData : .failed
}

// MARK: - 계정 세션 종료 (로그아웃·탈퇴·만료)

/// 현재 보유 세션 — 로그아웃 시 서버 취소 대상 routeId의 출처(스냅샷 부재 대비).
var currentSession: AlarmInfo? { lastInfo }

/// 계정이 바뀌면 이전 계정의 세션 기억이 새 계정의 diff·만료 판정을 오염시킨다 —
/// 메모리 상태를 전부 비우고, 다음 로그인의 첫 sync가 (이미 비워진) 스냅샷부터 다시 시딩한다.
func resetForSignOut() {
inFlight?.cancel()
inFlight = nil
lastInfo = nil
lastCheckedAt = nil
locallyExpiredSession = nil
sessionWalkSeconds = nil
changeBadgeExpiry = nil
isSeededFromSnapshot = false
}

// MARK: - AlarmSyncRequesting (Phase 16 — 홈 pull-to-refresh)

/// 수동 갱신 트리거(4번째) — 기존 sync()에 그대로 합류한다. 실패는 던지지 않고
Expand Down
42 changes: 42 additions & 0 deletions Projects/App/Sources/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,32 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate {
navigationController.setViewControllers([splash], animated: false)
observeSessionExpiry()
bootstrap()
checkForAppUpdate()
}

/// 부트스트랩과 병렬 — 결과가 늦거나 실패하면 아무 일도 없다(레거시 스플래시 정지 버그 방지).
private func checkForAppUpdate() {
let container = container
Task { [weak self] in
guard case .recommended = await container.checkForAppUpdate() else { return }
self?.presentUpdateRecommendation()
}
}

private func presentUpdateRecommendation() {
let alert = UIAlertController(
title: nil,
message: "더 좋아진 앗차를 사용하기 위해\n업데이트를 권장해요",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "나중에", style: .cancel))
alert.addAction(UIAlertAction(title: "업데이트", style: .default) { _ in
UIApplication.shared.open(AppEnvironment.current.appStoreURL)
})
// 로그인 시트가 떠 있으면 그 위에 — 가장 위의 presented에 얹는다.
var top: UIViewController = navigationController
while let presented = top.presentedViewController { top = presented }
top.present(alert, animated: true)
}

private func bootstrap() {
Expand Down Expand Up @@ -60,6 +86,7 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate {
self.isShowingLogin = false
self.startHome()
self.container.alarmSyncService.activate()
self.syncPushTokenAfterLogin()
if self.needsSyncAfterLogin {
self.needsSyncAfterLogin = false
let syncService = self.container.alarmSyncService
Expand Down Expand Up @@ -87,6 +114,12 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate {
private func handleSessionExpiry() {
guard !isShowingLogin else { return }
needsSyncAfterLogin = true
// 로그아웃·탈퇴·강제 만료 공통 합류점 — 이전 계정의 알람이 로그인 화면에서 울리지 않게
// 로컬 정리를 여기서 한 번 더 보장한다(로그아웃 경로의 선행 정리와 겹쳐도 멱등).
let teardown = container.alarmSessionTeardown
Task { await teardown.tearDown(cancelOnServer: false) }
// 다음 계정에는 같은 FCM 토큰이라도 다시 전달해야 한다.
container.syncPushTokenUseCase.reset()
navigationController.presentedViewController?.dismiss(animated: false)
// setViewControllers가 didShow를 태워 SearchCoordinator류의 정리 경로도 돈다.
let splash = SplashViewController()
Expand All @@ -97,6 +130,15 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate {
showLogin()
}

/// 로그인 중에 토큰이 갱신됐을 수 있다 — 세션이 생긴 직후 현재 토큰을 한 번 맞춘다.
private func syncPushTokenAfterLogin() {
let syncPushToken = container.syncPushTokenUseCase
Task {
guard let token = await FCMPushTokenAdapter().currentPushToken() else { return }
await syncPushToken.execute(token: token)
}
}

private func startHome() {
let homeCoordinator = container.makeHomeDIContainer()
.makeHomeCoordinator(navigationController: navigationController)
Expand Down
73 changes: 70 additions & 3 deletions Projects/App/Sources/AppDIContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import HomeFeature
import HomeFeatureInterface
import SearchFeature
import SearchFeatureInterface
import SettingsFeature
import SettingsFeatureInterface

/// Composition root — the only place that sees concrete Data/Network types.
/// Presentation modules depend on Domain protocols only.
Expand Down Expand Up @@ -44,6 +46,9 @@ final class AppDIContainer {
/// 노티 탭 라우팅 델리게이트(Phase 15) — AppDelegate가 launch 시 등록하고,
/// SceneDelegate가 홈 랜딩 훅(onTap)을 배선한다.
let notificationTapDelegate = NotificationTapRoutingDelegate()
/// FCM 토큰 갱신 전달 — 프로세스 내 중복 방지 상태를 갖으므로 1회 생성 공유한다.
let syncPushTokenUseCase: any SyncPushTokenUseCase =
DefaultSyncPushTokenUseCase(repository: UnconfirmedPushTokenRepository())
#if DEV
/// DEV 플로팅 디버그 메뉴가 dismiss 기록 강제 토글에 접근하는 유일한 통로 (Phase 12 검수).
var devLiveActivityAdapter: LastTrainLiveActivityAdapter { liveActivityAdapter }
Expand Down Expand Up @@ -75,6 +80,8 @@ final class AppDIContainer {
// 조용히 덮이지 않도록 이중 방어로 전부 public 처리한다(레거시 allowlist 대응).
publicPathSuffixes: [
"/auth/reissue", "/auth/check", "/auth/login", "/auth/sign-up", "/auth/logout",
// 로그인 전 스플래시에서 호출된다 — 토큰이 없어 401 복구가 로그인 라우팅을 촉발하면 안 된다.
"/app/version",
]
)
self.networkClient = networkClient
Expand Down Expand Up @@ -154,7 +161,7 @@ final class AppDIContainer {
)
}

// MARK: - 계정 스택 (설정 화면 前 단계 — 로그아웃은 DEV 디버그 메뉴가 유일한 진입점)
// MARK: - 계정 스택 (설정 화면 + DEV 디버그 메뉴의 로그아웃)

private var userRepository: any UserRepository {
UserRepositoryImpl(networkClient: networkClient)
Expand All @@ -164,14 +171,71 @@ final class AppDIContainer {
AuthSessionEndingAdapter(sessionManager: authSessionManager)
}

/// 세션 종료 시 알람 정리 — 등록/취소 UseCase와 같은 공유 인스턴스를 봐야 한다.
var alarmSessionTeardown: any AlarmSessionTeardown {
AlarmSessionTeardownAdapter(
alarmRepository: alarmRepository,
scheduler: alarmScheduler,
activityPort: liveActivityPort,
snapshotStore: alarmSessionSnapshotStore,
syncService: alarmSyncService
)
}

func makeLogoutUseCase() -> any LogoutUseCase {
DefaultLogoutUseCase(sessionEnding: sessionEnding)
DefaultLogoutUseCase(sessionEnding: sessionEnding, alarmTeardown: alarmSessionTeardown)
}

func makeWithdrawUseCase() -> any WithdrawUseCase {
DefaultWithdrawUseCase(userRepository: userRepository, sessionEnding: sessionEnding)
}

func makeSettingsDIContainer(
getCurrentLocationUseCase: any GetCurrentLocationUseCase
) -> any SettingsCoordinatorBuildable {
SettingsDIContainer(
getUserProfileUseCase: DefaultGetUserProfileUseCase(userRepository: userRepository),
logoutUseCase: makeLogoutUseCase(),
withdrawUseCase: makeWithdrawUseCase(),
updateHomeAddressUseCase: DefaultUpdateHomeAddressUseCase(
userRepository: userRepository,
placeRepository: placeRepository
),
searchPlacesUseCase: DefaultSearchPlacesUseCase(repository: placeRepository),
getCurrentLocationUseCase: getCurrentLocationUseCase,
reverseGeocodeUseCase: DefaultReverseGeocodeUseCase(repository: placeRepository),
checkAppUpdateUseCase: DefaultCheckAppUpdateUseCase(
repository: AppVersionRepositoryImpl(networkClient: networkClient)
),
currentVersion: Self.currentAppVersion ?? "-",
appStoreURL: AppEnvironment.current.appStoreURL
)
}

// MARK: - 앱 버전

private static var currentAppVersion: String? {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
}

/// 스플래시를 절대 막지 않는다 — 1.5초 안에 답이 없거나 실패하면 nil(체크 생략).
func checkForAppUpdate() async -> AppUpdateStatus? {
let useCase = DefaultCheckAppUpdateUseCase(
repository: AppVersionRepositoryImpl(networkClient: networkClient)
)
guard let current = Self.currentAppVersion else { return nil }
return await withTaskGroup(of: AppUpdateStatus?.self) { group in
group.addTask { try? await useCase.execute(currentVersion: current) }
group.addTask {
try? await Task.sleep(for: .milliseconds(1500))
return nil
}
let first = await group.next() ?? nil
group.cancelAll()
return first
}
}

func makeHomeDIContainer() -> any HomeCoordinatorBuildable {
let recentSearchRepository = RecentSearchRepositoryImpl(store: UserDefaultsKeyValueStore())
let locationService = CoreLocationServiceAdapter()
Expand Down Expand Up @@ -220,7 +284,10 @@ final class AppDIContainer {
// 원탭 칩(Phase 18) — 검색 화면과 같은 인스턴스 공유(위 주석 참조).
searchLastRoutesUseCase: searchLastRoutes,
recentSearchesUseCase: recentSearches,
searchCoordinatorBuildable: searchContainer
searchCoordinatorBuildable: searchContainer,
settingsCoordinatorBuildable: makeSettingsDIContainer(
getCurrentLocationUseCase: getCurrentLocation
)
)
}
}
Loading
Loading