diff --git a/CLAUDE.md b/CLAUDE.md index 163bfd13..a7d1a376 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,7 +87,7 @@ AtchaV2(앱, 조합 루트: 어댑터·스플래시·AlarmSyncService) ─► Ho ### 미완 상태 (작업 시 참고) - **인증은 게스트 계정 기반(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에서도 그대로 표면화된다. +- **소셜 로그인은 V2에서 완전히 제거됐다(2026-09-24)** — `Projects/Feature/Auth` 모듈 전체, `SignInUseCase`, `SocialLoginAdapter`, `KakaoConfig`, 카카오 SDK 의존성(`KakaoSDK*`, `Tuist/Package.swift`의 kakao-ios-sdk), Apple Sign In entitlement, 카카오 URL 스킴·`LSApplicationQueriesSchemes`·`TUIST_KAKAO_APP_KEY`가 모두 사라졌다. 관련 서버 API(`/auth/check`·`/auth/login`·`/auth/sign-up`)와 Domain 계약(`SocialCredential`, `SocialLoginService`, `SignUpForm`)도 함께 제거. `Projects/Core/Auth`는 **세션·토큰 관리라 그대로 유지**된다(게스트 인증이 쓴다). 회원탈퇴(`DELETE /members/me`)·유저 정보(`GET /members/me`, 홈주소/알림빈도 PATCH — `UserRepository`)는 `SettingsFeature`(홈 상단 톱니바퀴 진입: 우리집 설정·약관·피드백·버전·로그아웃·계정 탈퇴)로 연결돼 있다. 알림 빈도는 레거시처럼 미노출. 로그아웃(`POST /auth/logout`, CoreAuth `AuthSessionManager.signOut()`)·탈퇴·강제 만료는 전부 `AppCoordinator.handleSessionExpiry`에 합류하고 거기서 `AlarmSessionTeardown`이 로컬 알람·LA·스냅샷·동기화 상태를 비운다. **다만 게스트 모델에서 로그아웃/탈퇴 직후 같은 deviceId로 재인증되므로 UX가 재검토 대상이다(미해결).** 홈주소 변경은 `GET /locations/is-service-region` 통과 후 PATCH. 앱 버전은 `GET /app/version`(무토큰, 1.5초 예산·실패 시 통과)으로 권장 업데이트 팝업만. `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(수동)뿐. diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index 382a0bf6..8c2393d9 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -1,12 +1,7 @@ import ProjectDescription import ProjectDescriptionHelpers -// 카카오 네이티브 앱 키 — xcconfig 금지 규약(AtchaV2) 하에서 env(TUIST_KAKAO_APP_KEY)로 -// 주입한다(mise [env] 또는 CI secret). 미주입이면 빈 문자열: generate/빌드는 통과하고 -// 카카오 로그인만 런타임 비활성(providerUnavailable → 실패 토스트)이다. -let kakaoAppKey = Environment.kakaoAppKey.getString(default: "") - -var appInfoPlist: [String: Plist.Value] = [ +let appInfoPlist: [String: Plist.Value] = [ "CFBundleDisplayName": "앗차", "UILaunchStoryboardName": "LaunchScreen", "NSAlarmKitUsageDescription": "막차 시간에 맞춰 알람을 울리기 위해 권한이 필요합니다.", @@ -27,17 +22,7 @@ var appInfoPlist: [String: Plist.Value] = [ ], "UISupportedInterfaceOrientations": ["UIInterfaceOrientationPortrait"], "ITSAppUsesNonExemptEncryption": false, - // 카카오톡 로그인 앱 전환 조회용(레거시 Info.plist 실측). - "LSApplicationQueriesSchemes": ["kakaokompassauth", "kakaolink"], - // 런타임(KakaoConfig)이 읽는 키 — 빈 값이면 KakaoSDK.initSDK를 건너뛴다. - "KAKAO_APP_KEY": .string(kakaoAppKey), ] -// 키 미주입 시 무의미한 `kakao` 빈 스킴이 등록되지 않도록 조건 분기. -if !kakaoAppKey.isEmpty { - appInfoPlist["CFBundleURLTypes"] = [ - ["CFBundleURLSchemes": ["kakao\(kakaoAppKey)"]], - ] -} let appTarget = Target.target( name: "AtchaV2", @@ -54,13 +39,9 @@ let appTarget = Target.target( "aps-environment": "development", // 폴백 노티의 .timeSensitive interruptionLevel용(Phase 15) — 집중 모드 관통. "com.apple.developer.usernotifications.time-sensitive": true, - // 소셜 로그인(Apple) — App ID capability 활성 + 프로비저닝 갱신 필요(콘솔 작업). - "com.apple.developer.applesignin": ["Default"], ]), dependencies: [ .target(name: "AtchaWidget"), - .project(target: "AuthFeature", path: "../Feature/Auth"), - .project(target: "AuthFeatureInterface", path: "../Feature/Auth"), .project(target: "HomeFeature", path: "../Feature/Home"), .project(target: "HomeFeatureInterface", path: "../Feature/Home"), .project(target: "SearchFeature", path: "../Feature/Search"), @@ -79,11 +60,6 @@ let appTarget = Target.target( .external(name: "FirebaseCore"), .external(name: "FirebaseCrashlytics"), .external(name: "FirebaseMessaging"), - // 소셜 SDK는 앱 타겟에서만 링크(내부 모듈 static framework 중복 심볼 방지 규약) - // — import 지점은 SocialLoginAdapter·AppDelegate·SceneDelegate뿐. - .external(name: "KakaoSDKCommon"), - .external(name: "KakaoSDKAuth"), - .external(name: "KakaoSDKUser"), ], settings: .atchaV2(base: [ // Firebase static libraries under XcodeProj-based integration need diff --git a/Projects/App/Sources/Adapters/SocialLoginAdapter.swift b/Projects/App/Sources/Adapters/SocialLoginAdapter.swift deleted file mode 100644 index b1f833b4..00000000 --- a/Projects/App/Sources/Adapters/SocialLoginAdapter.swift +++ /dev/null @@ -1,127 +0,0 @@ -import AuthenticationServices -import Domain -import KakaoSDKAuth -import KakaoSDKCommon -import KakaoSDKUser -import UIKit - -/// 소셜 SDK를 아는 곳은 이 어댑터뿐(외부 라이브러리 앱 타겟 전용 링크 규약). -/// 레거시 `LoginUseCaseImpl`의 delegate 이중 보관·`self.delegate!`·`view.window!` -/// force unwrap을 제거한 async 래핑 — 진행 중 continuation은 take()(nil 교체 후 사용)로 -/// 이중 resume을 차단한다. -@MainActor -final class SocialLoginAdapter: NSObject, SocialLoginService { - // 진행 중 Apple 플로우 — 어댑터가 delegate로 살아 있는 동안 controller도 함께 보관. - private var appleContinuation: CheckedContinuation? - private var appleController: ASAuthorizationController? - - func authorize(provider: SocialLoginProvider) async throws -> SocialCredential { - switch provider { - case .kakao: try await authorizeKakao() - case .apple: try await authorizeApple() - } - } - - // MARK: - Kakao - - private func authorizeKakao() async throws -> SocialCredential { - // initSDK가 안 된 상태(키 미주입)에서 UserApi를 부르면 SDK가 fatal — 선차단. - guard KakaoConfig.appKey != nil else { throw SocialLoginError.providerUnavailable } - let accessToken: String = try await withCheckedThrowingContinuation { continuation in - let completion: (OAuthToken?, (any Error)?) -> Void = { oauthToken, error in - if let error { - continuation.resume(throwing: Self.mapKakaoError(error)) - } else if let accessToken = oauthToken?.accessToken { - continuation.resume(returning: accessToken) - } else { - continuation.resume(throwing: SocialLoginError.failed) - } - } - // 카카오톡 앱 설치 시 앱 전환, 아니면 계정 웹뷰 — 레거시 분기 그대로. - if UserApi.isKakaoTalkLoginAvailable() { - UserApi.shared.loginWithKakaoTalk(completion: completion) - } else { - UserApi.shared.loginWithKakaoAccount(completion: completion) - } - } - return SocialCredential(provider: .kakao, accessToken: accessToken) - } - - private static func mapKakaoError(_ error: any Error) -> any Error { - // 사용자 취소는 조용한 idle 복귀 대상 — 그 외는 실패 토스트로 표면화. - if let sdkError = error as? SdkError, - case let .ClientFailed(reason, _) = sdkError, - reason == .Cancelled { - return SocialLoginError.cancelled - } - return error - } - - // MARK: - Apple - - private func authorizeApple() async throws -> SocialCredential { - // ViewModel의 로딩 가드가 1차 방어지만, 플로우 단일성은 여기서도 보장한다. - guard appleContinuation == nil else { throw SocialLoginError.failed } - let request = ASAuthorizationAppleIDProvider().createRequest() - request.requestedScopes = [.fullName, .email] - let controller = ASAuthorizationController(authorizationRequests: [request]) - controller.delegate = self - controller.presentationContextProvider = self - appleController = controller - return try await withCheckedThrowingContinuation { continuation in - appleContinuation = continuation - controller.performRequests() - } - } - - private func takeAppleContinuation() -> CheckedContinuation? { - defer { - appleContinuation = nil - appleController = nil - } - return appleContinuation - } -} - -extension SocialLoginAdapter: ASAuthorizationControllerDelegate { - func authorizationController( - controller: ASAuthorizationController, - didCompleteWithAuthorization authorization: ASAuthorization - ) { - guard let continuation = takeAppleContinuation() else { return } - guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential, - let tokenData = credential.identityToken, - let identityToken = String(data: tokenData, encoding: .utf8) - else { - continuation.resume(throwing: SocialLoginError.failed) - return - } - continuation.resume( - returning: SocialCredential(provider: .apple, accessToken: identityToken) - ) - } - - func authorizationController( - controller: ASAuthorizationController, - didCompleteWithError error: any Error - ) { - guard let continuation = takeAppleContinuation() else { return } - if let authorizationError = error as? ASAuthorizationError, - authorizationError.code == .canceled { - continuation.resume(throwing: SocialLoginError.cancelled) - } else { - continuation.resume(throwing: error) - } - } -} - -extension SocialLoginAdapter: ASAuthorizationControllerPresentationContextProviding { - func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { - // 레거시의 `view.window!` 대체 — 호출 시점의 활성 씬 key window를 해석하고, - // 끝내 없으면 빈 앵커로 강제 크래시만 피한다(시트가 안 뜰 뿐). - let scene = UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .first { $0.activationState == .foregroundActive } - return scene?.keyWindow ?? scene?.windows.first ?? ASPresentationAnchor() - } -} diff --git a/Projects/App/Sources/AppCoordinator.swift b/Projects/App/Sources/AppCoordinator.swift index c7d10000..d29fcbd9 100644 --- a/Projects/App/Sources/AppCoordinator.swift +++ b/Projects/App/Sources/AppCoordinator.swift @@ -14,8 +14,6 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate { /// 게스트 부트스트랩 in-flight — 만료 스트림의 중복 yield나 재시도 연타에 /// /auth/guest를 겹쳐 쏘지 않는다. private var bootstrapTask: Task? - /// 로그인 플로우 표시 중 가드 — 만료 스트림의 중복 yield에 로그인을 겹치지 않는다. - private var isShowingLogin = false /// 세션 만료 경유 재인증이면 성공 직후 수동 동기화 1회 — activate()의 시작 /// 동기화는 최초 1회 가드라 재인증 경로에선 돌지 않기 때문. private var needsSyncAfterLogin = false @@ -111,38 +109,8 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate { } } - /// 소셜 로그인 시트 — 스플래시를 root로 유지한 채 present한다 - /// (스플래시 배경 위 바텀시트 = 레거시와 같은 시각 결과). - /// - /// 게스트 인증 전환(2026-09-24) 이후 **호출처가 없다.** 서버가 게스트 계정으로 - /// 부트스트랩을 처리하므로 강제 로그인 단계 자체가 사라졌다. 소셜 계정 승격이 - /// 도입되면 이 경로가 그대로 진입점이 되므로 AuthFeature와 함께 남겨둔다. - @available(*, deprecated, message: "게스트 부트스트랩으로 대체됨. 소셜 계정 승격 도입 시 재사용.") - private func showLogin() { - guard !isShowingLogin else { return } - isShowingLogin = true - let loginCoordinator = container.makeAuthDIContainer().makeLoginCoordinator( - navigationController: navigationController, - onAuthenticated: { [weak self] in - guard let self else { return } - self.isShowingLogin = false - self.startHome() - self.container.alarmSyncService.activate() - self.syncPushTokenAfterLogin() - if self.needsSyncAfterLogin { - self.needsSyncAfterLogin = false - let syncService = self.container.alarmSyncService - Task { await syncService.syncNow() } - } - } - ) - loginCoordinator.finishDelegate = self - addChild(loginCoordinator) - loginCoordinator.start() - } - - /// refresh 확정 사망(AuthSessionManager.sessionExpired) 관찰 — 홈을 접고 스플래시 - /// 위 로그인으로 되돌린다. 스트림은 단일 소비자(이 코디네이터) 전제. + /// refresh 확정 사망(AuthSessionManager.sessionExpired) 관찰 — 홈을 접고 스플래시로 + /// 되돌린 뒤 게스트 재인증한다. 스트림은 단일 소비자(이 코디네이터) 전제. private func observeSessionExpiry() { sessionExpiryTask = Task { [weak self] in guard let stream = self?.container.authSessionManager.sessionExpired else { return } @@ -154,7 +122,7 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate { } private func handleSessionExpiry() { - guard bootstrapTask == nil, !isShowingLogin else { return } + guard bootstrapTask == nil else { return } needsSyncAfterLogin = true // 로그아웃·탈퇴·강제 만료 공통 합류점 — 이전 계정의 알람이 로그인 화면에서 울리지 않게 // 로컬 정리를 여기서 한 번 더 보장한다(로그아웃 경로의 선행 정리와 겹쳐도 멱등). diff --git a/Projects/App/Sources/AppDIContainer.swift b/Projects/App/Sources/AppDIContainer.swift index 1f0c0fd5..642c96be 100644 --- a/Projects/App/Sources/AppDIContainer.swift +++ b/Projects/App/Sources/AppDIContainer.swift @@ -1,6 +1,4 @@ import AtchaData -import AuthFeature -import AuthFeatureInterface import CoreAlarm import CoreAuth import CoreNetwork @@ -157,23 +155,6 @@ final class AppDIContainer { ) } - func makeAuthDIContainer() -> any AuthCoordinatorBuildable { - AuthDIContainer( - signInUseCase: DefaultSignInUseCase( - socialLoginService: SocialLoginAdapter(), - // plain client — 소셜 Bearer 보존 + 401이 세션 복구를 촉발하지 않게. - authRepository: AuthRepositoryImpl(networkClient: plainNetworkClient), - sessionStore: AuthSessionStoreAdapter(sessionManager: authSessionManager), - pushTokenProvider: FCMPushTokenAdapter(), - // 최소 가입 폼 재료 — 신규 계정일 때만 쓰인다(현재 위치 + 역지오코딩 주소). - getCurrentLocationUseCase: DefaultGetCurrentLocationUseCase( - locationService: CoreLocationServiceAdapter() - ), - reverseGeocodeUseCase: DefaultReverseGeocodeUseCase(repository: placeRepository) - ) - ) - } - // MARK: - 계정 스택 (설정 화면 + DEV 디버그 메뉴의 로그아웃) private var userRepository: any UserRepository { diff --git a/Projects/App/Sources/AppDelegate.swift b/Projects/App/Sources/AppDelegate.swift index f98eceaa..61dea537 100644 --- a/Projects/App/Sources/AppDelegate.swift +++ b/Projects/App/Sources/AppDelegate.swift @@ -1,6 +1,5 @@ import FirebaseCore import FirebaseMessaging -import KakaoSDKCommon import UIKit import os @@ -28,10 +27,6 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { // 노티 탭 라우팅(Phase 15) — launch 완료 전에 등록해야 탭이 앱을 cold start시키는 // 경우의 didReceive까지 잡는다. 홈 랜딩 훅 배선은 SceneDelegate 몫. container.notificationTapDelegate.attachToNotificationCenter() - // 카카오 로그인 — 키 미주입(TUIST_KAKAO_APP_KEY 부재) 빌드에선 건너뛴다. - if let kakaoAppKey = KakaoConfig.appKey { - KakaoSDK.initSDK(appKey: kakaoAppKey) - } configureFirebaseIfAvailable() if isFirebaseEnabled { Messaging.messaging().delegate = self diff --git a/Projects/App/Sources/KakaoConfig.swift b/Projects/App/Sources/KakaoConfig.swift deleted file mode 100644 index 782040eb..00000000 --- a/Projects/App/Sources/KakaoConfig.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Foundation - -/// 카카오 네이티브 앱 키 — Tuist env(TUIST_KAKAO_APP_KEY)가 Info.plist로 주입한 값을 -/// 읽는다(xcconfig 금지 규약). 미주입(빈 값)이면 nil — 카카오 로그인만 비활성. -enum KakaoConfig { - static var appKey: String? { - guard let key = Bundle.main.object(forInfoDictionaryKey: "KAKAO_APP_KEY") as? String, - !key.isEmpty - else { return nil } - return key - } -} diff --git a/Projects/App/Sources/SceneDelegate.swift b/Projects/App/Sources/SceneDelegate.swift index b9b8a6b1..7fa5dac9 100644 --- a/Projects/App/Sources/SceneDelegate.swift +++ b/Projects/App/Sources/SceneDelegate.swift @@ -1,4 +1,3 @@ -import KakaoSDKAuth import UIKit final class SceneDelegate: UIResponder, UIWindowSceneDelegate { @@ -36,12 +35,6 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { #endif } - /// 카카오톡 앱 로그인 리디렉션(kakao{KEY}:// 스킴) 복귀 처리 — 레거시 동일. - func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { - guard let url = URLContexts.first?.url, AuthApi.isKakaoTalkLoginUrl(url) else { return } - _ = AuthController.handleOpenUrl(url: url) - } - #if DEV // MARK: - Phase 11 변경 시뮬레이터 (DEV 한정 플로팅 버튼) diff --git a/Projects/Data/Sources/DTO/AuthResponseDTO.swift b/Projects/Data/Sources/DTO/AuthResponseDTO.swift index 0eaeed0b..8d5f75f2 100644 --- a/Projects/Data/Sources/DTO/AuthResponseDTO.swift +++ b/Projects/Data/Sources/DTO/AuthResponseDTO.swift @@ -1,11 +1,7 @@ import CoreNetwork import Domain -struct AuthCheckResponseDTO: Decodable, Sendable { - let exists: Bool -} - -/// `/auth/login` 응답(레거시 LoginDTO 실측) — 전 필드 옵셔널, 좌표 키는 "lat"/"lon". +/// `/auth/guest` 응답 — 전 필드 옵셔널, 좌표 키는 "lat"/"lon". /// lat/lon(집 좌표)은 현 스코프 미사용이라 디코딩만 하고 엔티티로 올리지 않는다. struct LoginResponseDTO: Decodable, Sendable { let id: Int? diff --git a/Projects/Data/Sources/DTO/SignUpRequestDTO.swift b/Projects/Data/Sources/DTO/SignUpRequestDTO.swift deleted file mode 100644 index 8ba37845..00000000 --- a/Projects/Data/Sources/DTO/SignUpRequestDTO.swift +++ /dev/null @@ -1,12 +0,0 @@ -/// `POST /auth/sign-up` body(레거시 OnboardingDTO.SignUpRequest 실측). -/// 응답은 `/auth/login`과 계약이 같아 `LoginResponseDTO`를 재사용한다. -struct SignUpRequestDTO: Encodable, Sendable { - let provider: Int - let userName: String? - let address: String - let lat: Double - let lon: Double - let alertFrequencies: [Int] - /// 레거시 실측: FCM 토큰 부재 시 빈 문자열 전송. - let fcmToken: String -} diff --git a/Projects/Data/Sources/Network/AuthEndpoint.swift b/Projects/Data/Sources/Network/AuthEndpoint.swift deleted file mode 100644 index b13d7e6c..00000000 --- a/Projects/Data/Sources/Network/AuthEndpoint.swift +++ /dev/null @@ -1,70 +0,0 @@ -import CoreNetwork -import Domain -import Foundation - -/// `/auth/check`·`/auth/login` — 소셜 자격 증명을 Bearer로 보내는 특수 계약(레거시 실측). -/// AuthenticatedNetworkClient를 타면 Authorization이 서버 토큰으로 덮이므로, -/// 이 엔드포인트는 반드시 plain client로 호출한다(AuthRepositoryImpl 주입 규약). -enum AuthEndpoint: Endpoint { - case check(SocialCredential) - case login(SocialCredential, fcmToken: String?) - case signUp(SocialCredential, SignUpRequestDTO) - - var path: String { - switch self { - case .check: "/auth/check" - case .login: "/auth/login" - case .signUp: "/auth/sign-up" - } - } - - var method: HTTPMethod { - switch self { - case .check, .login: .get - case .signUp: .post - } - } - - var headers: [String: String] { - switch self { - case let .check(credential), let .login(credential, _): - ["Authorization": "Bearer \(credential.accessToken)"] - case let .signUp(credential, _): - [ - "Authorization": "Bearer \(credential.accessToken)", - "Content-Type": "application/json", - ] - } - } - - var queryItems: [URLQueryItem] { - switch self { - case let .check(credential): - [URLQueryItem(name: "provider", value: String(credential.provider.serverCode))] - case let .login(credential, fcmToken): - [ - URLQueryItem(name: "provider", value: String(credential.provider.serverCode)), - // 레거시 실측: FCM 토큰 부재 시 빈 문자열 전송. - URLQueryItem(name: "fcmToken", value: fcmToken ?? ""), - ] - case .signUp: [] - } - } - - var body: Data? { - switch self { - case .check, .login: nil - case let .signUp(_, request): try? JSONEncoder().encode(request) - } - } -} - -extension SocialLoginProvider { - /// 서버 provider 코드 — 레거시 `LoginType` raw value 실측(kakao=0, apple=1). - var serverCode: Int { - switch self { - case .kakao: 0 - case .apple: 1 - } - } -} diff --git a/Projects/Data/Sources/Repositories/AuthRepositoryImpl.swift b/Projects/Data/Sources/Repositories/AuthRepositoryImpl.swift index b4a012f4..06cb3822 100644 --- a/Projects/Data/Sources/Repositories/AuthRepositoryImpl.swift +++ b/Projects/Data/Sources/Repositories/AuthRepositoryImpl.swift @@ -5,14 +5,12 @@ public struct AuthRepositoryImpl: AuthRepository { private let networkClient: any NetworkClient /// plain client(AuthenticatedNetworkClient 미적용)를 주입할 것 — 게스트는 토큰이 - /// 없고 소셜 경로는 Authorization에 소셜 자격 증명을 실어야 하며, 어느 쪽도 - /// 401이 세션 복구를 촉발해서는 안 된다. + /// 없는 것이 정상이고, 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) @@ -20,40 +18,4 @@ public struct AuthRepositoryImpl: AuthRepository { ) return try dto.toEntity() } - - public func checkRegistration(credential: SocialCredential) async throws -> Bool { - let dto: AuthCheckResponseDTO = try await networkClient.requestEnveloped( - AuthEndpoint.check(credential) - ) - return dto.exists - } - - public func login(credential: SocialCredential, fcmToken: String?) async throws -> LoginSession { - let dto: LoginResponseDTO = try await networkClient.requestEnveloped( - AuthEndpoint.login(credential, fcmToken: fcmToken) - ) - return try dto.toEntity() - } - - public func signUp( - credential: SocialCredential, - form: SignUpForm, - fcmToken: String? - ) async throws -> LoginSession { - let request = SignUpRequestDTO( - provider: credential.provider.serverCode, - userName: form.userName, - address: form.address, - lat: form.coordinate.latitude, - lon: form.coordinate.longitude, - alertFrequencies: form.alertFrequencies, - // 레거시 실측: FCM 토큰 부재 시 빈 문자열 전송. - fcmToken: fcmToken ?? "" - ) - // 응답 계약은 /auth/login과 동일 — LoginResponseDTO 재사용. - let dto: LoginResponseDTO = try await networkClient.requestEnveloped( - AuthEndpoint.signUp(credential, request) - ) - return try dto.toEntity() - } } diff --git a/Projects/Data/Tests/AuthEndpointTests.swift b/Projects/Data/Tests/AuthEndpointTests.swift deleted file mode 100644 index 1d29c359..00000000 --- a/Projects/Data/Tests/AuthEndpointTests.swift +++ /dev/null @@ -1,88 +0,0 @@ -@testable import AtchaData -import CoreNetwork -import Domain -import Foundation -import Testing - -struct AuthEndpointTests { - private let kakaoCredential = SocialCredential(provider: .kakao, accessToken: "KAKAO_AT") - private let appleCredential = SocialCredential(provider: .apple, accessToken: "APPLE_IDT") - - /// 레거시 실측 계약 핀: 소셜 토큰이 Bearer로, provider는 kakao=0. - @Test - func check_kakao_sendsSocialBearerAndProviderZero() { - let endpoint = AuthEndpoint.check(kakaoCredential) - - #expect(endpoint.path == "/auth/check") - #expect(endpoint.method == .get) - #expect(endpoint.headers == ["Authorization": "Bearer KAKAO_AT"]) - #expect(endpoint.queryItems == [URLQueryItem(name: "provider", value: "0")]) - } - - @Test - func check_apple_sendsProviderOne() { - let endpoint = AuthEndpoint.check(appleCredential) - - #expect(endpoint.headers == ["Authorization": "Bearer APPLE_IDT"]) - #expect(endpoint.queryItems == [URLQueryItem(name: "provider", value: "1")]) - } - - @Test - func login_sendsProviderAndFcmToken() { - let endpoint = AuthEndpoint.login(kakaoCredential, fcmToken: "FCM") - - #expect(endpoint.path == "/auth/login") - #expect(endpoint.method == .get) - #expect(endpoint.headers == ["Authorization": "Bearer KAKAO_AT"]) - #expect(endpoint.queryItems == [ - URLQueryItem(name: "provider", value: "0"), - URLQueryItem(name: "fcmToken", value: "FCM"), - ]) - } - - /// 레거시 실측: FCM 토큰 부재 시 빈 문자열 전송. - @Test - func login_withoutFcmToken_sendsEmptyStringLikeLegacy() { - let endpoint = AuthEndpoint.login(appleCredential, fcmToken: nil) - - #expect(endpoint.queryItems == [ - URLQueryItem(name: "provider", value: "1"), - URLQueryItem(name: "fcmToken", value: ""), - ]) - } - - /// 레거시 실측 계약 핀: sign-up도 소셜 토큰이 Bearer로, 필드는 JSON body로. - @Test - func signUp_sendsSocialBearerAndJSONBody() throws { - let request = SignUpRequestDTO( - provider: 0, - userName: "", - address: "서울 어딘가 1-2", - lat: 37.5, - lon: 127.0, - alertFrequencies: [1, 10], - fcmToken: "" - ) - let endpoint = AuthEndpoint.signUp(kakaoCredential, request) - - #expect(endpoint.path == "/auth/sign-up") - #expect(endpoint.method == .post) - #expect(endpoint.headers == [ - "Authorization": "Bearer KAKAO_AT", - "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["provider"] as? Int == 0) - #expect(json["userName"] as? String == "") - #expect(json["address"] as? String == "서울 어딘가 1-2") - #expect(json["lat"] as? Double == 37.5) - #expect(json["lon"] as? Double == 127.0) - #expect(json["alertFrequencies"] as? [Int] == [1, 10]) - #expect(json["fcmToken"] as? String == "") - } -} diff --git a/Projects/Data/Tests/AuthResponseDTOTests.swift b/Projects/Data/Tests/AuthResponseDTOTests.swift index 99aea7f7..130d7294 100644 --- a/Projects/Data/Tests/AuthResponseDTOTests.swift +++ b/Projects/Data/Tests/AuthResponseDTOTests.swift @@ -34,14 +34,4 @@ struct AuthResponseDTOTests { #expect(try dto.toEntity().userID == nil) } - - @Test - func checkResponse_decodesExists() throws { - let dto = try JSONDecoder().decode( - AuthCheckResponseDTO.self, - from: Data(#"{"exists": true}"#.utf8) - ) - - #expect(dto.exists) - } } diff --git a/Projects/Domain/Sources/Entities/SignUpForm.swift b/Projects/Domain/Sources/Entities/SignUpForm.swift deleted file mode 100644 index 4d613c0a..00000000 --- a/Projects/Domain/Sources/Entities/SignUpForm.swift +++ /dev/null @@ -1,32 +0,0 @@ -/// `POST /auth/sign-up` 입력 — V2는 온보딩 화면 없이 최소 가입으로 보낸다. -/// 집 주소·알림 빈도는 가입 후 `/members/me` PATCH로 언제든 바꿀 수 있다. -public struct SignUpForm: Sendable, Equatable { - public let userName: String? - public let address: String - public let coordinate: Coordinate - public let alertFrequencies: [Int] - - public init( - userName: String?, - address: String, - coordinate: Coordinate, - alertFrequencies: [Int] - ) { - self.userName = userName - self.address = address - self.coordinate = coordinate - self.alertFrequencies = alertFrequencies - } - - /// 레거시 실측 기본 알림 빈도(HomeFindViewModel): 1분·10분 전. - public static let defaultAlertFrequencies = [1, 10] - - /// 위치를 확보하지 못했을 때의 폼 — 레거시가 실제로 보내던 값 - /// (userName ""·address ""·좌표 (0,0))이라 서버 수용이 실측돼 있다. - public static let minimalFallback = SignUpForm( - userName: "", - address: "", - coordinate: Coordinate(latitude: 0, longitude: 0), - alertFrequencies: defaultAlertFrequencies - ) -} diff --git a/Projects/Domain/Sources/Entities/SocialCredential.swift b/Projects/Domain/Sources/Entities/SocialCredential.swift deleted file mode 100644 index cf7ccd9c..00000000 --- a/Projects/Domain/Sources/Entities/SocialCredential.swift +++ /dev/null @@ -1,26 +0,0 @@ -/// 소셜 로그인 제공자 — 서버 provider 파라미터 값(kakao=0, apple=1)은 Data 레이어가 매핑한다. -public enum SocialLoginProvider: Sendable, Equatable, CaseIterable { - case kakao - case apple -} - -/// 소셜 SDK가 발급한 자격 증명 — 카카오는 OAuth access token, 애플은 identityToken. -/// 서버 `/auth/*` 호출에서 Bearer로 실려 서버 토큰과 교환된다. -public struct SocialCredential: Sendable, Equatable { - public let provider: SocialLoginProvider - public let accessToken: String - - public init(provider: SocialLoginProvider, accessToken: String) { - self.provider = provider - self.accessToken = accessToken - } -} - -public enum SocialLoginError: Error, Equatable, Sendable { - /// 사용자가 소셜 로그인 UI를 스스로 닫음 — 에러 표출 없이 idle 복귀. - case cancelled - /// 제공자를 쓸 수 없는 상태(카카오 SDK 미초기화 등). - case providerUnavailable - /// 그 외 제공자 실패. - case failed -} diff --git a/Projects/Domain/Sources/Interfaces/AuthRepository.swift b/Projects/Domain/Sources/Interfaces/AuthRepository.swift index d20d4494..3494b959 100644 --- a/Projects/Domain/Sources/Interfaces/AuthRepository.swift +++ b/Projects/Domain/Sources/Interfaces/AuthRepository.swift @@ -1,15 +1,8 @@ /// 인증 도메인 API — 구현은 AtchaData. -/// 전부 서버 토큰 데코레이터를 타지 않는다(조합 루트가 plain client를 주입): -/// 게스트는 토큰 자체가 없고, 소셜 경로는 Authorization에 소셜 자격 증명을 실어야 한다. +/// 게스트는 토큰 자체가 없으므로 서버 토큰 데코레이터를 타지 않는다 +/// (조합 루트가 plain client를 주입). 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 — 소셜 자격 증명을 서버 토큰 쌍으로 교환. - func login(credential: SocialCredential, fcmToken: String?) async throws -> LoginSession - /// POST /auth/sign-up — 최소 가입 후 서버 토큰 쌍 수령(레거시 실측 계약). - func signUp(credential: SocialCredential, form: SignUpForm, fcmToken: String?) async throws -> LoginSession } diff --git a/Projects/Domain/Sources/Interfaces/SocialLoginService.swift b/Projects/Domain/Sources/Interfaces/SocialLoginService.swift deleted file mode 100644 index 1d9bed18..00000000 --- a/Projects/Domain/Sources/Interfaces/SocialLoginService.swift +++ /dev/null @@ -1,5 +0,0 @@ -/// 소셜 SDK 포트 — 어댑터 구현은 App에 둔다(외부 SDK는 앱 타겟에서만 링크). -/// 제공자 UI(카카오톡 전환·Apple 시트)를 띄우므로 메인 액터 격리. -public protocol SocialLoginService: Sendable { - @MainActor func authorize(provider: SocialLoginProvider) async throws -> SocialCredential -} diff --git a/Projects/Domain/Sources/UseCases/SignInUseCase.swift b/Projects/Domain/Sources/UseCases/SignInUseCase.swift deleted file mode 100644 index d56af840..00000000 --- a/Projects/Domain/Sources/UseCases/SignInUseCase.swift +++ /dev/null @@ -1,86 +0,0 @@ -/// 소셜 로그인 전 과정: 소셜 인가 → 가입 여부 확인 → 토큰 교환(기존 회원) 또는 -/// 최소 가입(신규 — 온보딩 화면 없이 현재 위치 기반 폼) → 세션 채택. -/// 소셜 UI를 띄우는 authorize가 메인 액터 격리라 execute도 @MainActor. -public protocol SignInUseCase: Sendable { - @MainActor func execute(provider: SocialLoginProvider) async throws -} - -public final class DefaultSignInUseCase: SignInUseCase { - private let socialLoginService: any SocialLoginService - private let authRepository: any AuthRepository - private let sessionStore: any SessionStoring - private let pushTokenProvider: any PushTokenProviding - private let getCurrentLocationUseCase: any GetCurrentLocationUseCase - private let reverseGeocodeUseCase: any ReverseGeocodeUseCase - private let locationTimeout: Duration - - public init( - socialLoginService: any SocialLoginService, - authRepository: any AuthRepository, - sessionStore: any SessionStoring, - pushTokenProvider: any PushTokenProviding, - getCurrentLocationUseCase: any GetCurrentLocationUseCase, - reverseGeocodeUseCase: any ReverseGeocodeUseCase, - locationTimeout: Duration = .seconds(8) - ) { - self.socialLoginService = socialLoginService - self.authRepository = authRepository - self.sessionStore = sessionStore - self.pushTokenProvider = pushTokenProvider - self.getCurrentLocationUseCase = getCurrentLocationUseCase - self.reverseGeocodeUseCase = reverseGeocodeUseCase - self.locationTimeout = locationTimeout - } - - @MainActor - public func execute(provider: SocialLoginProvider) async throws { - let credential = try await socialLoginService.authorize(provider: provider) - let fcmToken = await pushTokenProvider.currentPushToken() - let session: LoginSession - if try await authRepository.checkRegistration(credential: credential) { - session = try await authRepository.login(credential: credential, fcmToken: fcmToken) - } else { - session = try await authRepository.signUp( - credential: credential, - form: await makeMinimalForm(), - fcmToken: fcmToken - ) - } - try await sessionStore.store( - accessToken: session.accessToken, - refreshToken: session.refreshToken - ) - } - - /// 최소 가입 폼 — 위치 실패(권한 거부·타임아웃)면 레거시 실측 폴백, - /// 역지오코딩만 실패하면 주소만 빈 문자열로 강등한다. - private func makeMinimalForm() async -> SignUpForm { - guard let coordinate = await currentCoordinate() else { - return .minimalFallback - } - let address = (try? await reverseGeocodeUseCase.execute(coordinate: coordinate))?.address ?? "" - return SignUpForm( - userName: "", - address: address, - coordinate: coordinate, - alertFrequencies: SignUpForm.defaultAlertFrequencies - ) - } - - /// 위치 조회는 권한 프롬프트 대기 등으로 길어질 수 있어 타임아웃과 레이스한다 — - /// 가입이 위치 때문에 무한정 막히면 안 된다. - private func currentCoordinate() async -> Coordinate? { - let locationUseCase = getCurrentLocationUseCase - let timeout = locationTimeout - return await withTaskGroup(of: Coordinate?.self) { group in - group.addTask { try? await locationUseCase.execute() } - group.addTask { - try? await Task.sleep(for: timeout) - return nil - } - let first = await group.next() ?? nil - group.cancelAll() - return first - } - } -} diff --git a/Projects/Domain/Tests/DefaultSignInAsGuestUseCaseTests.swift b/Projects/Domain/Tests/DefaultSignInAsGuestUseCaseTests.swift index 672a12a4..a62fabc3 100644 --- a/Projects/Domain/Tests/DefaultSignInAsGuestUseCaseTests.swift +++ b/Projects/Domain/Tests/DefaultSignInAsGuestUseCaseTests.swift @@ -20,21 +20,6 @@ private struct StubGuestAuthRepository: AuthRepository { 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 { diff --git a/Projects/Domain/Tests/DefaultSignInUseCaseTests.swift b/Projects/Domain/Tests/DefaultSignInUseCaseTests.swift deleted file mode 100644 index 2a224ef9..00000000 --- a/Projects/Domain/Tests/DefaultSignInUseCaseTests.swift +++ /dev/null @@ -1,229 +0,0 @@ -@testable import Domain -import Foundation -import Synchronization -import Testing - -/// 스텁 공유 기록기 — 호출 순서·횟수·전달 인자를 한 곳에서 검증한다. -private final class SignInRecorder: Sendable { - private let events = Mutex<[String]>([]) - - func record(_ event: String) { events.withLock { $0.append(event) } } - var log: [String] { events.withLock { $0 } } - func count(of event: String) -> Int { log.count(where: { $0 == event }) } -} - -private struct StubSocialLoginService: SocialLoginService { - let recorder: SignInRecorder - var result: Result = - .success(SocialCredential(provider: .kakao, accessToken: "SOCIAL")) - - func authorize(provider: SocialLoginProvider) async throws -> SocialCredential { - recorder.record("authorize") - return try result.get() - } -} - -private struct StubAuthRepository: AuthRepository { - let recorder: SignInRecorder - var exists = true - 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 - } - - func login(credential: SocialCredential, fcmToken: String?) async throws -> LoginSession { - recorder.record("login(fcm:\(fcmToken ?? "nil"))") - return session - } - - func signUp( - credential: SocialCredential, - form: SignUpForm, - fcmToken: String? - ) async throws -> LoginSession { - recorder.record( - "signUp(addr:\(form.address)/lat:\(form.coordinate.latitude)" - + "/freq:\(form.alertFrequencies)/name:\(form.userName ?? "nil")" - + "/fcm:\(fcmToken ?? "nil"))" - ) - if let signUpError { throw signUpError } - return session - } -} - -private struct StubSessionStore: SessionStoring { - let recorder: SignInRecorder - var error: (any Error)? - - func store(accessToken: String, refreshToken: String) async throws { - recorder.record("store(\(accessToken)/\(refreshToken))") - if let error { throw error } - } -} - -private struct StubPushTokenProvider: PushTokenProviding { - var token: String? - - func currentPushToken() async -> String? { token } -} - -private struct StubGetCurrentLocationUseCase: GetCurrentLocationUseCase { - var result: Result = - .success(Coordinate(latitude: 37.5, longitude: 127.0)) - - func execute() async throws -> Coordinate { - try result.get() - } -} - -private struct StubReverseGeocodeUseCase: ReverseGeocodeUseCase { - var result: Result = .success( - Place(name: "집앞", address: "서울 어딘가 1-2", coordinate: Coordinate(latitude: 37.5, longitude: 127.0)) - ) - - func execute(coordinate: Coordinate) async throws -> Place { - try result.get() - } -} - -private struct StoreError: Error {} -private struct SignUpError: Error {} -private struct GeocodeError: Error {} - -struct DefaultSignInUseCaseTests { - private let recorder = SignInRecorder() - - private func makeSUT( - social: StubSocialLoginService? = nil, - repository: StubAuthRepository? = nil, - store: StubSessionStore? = nil, - pushToken: String? = nil, - location: StubGetCurrentLocationUseCase = StubGetCurrentLocationUseCase(), - geocode: StubReverseGeocodeUseCase = StubReverseGeocodeUseCase() - ) -> DefaultSignInUseCase { - DefaultSignInUseCase( - socialLoginService: social ?? StubSocialLoginService(recorder: recorder), - authRepository: repository ?? StubAuthRepository(recorder: recorder), - sessionStore: store ?? StubSessionStore(recorder: recorder), - pushTokenProvider: StubPushTokenProvider(token: pushToken), - getCurrentLocationUseCase: location, - reverseGeocodeUseCase: geocode - ) - } - - @Test - func execute_registered_runsAuthorizeCheckLoginStoreInOrder() async throws { - let sut = makeSUT(pushToken: "FCM") - - try await sut.execute(provider: .kakao) - - #expect(recorder.log == [ - "authorize", - "check(SOCIAL)", - "login(fcm:FCM)", - "store(SA/SR)", - ]) - } - - @Test - func execute_noPushToken_passesNilToLogin() async throws { - let sut = makeSUT() - - try await sut.execute(provider: .apple) - - #expect(recorder.count(of: "login(fcm:nil)") == 1) - } - - /// 미가입이면 자동으로 최소 가입한다 — 현재 좌표 + 역지오코딩 주소 + 기본 알림 빈도. - @Test - func execute_notRegistered_signsUpWithLocationFormThenStores() async throws { - let sut = makeSUT( - repository: StubAuthRepository(recorder: recorder, exists: false), - pushToken: "FCM" - ) - - try await sut.execute(provider: .kakao) - - #expect(recorder.log == [ - "authorize", - "check(SOCIAL)", - "signUp(addr:서울 어딘가 1-2/lat:37.5/freq:[1, 10]/name:/fcm:FCM)", - "store(SA/SR)", - ]) - } - - /// 위치 실패(권한 거부 등) → 레거시 실측 폴백(address ""·좌표 (0,0))으로 가입한다. - @Test - func execute_notRegistered_locationFailure_fallsBackToMinimalForm() async throws { - let sut = makeSUT( - repository: StubAuthRepository(recorder: recorder, exists: false), - location: StubGetCurrentLocationUseCase(result: .failure(.permissionDenied)) - ) - - try await sut.execute(provider: .kakao) - - #expect(recorder.count(of: "signUp(addr:/lat:0.0/freq:[1, 10]/name:/fcm:nil)") == 1) - } - - /// 역지오코딩만 실패하면 좌표는 유지하고 주소만 빈 문자열로 강등한다. - @Test - func execute_notRegistered_geocodeFailure_keepsCoordinateWithEmptyAddress() async throws { - let sut = makeSUT( - repository: StubAuthRepository(recorder: recorder, exists: false), - geocode: StubReverseGeocodeUseCase(result: .failure(GeocodeError())) - ) - - try await sut.execute(provider: .kakao) - - #expect(recorder.count(of: "signUp(addr:/lat:37.5/freq:[1, 10]/name:/fcm:nil)") == 1) - } - - /// 가입 실패는 그대로 전파되고 세션은 채택되지 않는다. - @Test - func execute_signUpFailure_propagatesWithoutStore() async { - let sut = makeSUT( - repository: StubAuthRepository( - recorder: recorder, exists: false, signUpError: SignUpError() - ) - ) - - await #expect(throws: SignUpError.self) { - try await sut.execute(provider: .kakao) - } - - #expect(recorder.count(of: "store(SA/SR)") == 0) - } - - @Test - func execute_authorizeCancelled_propagatesWithoutServerCalls() async { - let sut = makeSUT( - social: StubSocialLoginService(recorder: recorder, result: .failure(.cancelled)) - ) - - await #expect(throws: SocialLoginError.cancelled) { - try await sut.execute(provider: .kakao) - } - - #expect(recorder.log == ["authorize"]) - } - - @Test - func execute_storeFailure_propagates() async { - let sut = makeSUT(store: StubSessionStore(recorder: recorder, error: StoreError())) - - await #expect(throws: StoreError.self) { - try await sut.execute(provider: .kakao) - } - - #expect(recorder.count(of: "store(SA/SR)") == 1) - } -} diff --git a/Projects/Feature/Auth/Example/ExampleApp.swift b/Projects/Feature/Auth/Example/ExampleApp.swift deleted file mode 100644 index 1f177240..00000000 --- a/Projects/Feature/Auth/Example/ExampleApp.swift +++ /dev/null @@ -1,106 +0,0 @@ -import AuthFeature -import AuthFeatureInterface -import CoreCoordinator -import Domain -import UIKit - -@main -final class AppDelegate: UIResponder, UIApplicationDelegate { - func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil - ) -> Bool { - true - } - - func application( - _ application: UIApplication, - configurationForConnecting connectingSceneSession: UISceneSession, - options: UIScene.ConnectionOptions - ) -> UISceneConfiguration { - UISceneConfiguration(name: "Default", sessionRole: connectingSceneSession.role) - } -} - -final class SceneDelegate: UIResponder, UIWindowSceneDelegate { - var window: UIWindow? - private let navigationController = UINavigationController() - private var loginCoordinator: (any Coordinator)? - - func scene( - _ scene: UIScene, - willConnectTo session: UISceneSession, - options connectionOptions: UIScene.ConnectionOptions - ) { - guard let windowScene = scene as? UIWindowScene else { return } - - let launcher = LauncherViewController() - launcher.onStart = { [weak self] in self?.startLoginFlow() } - navigationController.viewControllers = [launcher] - - let window = UIWindow(windowScene: windowScene) - window.rootViewController = navigationController - window.makeKeyAndVisible() - self.window = window - } - - private func startLoginFlow() { - let container = AuthDIContainer(signInUseCase: PreviewSignInUseCase()) - let coordinator = container.makeLoginCoordinator( - navigationController: navigationController, - onAuthenticated: { [weak self] in self?.showAuthenticated() } - ) - coordinator.finishDelegate = self - loginCoordinator = coordinator - coordinator.start() - } - - private func showAuthenticated() { - let alert = UIAlertController(title: "로그인 완료", message: "세션 채택 성공 경로", preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "확인", style: .default)) - navigationController.present(alert, animated: true) - } -} - -extension SceneDelegate: CoordinatorFinishDelegate { - // finish 후 런처로 복귀 — 플로우를 반복 시연할 수 있다. - func coordinatorDidFinish(_ coordinator: any Coordinator) { - loginCoordinator = nil - } -} - -private final class LauncherViewController: UIViewController { - var onStart: (() -> Void)? - - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .systemBackground - title = "AuthFeature Example" - - let startButton = UIButton(type: .system) - startButton.setTitle("로그인 시트 열기 (실패→성공 순환)", for: .normal) - startButton.addAction(UIAction { [weak self] _ in self?.onStart?() }, for: .touchUpInside) - startButton.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(startButton) - NSLayoutConstraint.activate([ - startButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), - startButton.centerYAnchor.constraint(equalTo: view.centerYAnchor), - ]) - } -} - -/// 탭할 때마다 실패 토스트 → 성공을 순환 — 두 UX를 모두 시연한다 -/// (미가입은 UseCase 내부에서 자동 가입으로 흡수돼 화면 분기가 없다). -/// (스텁이 Tests와 중복되는 것은 의도된 트레이드오프 — 규약 참고.) -@MainActor -private final class PreviewSignInUseCase: SignInUseCase { - private var attempt = 0 - - private struct PreviewError: Error {} - - func execute(provider: SocialLoginProvider) async throws { - try? await Task.sleep(for: .seconds(1)) - defer { attempt += 1 } - if attempt % 2 == 0 { throw PreviewError() } - } -} diff --git a/Projects/Feature/Auth/Interface/AuthCoordinatorBuildable.swift b/Projects/Feature/Auth/Interface/AuthCoordinatorBuildable.swift deleted file mode 100644 index 8f7482b5..00000000 --- a/Projects/Feature/Auth/Interface/AuthCoordinatorBuildable.swift +++ /dev/null @@ -1,15 +0,0 @@ -import CoreCoordinator -import UIKit - -/// Entry point other modules use to start the login flow. -/// The concrete builder is the feature's DIContainer; wiring happens at the -/// App composition root. -@MainActor -public protocol AuthCoordinatorBuildable { - /// 강제 로그인 플로우 — 성공(세션 채택 완료) 시에만 onAuthenticated가 불린다. - /// 취소 경로는 없다(시트 dismiss 제스처 미제공). - func makeLoginCoordinator( - navigationController: UINavigationController, - onAuthenticated: @escaping () -> Void - ) -> any Coordinator -} diff --git a/Projects/Feature/Auth/Project.swift b/Projects/Feature/Auth/Project.swift deleted file mode 100644 index 453a3675..00000000 --- a/Projects/Feature/Auth/Project.swift +++ /dev/null @@ -1,23 +0,0 @@ -import ProjectDescription -import ProjectDescriptionHelpers - -let project = Project.feature( - name: "Auth", - dependencies: [ - .project(target: "Domain", path: "../../Domain"), - .project(target: "DesignSystem", path: "../../DesignSystem"), - .project(target: "CoreCoordinator", path: "../../Core/Coordinator"), - .external(name: "SnapKit"), - ], - interfaceDependencies: [ - .project(target: "CoreCoordinator", path: "../../Core/Coordinator"), - ], - testDependencies: [ - .project(target: "Domain", path: "../../Domain"), - ], - exampleDependencies: [ - .project(target: "Domain", path: "../../Domain"), - .project(target: "CoreCoordinator", path: "../../Core/Coordinator"), - ], - resources: ["Resources/**"] -) diff --git a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/Contents.json b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/Contents.json deleted file mode 100644 index 73c00596..00000000 --- a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Button Icon.png b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Button Icon.png deleted file mode 100644 index 1619631e..00000000 Binary files a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Button Icon.png and /dev/null differ diff --git a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Button Icon@2x.png b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Button Icon@2x.png deleted file mode 100644 index 230f9e87..00000000 Binary files a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Button Icon@2x.png and /dev/null differ diff --git a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Button Icon@3x.png b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Button Icon@3x.png deleted file mode 100644 index bcbb7f8b..00000000 Binary files a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Button Icon@3x.png and /dev/null differ diff --git a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Contents.json b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Contents.json deleted file mode 100644 index e1feba99..00000000 --- a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Contents.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "images" : [ - { - "filename" : "Button Icon.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "Button Icon@2x.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "Button Icon@3x.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "template-rendering-intent" : "template" - } -} diff --git a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/kakao.imageset/Button Icon.png b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/kakao.imageset/Button Icon.png deleted file mode 100644 index e724295b..00000000 Binary files a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/kakao.imageset/Button Icon.png and /dev/null differ diff --git a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/kakao.imageset/Button Icon@2x.png b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/kakao.imageset/Button Icon@2x.png deleted file mode 100644 index 256f00de..00000000 Binary files a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/kakao.imageset/Button Icon@2x.png and /dev/null differ diff --git a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/kakao.imageset/Button Icon@3x.png b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/kakao.imageset/Button Icon@3x.png deleted file mode 100644 index 41405a0c..00000000 Binary files a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/kakao.imageset/Button Icon@3x.png and /dev/null differ diff --git a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/kakao.imageset/Contents.json b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/kakao.imageset/Contents.json deleted file mode 100644 index e1feba99..00000000 --- a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/kakao.imageset/Contents.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "images" : [ - { - "filename" : "Button Icon.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "Button Icon@2x.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "Button Icon@3x.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "template-rendering-intent" : "template" - } -} diff --git a/Projects/Feature/Auth/Sources/AuthDIContainer.swift b/Projects/Feature/Auth/Sources/AuthDIContainer.swift deleted file mode 100644 index 3d901da2..00000000 --- a/Projects/Feature/Auth/Sources/AuthDIContainer.swift +++ /dev/null @@ -1,31 +0,0 @@ -import AuthFeatureInterface -import CoreCoordinator -import Domain -import UIKit - -/// Assembles the Auth feature's screens. Coordinators own flow only; -/// screen/ViewModel assembly stays here. -public final class AuthDIContainer: AuthCoordinatorBuildable { - private let signInUseCase: any SignInUseCase - - public init(signInUseCase: any SignInUseCase) { - self.signInUseCase = signInUseCase - } - - public func makeLoginCoordinator( - navigationController: UINavigationController, - onAuthenticated: @escaping () -> Void - ) -> any Coordinator { - LoginCoordinator( - navigationController: navigationController, - container: self, - onAuthenticated: onAuthenticated - ) - } - - func makeLoginViewController(onAuthenticated: @escaping () -> Void) -> LoginViewController { - let viewModel = LoginViewModel(signInUseCase: signInUseCase) - viewModel.onAuthenticated = onAuthenticated - return LoginViewController(viewModel: viewModel) - } -} diff --git a/Projects/Feature/Auth/Sources/AuthPalette.swift b/Projects/Feature/Auth/Sources/AuthPalette.swift deleted file mode 100644 index 77388f37..00000000 --- a/Projects/Feature/Auth/Sources/AuthPalette.swift +++ /dev/null @@ -1,10 +0,0 @@ -import UIKit - -/// 소셜 브랜드 고정색 — 테마를 타는 시맨틱 토큰이 아니라 브랜드 상수라 DSPalette에 -/// 올리지 않는다(소비처가 로그인 화면뿐). 값은 레거시 colorset 실측. -enum AuthPalette { - /// 카카오 컨테이너 배경 (#FEE500). - static let kakaoYellow = UIColor(red: 0xFE / 255, green: 0xE5 / 255, blue: 0x00 / 255, alpha: 1) - /// 카카오 심볼 틴트 (#181600). - static let kakaoSymbol = UIColor(red: 0x18 / 255, green: 0x16 / 255, blue: 0x00 / 255, alpha: 1) -} diff --git a/Projects/Feature/Auth/Sources/LoginCoordinator.swift b/Projects/Feature/Auth/Sources/LoginCoordinator.swift deleted file mode 100644 index a291a10d..00000000 --- a/Projects/Feature/Auth/Sources/LoginCoordinator.swift +++ /dev/null @@ -1,46 +0,0 @@ -import CoreCoordinator -import UIKit - -final class LoginCoordinator: Coordinator { - var childCoordinators: [any Coordinator] = [] - weak var finishDelegate: (any CoordinatorFinishDelegate)? - - // The App (window root) owns the navigation controller. - private weak var navigationController: UINavigationController? - private let container: AuthDIContainer - private let onAuthenticated: () -> Void - private weak var loginViewController: LoginViewController? - - init( - navigationController: UINavigationController, - container: AuthDIContainer, - onAuthenticated: @escaping () -> Void - ) { - self.navigationController = navigationController - self.container = container - self.onAuthenticated = onAuthenticated - } - - func start() { - guard let navigationController else { return } - let viewController = container.makeLoginViewController(onAuthenticated: { [weak self] in - self?.completeFlow() - }) - loginViewController = viewController - navigationController.present(viewController, animated: false) - } - - /// 성공 유일 종료 경로 — 시트 하강 연출 후 onAuthenticated → finish. - private func completeFlow() { - guard let loginViewController else { - onAuthenticated() - finish() - return - } - loginViewController.animateDismiss { [weak self] in - guard let self else { return } - self.onAuthenticated() - self.finish() - } - } -} diff --git a/Projects/Feature/Auth/Sources/LoginViewController.swift b/Projects/Feature/Auth/Sources/LoginViewController.swift deleted file mode 100644 index 3ffd61f2..00000000 --- a/Projects/Feature/Auth/Sources/LoginViewController.swift +++ /dev/null @@ -1,138 +0,0 @@ -import DesignSystem -import Domain -import SnapKit -import UIKit - -/// 레거시 로그인 바텀시트 비주얼 이식(dim 0.9 + 컨테이너 198 + 핸들 + 슬라이드업). -/// 강제 로그인이라 dim 탭·팬 다운 dismiss 제스처는 계승하지 않는다 — 핸들은 장식. -final class LoginViewController: UIViewController { - private let viewModel: LoginViewModel - - private let dimView = UIView() - private let containerView = UIView() - private let sheetHandle = UIView() - private let kakaoButton = SocialLoginButton( - title: "카카오톡으로 3초만에 시작", - icon: AuthFeatureAsset.kakao.image, - textColor: .black, - background: AuthPalette.kakaoYellow, - iconTint: AuthPalette.kakaoSymbol - ) - private let appleButton = SocialLoginButton( - title: "Apple로 시작", - icon: AuthFeatureAsset.apple.image, - textColor: .white, - background: .black, - iconTint: .white - ) - private let sheetHeight: CGFloat = 198 - - init(viewModel: LoginViewModel) { - self.viewModel = viewModel - super.init(nibName: nil, bundle: nil) - modalPresentationStyle = .overFullScreen - modalTransitionStyle = .crossDissolve - } - - override func viewDidLoad() { - super.viewDidLoad() - setupLayout() - bindViewModel() - containerView.transform = CGAffineTransform(translationX: 0, y: sheetHeight) - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseOut) { - self.dimView.alpha = 1 - self.containerView.transform = .identity - } - } - - /// 성공 종료 연출(레거시 dismissSheet) — 시트 하강 + dim 페이드 후 실제 dismiss. - /// 코디네이터가 부른다. - func animateDismiss(completion: @escaping () -> Void) { - UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseIn) { - self.dimView.alpha = 0 - self.containerView.transform = CGAffineTransform(translationX: 0, y: self.sheetHeight) - } completion: { _ in - self.dismiss(animated: false, completion: completion) - } - } - - private func setupLayout() { - view.backgroundColor = .clear - - dimView.backgroundColor = UIColor.black.withAlphaComponent(0.9) - dimView.alpha = 0 - view.addSubview(dimView) - dimView.snp.makeConstraints { $0.edges.equalToSuperview() } - - containerView.backgroundColor = DSColor.Background.elevated - containerView.layer.cornerRadius = 20 - containerView.clipsToBounds = true - view.addSubview(containerView) - containerView.snp.makeConstraints { make in - make.leading.trailing.bottom.equalToSuperview() - make.height.equalTo(sheetHeight) - } - - sheetHandle.backgroundColor = DSColor.Fill.highlight - sheetHandle.layer.cornerRadius = 2 - containerView.addSubview(sheetHandle) - sheetHandle.snp.makeConstraints { make in - make.top.equalToSuperview().offset(DSSpacing.sm12) - make.centerX.equalToSuperview() - make.width.equalTo(40) - make.height.equalTo(4) - } - - let buttonStack = UIStackView(arrangedSubviews: [kakaoButton, appleButton]) - buttonStack.axis = .vertical - buttonStack.spacing = DSSpacing.sm12 - containerView.addSubview(buttonStack) - buttonStack.snp.makeConstraints { make in - make.horizontalEdges.equalToSuperview().inset(DSSpacing.lg) - make.bottom.equalToSuperview().inset(40) - } - - kakaoButton.addTarget(self, action: #selector(didTapKakao), for: .touchUpInside) - appleButton.addTarget(self, action: #selector(didTapApple), for: .touchUpInside) - } - - private func bindViewModel() { - viewModel.onStateChange = { [weak self] state in - self?.render(state) - } - viewModel.onEvent = { [weak self] event in - self?.handle(event) - } - render(viewModel.state) - } - - private func render(_ state: LoginViewModel.State) { - let isLoading = state != .idle - kakaoButton.isEnabled = !isLoading - appleButton.isEnabled = !isLoading - } - - private func handle(_ event: LoginViewModel.Event) { - switch event { - case let .showFailureToast(message): - DSToast.show(message, in: view) - } - } - - @objc private func didTapKakao() { - viewModel.kakaoTapped() - } - - @objc private func didTapApple() { - viewModel.appleTapped() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) is not supported") - } -} diff --git a/Projects/Feature/Auth/Sources/LoginViewModel.swift b/Projects/Feature/Auth/Sources/LoginViewModel.swift deleted file mode 100644 index 2a3c637c..00000000 --- a/Projects/Feature/Auth/Sources/LoginViewModel.swift +++ /dev/null @@ -1,70 +0,0 @@ -import Domain -import Foundation - -@MainActor -final class LoginViewModel { - enum State: Equatable { - case idle - /// 어느 제공자로 진행 중인지 — 화면은 양 버튼을 잠근다(중복 탭 방지). - case loading(SocialLoginProvider) - } - - /// 원샷 안내 — 상태와 분리한다(Home의 ToastEvent 패턴). - enum Event: Equatable { - case showFailureToast(message: String) - } - - static let failureMessage = "로그인에 실패했어요. 잠시 후 다시 시도해주세요." - - var onStateChange: ((State) -> Void)? - var onEvent: ((Event) -> Void)? - /// 세션 채택까지 완료된 뒤에만 불린다 — 코디네이터가 플로우를 닫는 신호. - var onAuthenticated: (() -> Void)? - - private(set) var state: State = .idle { - didSet { onStateChange?(state) } - } - - private let signInUseCase: any SignInUseCase - private var signInTask: Task? - - init(signInUseCase: any SignInUseCase) { - self.signInUseCase = signInUseCase - } - - deinit { - signInTask?.cancel() - } - - func kakaoTapped() { - signIn(provider: .kakao) - } - - func appleTapped() { - signIn(provider: .apple) - } - - private func signIn(provider: SocialLoginProvider) { - guard state == .idle else { return } - state = .loading(provider) - signInTask = Task { [weak self] in - guard let self else { return } - do { - // 미가입 계정은 UseCase가 최소 가입까지 수행한다 — 성공은 곧 세션 채택 완료. - try await self.signInUseCase.execute(provider: provider) - guard !Task.isCancelled else { return } - // 성공 시 idle 복귀 없이 바로 닫는다 — 버튼이 다시 살아나며 - // 깜빡이는 프레임을 만들지 않는다. - self.onAuthenticated?() - } catch SocialLoginError.cancelled { - // 사용자가 소셜 UI를 스스로 닫음 — 에러 표출 없이 재시도 대기. - guard !Task.isCancelled else { return } - self.state = .idle - } catch { - guard !Task.isCancelled else { return } - self.state = .idle - self.onEvent?(.showFailureToast(message: Self.failureMessage)) - } - } - } -} diff --git a/Projects/Feature/Auth/Sources/SocialLoginButton.swift b/Projects/Feature/Auth/Sources/SocialLoginButton.swift deleted file mode 100644 index bc335fb5..00000000 --- a/Projects/Feature/Auth/Sources/SocialLoginButton.swift +++ /dev/null @@ -1,53 +0,0 @@ -import DesignSystem -import SnapKit -import UIKit - -/// 레거시 로그인 버튼 스펙 이식: 높이 52 · radius 8 · 아이콘 leading 16(24pt) · 라벨 중앙. -/// DSButton은 아이콘을 타이틀에 인접 배치하는 Configuration 기반이라 이 스펙(아이콘 -/// 고정 + 라벨 독립 중앙)에는 전용 컴포넌트가 맞다. -final class SocialLoginButton: UIButton { - static let height: CGFloat = 52 - - private let iconView = UIImageView() - private let label = UILabel() - - init(title: String, icon: UIImage?, textColor: UIColor, background: UIColor, iconTint: UIColor) { - super.init(frame: .zero) - - backgroundColor = background - layer.cornerRadius = DSRadius.sm - clipsToBounds = true - - iconView.image = icon - iconView.contentMode = .scaleAspectFit - iconView.tintColor = iconTint - iconView.isUserInteractionEnabled = false - - label.attributedText = DSTypography.label1.attributed(title, color: textColor, alignment: .center) - label.isUserInteractionEnabled = false - - addSubview(iconView) - addSubview(label) - - iconView.snp.makeConstraints { make in - make.leading.equalToSuperview().offset(DSSpacing.md) - make.centerY.equalToSuperview() - make.width.height.equalTo(DSIconSize.lg) - } - label.snp.makeConstraints { make in - make.center.equalToSuperview() - } - snp.makeConstraints { make in - make.height.equalTo(Self.height) - } - } - - override var isEnabled: Bool { - didSet { alpha = isEnabled ? 1 : 0.5 } - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) is not supported") - } -} diff --git a/Projects/Feature/Auth/Tests/LoginViewModelTests.swift b/Projects/Feature/Auth/Tests/LoginViewModelTests.swift deleted file mode 100644 index aba3d00c..00000000 --- a/Projects/Feature/Auth/Tests/LoginViewModelTests.swift +++ /dev/null @@ -1,133 +0,0 @@ -@testable import AuthFeature -import Domain -import Foundation -import Testing - -@MainActor -private final class StubSignInUseCase: SignInUseCase { - private(set) var executeCallCount = 0 - private(set) var providers: [SocialLoginProvider] = [] - private var result: Result - private let suspends: Bool - private var continuation: CheckedContinuation? - - init(result: Result = .success(()), suspends: Bool = false) { - self.result = result - self.suspends = suspends - } - - func execute(provider: SocialLoginProvider) async throws { - executeCallCount += 1 - providers.append(provider) - if suspends { - return try await withCheckedThrowingContinuation { continuation = $0 } - } - return try result.get() - } - - func resumeSuccessfully() { - continuation?.resume(returning: ()) - continuation = nil - } -} - -@MainActor -private final class Recorder { - private(set) var states: [LoginViewModel.State] = [] - private(set) var events: [LoginViewModel.Event] = [] - private(set) var authenticatedCount = 0 - - func attach(to viewModel: LoginViewModel) { - viewModel.onStateChange = { [weak self] state in self?.states.append(state) } - viewModel.onEvent = { [weak self] event in self?.events.append(event) } - viewModel.onAuthenticated = { [weak self] in self?.authenticatedCount += 1 } - } - - // Home 템플릿의 드레인 패턴: 스텁이 즉시 resolve하므로 yield로 충분하다. - func waitUntil(_ predicate: () -> Bool) async { - while !predicate() { - await Task.yield() - } - } -} - -private struct StubError: Error {} - -@MainActor -struct LoginViewModelTests { - @Test - func kakaoTapped_success_callsOnAuthenticatedWithoutIdleFlicker() async { - let useCase = StubSignInUseCase() - let sut = LoginViewModel(signInUseCase: useCase) - let recorder = Recorder() - recorder.attach(to: sut) - - sut.kakaoTapped() - await recorder.waitUntil { recorder.authenticatedCount == 1 } - - #expect(useCase.providers == [.kakao]) - // 성공 시 idle 복귀 없이 바로 닫힌다 — 버튼 재활성 프레임 없음. - #expect(recorder.states == [.loading(.kakao)]) - #expect(recorder.events.isEmpty) - } - - @Test - func appleTapped_passesAppleProvider() async { - let useCase = StubSignInUseCase() - let sut = LoginViewModel(signInUseCase: useCase) - let recorder = Recorder() - recorder.attach(to: sut) - - sut.appleTapped() - await recorder.waitUntil { recorder.authenticatedCount == 1 } - - #expect(useCase.providers == [.apple]) - } - - @Test - func tapped_socialCancelled_returnsIdleSilently() async { - let useCase = StubSignInUseCase(result: .failure(SocialLoginError.cancelled)) - let sut = LoginViewModel(signInUseCase: useCase) - let recorder = Recorder() - recorder.attach(to: sut) - - sut.kakaoTapped() - await recorder.waitUntil { recorder.states.last == .idle } - - #expect(recorder.events.isEmpty) - #expect(recorder.authenticatedCount == 0) - } - - @Test - func tapped_failure_returnsIdleAndEmitsFailureToast() async { - let useCase = StubSignInUseCase(result: .failure(StubError())) - let sut = LoginViewModel(signInUseCase: useCase) - let recorder = Recorder() - recorder.attach(to: sut) - - sut.appleTapped() - await recorder.waitUntil { recorder.states.last == .idle } - - #expect(recorder.events == [.showFailureToast(message: LoginViewModel.failureMessage)]) - #expect(recorder.authenticatedCount == 0) - } - - /// 로딩 중 중복 탭은 무시된다 — 소셜 UI 이중 기동 방지. - @Test - func tapped_whileLoading_ignoresSecondTap() async { - let useCase = StubSignInUseCase(suspends: true) - let sut = LoginViewModel(signInUseCase: useCase) - let recorder = Recorder() - recorder.attach(to: sut) - - sut.kakaoTapped() - await recorder.waitUntil { useCase.executeCallCount == 1 } - sut.appleTapped() - - #expect(useCase.executeCallCount == 1) - #expect(recorder.states == [.loading(.kakao)]) - - useCase.resumeSuccessfully() - await recorder.waitUntil { recorder.authenticatedCount == 1 } - } -} diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index fc41a82b..86b304ab 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -1,10 +1,10 @@ { - "originHash" : "0b05e39d91c67dfb05d736da41f4a037d02edbb517fedfd41f0ecb99ae4db844", + "originHash" : "46ccec71664fcf5e68d39b2374f3c5e057acc68c7a89b2d71a12ddd454a37452", "pins" : [ { "identity" : "abseil-cpp-binary", "kind" : "remoteSourceControl", - "location" : "https://github.com/google/abseil-cpp-binary.git", + "location" : "https://github.com/google/abseil-cpp-binary", "state" : { "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", "version" : "1.2024072200.0" @@ -13,10 +13,10 @@ { "identity" : "alamofire", "kind" : "remoteSourceControl", - "location" : "https://github.com/Alamofire/Alamofire.git", + "location" : "https://github.com/Alamofire/Alamofire", "state" : { - "revision" : "7595cbcf59809f9977c5f6378500de2ad73b7ddb", - "version" : "5.12.0" + "revision" : "bda9ed57d72988a3a2ada33d824583541f86eac6", + "version" : "5.12.2" } }, { @@ -31,7 +31,7 @@ { "identity" : "amplitudecore-swift", "kind" : "remoteSourceControl", - "location" : "https://github.com/amplitude/AmplitudeCore-Swift.git", + "location" : "https://github.com/amplitude/AmplitudeCore-Swift", "state" : { "revision" : "bbad108b92f332fdaa76b67bce3f3ae88015ad0b", "version" : "1.4.10" @@ -40,7 +40,7 @@ { "identity" : "analytics-connector-ios", "kind" : "remoteSourceControl", - "location" : "https://github.com/amplitude/analytics-connector-ios.git", + "location" : "https://github.com/amplitude/analytics-connector-ios", "state" : { "revision" : "982b4c787285d213653bd2a504d6a86b52227cea", "version" : "1.3.2" @@ -49,7 +49,7 @@ { "identity" : "app-check", "kind" : "remoteSourceControl", - "location" : "https://github.com/google/app-check.git", + "location" : "https://github.com/google/app-check", "state" : { "revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902", "version" : "11.3.1" @@ -76,7 +76,7 @@ { "identity" : "googleappmeasurement", "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleAppMeasurement.git", + "location" : "https://github.com/google/GoogleAppMeasurement", "state" : { "revision" : "45ce435e9406d3c674dd249a042b932bee006f60", "version" : "11.15.0" @@ -85,7 +85,7 @@ { "identity" : "googledatatransport", "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleDataTransport.git", + "location" : "https://github.com/google/GoogleDataTransport", "state" : { "revision" : "ba3358d3c3dbae8ef230b58a46b97ad65e84e974", "version" : "10.1.1" @@ -94,7 +94,7 @@ { "identity" : "googleutilities", "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleUtilities.git", + "location" : "https://github.com/google/GoogleUtilities", "state" : { "revision" : "9f183ae842be978784f2963a343682e0c46d8fb3", "version" : "8.1.2" @@ -103,7 +103,7 @@ { "identity" : "grpc-binary", "kind" : "remoteSourceControl", - "location" : "https://github.com/google/grpc-binary.git", + "location" : "https://github.com/google/grpc-binary", "state" : { "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", "version" : "1.69.1" @@ -112,7 +112,7 @@ { "identity" : "gtm-session-fetcher", "kind" : "remoteSourceControl", - "location" : "https://github.com/google/gtm-session-fetcher.git", + "location" : "https://github.com/google/gtm-session-fetcher", "state" : { "revision" : "c756a29784521063b6a1202907e2cc47f41b667c", "version" : "4.5.0" @@ -121,7 +121,7 @@ { "identity" : "interop-ios-for-google-sdks", "kind" : "remoteSourceControl", - "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "location" : "https://github.com/google/interop-ios-for-google-sdks", "state" : { "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", "version" : "101.0.0" @@ -132,14 +132,14 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/kakao/kakao-ios-sdk", "state" : { - "revision" : "2a68ca01e2d7900a1559b31d0d59843837f130f2", - "version" : "2.28.0" + "revision" : "8627646b7430bcadc5a2f726b21ea25db70cd189", + "version" : "2.29.0" } }, { "identity" : "leveldb", "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/leveldb.git", + "location" : "https://github.com/firebase/leveldb", "state" : { "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", "version" : "1.22.5" @@ -148,7 +148,7 @@ { "identity" : "lottie-ios", "kind" : "remoteSourceControl", - "location" : "https://github.com/airbnb/lottie-ios.git", + "location" : "https://github.com/airbnb/lottie-ios", "state" : { "revision" : "f4db77d7feacba0c2360b84a40c38a6ce8ff399d", "version" : "4.6.1" @@ -157,7 +157,7 @@ { "identity" : "nanopb", "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/nanopb.git", + "location" : "https://github.com/firebase/nanopb", "state" : { "revision" : "3851d94a41890dea16dc3db34caf60e585cb4163", "version" : "2.30910.1" @@ -166,7 +166,7 @@ { "identity" : "promises", "kind" : "remoteSourceControl", - "location" : "https://github.com/google/promises.git", + "location" : "https://github.com/google/promises", "state" : { "revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837", "version" : "2.4.1" @@ -175,7 +175,7 @@ { "identity" : "snapkit", "kind" : "remoteSourceControl", - "location" : "https://github.com/SnapKit/SnapKit.git", + "location" : "https://github.com/SnapKit/SnapKit", "state" : { "revision" : "2842e6e84e82eb9a8dac0100ca90d9444b0307f4", "version" : "5.7.1" @@ -184,7 +184,7 @@ { "identity" : "swift-protobuf", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-protobuf.git", + "location" : "https://github.com/apple/swift-protobuf", "state" : { "revision" : "55d7a1cc5666b85c13464aea1c4b4a90feccb4c8", "version" : "1.38.1" diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 9a819e9c..aaa09f1a 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -24,8 +24,9 @@ let package = Package( name: "AtchaDependencies", dependencies: [ .package(url: "https://github.com/SnapKit/SnapKit.git", from: "5.7.1"), - .package(url: "https://github.com/kakao/kakao-ios-sdk", from: "2.24.4"), .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "11.14.0"), + // 레거시 앱 전용 — V2는 소셜 로그인 제거(2026-09-24)로 더 이상 쓰지 않는다. + .package(url: "https://github.com/kakao/kakao-ios-sdk", from: "2.24.4"), .package(url: "https://github.com/airbnb/lottie-ios.git", from: "4.5.2"), .package(url: "https://github.com/amplitude/Amplitude-Swift", branch: "main"), ] diff --git a/Workspace.swift b/Workspace.swift index a8cfd843..15b97644 100644 --- a/Workspace.swift +++ b/Workspace.swift @@ -4,7 +4,6 @@ let workspace = Workspace( name: "Atcha", projects: [ "Projects/App", - "Projects/Feature/Auth", "Projects/Feature/Home", "Projects/Feature/Search", "Projects/Feature/Settings",