From 3c6657bbb5dc9ca97e3c5e02cc71d14ef3303e0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Park=20hyeon=20gyu=28=EB=B0=94=EA=B2=AC=EA=B7=9C=29?= Date: Thu, 3 Sep 2026 00:04:46 +0900 Subject: [PATCH 1/3] =?UTF-8?q?chore:=201.1.1=20=ED=95=AB=ED=94=BD?= =?UTF-8?q?=EC=8A=A4=20=E2=80=94=20=EB=B0=B0=ED=8F=AC=20=EA=B5=AC=EC=84=B1?= =?UTF-8?q?=20=EC=A0=95=EC=A0=95=20=EC=9E=AC=EB=B9=8C=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App Store 라이브 1.1 이 `beta_internal` 레인 산출물이라 실사용자 트래픽이 dev 로 가고 있다. 그 레인만 xcargs 로 BASE_URL=dev·FIREBASE_ENV=Debug·INTERNAL 을 덮어쓰는데, 1.1.0 버전대 빌드는 전부 그 레인에서 나왔다 — cd-release 실행 이력 0건, cd-beta-external 마지막 실행 2026-08-13(1.0.4), 로컬 아카이브도 그 이후 없음. ASC 빌드 목록에 고를 수 있는 정상 빌드가 없었다. 코드 수정은 없다. 같은 커밋을 prod 구성 레인(beta_external)으로 다시 빌드해 내보내는 것이 이 릴리스의 전부다. - MARKETING_VERSION(CFBundleShortVersionString) 1.1.0 → 1.1.1 App·WatchApp·TimerWidget·WatchComplication 전 타깃 일치(Apple 검증). - changelog_external.txt 갱신 — 1.1 에서 만든 데이터가 dev DB 에 남아 보이지 않는다는 점을 테스터에게 명시. 배포 후 확인할 것: - prod Firebase(nursemate-96159) Remote Config 에 min_supported_version· app_store_url 이 채워져 있는지. 1.1 사용자는 dev 프로젝트를 보므로 긴급 제어는 당분간 dev·prod 양쪽에 걸어야 한다. - dev DB 에 쌓인 실사용자 계정 처리(백엔드). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VL6AihNosReBSERxCV3qZZ --- Projects/App/Project.swift | 8 ++++---- fastlane/changelog_external.txt | 11 +++-------- 2 files changed, 7 insertions(+), 12 deletions(-) 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/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 에서 가입·저장한 데이터는 개발용 서버에 남아 있어 이 버전에서는 보이지 않습니다. 번거로우시겠지만 다시 로그인해 주세요. +- 기능 변경은 없습니다. From bf29f1b0e68abe51852533db3a6f357fefddfa0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Park=20hyeon=20gyu=28=EB=B0=94=EA=B2=AC=EA=B7=9C=29?= Date: Thu, 3 Sep 2026 01:47:18 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[NM-413]=20feat:=20=EC=B9=B4=EC=B9=B4?= =?UTF-8?q?=EC=98=A4=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=EC=97=90=20account=5Fem?= =?UTF-8?q?ail=20=EB=8F=99=EC=9D=98=ED=95=AD=EB=AA=A9=EC=9D=84=20=EC=8B=A3?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서버가 카카오 액세스 토큰으로 사용자 정보를 조회해 이메일을 Firebase 레코드에 채우기 시작했다(backend NM-413). 그 조회는 토큰에 이메일 권한이 실려 있어야만 값을 돌려준다 — 빠지면 서버는 조용히 이메일 없이 넘어간다. iOS SDK 의 loginWithKakaoTalk · loginWithKakaoAccount 는 scope 인자를 받지 않는다. 동의항목을 정하는 건 카카오 콘솔이고, 앱이 개입할 수 있는 지점은 이미 받은 토큰에 추가 동의를 붙이는 loginWithKakaoAccount(scopes:) 하나뿐이다. 그래서 로그인 직후 scopes 조회로 권한을 확인하고, 빠져 있을 때만 추가 동의를 한 번 받는다. 콘솔에 필수 동의로 걸려 있어 보통은 이 창이 뜨지 않는다. 그럼에도 확인을 두는 건 콘솔 설정이 코드 밖에 있어 눈에 띄지 않게 바뀔 수 있고, 그때 이메일이 조용히 비어 버리기 때문이다. 실패는 전부 삼킨다. 이메일은 로그인의 성립 조건이 아니라는 것이 계약이라 (spec api/domains/auth.md 인증 §카카오), 조회가 실패하거나 사용자가 추가 동의를 취소하면 원래 토큰 그대로 교환을 진행한다. using 이 false 면 추가 동의를 요청하지 않는다. 앱이 쓰지 않는 항목이라 요청해도 오류만 나고, 판단이 서지 않을 때 창을 띄우는 쪽이 이메일을 못 받는 것보다 나쁘다. spec 서브모듈도 0.25.1 반영본으로 올린다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WkkHofXT2ZqnGQ6bhjGJYX --- .../Sources/Auth/KakaoSignInProvider.swift | 51 ++++++++++++++++++- spec | 2 +- 2 files changed, 51 insertions(+), 2 deletions(-) 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/spec b/spec index a7a76331..4713c8f8 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit a7a76331cf7ca31873ed88afc52e0286b6ded169 +Subproject commit 4713c8f823e81f86967940c60febc2fc0368b47e From bd3cbf24519db1234a39bd2c694dd34e20d43f99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Park=20hyeon=20gyu=28=EB=B0=94=EA=B2=AC=EA=B7=9C=29?= Date: Thu, 3 Sep 2026 01:57:33 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[NM-413]=20fix:=20=EC=A7=84=EC=9E=85=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EA=B0=80=20500=C2=B7503=20=EC=9D=B4=EB=A9=B4?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=EC=9C=BC=EB=A1=9C=20=EB=90=98?= =?UTF-8?q?=EB=8F=8C=EB=A6=AC=EC=A7=80=20=EC=95=8A=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /users/me` 의 공급자 불일치가 401 에서 500 으로 바뀌었다(spec 0.24.3). 401 로 주면 클라이언트가 세션 만료로 보고 로그아웃시키는데, 재로그인해도 같은 응답이 와 사용자가 빠져나갈 방법이 없어서다. 그런데 앱은 그 전환의 다른 쪽 절반을 안 하고 있었다. restoreSession 이 fetchMe 실패를 원인 구분 없이 전부 `.login` 으로 접어, 500 이 오면 결국 로그인 화면으로 튕긴다. 서버가 401 을 피한 이유가 앱에서 그대로 재현된다. 503 도 같은 자리에 있었다. 로그아웃 사유가 아니라고 spec 에 못박혀 있고 AuthErrorMapper 는 이미 `.serviceUnavailable` 로 가르고 있었지만, 진입 경로에서만 그 구분이 버려졌다. 그래서 두 상태를 세션을 버리지 않는 실패로 묶고, restoreSession 이 화면을 정하는 대신 throw 하게 했다. 라우트 enum 에 `.retry` 같은 걸 더하지 않는다 — 진입 라우팅은 세션 유무와 onboardingRequired 만으로 정해지는 세 갈래라는 게 spec 이고, "판정이 서지 않음" 은 그 표의 네 번째 행이 아니라 표를 못 채운 상태다. AppCoordinator 는 그때 스플래시에 머물러 다시 시도만 받는다. 500 이 공급자 불일치면 재시도해도 계속 실패하지만, 그렇다고 로그인 화면으로 보내면 사용자가 할 수 있는 유일한 행동(로그인)이 같은 500 을 부른다. 머무는 편이 덜 나쁘다. 400·401·네트워크 실패는 그대로 `.login` 이다. spec 이 이름 붙인 건 500·503 둘뿐이고, 나머지까지 스플래시에 묶으면 진짜 세션 만료가 빠져나갈 곳을 잃는다. 같이: 서버에서 삭제된 `GET /health` 를 공개 엔드포인트 목록에서 뺀다(spec 0.25.0). 목록은 서버 permitAll 과 1:1 이어야 의미가 있어, 없는 경로가 남아 있으면 대조가 어긋난다. 테스트에서는 인증 필요 경로 쪽으로 옮겨 다시 열리지 않게 잠근다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WkkHofXT2ZqnGQ6bhjGJYX --- .../Sources/Application/AppCoordinator.swift | 63 ++++++++++++++----- .../Data/Sources/Auth/AuthErrorMapper.swift | 5 ++ Projects/Domain/Sources/Entity/AuthUser.swift | 4 ++ .../Domain/Sources/UseCase/AuthUseCase.swift | 22 ++++++- .../Tests/Sources/AuthUseCaseTests.swift | 39 ++++++++---- .../Demo/Sources/MockAuthUseCase.swift | 2 +- .../Tests/Sources/ConsentViewModelTests.swift | 2 +- .../Foundation/APIRequestInterceptor.swift | 5 +- .../Sources/APIRequestInterceptorTests.swift | 3 +- 9 files changed, 111 insertions(+), 34 deletions(-) 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/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",