diff --git a/CLAUDE.md b/CLAUDE.md index 8b36e088..163bfd13 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,13 @@ xcodebuild -workspace Atcha.xcworkspace -scheme Atcha-Dev -configuration Debug \ -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO build ``` -도구는 mise로 고정(`mise.toml`, Tuist 4.202). `bundle exec`은 로컬에서 동작하지 않음(시스템 ruby 2.6 ↔ Gemfile.lock의 bundler 4.0 비호환) — fastlane은 CI 전용으로 취급. +도구는 mise로 고정(`mise.toml`, Tuist 4.209). **`/usr/local/bin/tuist`에 구버전(3.33.3)이 깔려 있고 PATH에서 mise shim보다 앞선다** — `tuist` 명령을 그냥 부르면 매니페스트 컴파일이 `cannot find type 'DeploymentTargets'`로 실패한다. `mise exec -- tuist ...`도 서브셸(`sh Scripts/bootstrap.sh` 등)에서는 구버전을 타므로, 스크립트를 돌릴 때는 PATH를 직접 앞세울 것: + +```bash +export PATH="$(dirname "$(mise which tuist)"):$PATH" +``` + +`bundle exec`은 로컬에서 동작하지 않음(시스템 ruby 2.6 ↔ Gemfile.lock의 bundler 4.0 비호환) — fastlane은 CI 전용으로 취급. ### 알아야 할 함정 @@ -80,8 +86,10 @@ 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`)은 `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 호스트를 공유 중** — 전용 호스트만 미정. +- **인증은 게스트 계정 기반(2026-09-24 전환)** — 앱 시작 시 토큰이 없으면 `AppCoordinator.signInAsGuest()`가 `POST /auth/guest` `{deviceId, fcmToken?}`로 JWT를 받아 세션에 채택하고 곧장 홈으로 간다(`DefaultSignInAsGuestUseCase`, plain client 전용 — 토큰이 없는 것이 정상이라 `publicPathSuffixes`에 등록). **로그인 화면 단계가 없다.** `deviceId`는 게스트 계정의 유일한 신원이므로 `KeychainDeviceIdentifierAdapter`가 IDFV를 **키체인에 고정**한다 — IDFV는 앱 전체 삭제 후 재설치 시 값이 바뀌어 기존 계정에 영영 못 돌아가기 때문(키체인 항목은 앱 삭제 후 잔존). 토큰 만료도 로그인 화면이 아니라 조용한 재인증으로 끝난다: `GET /auth/reissue` 실패 → `AppCoordinator.handleSessionExpiry` → 같은 deviceId로 `/auth/guest` 재호출 → 서버가 같은 계정을 돌려준다. +- **소셜 로그인(카카오·애플) 코드는 보존돼 있으나 호출처가 없다** — `AuthFeature`·`SignInUseCase`·`SocialLoginAdapter`·`AppCoordinator.showLogin()`(`@available(deprecated)` 표시)은 그대로 남겨뒀다. 계정 승격이 도입되면 그 경로가 진입점이 된다. 관련 API(`GET /auth/check`·`/auth/login`·`POST /auth/sign-up`, 소셜 토큰을 Bearer로)와 미가입 시 **최소 가입**(현재 위치+역지오코딩 주소, 실패 시 폴백 `""`·(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.apiBaseURL`은 **세 환경 모두 `https://atcha.kro.kr/api` 단일값**(2026-09-24 확정). 이전 호스트 둘은 모두 죽었다 — `atcha.online`은 NXDOMAIN, `atcha.p-e.kr`은 443 연결 불가. **`/api` 접두어는 base URL에서만 붙인다** — 각 `Endpoint.path`는 접두어를 모르므로(`/auth/guest`, `/routes/user-routes` …) 서버가 접두어 체계를 바꾸면 이 프로퍼티 한 줄만 고친다. 환경별 호스트가 다시 생기면 `switch`로 되돌리면 된다. +- **네트워크 진단**: `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에서 구축 완료. - Phase 12 이후의 갭 분석·후속 로드맵: `docs/planning/atcha-v2-post12-roadmap.md` / Phase 13·14(알람 이후 + 재실행 정합성) 구현 프롬프트: `docs/prompts/atcha-v2-session-lifecycle-prompt.md`. diff --git a/Projects/App/Sources/Adapters/KeychainDeviceIdentifierAdapter.swift b/Projects/App/Sources/Adapters/KeychainDeviceIdentifierAdapter.swift new file mode 100644 index 00000000..18fe193d --- /dev/null +++ b/Projects/App/Sources/Adapters/KeychainDeviceIdentifierAdapter.swift @@ -0,0 +1,45 @@ +import CoreStorage +import Domain +import Foundation +import UIKit + +/// 게스트 계정의 신원(deviceId) 제공자. +/// +/// IDFV를 그대로 쓰지 않는 이유: IDFV는 같은 개발사 앱을 **전부 삭제한 뒤 재설치하면 +/// 값이 바뀐다**. 서버는 deviceId로 게스트 계정을 되찾아주므로(토큰이 모두 죽어도 +/// /auth/guest 재호출로 같은 계정 복귀), 값이 바뀌면 기존 계정의 알림 설정·검색 이력에 +/// 영구히 접근할 수 없게 된다. 최초 1회 읽은 IDFV를 키체인에 고정해 재설치를 견딘다 +/// (키체인 항목은 앱 삭제 후에도 잔존한다). +struct KeychainDeviceIdentifierAdapter: DeviceIdentifierProviding { + private enum Key { + static let deviceID = "deviceId" + } + + private let store: any KeyValueStore + + init(store: any KeyValueStore = KeychainStore()) { + self.store = store + } + + func currentDeviceID() async -> String { + if let stored = storedDeviceID() { return stored } + let identifier = await freshIdentifier() + // 저장 실패는 치명적이지 않다 — 이번 호출은 유효한 값을 돌려주고, + // 다음 실행에서 IDFV를 다시 읽어 같은 값으로 복구될 가능성이 높다. + try? store.set(Data(identifier.utf8), forKey: Key.deviceID) + return identifier + } + + private func storedDeviceID() -> String? { + guard let data = try? store.data(forKey: Key.deviceID) else { return nil } + let value = String(decoding: data, as: UTF8.self) + return value.isEmpty ? nil : value + } + + /// IDFV는 기기가 잠긴 직후 등 드물게 nil이다 — 그때는 임의 UUID로 계정을 열고 + /// 키체인 고정에 맡긴다(서버 입장에선 새 게스트, 이후로는 안정). + @MainActor + private func freshIdentifier() async -> String { + UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString + } +} diff --git a/Projects/App/Sources/AppCoordinator.swift b/Projects/App/Sources/AppCoordinator.swift index 9769b497..c7d10000 100644 --- a/Projects/App/Sources/AppCoordinator.swift +++ b/Projects/App/Sources/AppCoordinator.swift @@ -11,10 +11,13 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate { private weak var splashViewController: SplashViewController? private var sessionExpiryTask: Task? + /// 게스트 부트스트랩 in-flight — 만료 스트림의 중복 yield나 재시도 연타에 + /// /auth/guest를 겹쳐 쏘지 않는다. + private var bootstrapTask: Task? /// 로그인 플로우 표시 중 가드 — 만료 스트림의 중복 yield에 로그인을 겹치지 않는다. private var isShowingLogin = false - /// 세션 만료 경유 재로그인이면 성공 직후 수동 동기화 1회 — activate()의 시작 - /// 동기화는 최초 1회 가드라 재로그인 경로에선 돌지 않기 때문. + /// 세션 만료 경유 재인증이면 성공 직후 수동 동기화 1회 — activate()의 시작 + /// 동기화는 최초 1회 가드라 재인증 경로에선 돌지 않기 때문. private var needsSyncAfterLogin = false init(navigationController: UINavigationController, container: AppDIContainer) { @@ -24,6 +27,7 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate { deinit { sessionExpiryTask?.cancel() + bootstrapTask?.cancel() } func start() { @@ -66,16 +70,54 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate { // 세션 판정은 동기(키체인 존재 여부) — 토큰 유효성은 첫 인증 요청이 증명한다. switch container.authSessionManager.bootstrapState() { case .active: - startHome() - // 앱 시작 동기화 + 포그라운드 관찰 시작 — 세션이 준비된 뒤에만. - container.alarmSyncService.activate() + enterHome() case .loginRequired: - showLogin() + signInAsGuest() } } - /// 강제 로그인 — 스플래시를 root로 유지한 채 로그인 시트를 present한다 + private func enterHome() { + startHome() + // 앱 시작 동기화 + 포그라운드 관찰 시작 — 세션이 준비된 뒤에만. + container.alarmSyncService.activate() + } + + /// 게스트 부트스트랩(POST /auth/guest) — 토큰이 없으면 deviceId로 계정을 만들거나 + /// 되찾는다. 서버 호출이라 여기서 부트스트랩이 비동기가 되고, 그래서 비로소 + /// 스플래시의 실패 표면화(showRetry)가 실제로 쓰인다. + private func signInAsGuest() { + // in-flight면 재시도 연타·만료 중복 yield에도 /auth/guest를 겹쳐 쏘지 않는다. + guard bootstrapTask == nil else { return } + let useCase = container.makeSignInAsGuestUseCase() + bootstrapTask = Task { [weak self] in + defer { self?.bootstrapTask = nil } + do { + try await useCase.execute() + guard let self, !Task.isCancelled else { return } + self.enterHome() + // 게스트 가입 시 FCM 토큰이 아직 없었을 수 있다 — 세션이 생긴 직후 한 번 맞춘다. + self.syncPushTokenAfterLogin() + if self.needsSyncAfterLogin { + self.needsSyncAfterLogin = false + let syncService = self.container.alarmSyncService + Task { await syncService.syncNow() } + } + } catch { + guard let self, !Task.isCancelled else { return } + self.splashViewController?.showRetry( + message: BootstrapFailureMessage.text(for: error) + ) + } + } + } + + /// 소셜 로그인 시트 — 스플래시를 root로 유지한 채 present한다 /// (스플래시 배경 위 바텀시트 = 레거시와 같은 시각 결과). + /// + /// 게스트 인증 전환(2026-09-24) 이후 **호출처가 없다.** 서버가 게스트 계정으로 + /// 부트스트랩을 처리하므로 강제 로그인 단계 자체가 사라졌다. 소셜 계정 승격이 + /// 도입되면 이 경로가 그대로 진입점이 되므로 AuthFeature와 함께 남겨둔다. + @available(*, deprecated, message: "게스트 부트스트랩으로 대체됨. 소셜 계정 승격 도입 시 재사용.") private func showLogin() { guard !isShowingLogin else { return } isShowingLogin = true @@ -112,7 +154,7 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate { } private func handleSessionExpiry() { - guard !isShowingLogin else { return } + guard bootstrapTask == nil, !isShowingLogin else { return } needsSyncAfterLogin = true // 로그아웃·탈퇴·강제 만료 공통 합류점 — 이전 계정의 알람이 로그인 화면에서 울리지 않게 // 로컬 정리를 여기서 한 번 더 보장한다(로그아웃 경로의 선행 정리와 겹쳐도 멱등). @@ -127,7 +169,10 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate { splashViewController = splash navigationController.setViewControllers([splash], animated: false) childCoordinators.removeAll() - showLogin() + splash.showLoading() + // 서버 계약: reissue가 죽어도 같은 deviceId로 /auth/guest를 부르면 같은 계정이 + // 돌아온다 — 만료가 로그인 화면이 아니라 조용한 재인증으로 끝나는 근거다. + signInAsGuest() } /// 로그인 중에 토큰이 갱신됐을 수 있다 — 세션이 생긴 직후 현재 토큰을 한 번 맞춘다. diff --git a/Projects/App/Sources/AppDIContainer.swift b/Projects/App/Sources/AppDIContainer.swift index 6c5a2b41..1f0c0fd5 100644 --- a/Projects/App/Sources/AppDIContainer.swift +++ b/Projects/App/Sources/AppDIContainer.swift @@ -79,6 +79,8 @@ final class AppDIContainer { // 호출되지만, 실수로 이 데코레이터를 타도 그 Authorization이 서버 access 토큰으로 // 조용히 덮이지 않도록 이중 방어로 전부 public 처리한다(레거시 allowlist 대응). publicPathSuffixes: [ + // 게스트 진입 — 토큰이 없는 것이 정상이므로 빈 Authorization이 붙으면 안 된다. + "/auth/guest", "/auth/reissue", "/auth/check", "/auth/login", "/auth/sign-up", "/auth/logout", // 로그인 전 스플래시에서 호출된다 — 토큰이 없어 401 복구가 로그인 라우팅을 촉발하면 안 된다. "/app/version", @@ -144,6 +146,17 @@ final class AppDIContainer { await liveActivityAdapter.reattachOrphans(snapshot: snapshot, now: Date()) } + /// 게스트 부트스트랩(서버 계약: POST /auth/guest) — 앱 시작 시 토큰이 없으면 이걸로 받는다. + /// plain client: 게스트는 토큰이 없고, 401이 세션 복구를 촉발해서도 안 된다. + func makeSignInAsGuestUseCase() -> any SignInAsGuestUseCase { + DefaultSignInAsGuestUseCase( + authRepository: AuthRepositoryImpl(networkClient: plainNetworkClient), + sessionStore: AuthSessionStoreAdapter(sessionManager: authSessionManager), + deviceIdentifierProvider: KeychainDeviceIdentifierAdapter(), + pushTokenProvider: FCMPushTokenAdapter() + ) + } + func makeAuthDIContainer() -> any AuthCoordinatorBuildable { AuthDIContainer( signInUseCase: DefaultSignInUseCase( diff --git a/Projects/App/Sources/AppEnvironment.swift b/Projects/App/Sources/AppEnvironment.swift index c1d40d98..0b439fea 100644 --- a/Projects/App/Sources/AppEnvironment.swift +++ b/Projects/App/Sources/AppEnvironment.swift @@ -18,15 +18,15 @@ enum AppEnvironment { #endif } - // Hosts recovered from the legacy trust-evaluator registrations - // (user-approved 2026-08-22). Stage shares the dev host until a dedicated - // one exists. + /// DEV·STAGE·LIVE가 같은 호스트를 쓴다(2026-09-24 확정). 이전 호스트 둘은 모두 + /// 죽었다 — atcha.online은 DNS 등록이 사라졌고(NXDOMAIN), atcha.p-e.kr은 DNS는 + /// 남았으나 443 연결이 되지 않는다. 환경별 호스트가 다시 생기면 switch로 되돌린다. + /// + /// `/api` 접두어는 여기 한 곳에서 붙인다 — URLSessionNetworkClient가 + /// baseURL.appendingPathComponent(endpoint.path)로 조립하므로 각 Endpoint의 + /// path는 접두어를 몰라도 된다(서버가 접두어 체계를 또 바꾸면 이 줄만 고친다). var apiBaseURL: URL { - switch self { - case .dev: URL(string: "https://atcha.p-e.kr")! - case .stage: URL(string: "https://atcha.p-e.kr")! - case .live: URL(string: "https://atcha.online")! - } + URL(string: "https://atcha.kro.kr/api")! } // TODO: [서버 확인] V2가 레거시 스토어 앱(id6747877903)을 잇는지 확정 — 별도 앱이면 교체. diff --git a/Projects/App/Tests/KeychainDeviceIdentifierAdapterTests.swift b/Projects/App/Tests/KeychainDeviceIdentifierAdapterTests.swift new file mode 100644 index 00000000..05f83b42 --- /dev/null +++ b/Projects/App/Tests/KeychainDeviceIdentifierAdapterTests.swift @@ -0,0 +1,69 @@ +@testable import AtchaV2 +import CoreStorage +import Foundation +import Synchronization +import Testing + +/// 키체인 대신 쓰는 인메모리 저장소 — 테스트가 실제 키체인을 건드리지 않게 한다. +private final class InMemoryKeyValueStore: KeyValueStore { + private let storage = Mutex<[String: Data]>([:]) + private let failOnSet: Bool + + init(seed: [String: Data] = [:], failOnSet: Bool = false) { + storage.withLock { $0 = seed } + self.failOnSet = failOnSet + } + + func data(forKey key: String) throws -> Data? { storage.withLock { $0[key] } } + + func set(_ data: Data, forKey key: String) throws { + if failOnSet { throw KeychainError.unexpectedStatus(-1) } + storage.withLock { $0[key] = data } + } + + func removeValue(forKey key: String) throws { storage.withLock { $0[key] = nil } } +} + +struct KeychainDeviceIdentifierAdapterTests { + /// 핵심 계약: 저장된 값이 있으면 IDFV를 다시 읽지 않는다. 게스트 계정의 신원이 + /// 앱 재설치(IDFV 변경)로 바뀌면 기존 계정에 영영 못 돌아가기 때문이다. + @Test + func currentDeviceID_storedValueWins() async { + let store = InMemoryKeyValueStore(seed: ["deviceId": Data("PINNED-ID".utf8)]) + let sut = KeychainDeviceIdentifierAdapter(store: store) + + #expect(await sut.currentDeviceID() == "PINNED-ID") + } + + /// 최초 실행: 값을 만들어 저장하고, 이후 호출은 저장된 값을 그대로 돌려준다. + @Test + func currentDeviceID_firstRun_persistsAndStaysStable() async throws { + let store = InMemoryKeyValueStore() + let sut = KeychainDeviceIdentifierAdapter(store: store) + + let first = await sut.currentDeviceID() + #expect(!first.isEmpty) + + let persisted = try #require(try store.data(forKey: "deviceId")) + #expect(String(decoding: persisted, as: UTF8.self) == first) + #expect(await sut.currentDeviceID() == first) + } + + /// 빈 문자열이 저장돼 있으면 부재로 취급하고 새로 만든다(빈 deviceId 전송 방지). + @Test + func currentDeviceID_emptyStoredValue_treatedAsMissing() async { + let store = InMemoryKeyValueStore(seed: ["deviceId": Data()]) + let sut = KeychainDeviceIdentifierAdapter(store: store) + + #expect(await !sut.currentDeviceID().isEmpty) + } + + /// 저장 실패는 치명적이지 않다 — 이번 호출은 유효한 값을 돌려줘야 한다. + @Test + func currentDeviceID_storeFailure_stillReturnsIdentifier() async { + let store = InMemoryKeyValueStore(failOnSet: true) + let sut = KeychainDeviceIdentifierAdapter(store: store) + + #expect(await !sut.currentDeviceID().isEmpty) + } +} diff --git a/Projects/Core/Network/Sources/NetworkError.swift b/Projects/Core/Network/Sources/NetworkError.swift index 15895ce0..41b215a3 100644 --- a/Projects/Core/Network/Sources/NetworkError.swift +++ b/Projects/Core/Network/Sources/NetworkError.swift @@ -27,3 +27,30 @@ public enum NetworkError: Error, Sendable { return false } } + +extension NetworkError: CustomDebugStringConvertible { + /// 진단용 한 줄. transport/offline이 URLError 코드를 삼키면 "타임아웃(-1001)"과 + /// "호스트에 연결 못 함(-1004)", "TLS 실패(-1200)"가 구분되지 않는다 — 원인이 + /// 앱인지 서버인지 네트워크인지 가르는 첫 단서라 코드를 그대로 노출한다. + public var debugDescription: String { + switch self { + case .invalidURL: + "invalidURL" + case let .offline(underlying): + "offline(\(Self.describe(underlying)))" + case let .transport(underlying): + "transport(\(Self.describe(underlying)))" + case .invalidResponse: + "invalidResponse(non-HTTP)" + case let .unacceptableStatus(code, data): + "unacceptableStatus(\(code), \(data.count)B)" + case let .decoding(underlying): + "decoding(\(underlying))" + } + } + + private static func describe(_ error: any Error) -> String { + guard let urlError = error as? URLError else { return "\(error)" } + return "URLError \(urlError.code.rawValue) \(urlError.localizedDescription)" + } +} diff --git a/Projects/Core/Network/Sources/URLSessionNetworkClient.swift b/Projects/Core/Network/Sources/URLSessionNetworkClient.swift index f00858fa..2e1565b8 100644 --- a/Projects/Core/Network/Sources/URLSessionNetworkClient.swift +++ b/Projects/Core/Network/Sources/URLSessionNetworkClient.swift @@ -1,4 +1,11 @@ import Foundation +import os + +/// 요청 단위 진단 로그(DEBUG 전용). 이 스택에는 인터셉터·로거가 없어서 실패가 +/// 전부 같은 얼굴로 보였다 — base URL이 틀렸는지, 서버가 죽었는지, 디코딩이 +/// 깨졌는지를 Console.app에서 한 줄로 가르기 위한 최소 계측이다. +/// Authorization 헤더와 본문은 절대 남기지 않는다(토큰이 실린다). +private let networkLogger = Logger(subsystem: "com.atcha.network", category: "request") public struct URLSessionNetworkClient: NetworkClient { private let baseURL: URL @@ -34,22 +41,68 @@ public struct URLSessionNetworkClient: NetworkClient { } private func send(_ request: URLRequest) async throws -> Data { + let started = ContinuousClock.now let data: Data let response: URLResponse do { (data, response) = try await session.data(for: request) } catch { - throw NetworkError.classifyingTransport(error) + throw Self.logging(NetworkError.classifyingTransport(error), request, started) } guard let http = response as? HTTPURLResponse else { - throw NetworkError.invalidResponse + throw Self.logging(NetworkError.invalidResponse, request, started) } guard (200 ..< 300).contains(http.statusCode) else { - throw NetworkError.unacceptableStatus(code: http.statusCode, data: data) + throw Self.logging( + NetworkError.unacceptableStatus(code: http.statusCode, data: data), + request, + started + ) } + Self.logSuccess(request, status: http.statusCode, started: started) return data } + /// 실패를 로그에 남기고 그대로 돌려준다 — 호출부의 `throw` 흐름을 바꾸지 않는다. + private static func logging( + _ error: NetworkError, + _ request: URLRequest, + _ started: ContinuousClock.Instant + ) -> NetworkError { + #if DEBUG + networkLogger.error( + """ + \(request.httpMethod ?? "?", privacy: .public) \ + \(request.url?.absoluteString ?? "?", privacy: .public) \ + → \(error.debugDescription, privacy: .public) \ + (\(Self.elapsedMilliseconds(since: started), privacy: .public)ms) + """ + ) + #endif + return error + } + + private static func logSuccess( + _ request: URLRequest, + status: Int, + started: ContinuousClock.Instant + ) { + #if DEBUG + networkLogger.debug( + """ + \(request.httpMethod ?? "?", privacy: .public) \ + \(request.url?.absoluteString ?? "?", privacy: .public) \ + → \(status, privacy: .public) \ + (\(Self.elapsedMilliseconds(since: started), privacy: .public)ms) + """ + ) + #endif + } + + private static func elapsedMilliseconds(since started: ContinuousClock.Instant) -> Int { + Int((ContinuousClock.now - started) / .milliseconds(1)) + } + public func request( _ endpoint: any Endpoint, as _: Response.Type diff --git a/Projects/Data/Sources/DTO/GuestAuthRequestDTO.swift b/Projects/Data/Sources/DTO/GuestAuthRequestDTO.swift new file mode 100644 index 00000000..8b41beaf --- /dev/null +++ b/Projects/Data/Sources/DTO/GuestAuthRequestDTO.swift @@ -0,0 +1,6 @@ +/// `POST /auth/guest` body. fcmToken은 알림 권한이 아직 없으면 부재가 정상이라 +/// 빈 문자열로 강등하지 않고 키를 생략한다(서버 계약상 optional). +struct GuestAuthRequestDTO: Encodable, Sendable { + let deviceId: String + let fcmToken: String? +} diff --git a/Projects/Data/Sources/Network/GuestAuthEndpoint.swift b/Projects/Data/Sources/Network/GuestAuthEndpoint.swift new file mode 100644 index 00000000..0810e1af --- /dev/null +++ b/Projects/Data/Sources/Network/GuestAuthEndpoint.swift @@ -0,0 +1,13 @@ +import CoreNetwork +import Foundation + +/// `POST /auth/guest` — 게스트 계정 생성/복구. 토큰이 필요 없는 유일한 인증 진입점이다. +/// base URL이 `/api`를 이미 포함하므로 여기 path에는 접두어를 쓰지 않는다(AppEnvironment 참조). +struct GuestAuthEndpoint: Endpoint { + let request: GuestAuthRequestDTO + + var path: String { "/auth/guest" } + var method: HTTPMethod { .post } + var headers: [String: String] { ["Content-Type": "application/json"] } + var body: Data? { try? JSONEncoder().encode(request) } +} diff --git a/Projects/Data/Sources/Repositories/AuthRepositoryImpl.swift b/Projects/Data/Sources/Repositories/AuthRepositoryImpl.swift index 486c7c7d..b4a012f4 100644 --- a/Projects/Data/Sources/Repositories/AuthRepositoryImpl.swift +++ b/Projects/Data/Sources/Repositories/AuthRepositoryImpl.swift @@ -4,12 +4,23 @@ import Domain public struct AuthRepositoryImpl: AuthRepository { private let networkClient: any NetworkClient - /// plain client(AuthenticatedNetworkClient 미적용)를 주입할 것 — Authorization에 - /// 소셜 자격 증명을 실어야 하고, 401이 세션 복구를 촉발해서도 안 된다. + /// plain client(AuthenticatedNetworkClient 미적용)를 주입할 것 — 게스트는 토큰이 + /// 없고 소셜 경로는 Authorization에 소셜 자격 증명을 실어야 하며, 어느 쪽도 + /// 401이 세션 복구를 촉발해서는 안 된다. public init(networkClient: any NetworkClient) { self.networkClient = networkClient } + public func signInAsGuest(deviceID: String, fcmToken: String?) async throws -> LoginSession { + // 응답 계약은 /auth/login과 동일한 토큰 쌍 — LoginResponseDTO 재사용. + let dto: LoginResponseDTO = try await networkClient.requestEnveloped( + GuestAuthEndpoint( + request: GuestAuthRequestDTO(deviceId: deviceID, fcmToken: fcmToken) + ) + ) + return try dto.toEntity() + } + public func checkRegistration(credential: SocialCredential) async throws -> Bool { let dto: AuthCheckResponseDTO = try await networkClient.requestEnveloped( AuthEndpoint.check(credential) diff --git a/Projects/Data/Tests/GuestAuthEndpointTests.swift b/Projects/Data/Tests/GuestAuthEndpointTests.swift new file mode 100644 index 00000000..a8027220 --- /dev/null +++ b/Projects/Data/Tests/GuestAuthEndpointTests.swift @@ -0,0 +1,39 @@ +@testable import AtchaData +import CoreNetwork +import Foundation +import Testing + +struct GuestAuthEndpointTests { + /// 서버 계약 핀: 토큰 없이 deviceId를 JSON body로 보내는 유일한 인증 진입점. + /// path에 `/api` 접두어가 없는 것이 정상이다 — base URL이 이미 포함한다(AppEnvironment). + @Test + func guest_sendsDeviceIDAsJSONBodyWithoutAuthorization() throws { + let endpoint = GuestAuthEndpoint( + request: GuestAuthRequestDTO(deviceId: "DEVICE-1", fcmToken: "FCM") + ) + + #expect(endpoint.path == "/auth/guest") + #expect(endpoint.method == .post) + #expect(endpoint.headers == ["Content-Type": "application/json"]) + #expect(endpoint.queryItems.isEmpty) + + let body = try #require(endpoint.body) + let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any]) + #expect(json["deviceId"] as? String == "DEVICE-1") + #expect(json["fcmToken"] as? String == "FCM") + } + + /// FCM 토큰 부재는 정상 상태(알림 권한 전) — 소셜 로그인과 달리 빈 문자열로 + /// 강등하지 않고 키를 생략한다. + @Test + func guest_withoutFcmToken_omitsKey() throws { + let endpoint = GuestAuthEndpoint( + request: GuestAuthRequestDTO(deviceId: "DEVICE-1", fcmToken: nil) + ) + + let body = try #require(endpoint.body) + let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any]) + #expect(json["deviceId"] as? String == "DEVICE-1") + #expect(json["fcmToken"] == nil) + } +} diff --git a/Projects/Domain/Sources/Interfaces/AuthRepository.swift b/Projects/Domain/Sources/Interfaces/AuthRepository.swift index 8edf532d..d20d4494 100644 --- a/Projects/Domain/Sources/Interfaces/AuthRepository.swift +++ b/Projects/Domain/Sources/Interfaces/AuthRepository.swift @@ -1,7 +1,11 @@ -/// 로그인 도메인 API(`/auth/check`·`/auth/login`·`/auth/sign-up`) — 구현은 AtchaData. -/// 소셜 자격 증명을 Bearer로 보내는 특수 계약이라 서버 토큰 데코레이터를 타지 않는다 -/// (조합 루트가 plain client를 주입). +/// 인증 도메인 API — 구현은 AtchaData. +/// 전부 서버 토큰 데코레이터를 타지 않는다(조합 루트가 plain client를 주입): +/// 게스트는 토큰 자체가 없고, 소셜 경로는 Authorization에 소셜 자격 증명을 실어야 한다. public protocol AuthRepository: Sendable { + /// POST /auth/guest — deviceId로 게스트 계정을 만들거나(최초) 되찾아(재호출) 토큰 쌍을 받는다. + /// 토큰 불필요. 서버가 같은 deviceId에 같은 계정을 돌려주는 것이 재설치·토큰 소실 복구의 근거다. + func signInAsGuest(deviceID: String, fcmToken: String?) async throws -> LoginSession + /// GET /auth/check — 이 소셜 계정이 이미 가입돼 있는가. func checkRegistration(credential: SocialCredential) async throws -> Bool /// GET /auth/login — 소셜 자격 증명을 서버 토큰 쌍으로 교환. diff --git a/Projects/Domain/Sources/Interfaces/DeviceIdentifierProviding.swift b/Projects/Domain/Sources/Interfaces/DeviceIdentifierProviding.swift new file mode 100644 index 00000000..2225bba5 --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/DeviceIdentifierProviding.swift @@ -0,0 +1,7 @@ +/// 디바이스 식별자 포트 — `POST /auth/guest`의 deviceId 소싱용. +/// 게스트 계정의 유일한 신원이므로 **같은 기기에서 항상 같은 값**이어야 한다 +/// (서버 계약: 토큰이 모두 죽어도 같은 deviceId로 /auth/guest를 부르면 같은 계정이 돌아온다). +/// 구현은 App 어댑터 — IDFV는 앱 전체 삭제 시 바뀌므로 그대로 쓰지 않는다. +public protocol DeviceIdentifierProviding: Sendable { + func currentDeviceID() async -> String +} diff --git a/Projects/Domain/Sources/UseCases/SignInAsGuestUseCase.swift b/Projects/Domain/Sources/UseCases/SignInAsGuestUseCase.swift new file mode 100644 index 00000000..a0a888d1 --- /dev/null +++ b/Projects/Domain/Sources/UseCases/SignInAsGuestUseCase.swift @@ -0,0 +1,38 @@ +/// 게스트 부트스트랩: deviceId(+있으면 FCM 토큰)로 서버 토큰 쌍을 받아 세션에 채택한다. +/// 소셜 로그인과 달리 UI·위치·역지오코딩이 개입하지 않으므로 메인 액터 격리가 필요 없다. +public protocol SignInAsGuestUseCase: Sendable { + func execute() async throws +} + +public final class DefaultSignInAsGuestUseCase: SignInAsGuestUseCase { + private let authRepository: any AuthRepository + private let sessionStore: any SessionStoring + private let deviceIdentifierProvider: any DeviceIdentifierProviding + private let pushTokenProvider: any PushTokenProviding + + public init( + authRepository: any AuthRepository, + sessionStore: any SessionStoring, + deviceIdentifierProvider: any DeviceIdentifierProviding, + pushTokenProvider: any PushTokenProviding + ) { + self.authRepository = authRepository + self.sessionStore = sessionStore + self.deviceIdentifierProvider = deviceIdentifierProvider + self.pushTokenProvider = pushTokenProvider + } + + public func execute() async throws { + let deviceID = await deviceIdentifierProvider.currentDeviceID() + // 알림 권한 전이면 nil이 정상 — 이후 PUT /members/me로 갱신한다(SyncPushTokenUseCase). + let fcmToken = await pushTokenProvider.currentPushToken() + let session = try await authRepository.signInAsGuest( + deviceID: deviceID, + fcmToken: fcmToken + ) + try await sessionStore.store( + accessToken: session.accessToken, + refreshToken: session.refreshToken + ) + } +} diff --git a/Projects/Domain/Tests/DefaultSignInAsGuestUseCaseTests.swift b/Projects/Domain/Tests/DefaultSignInAsGuestUseCaseTests.swift new file mode 100644 index 00000000..672a12a4 --- /dev/null +++ b/Projects/Domain/Tests/DefaultSignInAsGuestUseCaseTests.swift @@ -0,0 +1,143 @@ +@testable import Domain +import Foundation +import Synchronization +import Testing + +private final class GuestRecorder: Sendable { + private let events = Mutex<[String]>([]) + + func record(_ event: String) { events.withLock { $0.append(event) } } + var log: [String] { events.withLock { $0 } } +} + +private struct StubGuestAuthRepository: AuthRepository { + let recorder: GuestRecorder + var session = LoginSession(userID: 7, accessToken: "GA", refreshToken: "GR") + var guestError: (any Error)? + + func signInAsGuest(deviceID: String, fcmToken: String?) async throws -> LoginSession { + recorder.record("guest(device:\(deviceID)/fcm:\(fcmToken ?? "nil"))") + if let guestError { throw guestError } + return session + } + + // 게스트 스위트에서는 소셜 경로를 쓰지 않는다. + func checkRegistration(credential: SocialCredential) async throws -> Bool { true } + + func login(credential: SocialCredential, fcmToken: String?) async throws -> LoginSession { + session + } + + func signUp( + credential: SocialCredential, + form: SignUpForm, + fcmToken: String? + ) async throws -> LoginSession { + session + } +} + +private struct StubGuestSessionStore: SessionStoring { + let recorder: GuestRecorder + var error: (any Error)? + + func store(accessToken: String, refreshToken: String) async throws { + recorder.record("store(\(accessToken)/\(refreshToken))") + if let error { throw error } + } +} + +private struct StubDeviceIdentifierProvider: DeviceIdentifierProviding { + var deviceID = "DEVICE-1" + + func currentDeviceID() async -> String { deviceID } +} + +private struct StubGuestPushTokenProvider: PushTokenProviding { + var token: String? + + func currentPushToken() async -> String? { token } +} + +private struct GuestStoreError: Error {} +private struct GuestAuthError: Error {} + +struct DefaultSignInAsGuestUseCaseTests { + private let recorder = GuestRecorder() + + private func makeSUT( + repository: StubGuestAuthRepository? = nil, + store: StubGuestSessionStore? = nil, + deviceID: String = "DEVICE-1", + pushToken: String? = nil + ) -> DefaultSignInAsGuestUseCase { + DefaultSignInAsGuestUseCase( + authRepository: repository ?? StubGuestAuthRepository(recorder: recorder), + sessionStore: store ?? StubGuestSessionStore(recorder: recorder), + deviceIdentifierProvider: StubDeviceIdentifierProvider(deviceID: deviceID), + pushTokenProvider: StubGuestPushTokenProvider(token: pushToken) + ) + } + + /// 게스트 부트스트랩은 deviceId로 토큰을 받아 세션에 채택하는 것이 전부다 — + /// 소셜 인가·가입 여부 확인·위치 조회가 개입하지 않는다. + @Test + func execute_sendsDeviceIDThenStoresSession() async throws { + let sut = makeSUT(pushToken: "FCM") + + try await sut.execute() + + #expect(recorder.log == [ + "guest(device:DEVICE-1/fcm:FCM)", + "store(GA/GR)", + ]) + } + + /// 알림 권한 전이면 FCM 토큰이 없는 것이 정상 — 빈 문자열로 강등하지 않고 nil을 넘긴다. + @Test + func execute_noPushToken_passesNil() async throws { + let sut = makeSUT() + + try await sut.execute() + + #expect(recorder.log == [ + "guest(device:DEVICE-1/fcm:nil)", + "store(GA/GR)", + ]) + } + + /// 같은 기기는 항상 같은 deviceId를 보낸다(서버가 같은 계정을 돌려주는 근거). + @Test + func execute_usesProvidedDeviceIdentifier() async throws { + let sut = makeSUT(deviceID: "DEVICE-STABLE") + + try await sut.execute() + + #expect(recorder.log.first == "guest(device:DEVICE-STABLE/fcm:nil)") + } + + /// 서버 실패는 그대로 전파되고 세션은 채택되지 않는다 — 스플래시가 실패 문구를 띄울 근거. + @Test + func execute_guestFailure_propagatesWithoutStore() async { + let sut = makeSUT( + repository: StubGuestAuthRepository(recorder: recorder, guestError: GuestAuthError()) + ) + + await #expect(throws: GuestAuthError.self) { + try await sut.execute() + } + + #expect(recorder.log == ["guest(device:DEVICE-1/fcm:nil)"]) + } + + @Test + func execute_storeFailure_propagates() async { + let sut = makeSUT(store: StubGuestSessionStore(recorder: recorder, error: GuestStoreError())) + + await #expect(throws: GuestStoreError.self) { + try await sut.execute() + } + + #expect(recorder.log.count == 2) + } +} diff --git a/Projects/Domain/Tests/DefaultSignInUseCaseTests.swift b/Projects/Domain/Tests/DefaultSignInUseCaseTests.swift index b6fb998a..2a224ef9 100644 --- a/Projects/Domain/Tests/DefaultSignInUseCaseTests.swift +++ b/Projects/Domain/Tests/DefaultSignInUseCaseTests.swift @@ -29,6 +29,12 @@ private struct StubAuthRepository: AuthRepository { var session = LoginSession(userID: 1, accessToken: "SA", refreshToken: "SR") var signUpError: (any Error)? + /// 소셜 경로 테스트에서는 쓰이지 않는다 — 게스트 경로는 별도 스위트가 검증한다. + func signInAsGuest(deviceID: String, fcmToken: String?) async throws -> LoginSession { + recorder.record("guest(device:\(deviceID)/fcm:\(fcmToken ?? "nil"))") + return session + } + func checkRegistration(credential: SocialCredential) async throws -> Bool { recorder.record("check(\(credential.accessToken))") return exists diff --git a/mise.toml b/mise.toml index 2eed2c5b..f42472a1 100644 --- a/mise.toml +++ b/mise.toml @@ -1,2 +1,2 @@ [tools] -tuist = "4.202.0" +tuist = "4.209.0"