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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Projects/App/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down Expand Up @@ -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 이
Expand Down Expand Up @@ -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",
],
Expand All @@ -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": "치료 타이머 종료 알람을 예약·알림하기 위해 필요합니다.",
Expand Down
63 changes: 48 additions & 15 deletions Projects/App/Sources/Application/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,30 +69,63 @@ 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)
}
}
}
navigationController.setViewControllers([splash], animated: false)
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:
Expand Down
5 changes: 5 additions & 0 deletions Projects/Data/Sources/Auth/AuthErrorMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
51 changes: 50 additions & 1 deletion Projects/Data/Sources/Auth/KakaoSignInProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) }
Expand All @@ -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 경고).
Expand Down
4 changes: 4 additions & 0 deletions Projects/Domain/Sources/Entity/AuthUser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 — 계정이 남아 있을 수 있어 **로그아웃하지 않고** 재시도한다.
Expand Down
22 changes: 20 additions & 2 deletions Projects/Domain/Sources/UseCase/AuthUseCase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ public protocol AuthUseCase {
var user: CurrentValueSubject<AuthUser?, Never> { 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
Expand Down Expand Up @@ -53,21 +56,36 @@ 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
}

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)
// 최초 로그인이면 이 호출에서 서버가 회원을 만든다 — 별도 가입 단계가 없다.
Expand Down
39 changes: 28 additions & 11 deletions Projects/Domain/Tests/Sources/AuthUseCaseTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,47 +24,64 @@ 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)
// 세션이 없으면 서버에 물어볼 것도 없다.
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 {
Expand Down Expand Up @@ -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()
Expand All @@ -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 {
Expand All @@ -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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ final class MockAuthUseCase: AuthUseCase {
self.scenario = scenario
}

func restoreSession() async -> AuthRoute {
func restoreSession() async throws -> AuthRoute {
// 데모는 항상 로그인 화면에서 시작한다.
.login
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = [
"/actuator/health",
"/health",
"/api/v0/auth/kakao/token",
"/api/v0/consents",
]
Expand Down
Loading
Loading