diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index 33ea46b2..22ee2519 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -17,7 +17,7 @@ let project = Project( infoPlist: .extendingDefault(with: [ // 홈 화면 표시 이름 — 로케일 무관 영어 단일값. "CFBundleDisplayName": "NurseMate", - "CFBundleShortVersionString": "1.1.0", + "CFBundleShortVersionString": "1.1.1", "ITSAppUsesNonExemptEncryption": false, "AMPLITUDE_API_KEY": "$(AMPLITUDE_API_KEY)", "BASE_URL": "$(BASE_URL)", @@ -131,7 +131,7 @@ let project = Project( "WKApplication": true, "WKCompanionAppBundleIdentifier": "\(Environment.bundlePrefix).app", "CFBundleDisplayName": "NurseMate", - "CFBundleShortVersionString": "1.1.0", + "CFBundleShortVersionString": "1.1.1", // WKBackgroundModes: alarm 은 선언하지 않는다. // 26.1 미만 폰을 위해 워치가 WKExtendedRuntimeSession 을 예약하던 폴백이 있었으나, // 타이머가 AlarmKit 전용이 되면서(NM-381) 그 경로가 사라졌다. 26.1+ 는 AlarmKit 이 @@ -165,7 +165,7 @@ let project = Project( deploymentTargets: .watchOS("10.0"), infoPlist: .extendingDefault(with: [ "CFBundleDisplayName": "NurseMate", - "CFBundleShortVersionString": "1.1.0", + "CFBundleShortVersionString": "1.1.1", "NSExtension": [ "NSExtensionPointIdentifier": "com.apple.widgetkit-extension", ], @@ -182,7 +182,7 @@ let project = Project( deploymentTargets: Environment.deploymentTarget, infoPlist: .extendingDefault(with: [ "CFBundleDisplayName": "NurseMate", - "CFBundleShortVersionString": "1.1.0", + "CFBundleShortVersionString": "1.1.1", "NSSupportsLiveActivities": true, // 위젯에서 시작한 타이머의 AlarmKit 예약(iOS 26.1+)에 필요 (NM-302) "NSAlarmKitUsageDescription": "치료 타이머 종료 알람을 예약·알림하기 위해 필요합니다.", diff --git a/Projects/App/Sources/Application/AppCoordinator.swift b/Projects/App/Sources/Application/AppCoordinator.swift index d16d51a5..259c600c 100644 --- a/Projects/App/Sources/Application/AppCoordinator.swift +++ b/Projects/App/Sources/Application/AppCoordinator.swift @@ -69,23 +69,15 @@ final class AppCoordinator: BaseCoordinator { private func showSplash() { let splash = SplashViewController() // 스플래시 표시(0.8초)와 세션 판정을 겹쳐 돌린다. 둘 다 끝나야 다음 화면으로 간다. - let routeTask = Task { await authUseCase.restoreSession() } + let routeTask = Task { try await authUseCase.restoreSession() } - splash.onFinish = { [weak self] in + splash.onFinish = { [weak self, weak splash] in Task { @MainActor in - guard let self else { return } - let route = await routeTask.value - - UIView.transition( - with: self.navigationController.view, - duration: 0.3, - options: .transitionCrossDissolve - ) { - self.show(route) - } completion: { _ in - // 탭바로 곧장 들어간 경우에만 여기서 딥링크를 처리한다. - // 로그인을 거치는 경로는 탭바가 뜨는 showTabBar() 에서 다시 흘려보낸다. - if route == .home { self.flushTabBarReady() } + guard let self, let splash else { return } + do { + self.transition(to: try await routeTask.value) + } catch { + self.presentSessionRestoreRetry(on: splash) } } } @@ -93,6 +85,47 @@ final class AppCoordinator: BaseCoordinator { navigationController.setNavigationBarHidden(true, animated: false) } + @MainActor + private func transition(to route: AuthRoute) { + UIView.transition( + with: navigationController.view, + duration: 0.3, + options: .transitionCrossDissolve + ) { + self.show(route) + } completion: { _ in + // 탭바로 곧장 들어간 경우에만 여기서 딥링크를 처리한다. + // 로그인을 거치는 경로는 탭바가 뜨는 showTabBar() 에서 다시 흘려보낸다. + if route == .home { self.flushTabBarReady() } + } + } + + /* + 세션 복원이 서버 문제(500·503)로 실패했을 때 (spec: feature/auth/README.md §토큰·세션). + + 로그인 화면으로 되돌리지 않는다. 503 은 일시 장애라 잠시 뒤 풀리고, 500 의 공급자 불일치는 + 서버 쪽 상태 문제라 재로그인해도 같은 응답이 온다 — 어느 쪽이든 로그인 버튼을 눌러 봐야 + 사용자가 빠져나갈 수 없다. 세션을 든 채 스플래시에 머물러 재시도만 받는다. + */ + @MainActor + private func presentSessionRestoreRetry(on splash: SplashViewController) { + DSAlertCardView.present( + on: splash.view, + title: "잠시 후 다시 시도해 주세요", + message: "서버와 연결이 원활하지 않아요.", + confirmTitle: "다시 시도" + ) { [weak self, weak splash] in + guard let self, let splash else { return } + Task { @MainActor in + do { + self.transition(to: try await self.authUseCase.restoreSession()) + } catch { + self.presentSessionRestoreRetry(on: splash) + } + } + } + } + private func show(_ route: AuthRoute) { switch route { case .login, .consent: diff --git a/Projects/Data/Sources/Auth/AuthErrorMapper.swift b/Projects/Data/Sources/Auth/AuthErrorMapper.swift index 9d755789..f82794a0 100644 --- a/Projects/Data/Sources/Auth/AuthErrorMapper.swift +++ b/Projects/Data/Sources/Auth/AuthErrorMapper.swift @@ -30,6 +30,11 @@ enum AuthErrorMapper { case (503, _): // 일시 장애 — 세션을 유지하고 재시도만 안내한다. 로그아웃 사유가 아니다. return .serviceUnavailable + case (500, _): + // 서버 쪽 상태 문제. 503 과 마찬가지로 로그아웃 사유가 아니다 — 특히 `GET /users/me` 의 + // 공급자 불일치가 여기로 오는데, 재로그인해도 같은 500 이라 로그인 화면으로 보내면 + // 사용자가 빠져나갈 방법이 없다 (spec: domains/errors.md §회원). + return .serverError default: return .unknown } diff --git a/Projects/Data/Sources/Auth/KakaoSignInProvider.swift b/Projects/Data/Sources/Auth/KakaoSignInProvider.swift index 43809d35..151ad980 100644 --- a/Projects/Data/Sources/Auth/KakaoSignInProvider.swift +++ b/Projects/Data/Sources/Auth/KakaoSignInProvider.swift @@ -23,6 +23,10 @@ import Networks /// 카카오 로그인 → 액세스 토큰 → POST /auth/kakao/token → firebaseCustomToken → Firebase 로그인 struct KakaoSignInProvider: SocialSignInProviding { + /// 카카오계정 대표 이메일 동의항목. 서버가 이 권한이 실린 액세스 토큰으로 사용자 정보를 조회해 + /// 이메일을 Firebase 레코드에 채운다 (spec: domains/auth.md 인증 §카카오). + private static let emailScope = "account_email" + private let service: AuthService init(service: AuthService) { @@ -41,10 +45,17 @@ struct KakaoSignInProvider: SocialSignInProviding { } } + @MainActor + private func requestKakaoToken() async throws -> String { + let token = try await authorize() + // 서버가 이 토큰으로 이메일을 조회한다 — 권한이 빠져 있으면 값을 받지 못한다. + return await tokenWithEmailScope(token) + } + /// 카카오톡이 깔려 있으면 앱 전환 로그인, 아니면 웹 계정 로그인. /// 앱 전환은 사용자가 카카오톡에서 되돌아오지 않을 수 있어 실패 시 계정 로그인으로 폴백한다. @MainActor - private func requestKakaoToken() async throws -> String { + private func authorize() async throws -> String { if UserApi.isKakaoTalkLoginAvailable() { do { return try await login { UserApi.shared.loginWithKakaoTalk(completion: $0) } @@ -58,6 +69,44 @@ struct KakaoSignInProvider: SocialSignInProviding { return try await login { UserApi.shared.loginWithKakaoAccount(completion: $0) } } + /// 액세스 토큰에 이메일 권한을 실어 준다 (spec: domains/auth.md 인증 §카카오). + /// + /// 구글·애플은 Firebase 가 로그인을 처리해 이메일이 자동으로 들어가지만 카카오는 커스텀 토큰이라 + /// 비어 있다 — 콘솔에서 카카오 회원을 계정으로 구분할 방법이 이것뿐이라 서버가 채운다. + /// iOS SDK 의 로그인 메서드는 scope 인자를 받지 않으므로(동의항목은 카카오 콘솔이 정한다), + /// 이미 받은 토큰에 **추가 동의**를 붙이는 길만 있다. + /// + /// 콘솔에 필수 동의로 걸려 있어 보통은 로그인 시점에 이미 동의돼 있고 이 창은 뜨지 않는다. + /// 설정이 바뀌었거나 동의 이력이 어긋난 계정에서만 한 번 더 받는다. + /// + /// **실패해도 로그인을 막지 않는다** — 이메일은 로그인의 성립 조건이 아니라는 게 계약이고 + /// (서버도 조회에 실패하면 이메일 없이 커스텀 토큰을 발급한다), 추가 동의를 취소하면 + /// 원래 토큰 그대로 진행한다. + @MainActor + private func tokenWithEmailScope(_ accessToken: String) async -> String { + guard await isEmailScopeMissing() else { return accessToken } + + let reissued = try? await login { + UserApi.shared.loginWithKakaoAccount(scopes: [Self.emailScope], completion: $0) + } + return reissued ?? accessToken + } + + /// 이 토큰에 이메일 동의항목이 빠져 있는지. + /// + /// **판단이 서지 않으면 false 다** — 이미 권한이 있는데 동의 창을 한 번 더 띄우는 쪽이, + /// 이메일을 못 받는 것보다 사용자에게 나쁘다. `using` 이 false 면 앱이 쓰지 않는 항목이라 + /// 추가 동의를 요청해도 오류만 난다. + @MainActor + private func isEmailScopeMissing() async -> Bool { + await withCheckedContinuation { continuation in + UserApi.shared.scopes(scopes: [Self.emailScope]) { info, _ in + let scope = info?.scopes?.first { $0.id == Self.emailScope } + continuation.resume(returning: scope?.using == true && scope?.agreed == false) + } + } + } + /// **반드시 메인 액터에 머물러야 한다.** `nonisolated` 로 두면 호출부가 `@MainActor` 여도 /// async 함수가 메인을 벗어나 실행돼, 카카오 SDK 가 백그라운드에서 키 윈도우를 찾고 /// `UIApplication.open` 을 부른다(Main Thread Checker 경고). diff --git a/Projects/Domain/Sources/Entity/AuthUser.swift b/Projects/Domain/Sources/Entity/AuthUser.swift index 2cc6452c..e8e3939a 100644 --- a/Projects/Domain/Sources/Entity/AuthUser.swift +++ b/Projects/Domain/Sources/Entity/AuthUser.swift @@ -124,6 +124,10 @@ public enum AuthError: Error, Equatable { case kakaoTokenInvalid /// 503 — Firebase·카카오 일시 장애. **세션을 유지**하고 재시도만 안내한다(로그아웃 사유가 아니다). case serviceUnavailable + /// 500 `INTERNAL_ERROR` — 서버 쪽 상태 문제(공급자 불일치 포함). + /// **로그아웃 사유가 아니다** — 토큰 검증은 통과했고, 재로그인해도 같은 응답이 온다 + /// (spec: feature/auth/README.md §토큰·세션). + case serverError /// 동의 저장 400(버전 불일치 등) — 오류로 끝내지 않고 `GET /consents` 재조회 후 화면을 다시 그린다. case consentVersionMismatch /// 탈퇴 500 — 계정이 남아 있을 수 있어 **로그아웃하지 않고** 재시도한다. diff --git a/Projects/Domain/Sources/UseCase/AuthUseCase.swift b/Projects/Domain/Sources/UseCase/AuthUseCase.swift index e2fc2b77..c0c08718 100644 --- a/Projects/Domain/Sources/UseCase/AuthUseCase.swift +++ b/Projects/Domain/Sources/UseCase/AuthUseCase.swift @@ -14,7 +14,10 @@ public protocol AuthUseCase { var user: CurrentValueSubject { get } /// 앱 실행·포그라운드 복귀 시 갈 화면을 정한다. - func restoreSession() async -> AuthRoute + /// + /// 서버 문제(500·503)로 판정이 서지 않으면 **화면을 정하지 않고 throw** 한다 — 세션은 살아 있으므로 + /// 호출부는 지금 화면에 머문 채 재시도한다 (spec: feature/auth/README.md §진입 라우팅). + func restoreSession() async throws -> AuthRoute /// 소셜 로그인 → 회원 조회 → 갈 화면. 취소·실패는 throw. func signIn(with provider: AuthProvider) async throws -> AuthRoute @@ -53,8 +56,13 @@ public final class DefaultAuthUseCase: AuthUseCase { `/users/me` 조회가 실패하면 로그인으로 되돌린다. 동의 여부를 모르는 채 홈에 들여보내면 "로그인됐지만 미동의" 라는, spec 이 없다고 못박은 상태가 화면에 생긴다. + + 단 **500·503 은 예외로 throw 한다.** 둘 다 로그아웃 사유가 아니다 — 503 은 일시 장애라 잠시 뒤 + 풀리고, 500 의 공급자 불일치는 서버 쪽 상태 문제라 재로그인해도 같은 응답이 온다. 로그인으로 + 되돌리면 사용자가 빠져나갈 방법이 없어, 세션을 든 채 호출부가 재시도하게 넘긴다 + (spec: feature/auth/README.md §토큰·세션). */ - public func restoreSession() async -> AuthRoute { + public func restoreSession() async throws -> AuthRoute { guard repository.isSignedIn else { user.send(nil) return .login @@ -62,12 +70,22 @@ public final class DefaultAuthUseCase: AuthUseCase { do { return try await refreshUser() + } catch let error as AuthError where Self.keepsSession(error) { + throw error } catch { user.send(nil) return .login } } + /// 세션을 버리지 않고 재시도해야 하는 실패인지. + private static func keepsSession(_ error: AuthError) -> Bool { + switch error { + case .serverError, .serviceUnavailable: return true + default: return false + } + } + public func signIn(with provider: AuthProvider) async throws -> AuthRoute { try await repository.signIn(with: provider) // 최초 로그인이면 이 호출에서 서버가 회원을 만든다 — 별도 가입 단계가 없다. diff --git a/Projects/Domain/Tests/Sources/AuthUseCaseTests.swift b/Projects/Domain/Tests/Sources/AuthUseCaseTests.swift index d7672151..846f573b 100644 --- a/Projects/Domain/Tests/Sources/AuthUseCaseTests.swift +++ b/Projects/Domain/Tests/Sources/AuthUseCaseTests.swift @@ -24,10 +24,10 @@ final class AuthUseCaseTests: XCTestCase { // MARK: - 진입 라우팅 - func test_restoreSession_세션이없으면_로그인() async { + func test_restoreSession_세션이없으면_로그인() async throws { repository.isSignedIn = false - let route = await sut.restoreSession() + let route = try await sut.restoreSession() XCTAssertEqual(route, .login) XCTAssertNil(sut.user.value) @@ -35,36 +35,53 @@ final class AuthUseCaseTests: XCTestCase { XCTAssertEqual(repository.fetchMeCallCount, 0) } - func test_restoreSession_동의미충족이면_동의온보딩() async { + func test_restoreSession_동의미충족이면_동의온보딩() async throws { repository.isSignedIn = true repository.meResult = .success(.stub(onboardingRequired: true)) - let route = await sut.restoreSession() + let route = try await sut.restoreSession() XCTAssertEqual(route, .consent) } - func test_restoreSession_동의충족이면_홈() async { + func test_restoreSession_동의충족이면_홈() async throws { repository.isSignedIn = true repository.meResult = .success(.stub(onboardingRequired: false)) - let route = await sut.restoreSession() + let route = try await sut.restoreSession() XCTAssertEqual(route, .home) XCTAssertEqual(sut.user.value?.userId, "uid") } /// 동의 여부를 모르는 채 홈에 들여보내면 "로그인됐지만 미동의" 상태가 화면에 생긴다. - func test_restoreSession_회원조회실패면_로그인으로_되돌린다() async { + func test_restoreSession_회원조회실패면_로그인으로_되돌린다() async throws { repository.isSignedIn = true repository.meResult = .failure(AuthError.unknown) - let route = await sut.restoreSession() + let route = try await sut.restoreSession() XCTAssertEqual(route, .login) XCTAssertNil(sut.user.value) } + /// 500·503 은 로그아웃 사유가 아니다 — 공급자 불일치(500)는 재로그인해도 같은 응답이 오고, + /// 503 은 일시 장애다. 화면을 정하지 않고 throw 해 호출부가 재시도하게 한다 + /// (spec: feature/auth/README.md §토큰·세션). + func test_restoreSession_서버문제면_로그인으로_되돌리지_않고_throw() async { + for expected in [AuthError.serverError, .serviceUnavailable] { + repository.isSignedIn = true + repository.meResult = .failure(expected) + + do { + let route = try await sut.restoreSession() + XCTFail("\(expected) 는 화면을 정하지 않고 throw 되어야 한다 (받은 route: \(route))") + } catch { + XCTAssertEqual(error as? AuthError, expected) + } + } + } + // MARK: - 동의 func test_agreeToConsents_필수항목전체를_현재버전으로_보낸다() async throws { @@ -95,7 +112,7 @@ final class AuthUseCaseTests: XCTestCase { func test_signOut_계정정보를_비운다() async { repository.isSignedIn = true repository.meResult = .success(.stub(onboardingRequired: false)) - _ = await sut.restoreSession() + _ = try? await sut.restoreSession() XCTAssertNotNil(sut.user.value) sut.signOut() @@ -108,7 +125,7 @@ final class AuthUseCaseTests: XCTestCase { func test_deleteAccount_실패하면_로그아웃하지_않는다() async { repository.isSignedIn = true repository.meResult = .success(.stub(onboardingRequired: false)) - _ = await sut.restoreSession() + _ = try? await sut.restoreSession() repository.deleteResult = .failure(AuthError.accountDeletionFailed) do { @@ -125,7 +142,7 @@ final class AuthUseCaseTests: XCTestCase { func test_deleteAccount_성공하면_로그아웃하고_계정정보를_비운다() async throws { repository.isSignedIn = true repository.meResult = .success(.stub(onboardingRequired: false)) - _ = await sut.restoreSession() + _ = try await sut.restoreSession() try await sut.deleteAccount() diff --git a/Projects/Features/AuthFeature/Demo/Sources/MockAuthUseCase.swift b/Projects/Features/AuthFeature/Demo/Sources/MockAuthUseCase.swift index 859b2f09..ee7ccb0e 100644 --- a/Projects/Features/AuthFeature/Demo/Sources/MockAuthUseCase.swift +++ b/Projects/Features/AuthFeature/Demo/Sources/MockAuthUseCase.swift @@ -30,7 +30,7 @@ final class MockAuthUseCase: AuthUseCase { self.scenario = scenario } - func restoreSession() async -> AuthRoute { + func restoreSession() async throws -> AuthRoute { // 데모는 항상 로그인 화면에서 시작한다. .login } diff --git a/Projects/Features/AuthFeature/Tests/Sources/ConsentViewModelTests.swift b/Projects/Features/AuthFeature/Tests/Sources/ConsentViewModelTests.swift index d64e02b1..3881e60d 100644 --- a/Projects/Features/AuthFeature/Tests/Sources/ConsentViewModelTests.swift +++ b/Projects/Features/AuthFeature/Tests/Sources/ConsentViewModelTests.swift @@ -176,7 +176,7 @@ private final class StubAuthUseCase: AuthUseCase { private(set) var fetchCallCount = 0 private(set) var signOutCallCount = 0 - func restoreSession() async -> AuthRoute { .login } + func restoreSession() async throws -> AuthRoute { .login } func signIn(with provider: AuthProvider) async throws -> AuthRoute { .home } diff --git a/Projects/Modules/Networks/Sources/Foundation/APIRequestInterceptor.swift b/Projects/Modules/Networks/Sources/Foundation/APIRequestInterceptor.swift index 5d831286..6d3b6923 100644 --- a/Projects/Modules/Networks/Sources/Foundation/APIRequestInterceptor.swift +++ b/Projects/Modules/Networks/Sources/Foundation/APIRequestInterceptor.swift @@ -20,14 +20,13 @@ import Core /// (spec: feature/auth/README.md §토큰·세션) public final class APIRequestInterceptor: RequestInterceptor { - /// Bearer 를 붙이지 않는 공개 엔드포인트. spec 이 공개로 못박은 넷이 전부다 - /// (spec: domains/auth.md §방식 — "공개 목록은 이 넷뿐"). + /// Bearer 를 붙이지 않는 공개 엔드포인트. spec 이 공개로 못박은 셋이 전부다 + /// (spec: domains/auth.md §방식 — "공개 목록은 이 셋뿐"). /// /// Service 별로 플래그를 두는 대신 경로로 판정한다. 인터셉터는 Session 단위라 어느 API 든 여기를 지나고, /// 공개 여부는 서버 설정과 1:1로 맞춰야 하는 값이라 한 곳에 문자열로 두는 편이 대조하기 쉽다. private static let publicPaths: Set = [ "/actuator/health", - "/health", "/api/v0/auth/kakao/token", "/api/v0/consents", ] diff --git a/Projects/Modules/Networks/Tests/Sources/APIRequestInterceptorTests.swift b/Projects/Modules/Networks/Tests/Sources/APIRequestInterceptorTests.swift index 13a15f67..0ca3d5b2 100644 --- a/Projects/Modules/Networks/Tests/Sources/APIRequestInterceptorTests.swift +++ b/Projects/Modules/Networks/Tests/Sources/APIRequestInterceptorTests.swift @@ -10,7 +10,6 @@ final class APIRequestInterceptorTests: XCTestCase { func test_공개엔드포인트는_Bearer를_붙이지_않는다() { let publicPaths = [ "/actuator/health", - "/health", "/api/v0/auth/kakao/token", "/api/v0/consents", ] @@ -25,6 +24,8 @@ final class APIRequestInterceptorTests: XCTestCase { func test_그외_모든_경로는_Bearer가_필요하다() { let authenticatedPaths = [ + // 서버에서 삭제된 구 헬스 체크 경로(spec 0.25.0) — 공개 목록에 남아 있으면 안 된다. + "/health", "/api/v0/users/me", // 접두가 같아도 별개 경로다 — prefix 매칭으로 느슨하게 열리면 안 된다. "/api/v0/users/me/consents", diff --git a/fastlane/changelog_external.txt b/fastlane/changelog_external.txt index 41afeef3..aeb5a988 100644 --- a/fastlane/changelog_external.txt +++ b/fastlane/changelog_external.txt @@ -1,9 +1,4 @@ 이번 업데이트 -- 타이머가 iOS 26.1 이상 전용으로 바뀌었습니다. 무음 모드나 잠금 상태에서도 확실히 울리는 시스템 알람을 쓰기 위해서입니다. 26.1 미만 기기에서는 타이머 탭에 안내가 표시됩니다. -- 애플워치도 같은 기준으로 정리 (26.1 미만 아이폰과 연결된 경우 안내 표시) -- 알약 식별 결과 화면에 안내 문구 추가 -- 앱·애플워치 아이콘을 새 디자인으로 교체 (작은 화면에서도 또렷하게) -- 시작 화면 로고 선명도·크기 조정 -- 잠금화면 위젯을 눌러도 타이머가 시작되지 않던 문제 수정 -- 위젯으로 타이머를 시작하면 타이머 화면으로 바로 이동 -- 안정성 개선 및 버그 수정 +- 앱이 접속하는 서버 설정을 바로잡았습니다. 1.1 버전은 개발용 서버에 접속하도록 잘못 빌드돼 있었습니다. +- 1.1 에서 가입·저장한 데이터는 개발용 서버에 남아 있어 이 버전에서는 보이지 않습니다. 번거로우시겠지만 다시 로그인해 주세요. +- 기능 변경은 없습니다. diff --git a/spec b/spec index a7a76331..4713c8f8 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit a7a76331cf7ca31873ed88afc52e0286b6ded169 +Subproject commit 4713c8f823e81f86967940c60febc2fc0368b47e