From 75ab4c037fe2db3f9da4db0e35ae4ee3bab40112 Mon Sep 17 00:00:00 2001 From: Roy Date: Mon, 9 Feb 2026 14:51:32 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20Add=20TokenRefreshManager=20=20?= =?UTF-8?q?=EB=B0=8F=20401=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AccessTokenAuthenticator.swift | 72 +++++---- .../Authentication/AuthSessionManager.swift | 12 ++ .../Authentication/TokenRefreshManager.swift | 139 ++++++++++++++++++ 3 files changed, 196 insertions(+), 27 deletions(-) create mode 100644 Data/Sources/Network/Authentication/TokenRefreshManager.swift diff --git a/Data/Sources/Network/Authentication/AccessTokenAuthenticator.swift b/Data/Sources/Network/Authentication/AccessTokenAuthenticator.swift index e77abbe5..a33f3573 100644 --- a/Data/Sources/Network/Authentication/AccessTokenAuthenticator.swift +++ b/Data/Sources/Network/Authentication/AccessTokenAuthenticator.swift @@ -18,13 +18,13 @@ enum TokenRefreshError: Error { final class AccessTokenAuthenticator: Authenticator { typealias Credential = AccessTokenCredential - private let remote: any AuthRemoteDataSourceProtocol + private let tokenRefreshManager: TokenRefreshManager init(remote: any AuthRemoteDataSourceProtocol = AuthRemoteDataSource( authProvider: MoyaProvider.default, oauthProvider: MoyaProvider.default )) { - self.remote = remote + self.tokenRefreshManager = TokenRefreshManager(remote: remote) } func apply(_ credential: Credential, to urlRequest: inout URLRequest) { @@ -37,8 +37,12 @@ final class AccessTokenAuthenticator: Authenticator { completion: @escaping @Sendable (Result) -> Void ) { _Concurrency.Task { - let result = await refreshCredential(credential) - completion(result) + do { + let refreshedCredential = try await tokenRefreshManager.refreshCredentialIfNeeded(current: credential) + completion(.success(refreshedCredential)) + } catch { + completion(.failure(error)) + } } } @@ -47,7 +51,13 @@ final class AccessTokenAuthenticator: Authenticator { with response: HTTPURLResponse, failDueToAuthenticationError error: any Error ) -> Bool { - response.statusCode == 401 + // First check HTTP status code + if response.statusCode == 401 { + return true + } + + // Enhanced 401 detection logic (sync version) + return isRefreshTokenExpiredError(error) } func isRequest( @@ -58,33 +68,41 @@ final class AccessTokenAuthenticator: Authenticator { } } +// MARK: - Enhanced Error Detection private extension AccessTokenAuthenticator { - func refreshCredential(_ credential: Credential) async -> Result { - guard credential.refreshToken.isEmpty == false else { - return .failure(TokenRefreshError.missingRefreshToken) + /// Enhanced 401 detection with multiple layers (sync version) + func isRefreshTokenExpiredError(_ error: Error) -> Bool { + // 1. Check string description for "statusCodeError(401)" + if String(describing: error).contains("statusCodeError(401)") { + return true } - do { - let result = try await remote.refresh(token: credential.refreshToken) - let tokens = result.token - - KeychainManager.shared.saveTokens( - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken - ) - - guard - let refreshedCredential = AccessTokenCredential.make( - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken ?? "" - ) - else { - return .failure(TokenRefreshError.invalidAccessToken) + // 2. Check Moya MoyaError.statusCode + if let moyaError = error as? MoyaError { + switch moyaError { + case .statusCode(let response) where response.statusCode == 401: + return true + case .underlying(_, let response) where response?.statusCode == 401: + return true + default: + break } + } - return .success(refreshedCredential) - } catch { - return .failure(error) + // 3. Check AuthError.isTokenExpiredError + if let authError = error as? AuthError { + return authError.isTokenExpiredError } + + // 4. Check error message keywords + let errorDesc = error.localizedDescription.lowercased() + if errorDesc.contains("401") || + errorDesc.contains("unauthorized") || + errorDesc.contains("유효하지 않은 토큰") { + return true + } + + return false } } + diff --git a/Data/Sources/Network/Authentication/AuthSessionManager.swift b/Data/Sources/Network/Authentication/AuthSessionManager.swift index 39788305..82ca966a 100644 --- a/Data/Sources/Network/Authentication/AuthSessionManager.swift +++ b/Data/Sources/Network/Authentication/AuthSessionManager.swift @@ -55,6 +55,18 @@ final class AuthSessionManager { public func clear() { interceptor.credential = nil } + + func updateCredential(accessToken: String, refreshToken: String) { + guard let credential = AccessTokenCredential.make( + accessToken: accessToken, + refreshToken: refreshToken + ) else { + interceptor.credential = nil + return + } + + interceptor.credential = credential + } } private extension AuthSessionManager { diff --git a/Data/Sources/Network/Authentication/TokenRefreshManager.swift b/Data/Sources/Network/Authentication/TokenRefreshManager.swift new file mode 100644 index 00000000..1ebc70f6 --- /dev/null +++ b/Data/Sources/Network/Authentication/TokenRefreshManager.swift @@ -0,0 +1,139 @@ +// +// TokenRefreshManager.swift +// Data +// +// Created by Wonji Suh on 02/09/26. +// + +import Foundation +import Alamofire +import Domain +import Moya + +/// Actor-based token refresh manager for concurrency safety +/// Prevents multiple simultaneous token refresh attempts +actor TokenRefreshManager { + private var isRefreshing = false + private let remote: any AuthRemoteDataSourceProtocol + + init(remote: any AuthRemoteDataSourceProtocol = AuthRemoteDataSource( + authProvider: MoyaProvider.default, + oauthProvider: MoyaProvider.default + )) { + self.remote = remote + } + + /// Safely refreshes credential with concurrency protection + /// If already refreshing, yields and retries + func refreshCredentialIfNeeded(current credential: AccessTokenCredential) async throws -> AccessTokenCredential { + // If already refreshing, yield and retry + if isRefreshing { + await _Concurrency.Task.yield() + return try await refreshCredentialIfNeeded(current: credential) + } + + // Set refreshing flag + isRefreshing = true + defer { isRefreshing = false } + + return try await performRefresh(credential) + } + + /// Checks if error indicates refresh token expiration + func isRefreshTokenExpiredError(_ error: Error) -> Bool { + // 1. Check string description for "statusCodeError(401)" + if String(describing: error).contains("statusCodeError(401)") { + return true + } + + // 2. Check Moya MoyaError.statusCode + if let moyaError = error as? MoyaError { + switch moyaError { + case .statusCode(let response) where response.statusCode == 401: + return true + case .underlying(_, let response) where response?.statusCode == 401: + return true + default: + break + } + } + + // 3. Check AuthError.isTokenExpiredError + if let authError = error as? AuthError { + return authError.isTokenExpiredError + } + + // 4. Check error message keywords + let errorDesc = error.localizedDescription.lowercased() + if errorDesc.contains("401") || + errorDesc.contains("unauthorized") || + errorDesc.contains("유효하지 않은 토큰") { + return true + } + + return false + } +} + +// MARK: - Private Methods +private extension TokenRefreshManager { + func performRefresh(_ credential: AccessTokenCredential) async throws -> AccessTokenCredential { + guard !credential.refreshToken.isEmpty else { + throw TokenRefreshError.missingRefreshToken + } + + do { + let result = try await remote.refresh(token: credential.refreshToken) + let tokens = result.token + + // Save to keychain + KeychainManager.shared.saveTokens( + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken + ) + + // Update session manager credential + await MainActor.run { + AuthSessionManager.shared.updateCredential( + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken ?? "" + ) + } + + guard let refreshedCredential = AccessTokenCredential.make( + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken ?? "" + ) else { + throw TokenRefreshError.invalidAccessToken + } + + return refreshedCredential + + } catch { + // If refresh token is expired, perform automatic logout + if isRefreshTokenExpiredError(error) { + await performAutomaticLogout() + } + throw error + } + } + + func performAutomaticLogout() async { + // 1. Clear keychain + KeychainManager.shared.clearAll() + + // 2. Clear session manager credential + await MainActor.run { + AuthSessionManager.shared.clear() + } + + // 3. Send notification for automatic logout + await MainActor.run { + NotificationCenter.default.post( + name: .refreshTokenExpired, + object: nil, + userInfo: ["reason": "401_refresh_failed"] + ) + } + } +} \ No newline at end of file From 779a0ba7d7a4ce96c5f705ee02c435845021cfaa Mon Sep 17 00:00:00 2001 From: Roy Date: Mon, 9 Feb 2026 14:53:06 +0900 Subject: [PATCH 2/7] release: update version to 1.0.5 --- .../ProjectDescriptionHelpers/Environment.swift | 4 ++-- fastlane/metadata/ko/release_notes.txt | 7 +++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/Environment.swift b/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/Environment.swift index 7c23b3a6..901cd9b8 100644 --- a/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/Environment.swift +++ b/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/Environment.swift @@ -9,8 +9,8 @@ public enum Environment { public static let organizationTeamId = "N94CS4N6VR" // MARK: - Version Management - public static let mainAppVersion = "1.0.4" - public static let mainAppBuildVersion = "26" + public static let mainAppVersion = "1.0.5" + public static let mainAppBuildVersion = "28" public static let demoAppVersion = "0.1.0" // Demo app용 별도 버전 // MARK: - Platform diff --git a/fastlane/metadata/ko/release_notes.txt b/fastlane/metadata/ko/release_notes.txt index e40b26dc..35934c28 100644 --- a/fastlane/metadata/ko/release_notes.txt +++ b/fastlane/metadata/ko/release_notes.txt @@ -1,5 +1,4 @@ -[v 1.0.4] -- 버그 수정 -- 정산 내역 차트 추가 -- 디자인 수 +[v 1.0.5] +- 버그 수정 +- 분석 도구 추가 From ffe15ea34abc01dbd69b0d5bce2ca6857f39a21c Mon Sep 17 00:00:00 2001 From: Roy Date: Mon, 9 Feb 2026 14:54:26 +0900 Subject: [PATCH 3/7] =?UTF-8?q?refactor:=20Add=20KeychainManager=20=20prot?= =?UTF-8?q?ocol=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Domain/Sources/Error/AuthError.swift | 16 +++ .../Manager/InMemoryKeychainManager.swift | 123 ++++++++++++++++++ Domain/Sources/Manager/KeychainManager.swift | 67 +++++++--- .../Manager/KeychainManagerDependency.swift | 30 +++++ .../Manager/KeychainManagerProtocol.swift | 33 +++++ 5 files changed, 251 insertions(+), 18 deletions(-) create mode 100644 Domain/Sources/Manager/InMemoryKeychainManager.swift create mode 100644 Domain/Sources/Manager/KeychainManagerDependency.swift create mode 100644 Domain/Sources/Manager/KeychainManagerProtocol.swift diff --git a/Domain/Sources/Error/AuthError.swift b/Domain/Sources/Error/AuthError.swift index 33dddd42..23563773 100644 --- a/Domain/Sources/Error/AuthError.swift +++ b/Domain/Sources/Error/AuthError.swift @@ -24,6 +24,8 @@ public enum AuthError: Error, Equatable, LocalizedError, Hashable { case backendError(String) /// 약관 동의가 필요한 경우 case needsTermsAgreement(String) + /// 리프레시 토큰이 만료된 경우 + case refreshTokenExpired /// 그 외 알 수 없는 에러 case unknownError(String) @@ -47,8 +49,22 @@ public enum AuthError: Error, Equatable, LocalizedError, Hashable { return "서버에서 오류가 발생했습니다: \(message)" case .needsTermsAgreement(let message): return "\(message)" + case .refreshTokenExpired: + return "인증이 만료되어 다시 로그인이 필요합니다." case .unknownError(let message): return "알 수 없는 오류가 발생했습니다: \(message)" } } + + // MARK: - Token Expiration Helper + + /// Indicates whether this error represents a token expiration + public var isTokenExpiredError: Bool { + switch self { + case .refreshTokenExpired: + return true + default: + return false + } + } } diff --git a/Domain/Sources/Manager/InMemoryKeychainManager.swift b/Domain/Sources/Manager/InMemoryKeychainManager.swift new file mode 100644 index 00000000..90ffc9b3 --- /dev/null +++ b/Domain/Sources/Manager/InMemoryKeychainManager.swift @@ -0,0 +1,123 @@ +// +// InMemoryKeychainManager.swift +// Domain +// +// Created by Wonji Suh on 02/09/26. +// + +import Foundation + +// Import the protocol from the same module +// In a real project structure, this would be handled by module imports + +/// In-Memory implementation for KeychainManaging protocol +/// Used for testing and SwiftUI previews +public final class InMemoryKeychainManager: KeychainManaging, @unchecked Sendable { + private var accessTokenStorage: String? + private var refreshTokenStorage: String? + private let lock = NSLock() + + // MARK: - Test helpers + private(set) var saveAccessTokenCallCount = 0 + private(set) var saveRefreshTokenCallCount = 0 + private(set) var saveTokensCallCount = 0 + private(set) var clearAllCallCount = 0 + + public init() {} + + // MARK: - KeychainManaging Implementation + + public func saveAccessToken(_ token: String) { + lock.lock() + defer { lock.unlock() } + accessTokenStorage = token + saveAccessTokenCallCount += 1 + } + + public func saveRefreshToken(_ token: String) { + lock.lock() + defer { lock.unlock() } + refreshTokenStorage = token + saveRefreshTokenCallCount += 1 + } + + public func saveTokens(accessToken: String?, refreshToken: String?) { + lock.lock() + defer { lock.unlock() } + + if let accessToken { + accessTokenStorage = accessToken + } + if let refreshToken { + refreshTokenStorage = refreshToken + } + saveTokensCallCount += 1 + } + + public func loadAccessToken() -> String? { + lock.lock() + defer { lock.unlock() } + return accessTokenStorage + } + + public func loadRefreshToken() -> String? { + lock.lock() + defer { lock.unlock() } + return refreshTokenStorage + } + + public func loadTokens() -> (accessToken: String?, refreshToken: String?) { + lock.lock() + defer { lock.unlock() } + return (accessTokenStorage, refreshTokenStorage) + } + + public func clearAll() { + lock.lock() + defer { lock.unlock() } + accessTokenStorage = nil + refreshTokenStorage = nil + clearAllCallCount += 1 + } + + // MARK: - Test Helpers + + /// Reset all call counts for testing + public func resetCounts() { + lock.lock() + defer { lock.unlock() } + saveAccessTokenCallCount = 0 + saveRefreshTokenCallCount = 0 + saveTokensCallCount = 0 + clearAllCallCount = 0 + } + + /// Check if tokens are stored + public var hasStoredTokens: Bool { + lock.lock() + defer { lock.unlock() } + return accessTokenStorage != nil && refreshTokenStorage != nil + } + + /// Preload tokens for testing + public func preloadTokens(accessToken: String, refreshToken: String) { + lock.lock() + defer { lock.unlock() } + accessTokenStorage = accessToken + refreshTokenStorage = refreshToken + } + + /// Verify specific tokens are saved + public func verifyTokensSaved(accessToken: String, refreshToken: String) -> Bool { + lock.lock() + defer { lock.unlock() } + return accessTokenStorage == accessToken && refreshTokenStorage == refreshToken + } + + /// Get all call counts for verification + public func getAllCallCounts() -> (saveAccessToken: Int, saveRefreshToken: Int, saveTokens: Int, clearAll: Int) { + lock.lock() + defer { lock.unlock() } + return (saveAccessTokenCallCount, saveRefreshTokenCallCount, saveTokensCallCount, clearAllCallCount) + } +} \ No newline at end of file diff --git a/Domain/Sources/Manager/KeychainManager.swift b/Domain/Sources/Manager/KeychainManager.swift index a9eaeeaf..ee508aba 100644 --- a/Domain/Sources/Manager/KeychainManager.swift +++ b/Domain/Sources/Manager/KeychainManager.swift @@ -14,36 +14,50 @@ enum KeychainKey: String { case refreshToken = "refresh_token" } -public struct KeychainManager { - public static let shared = KeychainManager() - private init() {} +public final class KeychainManager: KeychainManaging, @unchecked Sendable { + /// DI를 위한 라이브 인스턴스 제공 + public static let live = KeychainManager() - // MARK: - Public API - func saveAccessToken(_ token: String) { + private let service: String + + /// Keychain 서비스 이름으로 앱의 Bundle ID 사용 + private init(service: String = "io.sseudam.co") { + self.service = service + } + + // MARK: - 공개 API + + /// 액세스 토큰을 키체인에 저장 + public func saveAccessToken(_ token: String) { save(token: token, for: .accessToken) } - func saveRefreshToken(_ token: String) { + /// 리프레시 토큰을 키체인에 저장 + public func saveRefreshToken(_ token: String) { save(token: token, for: .refreshToken) } + /// 키체인에서 액세스 토큰 로드 public func loadAccessToken() -> String? { loadToken(for: .accessToken) } + /// 키체인에서 리프레시 토큰 로드 public func loadRefreshToken() -> String? { loadToken(for: .refreshToken) } + /// 액세스 토큰 삭제 public func deleteAccessToken() { deleteToken(for: .accessToken) } + /// 리프레시 토큰 삭제 public func deleteRefreshToken() { deleteToken(for: .refreshToken) } - /// 둘 다 한 번에 저장 + /// 두 토큰을 원자적으로 저장 public func saveTokens( accessToken: String?, refreshToken: String? @@ -63,40 +77,54 @@ public struct KeychainManager { NotificationCenter.default.post(name: .tokensDidUpdate, object: nil) } - /// 둘 다 한 번에 불러오기 + /// 두 토큰을 모두 로드 public func loadTokens() -> (accessToken: String?, refreshToken: String?) { (loadAccessToken(), loadRefreshToken()) } - /// 모두 삭제 (로그아웃 시 등) + /// 모든 토큰 삭제 (로그아웃 시 사용) public func clearAll() { deleteAccessToken() deleteRefreshToken() NotificationCenter.default.post(name: .tokensDidClear, object: nil) } - // MARK: - Private helpers + // MARK: - 내부 헬퍼 메서드 + /// 토큰을 키체인에 저장 (Update-Add 패턴 사용) private func save(token: String, for key: KeychainKey) { guard let data = token.data(using: .utf8) else { - Log.info("Keychain: Failed to convert token to data for key \(key.rawValue)") + Log.info("Keychain: 토큰을 데이터로 변환 실패, key: \(key.rawValue)") return } - // 기존 항목이 있으면 먼저 삭제 - deleteToken(for: key) - + // Update-Add 패턴: 먼저 업데이트 시도, 없으면 새로 추가 let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key.rawValue, + kSecAttrService as String: service + ] + + let attributes: [String: Any] = [ kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly ] - let status = SecItemAdd(query as CFDictionary, nil) + // Try to update existing item first + let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + + if updateStatus == errSecItemNotFound { + // Item doesn't exist, add it + var addQuery = query + addQuery[kSecValueData as String] = data + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly - if status != errSecSuccess { - Log.info("Keychain: Failed to save token for key \(key.rawValue), status: \(status)") + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + if addStatus != errSecSuccess { + Log.info("Keychain: Failed to add token for key \(key.rawValue), status: \(addStatus)") + } + } else if updateStatus != errSecSuccess { + Log.info("Keychain: Failed to update token for key \(key.rawValue), status: \(updateStatus)") } } @@ -104,6 +132,7 @@ public struct KeychainManager { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key.rawValue, + kSecAttrService as String: service, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne ] @@ -127,7 +156,8 @@ public struct KeychainManager { private func deleteToken(for key: KeychainKey) { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: key.rawValue + kSecAttrAccount as String: key.rawValue, + kSecAttrService as String: service ] let status = SecItemDelete(query as CFDictionary) @@ -140,4 +170,5 @@ public struct KeychainManager { public extension Notification.Name { static let tokensDidUpdate = Notification.Name("tokensDidUpdate") static let tokensDidClear = Notification.Name("tokensDidClear") + static let refreshTokenExpired = Notification.Name("RefreshTokenExpired") } diff --git a/Domain/Sources/Manager/KeychainManagerDependency.swift b/Domain/Sources/Manager/KeychainManagerDependency.swift new file mode 100644 index 00000000..bb600d8e --- /dev/null +++ b/Domain/Sources/Manager/KeychainManagerDependency.swift @@ -0,0 +1,30 @@ +// +// KeychainManagerDependency.swift +// Domain +// +// Created by Wonji Suh on 02/09/26. +// + +import Foundation +import ComposableArchitecture + +// MARK: - Dependency Key +public struct KeychainManagerDependency: DependencyKey { + public static var liveValue: KeychainManaging { + KeychainManager.live + } + + public static var testValue: KeychainManaging { + InMemoryKeychainManager() + } + + public static var previewValue: KeychainManaging = testValue +} + +// MARK: - Dependency Values Extension +public extension DependencyValues { + var keychainManager: KeychainManaging { + get { self[KeychainManagerDependency.self] } + set { self[KeychainManagerDependency.self] = newValue } + } +} \ No newline at end of file diff --git a/Domain/Sources/Manager/KeychainManagerProtocol.swift b/Domain/Sources/Manager/KeychainManagerProtocol.swift new file mode 100644 index 00000000..d9c719e4 --- /dev/null +++ b/Domain/Sources/Manager/KeychainManagerProtocol.swift @@ -0,0 +1,33 @@ +// +// KeychainManagerProtocol.swift +// Domain +// +// Created by Wonji Suh on 02/09/26. +// + +import Foundation + +/// Protocol for keychain operations +/// Provides secure storage for authentication tokens +public protocol KeychainManaging: Sendable { + /// Save access token to keychain + func saveAccessToken(_ token: String) + + /// Save refresh token to keychain + func saveRefreshToken(_ token: String) + + /// Save both tokens atomically + func saveTokens(accessToken: String?, refreshToken: String?) + + /// Load access token from keychain + func loadAccessToken() -> String? + + /// Load refresh token from keychain + func loadRefreshToken() -> String? + + /// Load both tokens + func loadTokens() -> (accessToken: String?, refreshToken: String?) + + /// Clear all stored tokens + func clearAll() +} \ No newline at end of file From f8816f7927d19f5123e7f770d47eef0fb2fbd8c5 Mon Sep 17 00:00:00 2001 From: Roy Date: Mon, 9 Feb 2026 14:56:48 +0900 Subject: [PATCH 4/7] =?UTF-8?q?refactor:=20OAuth=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EB=B0=94=EC=9D=B4=EB=8D=94=20=EC=95=84=ED=82=A4=ED=85=8D?= =?UTF-8?q?=EC=B2=98=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20DI=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기본 OAuthProviderProtocol을 제공자별 전용 프로토콜로 분리 - Apple, Google 전용 프로토콜 인터페이스 추가 - 생성자 주입을 통한 의존성 주입 방식으로 제공자 업데이트 - 테스트 및 개발을 위한 Mock 구현체 작성 - 제공자 메서드에서 repository 파라미터 제거 --- .../Sources/UseCase/OAuth/OAuthUseCase.swift | 18 +- .../Providers/Apple/AppleOAuthProvider.swift | 30 +-- .../Apple/MockAppleOAuthProvider.swift | 189 ++++++++++++++++++ .../Google/GoogleOAuthProvider.swift | 25 ++- .../Google/MockGoogleOAuthProvider.swift | 146 ++++++++++++++ .../AppleOAuthProviderProtocol.swift | 19 ++ .../Protocols/BaseOAuthProviderProtocol.swift | 13 ++ .../GoogleOAuthProviderProtocol.swift | 13 ++ .../Protocols/OAuthProviderProtocol.swift | 27 --- 9 files changed, 423 insertions(+), 57 deletions(-) create mode 100644 Domain/Sources/UseCase/OAuth/Providers/Apple/MockAppleOAuthProvider.swift create mode 100644 Domain/Sources/UseCase/OAuth/Providers/Google/MockGoogleOAuthProvider.swift create mode 100644 Domain/Sources/UseCase/OAuth/Providers/Protocols/AppleOAuthProviderProtocol.swift create mode 100644 Domain/Sources/UseCase/OAuth/Providers/Protocols/BaseOAuthProviderProtocol.swift create mode 100644 Domain/Sources/UseCase/OAuth/Providers/Protocols/GoogleOAuthProviderProtocol.swift delete mode 100644 Domain/Sources/UseCase/OAuth/Providers/Protocols/OAuthProviderProtocol.swift diff --git a/Domain/Sources/UseCase/OAuth/OAuthUseCase.swift b/Domain/Sources/UseCase/OAuth/OAuthUseCase.swift index ede351ba..9ef09280 100644 --- a/Domain/Sources/UseCase/OAuth/OAuthUseCase.swift +++ b/Domain/Sources/UseCase/OAuth/OAuthUseCase.swift @@ -11,12 +11,16 @@ import LogMacro import AuthenticationServices public struct OAuthUseCase: OAuthUseCaseProtocol { - + @Dependency(\.oAuthRepository) private var repository: OAuthRepositoryProtocol @Dependency(\.googleOAuthRepository) private var googleRepository: GoogleOAuthRepositoryProtocol @Dependency(\.appleOAuthRepository) private var appleRepository: AppleOAuthRepositoryProtocol @Dependency(\.kakaoOAuthRepository) private var kakaoRepository: KakaoOAuthRepositoryProtocol + // ✅ Provider들도 DI로 주입받음 + @Dependency(\.appleOAuthProvider) private var appleProvider: AppleOAuthProviderProtocol + @Dependency(\.googleOAuthProvider) private var googleProvider: GoogleOAuthProviderProtocol + public init() {} @@ -24,11 +28,9 @@ public struct OAuthUseCase: OAuthUseCaseProtocol { credential: ASAuthorizationAppleIDCredential, nonce: String ) async throws -> UserProfile { - let provider = AppleOAuthProvider() - return try await provider.signInWithCredential( + return try await appleProvider.signInWithCredential( credential: credential, - nonce: nonce, - repository: repository + nonce: nonce ) } @@ -41,13 +43,11 @@ public struct OAuthUseCase: OAuthUseCaseProtocol { do { switch provider { case .apple: - let appleProvider = AppleOAuthProvider() - let result = try await appleProvider.signUp(repository: repository, appleRepository: appleRepository) + let result = try await appleProvider.signUp() Log.info("✅ Apple signUp completed successfully") return result case .google: - let googleProvider = GoogleOAuthProvider() - let result = try await googleProvider.signUp(repository: repository, googleRepository: googleRepository) + let result = try await googleProvider.signUp() Log.info("✅ Google signUp completed successfully") return result case .kakao: diff --git a/Domain/Sources/UseCase/OAuth/Providers/Apple/AppleOAuthProvider.swift b/Domain/Sources/UseCase/OAuth/Providers/Apple/AppleOAuthProvider.swift index a44109ab..6af36f07 100644 --- a/Domain/Sources/UseCase/OAuth/Providers/Apple/AppleOAuthProvider.swift +++ b/Domain/Sources/UseCase/OAuth/Providers/Apple/AppleOAuthProvider.swift @@ -10,15 +10,24 @@ import Dependencies import LogMacro import AuthenticationServices -public class AppleOAuthProvider: AppleOAuthProviderProtocol { +public class AppleOAuthProvider: AppleOAuthProviderProtocol, @unchecked Sendable { public let socialType: SocialType = .apple - public init() {} + private let oAuthRepository: OAuthRepositoryProtocol + private let appleRepository: AppleOAuthRepositoryProtocol + + /// DI를 위한 생성자 - Repository들을 한 번에 주입받음 + public init( + oAuthRepository: OAuthRepositoryProtocol, + appleRepository: AppleOAuthRepositoryProtocol + ) { + self.oAuthRepository = oAuthRepository + self.appleRepository = appleRepository + } public func signInWithCredential( credential: ASAuthorizationAppleIDCredential, - nonce: String, - repository: OAuthRepositoryProtocol + nonce: String ) async throws -> UserProfile { guard let identityTokenData = credential.identityToken, let identityToken = String(data: identityTokenData, encoding: .utf8), @@ -30,7 +39,7 @@ public class AppleOAuthProvider: AppleOAuthProviderProtocol { let displayName = formatDisplayName(credential.fullName) Log.info("Apple sign-in credential received for \(displayName ?? "unknown user")") - let profile = try await repository.signIn( + let profile = try await oAuthRepository.signIn( provider: .apple, idToken: identityToken, nonce: nonce, @@ -41,14 +50,11 @@ public class AppleOAuthProvider: AppleOAuthProviderProtocol { return profile } - public func signUp( - repository: OAuthRepositoryProtocol, - appleRepository: AppleOAuthRepositoryProtocol - ) async throws -> UserProfile { - let payload = try await fetchPayload(appleRepository: appleRepository) + public func signUp() async throws -> UserProfile { + let payload = try await fetchPayload() Log.info("apple sign-in succeeded for \(payload.displayName ?? "unknown user")") - let profile = try await repository.signIn( + let profile = try await oAuthRepository.signIn( provider: payload.provider, idToken: payload.idToken, nonce: payload.nonce, @@ -66,7 +72,7 @@ public class AppleOAuthProvider: AppleOAuthProviderProtocol { return name.isEmpty ? nil : name } - private func fetchPayload(appleRepository: AppleOAuthRepositoryProtocol) async throws -> OAuthSignInPayload { + private func fetchPayload() async throws -> OAuthSignInPayload { let payload = try await appleRepository.signIn() return OAuthSignInPayload( provider: .apple, diff --git a/Domain/Sources/UseCase/OAuth/Providers/Apple/MockAppleOAuthProvider.swift b/Domain/Sources/UseCase/OAuth/Providers/Apple/MockAppleOAuthProvider.swift new file mode 100644 index 00000000..bfff78b6 --- /dev/null +++ b/Domain/Sources/UseCase/OAuth/Providers/Apple/MockAppleOAuthProvider.swift @@ -0,0 +1,189 @@ +// +// MockAppleOAuthProvider.swift +// Domain +// +// Created by Wonji Suh on 02/09/26. +// + +import Foundation +import AuthenticationServices + +/// Mock implementation for Apple OAuth Provider for testing and previews +public class MockAppleOAuthProvider: AppleOAuthProviderProtocol, @unchecked Sendable { + public let socialType: SocialType = .apple + + // MARK: - Configuration + + /// Scenarios for testing + public enum Configuration { + case success + case failure(String) + case invalidCredentials + case networkError + case customUser(String) + case missingIDToken + case userCancelled + } + + private var configuration: Configuration = .success + private(set) var signInWithCredentialCallCount = 0 + private(set) var signUpCallCount = 0 + private var lastCredential: ASAuthorizationAppleIDCredential? + private var lastNonce: String? + + // MARK: - Initialization + + public init(configuration: Configuration = .success) { + self.configuration = configuration + } + + /// Default mock provider + public static func `default`() -> MockAppleOAuthProvider { + MockAppleOAuthProvider(configuration: .success) + } + + /// Convenience constructors + public static func success() -> MockAppleOAuthProvider { + MockAppleOAuthProvider(configuration: .success) + } + + public static func failure(_ message: String = "Mock failure") -> MockAppleOAuthProvider { + MockAppleOAuthProvider(configuration: .failure(message)) + } + + public static func customUser(_ name: String) -> MockAppleOAuthProvider { + MockAppleOAuthProvider(configuration: .customUser(name)) + } + + public static func invalidCredentials() -> MockAppleOAuthProvider { + MockAppleOAuthProvider(configuration: .invalidCredentials) + } + + public static func networkError() -> MockAppleOAuthProvider { + MockAppleOAuthProvider(configuration: .networkError) + } + + // MARK: - Configuration Management + + /// Change configuration dynamically + public func setConfiguration(_ configuration: Configuration) { + self.configuration = configuration + resetCounts() + } + + /// Reset call counts for testing + public func resetCounts() { + signInWithCredentialCallCount = 0 + signUpCallCount = 0 + lastCredential = nil + lastNonce = nil + } + + // MARK: - Test Helpers + + /// Get call count for verification + public func getSignInWithCredentialCallCount() -> Int { signInWithCredentialCallCount } + public func getSignUpCallCount() -> Int { signUpCallCount } + public func getLastNonce() -> String? { lastNonce } + + // MARK: - AppleOAuthProviderProtocol Implementation + + public func signInWithCredential( + credential: ASAuthorizationAppleIDCredential, + nonce: String + ) async throws -> UserProfile { + signInWithCredentialCallCount += 1 + lastCredential = credential + lastNonce = nonce + + // Simulate delay for realistic testing + try await Task.sleep(for: .milliseconds(100)) + + switch configuration { + case .success: + return createMockUserProfile(name: "Mock User") + + case .customUser(let name): + return createMockUserProfile(name: name) + + case .failure(let message): + throw AuthError.invalidCredential(message) + + case .invalidCredentials: + throw AuthError.missingIDToken + + case .networkError: + throw AuthError.networkError("Mock network error") + + case .missingIDToken: + throw AuthError.missingIDToken + + case .userCancelled: + throw AuthError.userCancelled + } + } + + public func signUp() async throws -> UserProfile { + signUpCallCount += 1 + + // Simulate delay for realistic testing + try await Task.sleep(for: .milliseconds(100)) + + switch configuration { + case .success: + return createMockUserProfile(name: "Mock User") + + case .customUser(let name): + return createMockUserProfile(name: name) + + case .failure(let message): + throw AuthError.invalidCredential(message) + + case .invalidCredentials: + throw AuthError.missingIDToken + + case .networkError: + throw AuthError.networkError("Mock network error") + + case .missingIDToken: + throw AuthError.missingIDToken + + case .userCancelled: + throw AuthError.userCancelled + } + } + + // MARK: - Private Helpers + + private func createMockUserProfile(name: String) -> UserProfile { + UserProfile( + id: UUID().uuidString, + email: "mock@apple.com", + displayName: name, + provider: .apple, + tokens: AuthTokens( + authToken: "mock_auth_token", + accessToken: "mock_access_token", + refreshToken: "mock_refresh_token", + sessionID: "mock_session_id" + ) + ) + } + + /// Create mock credential data for advanced testing scenarios + public func createMockCredential() -> MockCredential { + MockCredential() + } + + // Helper for creating mock ASAuthorizationAppleIDCredential data + public struct MockCredential { + public let identityToken = "mock.identity.token".data(using: .utf8) + public let authorizationCode = "mock.authorization.code".data(using: .utf8) + public let fullName: PersonNameComponents = { + var components = PersonNameComponents() + components.givenName = "Mock" + components.familyName = "User" + return components + }() + } +} \ No newline at end of file diff --git a/Domain/Sources/UseCase/OAuth/Providers/Google/GoogleOAuthProvider.swift b/Domain/Sources/UseCase/OAuth/Providers/Google/GoogleOAuthProvider.swift index 5ebff3b1..5b02c058 100644 --- a/Domain/Sources/UseCase/OAuth/Providers/Google/GoogleOAuthProvider.swift +++ b/Domain/Sources/UseCase/OAuth/Providers/Google/GoogleOAuthProvider.swift @@ -9,20 +9,27 @@ import Foundation import Dependencies import LogMacro -public class GoogleOAuthProvider: OAuthProviderProtocol { +public class GoogleOAuthProvider: GoogleOAuthProviderProtocol, @unchecked Sendable { public let socialType: SocialType = .google - public init() {} + private let oAuthRepository: OAuthRepositoryProtocol + private let googleRepository: GoogleOAuthRepositoryProtocol - - public func signUp( - repository: OAuthRepositoryProtocol, + /// DI를 위한 생성자 - Repository들을 한 번에 주입받음 + public init( + oAuthRepository: OAuthRepositoryProtocol, googleRepository: GoogleOAuthRepositoryProtocol - ) async throws -> UserProfile { - let payload = try await fetchPayload(googleRepository: googleRepository) + ) { + self.oAuthRepository = oAuthRepository + self.googleRepository = googleRepository + } + + + public func signUp() async throws -> UserProfile { + let payload = try await fetchPayload() Log.info("google sign-in succeeded for \(payload.displayName ?? "unknown user")") - let profile = try await repository.signIn( + let profile = try await oAuthRepository.signIn( provider: payload.provider, idToken: payload.idToken, nonce: payload.nonce, @@ -33,7 +40,7 @@ public class GoogleOAuthProvider: OAuthProviderProtocol { return profile } - private func fetchPayload(googleRepository: GoogleOAuthRepositoryProtocol) async throws -> OAuthSignInPayload { + private func fetchPayload() async throws -> OAuthSignInPayload { let payload = try await googleRepository.signIn() return OAuthSignInPayload( provider: .google, diff --git a/Domain/Sources/UseCase/OAuth/Providers/Google/MockGoogleOAuthProvider.swift b/Domain/Sources/UseCase/OAuth/Providers/Google/MockGoogleOAuthProvider.swift new file mode 100644 index 00000000..1c7cf6fd --- /dev/null +++ b/Domain/Sources/UseCase/OAuth/Providers/Google/MockGoogleOAuthProvider.swift @@ -0,0 +1,146 @@ +// +// MockGoogleOAuthProvider.swift +// Domain +// +// Created by Wonji Suh on 02/09/26. +// + +import Foundation + +/// Mock implementation for Google OAuth Provider for testing and previews +public class MockGoogleOAuthProvider: GoogleOAuthProviderProtocol, @unchecked Sendable { + public let socialType: SocialType = .google + + // MARK: - Configuration + + /// Scenarios for testing + public enum Configuration { + case success + case failure(String) + case invalidCredentials + case networkError + case customUser(String) + case userCancelled + case configurationMissing + } + + private var configuration: Configuration = .success + private(set) var signUpCallCount = 0 + private var lastRepository: String? // For testing repository injection + + // MARK: - Initialization + + public init(configuration: Configuration = .success) { + self.configuration = configuration + } + + /// Default mock provider + public static func `default`() -> MockGoogleOAuthProvider { + MockGoogleOAuthProvider(configuration: .success) + } + + /// Convenience constructors + public static func success() -> MockGoogleOAuthProvider { + MockGoogleOAuthProvider(configuration: .success) + } + + public static func failure(_ message: String = "Mock failure") -> MockGoogleOAuthProvider { + MockGoogleOAuthProvider(configuration: .failure(message)) + } + + public static func customUser(_ name: String) -> MockGoogleOAuthProvider { + MockGoogleOAuthProvider(configuration: .customUser(name)) + } + + public static func invalidCredentials() -> MockGoogleOAuthProvider { + MockGoogleOAuthProvider(configuration: .invalidCredentials) + } + + public static func networkError() -> MockGoogleOAuthProvider { + MockGoogleOAuthProvider(configuration: .networkError) + } + + // MARK: - Configuration Management + + /// Change configuration dynamically + public func setConfiguration(_ configuration: Configuration) { + self.configuration = configuration + resetCounts() + } + + /// Reset call counts for testing + public func resetCounts() { + signUpCallCount = 0 + lastRepository = nil + } + + // MARK: - Test Helpers + + /// Get call count for verification + public func getSignUpCallCount() -> Int { signUpCallCount } + public func getLastRepository() -> String? { lastRepository } + + // MARK: - GoogleOAuthProviderProtocol Implementation + + public func signUp() async throws -> UserProfile { + signUpCallCount += 1 + lastRepository = "GoogleOAuthRepository" + + // Simulate delay for realistic testing + try await Task.sleep(for: .milliseconds(100)) + + switch configuration { + case .success: + return createMockUserProfile(name: "Mock Google User") + + case .customUser(let name): + return createMockUserProfile(name: name) + + case .failure(let message): + throw AuthError.invalidCredential(message) + + case .invalidCredentials: + throw AuthError.invalidCredential("Google credentials invalid") + + case .networkError: + throw AuthError.networkError("Google network error") + + case .userCancelled: + throw AuthError.userCancelled + + case .configurationMissing: + throw AuthError.configurationMissing + } + } + + // MARK: - Private Helpers + + private func createMockUserProfile(name: String) -> UserProfile { + UserProfile( + id: UUID().uuidString, + email: "\(name.lowercased())@gmail.com", + displayName: name, + provider: .google, + tokens: AuthTokens( + authToken: "mock_auth_token", + accessToken: "mock_access_token", + refreshToken: "mock_refresh_token", + sessionID: "mock_session_id" + ) + ) + } + + /// Create mock Google payload data for testing + public func createMockGooglePayload() -> MockGooglePayload { + MockGooglePayload() + } + + // Helper for creating mock Google OAuth data + public struct MockGooglePayload { + public let idToken = "mock.google.id.token" + public let authorizationCode = "mock.google.auth.code" + public let displayName = "Mock Google User" + public let email = "mockuser@gmail.com" + public let profileImageURL = "https://lh3.googleusercontent.com/a/mock-profile-image" + } +} \ No newline at end of file diff --git a/Domain/Sources/UseCase/OAuth/Providers/Protocols/AppleOAuthProviderProtocol.swift b/Domain/Sources/UseCase/OAuth/Providers/Protocols/AppleOAuthProviderProtocol.swift new file mode 100644 index 00000000..dc3be9a6 --- /dev/null +++ b/Domain/Sources/UseCase/OAuth/Providers/Protocols/AppleOAuthProviderProtocol.swift @@ -0,0 +1,19 @@ +// +// AppleOAuthProviderProtocol.swift +// Domain +// +// Created by Wonji Suh on 02/09/26. +// + +import Foundation +import AuthenticationServices + +/// Apple-specific OAuth provider protocol +public protocol AppleOAuthProviderProtocol: OAuthProviderProtocol, Sendable { + func signInWithCredential( + credential: ASAuthorizationAppleIDCredential, + nonce: String + ) async throws -> UserProfile + + func signUp() async throws -> UserProfile +} \ No newline at end of file diff --git a/Domain/Sources/UseCase/OAuth/Providers/Protocols/BaseOAuthProviderProtocol.swift b/Domain/Sources/UseCase/OAuth/Providers/Protocols/BaseOAuthProviderProtocol.swift new file mode 100644 index 00000000..d91cd1e6 --- /dev/null +++ b/Domain/Sources/UseCase/OAuth/Providers/Protocols/BaseOAuthProviderProtocol.swift @@ -0,0 +1,13 @@ +// +// BaseOAuthProviderProtocol.swift +// Domain +// +// Created by Wonji Suh on 02/09/26. +// + +import Foundation + +/// Base protocol for all OAuth providers +public protocol OAuthProviderProtocol { + var socialType: SocialType { get } +} \ No newline at end of file diff --git a/Domain/Sources/UseCase/OAuth/Providers/Protocols/GoogleOAuthProviderProtocol.swift b/Domain/Sources/UseCase/OAuth/Providers/Protocols/GoogleOAuthProviderProtocol.swift new file mode 100644 index 00000000..eea6ba4c --- /dev/null +++ b/Domain/Sources/UseCase/OAuth/Providers/Protocols/GoogleOAuthProviderProtocol.swift @@ -0,0 +1,13 @@ +// +// GoogleOAuthProviderProtocol.swift +// Domain +// +// Created by Wonji Suh on 02/09/26. +// + +import Foundation + +/// Google-specific OAuth provider protocol +public protocol GoogleOAuthProviderProtocol: OAuthProviderProtocol, Sendable { + func signUp() async throws -> UserProfile +} \ No newline at end of file diff --git a/Domain/Sources/UseCase/OAuth/Providers/Protocols/OAuthProviderProtocol.swift b/Domain/Sources/UseCase/OAuth/Providers/Protocols/OAuthProviderProtocol.swift deleted file mode 100644 index fe730f2b..00000000 --- a/Domain/Sources/UseCase/OAuth/Providers/Protocols/OAuthProviderProtocol.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// OAuthProviderProtocol.swift -// Domain -// -// Created by Wonji Suh on 12/17/25. -// - -import Foundation -import AuthenticationServices - -public protocol OAuthProviderProtocol { - var socialType: SocialType { get } -} - -// Apple 전용 프로토콜 -public protocol AppleOAuthProviderProtocol: OAuthProviderProtocol { - func signInWithCredential( - credential: ASAuthorizationAppleIDCredential, - nonce: String, - repository: OAuthRepositoryProtocol - ) async throws -> UserProfile - - func signUp( - repository: OAuthRepositoryProtocol, - appleRepository: AppleOAuthRepositoryProtocol - ) async throws -> UserProfile -} \ No newline at end of file From 97471dbefb4501d28406477ab215c4084556f4ed Mon Sep 17 00:00:00 2001 From: Roy Date: Mon, 9 Feb 2026 15:02:43 +0900 Subject: [PATCH 5/7] =?UTF-8?q?refactor:=20OAuth=20DI=20=ED=8C=A8=ED=84=B4?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EB=A7=8C=EB=A3=8C=20=EB=A6=AC=EC=8A=A4=EB=84=88=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Travels/Components/TravelEmptyView.swift | 2 +- .../Application/LiveDependencies.swift | 17 ++++++++++- SseuDamApp/Sources/Reducer/AppFeature.swift | 30 +++++++++++++++++-- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/Features/Travel/Sources/View/Travels/Components/TravelEmptyView.swift b/Features/Travel/Sources/View/Travels/Components/TravelEmptyView.swift index 7534680a..ca4d6236 100644 --- a/Features/Travel/Sources/View/Travels/Components/TravelEmptyView.swift +++ b/Features/Travel/Sources/View/Travels/Components/TravelEmptyView.swift @@ -11,7 +11,7 @@ import DesignSystem struct TravelEmptyView: View { var body: some View { VStack(alignment: .center, spacing: 0) { - Image(assetName: "emptyTravelList") + Image(assetName: "EmptyTravelList") .resizable() .scaledToFit() .frame(height: 160) diff --git a/SseuDamApp/Sources/Application/LiveDependencies.swift b/SseuDamApp/Sources/Application/LiveDependencies.swift index df6e2841..acf030c3 100644 --- a/SseuDamApp/Sources/Application/LiveDependencies.swift +++ b/SseuDamApp/Sources/Application/LiveDependencies.swift @@ -13,7 +13,10 @@ import Domain public enum LiveDependencies { @MainActor public static func register(_ dependencies: inout DependencyValues) { - // Auth & Session + // ✅ DI 패턴으로 변경된 의존성들 (순서 중요!) + dependencies.keychainManager = KeychainManager.live + + // Repository 먼저 등록 dependencies.authRepository = AuthRepository() dependencies.sessionRepository = SessionRepository() @@ -24,6 +27,18 @@ public enum LiveDependencies { presentationContextProvider: AppPresentationContextProvider() ) dependencies.sessionStoreRepository = SessionStoreRepository() + + // ✅ Provider 등록 (Repository 의존성 주입) + dependencies.appleOAuthProvider = AppleOAuthProvider( + oAuthRepository: dependencies.oAuthRepository, + appleRepository: dependencies.appleOAuthRepository + ) + + dependencies.googleOAuthProvider = GoogleOAuthProvider( + oAuthRepository: dependencies.oAuthRepository, + googleRepository: dependencies.googleOAuthRepository + ) + // Analytics dependencies.analyticsRepository = AnalyticsRepository() diff --git a/SseuDamApp/Sources/Reducer/AppFeature.swift b/SseuDamApp/Sources/Reducer/AppFeature.swift index f9d073c3..2c093a44 100644 --- a/SseuDamApp/Sources/Reducer/AppFeature.swift +++ b/SseuDamApp/Sources/Reducer/AppFeature.swift @@ -68,6 +68,8 @@ struct AppFeature { case setupPushNotificationObserver case handlePushDeepLink(String) case checkPendingPushDeepLink + case startTokenExpiryListener // ✅ 토큰 만료 리스너 시작 + case handleTokenExpiry // ✅ 토큰 만료 처리 } @CasePathable @@ -90,6 +92,7 @@ struct AppFeature { case transitionToLogin case transitionToMain case transitionToProfile + case refreshTokenExpiredListener // 토큰 만료 리스너 } // MARK: - body @@ -220,12 +223,18 @@ extension AppFeature { state.splash = nil state.login = nil + // 토큰 만료 리스너 시작 + let tokenExpiryEffect = Effect.send(.inner(.startTokenExpiryListener)) + // 대기 중인 푸시 딥 링크가 있으면 처리 if case .push(let deepLink) = pending { - return .send(.inner(.handlePushDeepLink(deepLink))) + return .merge( + tokenExpiryEffect, + .send(.inner(.handlePushDeepLink(deepLink))) + ) } - return .none + return tokenExpiryEffect case .handleDeepLinkJoin(let inviteCode): // 딥링크를 통한 여행 참여 처리 (팝업 우선 표시) @@ -273,6 +282,13 @@ extension AppFeature { } } + case .startTokenExpiryListener: + return setupRefreshTokenExpiredListener() + + case .handleTokenExpiry: + // 토큰 만료 시 로그인 화면으로 이동 + return .send(.view(.presentLogin)) + case .setupPushNotificationObserver: return .run { send in for await notification in NotificationCenter.default.notifications(named: .pushNotificationDeepLink) { @@ -331,4 +347,14 @@ extension AppFeature { return .none } } + + // MARK: - 토큰 만료 리스너 설정 (효율적인 Publisher 패턴) + private func setupRefreshTokenExpiredListener() -> Effect { + return .publisher { + NotificationCenter.default + .publisher(for: .refreshTokenExpired) + .map { _ in Action.inner(.handleTokenExpiry) } + } + .cancellable(id: CancelID.refreshTokenExpiredListener, cancelInFlight: true) + } } From 1efb1ed0a1ce1de93f4733333834f82bb41c1661 Mon Sep 17 00:00:00 2001 From: Roy Date: Mon, 9 Feb 2026 23:07:56 +0900 Subject: [PATCH 6/7] =?UTF-8?q?refactor:=20DI=20pattern=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=EC=9C=BC=EB=A1=9C=20KeychainManager=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=A3=BC=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Network/Authentication/AuthSessionManager.swift | 4 ++-- .../Network/Authentication/TokenRefreshManager.swift | 6 +++--- .../SessionStore/SessionStoreRepository.swift | 12 ++++++++---- .../TargetType/Common/Extension+APIHeader.swift | 10 +++++----- Domain/Sources/UseCase/Auth/AuthUseCase.swift | 5 +++-- .../Profile/Sources/Reducer/ProfileFeature.swift | 3 ++- Features/Splash/Sources/Reducer/SplashFeature.swift | 3 ++- 7 files changed, 25 insertions(+), 18 deletions(-) diff --git a/Data/Sources/Network/Authentication/AuthSessionManager.swift b/Data/Sources/Network/Authentication/AuthSessionManager.swift index 82ca966a..3747821c 100644 --- a/Data/Sources/Network/Authentication/AuthSessionManager.swift +++ b/Data/Sources/Network/Authentication/AuthSessionManager.swift @@ -96,8 +96,8 @@ private extension AuthSessionManager { static func loadCredentialFromKeychain() -> AccessTokenCredential? { guard - let accessToken = KeychainManager.shared.loadAccessToken(), - let refreshToken = KeychainManager.shared.loadRefreshToken() + let accessToken = KeychainManager.live.loadAccessToken(), + let refreshToken = KeychainManager.live.loadRefreshToken() else { return nil } diff --git a/Data/Sources/Network/Authentication/TokenRefreshManager.swift b/Data/Sources/Network/Authentication/TokenRefreshManager.swift index 1ebc70f6..7eeabdd3 100644 --- a/Data/Sources/Network/Authentication/TokenRefreshManager.swift +++ b/Data/Sources/Network/Authentication/TokenRefreshManager.swift @@ -87,7 +87,7 @@ private extension TokenRefreshManager { let tokens = result.token // Save to keychain - KeychainManager.shared.saveTokens( + KeychainManager.live.saveTokens( accessToken: tokens.accessToken, refreshToken: tokens.refreshToken ) @@ -120,7 +120,7 @@ private extension TokenRefreshManager { func performAutomaticLogout() async { // 1. Clear keychain - KeychainManager.shared.clearAll() + KeychainManager.live.clearAll() // 2. Clear session manager credential await MainActor.run { @@ -136,4 +136,4 @@ private extension TokenRefreshManager { ) } } -} \ No newline at end of file +} diff --git a/Data/Sources/Repository/SessionStore/SessionStoreRepository.swift b/Data/Sources/Repository/SessionStore/SessionStoreRepository.swift index eac24c6e..e28ec5a0 100644 --- a/Data/Sources/Repository/SessionStore/SessionStoreRepository.swift +++ b/Data/Sources/Repository/SessionStore/SessionStoreRepository.swift @@ -16,10 +16,14 @@ public final class SessionStoreRepository: SessionStoreRepositoryProtocol, @unch static let sessionId = "sessionId" } - public init() {} + private let keychainManager: KeychainManaging + + public init(keychainManager: KeychainManaging = KeychainManager.live) { + self.keychainManager = keychainManager + } public func save(tokens: AuthTokens, socialType: SocialType?, userId: String?) async { - KeychainManager.shared.saveTokens( + keychainManager.saveTokens( accessToken: tokens.accessToken, refreshToken: tokens.refreshToken ) @@ -37,7 +41,7 @@ public final class SessionStoreRepository: SessionStoreRepositoryProtocol, @unch } public func loadTokens() async -> AuthTokens? { - let tokens = KeychainManager.shared.loadTokens() + let tokens = keychainManager.loadTokens() guard let access = tokens.accessToken, let refresh = tokens.refreshToken else { return nil } @@ -61,7 +65,7 @@ public final class SessionStoreRepository: SessionStoreRepositoryProtocol, @unch } public func clearAll() async { - KeychainManager.shared.clearAll() + keychainManager.clearAll() UserDefaults.standard.removeObject(forKey: Keys.sessionId) UserDefaults.standard.removeObject(forKey: Keys.socialType) UserDefaults.standard.removeObject(forKey: Keys.userId) diff --git a/Data/Sources/TargetType/Common/Extension+APIHeader.swift b/Data/Sources/TargetType/Common/Extension+APIHeader.swift index c5bf4301..4cc27b0b 100644 --- a/Data/Sources/TargetType/Common/Extension+APIHeader.swift +++ b/Data/Sources/TargetType/Common/Extension+APIHeader.swift @@ -12,17 +12,17 @@ import Domain public extension APIHeaders { static let registerKeychainTokenProvider: Void = { APIHeaders.setTokenProvider { - KeychainManager.shared.loadAccessToken() + KeychainManager.live.loadAccessToken() } }() - + static var accessTokenHeader: [String: String] { _ = registerKeychainTokenProvider - - guard let token = KeychainManager.shared.loadAccessToken() else { + + guard let token = KeychainManager.live.loadAccessToken() else { return ["Content-Type": "application/json"] } - + return [ "Content-Type": "application/json", "Authorization": "Bearer \(token)" diff --git a/Domain/Sources/UseCase/Auth/AuthUseCase.swift b/Domain/Sources/UseCase/Auth/AuthUseCase.swift index 4dc0eeb4..37e1de33 100644 --- a/Domain/Sources/UseCase/Auth/AuthUseCase.swift +++ b/Domain/Sources/UseCase/Auth/AuthUseCase.swift @@ -15,7 +15,8 @@ public struct AuthUseCase: AuthUseCaseProtocol { @Dependency(\.authRepository) private var repository: AuthRepositoryProtocol @Dependency(\.authRepository) private var authRepository: AuthRepositoryProtocol @Dependency(\.tokenStorageUseCase) private var tokenStorageUseCase: TokenStorageUseCase - + @Dependency(\.keychainManager) private var keychainManager + public init() {} @@ -41,7 +42,7 @@ public struct AuthUseCase: AuthUseCaseProtocol { public func deleteUser() async throws -> AuthDeleteStatus { let result = try await repository.delete() - KeychainManager.shared.clearAll() + keychainManager.clearAll() return result } } diff --git a/Features/Profile/Sources/Reducer/ProfileFeature.swift b/Features/Profile/Sources/Reducer/ProfileFeature.swift index ac5ad024..5834c1d0 100644 --- a/Features/Profile/Sources/Reducer/ProfileFeature.swift +++ b/Features/Profile/Sources/Reducer/ProfileFeature.swift @@ -122,6 +122,7 @@ public struct ProfileFeature { @Dependency(AuthUseCase.self) var authUseCase @Dependency(ProfileUseCase.self) var profileUseCase + @Dependency(\.keychainManager) var keychainManager // MARK: - Email Support public static func emailRecipients() -> [String] { @@ -425,7 +426,7 @@ extension ProfileFeature { case .success(let loginData): state.logoutStatus = loginData // Keychain과 AppStorage 모두 정리 - KeychainManager.shared.clearAll() + keychainManager.clearAll() return .send(.delegate(.presentLogin)) case .failure(let error): diff --git a/Features/Splash/Sources/Reducer/SplashFeature.swift b/Features/Splash/Sources/Reducer/SplashFeature.swift index 57d9cdeb..428f98f9 100644 --- a/Features/Splash/Sources/Reducer/SplashFeature.swift +++ b/Features/Splash/Sources/Reducer/SplashFeature.swift @@ -84,6 +84,7 @@ public struct SplashFeature { @Dependency(SessionUseCase.self) var sessionUseCase @Dependency(\.continuousClock) var clock @Dependency(\.versionUseCase) var versionUseCase + @Dependency(\.keychainManager) var keychainManager public var body: some Reducer { BindingReducer() @@ -280,7 +281,7 @@ private extension SplashFeature { return } - guard let accessToken = KeychainManager.shared.loadAccessToken(), + guard let accessToken = keychainManager.loadAccessToken(), !accessToken.isEmpty else { await send(.async(.checkSession)) return From ff6bca52d1c5e30162168506684bdf3344811ebd Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 10 Feb 2026 21:28:13 +0900 Subject: [PATCH 7/7] =?UTF-8?q?refactor:=20KeychainManager=20async/await?= =?UTF-8?q?=20=EB=B3=80=ED=99=98=20=EB=B0=8F=20=EB=8F=99=EA=B8=B0=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Authentication/AuthSessionManager.swift | 4 +- .../Authentication/TokenRefreshManager.swift | 4 +- .../SessionStore/SessionStoreRepository.swift | 6 +- .../Common/Extension+APIHeader.swift | 6 +- .../Manager/InMemoryKeychainManager.swift | 50 ++++--------- Domain/Sources/Manager/KeychainManager.swift | 71 +++++++++++-------- .../Manager/KeychainManagerProtocol.swift | 20 ++---- Domain/Sources/UseCase/Auth/AuthUseCase.swift | 2 +- .../Sources/Reducer/ProfileFeature.swift | 4 +- .../Sources/Reducer/SplashFeature.swift | 2 +- 10 files changed, 76 insertions(+), 93 deletions(-) diff --git a/Data/Sources/Network/Authentication/AuthSessionManager.swift b/Data/Sources/Network/Authentication/AuthSessionManager.swift index 3747821c..dd4a8be4 100644 --- a/Data/Sources/Network/Authentication/AuthSessionManager.swift +++ b/Data/Sources/Network/Authentication/AuthSessionManager.swift @@ -96,8 +96,8 @@ private extension AuthSessionManager { static func loadCredentialFromKeychain() -> AccessTokenCredential? { guard - let accessToken = KeychainManager.live.loadAccessToken(), - let refreshToken = KeychainManager.live.loadRefreshToken() + let accessToken = KeychainManager.live.loadAccessTokenSync(), + let refreshToken = KeychainManager.live.loadRefreshTokenSync() else { return nil } diff --git a/Data/Sources/Network/Authentication/TokenRefreshManager.swift b/Data/Sources/Network/Authentication/TokenRefreshManager.swift index 7eeabdd3..dd8a8add 100644 --- a/Data/Sources/Network/Authentication/TokenRefreshManager.swift +++ b/Data/Sources/Network/Authentication/TokenRefreshManager.swift @@ -87,7 +87,7 @@ private extension TokenRefreshManager { let tokens = result.token // Save to keychain - KeychainManager.live.saveTokens( + await KeychainManager.live.saveTokens( accessToken: tokens.accessToken, refreshToken: tokens.refreshToken ) @@ -120,7 +120,7 @@ private extension TokenRefreshManager { func performAutomaticLogout() async { // 1. Clear keychain - KeychainManager.live.clearAll() + await KeychainManager.live.clearAll() // 2. Clear session manager credential await MainActor.run { diff --git a/Data/Sources/Repository/SessionStore/SessionStoreRepository.swift b/Data/Sources/Repository/SessionStore/SessionStoreRepository.swift index e28ec5a0..9d781706 100644 --- a/Data/Sources/Repository/SessionStore/SessionStoreRepository.swift +++ b/Data/Sources/Repository/SessionStore/SessionStoreRepository.swift @@ -23,7 +23,7 @@ public final class SessionStoreRepository: SessionStoreRepositoryProtocol, @unch } public func save(tokens: AuthTokens, socialType: SocialType?, userId: String?) async { - keychainManager.saveTokens( + await keychainManager.saveTokens( accessToken: tokens.accessToken, refreshToken: tokens.refreshToken ) @@ -41,7 +41,7 @@ public final class SessionStoreRepository: SessionStoreRepositoryProtocol, @unch } public func loadTokens() async -> AuthTokens? { - let tokens = keychainManager.loadTokens() + let tokens = await keychainManager.loadTokens() guard let access = tokens.accessToken, let refresh = tokens.refreshToken else { return nil } @@ -65,7 +65,7 @@ public final class SessionStoreRepository: SessionStoreRepositoryProtocol, @unch } public func clearAll() async { - keychainManager.clearAll() + await keychainManager.clearAll() UserDefaults.standard.removeObject(forKey: Keys.sessionId) UserDefaults.standard.removeObject(forKey: Keys.socialType) UserDefaults.standard.removeObject(forKey: Keys.userId) diff --git a/Data/Sources/TargetType/Common/Extension+APIHeader.swift b/Data/Sources/TargetType/Common/Extension+APIHeader.swift index 4cc27b0b..6f385ab8 100644 --- a/Data/Sources/TargetType/Common/Extension+APIHeader.swift +++ b/Data/Sources/TargetType/Common/Extension+APIHeader.swift @@ -12,14 +12,14 @@ import Domain public extension APIHeaders { static let registerKeychainTokenProvider: Void = { APIHeaders.setTokenProvider { - KeychainManager.live.loadAccessToken() + KeychainManager.live.loadAccessTokenSync() } }() static var accessTokenHeader: [String: String] { _ = registerKeychainTokenProvider - guard let token = KeychainManager.live.loadAccessToken() else { + guard let token = KeychainManager.live.loadAccessTokenSync() else { return ["Content-Type": "application/json"] } @@ -37,4 +37,4 @@ public extension BaseTargetType where Domain == SseuDamDomain { ? APIHeaders.authorizedOrCached : APIHeaders.cached } -} +} \ No newline at end of file diff --git a/Domain/Sources/Manager/InMemoryKeychainManager.swift b/Domain/Sources/Manager/InMemoryKeychainManager.swift index 90ffc9b3..c40a5a61 100644 --- a/Domain/Sources/Manager/InMemoryKeychainManager.swift +++ b/Domain/Sources/Manager/InMemoryKeychainManager.swift @@ -12,10 +12,9 @@ import Foundation /// In-Memory implementation for KeychainManaging protocol /// Used for testing and SwiftUI previews -public final class InMemoryKeychainManager: KeychainManaging, @unchecked Sendable { +public actor InMemoryKeychainManager: KeychainManaging { private var accessTokenStorage: String? private var refreshTokenStorage: String? - private let lock = NSLock() // MARK: - Test helpers private(set) var saveAccessTokenCallCount = 0 @@ -27,24 +26,17 @@ public final class InMemoryKeychainManager: KeychainManaging, @unchecked Sendabl // MARK: - KeychainManaging Implementation - public func saveAccessToken(_ token: String) { - lock.lock() - defer { lock.unlock() } + public func saveAccessToken(_ token: String) async { accessTokenStorage = token saveAccessTokenCallCount += 1 } - public func saveRefreshToken(_ token: String) { - lock.lock() - defer { lock.unlock() } + public func saveRefreshToken(_ token: String) async { refreshTokenStorage = token saveRefreshTokenCallCount += 1 } - public func saveTokens(accessToken: String?, refreshToken: String?) { - lock.lock() - defer { lock.unlock() } - + public func saveTokens(accessToken: String?, refreshToken: String?) async { if let accessToken { accessTokenStorage = accessToken } @@ -54,27 +46,19 @@ public final class InMemoryKeychainManager: KeychainManaging, @unchecked Sendabl saveTokensCallCount += 1 } - public func loadAccessToken() -> String? { - lock.lock() - defer { lock.unlock() } + public func loadAccessToken() async -> String? { return accessTokenStorage } - public func loadRefreshToken() -> String? { - lock.lock() - defer { lock.unlock() } + public func loadRefreshToken() async -> String? { return refreshTokenStorage } - public func loadTokens() -> (accessToken: String?, refreshToken: String?) { - lock.lock() - defer { lock.unlock() } + public func loadTokens() async -> (accessToken: String?, refreshToken: String?) { return (accessTokenStorage, refreshTokenStorage) } - public func clearAll() { - lock.lock() - defer { lock.unlock() } + public func clearAll() async { accessTokenStorage = nil refreshTokenStorage = nil clearAllCallCount += 1 @@ -83,9 +67,7 @@ public final class InMemoryKeychainManager: KeychainManaging, @unchecked Sendabl // MARK: - Test Helpers /// Reset all call counts for testing - public func resetCounts() { - lock.lock() - defer { lock.unlock() } + public func resetCounts() async { saveAccessTokenCallCount = 0 saveRefreshTokenCallCount = 0 saveTokensCallCount = 0 @@ -94,30 +76,22 @@ public final class InMemoryKeychainManager: KeychainManaging, @unchecked Sendabl /// Check if tokens are stored public var hasStoredTokens: Bool { - lock.lock() - defer { lock.unlock() } return accessTokenStorage != nil && refreshTokenStorage != nil } /// Preload tokens for testing - public func preloadTokens(accessToken: String, refreshToken: String) { - lock.lock() - defer { lock.unlock() } + public func preloadTokens(accessToken: String, refreshToken: String) async { accessTokenStorage = accessToken refreshTokenStorage = refreshToken } /// Verify specific tokens are saved - public func verifyTokensSaved(accessToken: String, refreshToken: String) -> Bool { - lock.lock() - defer { lock.unlock() } + public func verifyTokensSaved(accessToken: String, refreshToken: String) async -> Bool { return accessTokenStorage == accessToken && refreshTokenStorage == refreshToken } /// Get all call counts for verification - public func getAllCallCounts() -> (saveAccessToken: Int, saveRefreshToken: Int, saveTokens: Int, clearAll: Int) { - lock.lock() - defer { lock.unlock() } + public func getAllCallCounts() async -> (saveAccessToken: Int, saveRefreshToken: Int, saveTokens: Int, clearAll: Int) { return (saveAccessTokenCallCount, saveRefreshTokenCallCount, saveTokensCallCount, clearAllCallCount) } } \ No newline at end of file diff --git a/Domain/Sources/Manager/KeychainManager.swift b/Domain/Sources/Manager/KeychainManager.swift index ee508aba..0f2e470b 100644 --- a/Domain/Sources/Manager/KeychainManager.swift +++ b/Domain/Sources/Manager/KeychainManager.swift @@ -7,7 +7,6 @@ import Foundation import Security -import LogMacro enum KeychainKey: String { case accessToken = "access_token" @@ -25,51 +24,41 @@ public final class KeychainManager: KeychainManaging, @unchecked Sendable { self.service = service } - // MARK: - 공개 API + // MARK: - KeychainManaging Protocol (Async Methods) /// 액세스 토큰을 키체인에 저장 - public func saveAccessToken(_ token: String) { + public func saveAccessToken(_ token: String) async { save(token: token, for: .accessToken) } /// 리프레시 토큰을 키체인에 저장 - public func saveRefreshToken(_ token: String) { + public func saveRefreshToken(_ token: String) async { save(token: token, for: .refreshToken) } /// 키체인에서 액세스 토큰 로드 - public func loadAccessToken() -> String? { + public func loadAccessToken() async -> String? { loadToken(for: .accessToken) } /// 키체인에서 리프레시 토큰 로드 - public func loadRefreshToken() -> String? { + public func loadRefreshToken() async -> String? { loadToken(for: .refreshToken) } - /// 액세스 토큰 삭제 - public func deleteAccessToken() { - deleteToken(for: .accessToken) - } - - /// 리프레시 토큰 삭제 - public func deleteRefreshToken() { - deleteToken(for: .refreshToken) - } - /// 두 토큰을 원자적으로 저장 public func saveTokens( accessToken: String?, refreshToken: String? - ) { + ) async { if let accessToken { - saveAccessToken(accessToken) + await saveAccessToken(accessToken) } else { deleteAccessToken() } if let refreshToken { - saveRefreshToken(refreshToken) + await saveRefreshToken(refreshToken) } else { deleteRefreshToken() } @@ -78,23 +67,47 @@ public final class KeychainManager: KeychainManaging, @unchecked Sendable { } /// 두 토큰을 모두 로드 - public func loadTokens() -> (accessToken: String?, refreshToken: String?) { - (loadAccessToken(), loadRefreshToken()) + public func loadTokens() async -> (accessToken: String?, refreshToken: String?) { + (await loadAccessToken(), await loadRefreshToken()) } /// 모든 토큰 삭제 (로그아웃 시 사용) - public func clearAll() { + public func clearAll() async { deleteAccessToken() deleteRefreshToken() NotificationCenter.default.post(name: .tokensDidClear, object: nil) } - // MARK: - 내부 헬퍼 메서드 + // MARK: - Synchronous Methods for Compatibility + + /// 키체인에서 액세스 토큰 로드 (동기 버전) + public func loadAccessTokenSync() -> String? { + return loadToken(for: .accessToken) + } + + /// 키체인에서 리프레시 토큰 로드 (동기 버전) + public func loadRefreshTokenSync() -> String? { + return loadToken(for: .refreshToken) + } + + // MARK: - Additional Methods + + /// 액세스 토큰 삭제 + public func deleteAccessToken() { + deleteToken(for: .accessToken) + } + + /// 리프레시 토큰 삭제 + public func deleteRefreshToken() { + deleteToken(for: .refreshToken) + } + + // MARK: - Private Helper Methods /// 토큰을 키체인에 저장 (Update-Add 패턴 사용) private func save(token: String, for key: KeychainKey) { guard let data = token.data(using: .utf8) else { - Log.info("Keychain: 토큰을 데이터로 변환 실패, key: \(key.rawValue)") + print("Keychain: 토큰을 데이터로 변환 실패, key: \(key.rawValue)") return } @@ -121,10 +134,10 @@ public final class KeychainManager: KeychainManaging, @unchecked Sendable { let addStatus = SecItemAdd(addQuery as CFDictionary, nil) if addStatus != errSecSuccess { - Log.info("Keychain: Failed to add token for key \(key.rawValue), status: \(addStatus)") + print("Keychain: Failed to add token for key \(key.rawValue), status: \(addStatus)") } } else if updateStatus != errSecSuccess { - Log.info("Keychain: Failed to update token for key \(key.rawValue), status: \(updateStatus)") + print("Keychain: Failed to update token for key \(key.rawValue), status: \(updateStatus)") } } @@ -145,7 +158,7 @@ public final class KeychainManager: KeychainManaging, @unchecked Sendable { let token = String(data: data, encoding: .utf8) else { if status != errSecItemNotFound { - Log.info("Keychain: Failed to load token for key \(key.rawValue), status: \(status)") + print("Keychain: Failed to load token for key \(key.rawValue), status: \(status)") } return nil } @@ -162,7 +175,7 @@ public final class KeychainManager: KeychainManaging, @unchecked Sendable { let status = SecItemDelete(query as CFDictionary) if status != errSecSuccess && status != errSecItemNotFound { - Log.info("Keychain: Failed to delete token for key \(key.rawValue), status: \(status)") + print("Keychain: Failed to delete token for key \(key.rawValue), status: \(status)") } } } @@ -171,4 +184,4 @@ public extension Notification.Name { static let tokensDidUpdate = Notification.Name("tokensDidUpdate") static let tokensDidClear = Notification.Name("tokensDidClear") static let refreshTokenExpired = Notification.Name("RefreshTokenExpired") -} +} \ No newline at end of file diff --git a/Domain/Sources/Manager/KeychainManagerProtocol.swift b/Domain/Sources/Manager/KeychainManagerProtocol.swift index d9c719e4..736590e2 100644 --- a/Domain/Sources/Manager/KeychainManagerProtocol.swift +++ b/Domain/Sources/Manager/KeychainManagerProtocol.swift @@ -11,23 +11,17 @@ import Foundation /// Provides secure storage for authentication tokens public protocol KeychainManaging: Sendable { /// Save access token to keychain - func saveAccessToken(_ token: String) - + func saveAccessToken(_ token: String) async /// Save refresh token to keychain - func saveRefreshToken(_ token: String) - + func saveRefreshToken(_ token: String) async /// Save both tokens atomically - func saveTokens(accessToken: String?, refreshToken: String?) - + func saveTokens(accessToken: String?, refreshToken: String?) async /// Load access token from keychain - func loadAccessToken() -> String? - + func loadAccessToken() async -> String? /// Load refresh token from keychain - func loadRefreshToken() -> String? - + func loadRefreshToken() async -> String? /// Load both tokens - func loadTokens() -> (accessToken: String?, refreshToken: String?) - + func loadTokens() async -> (accessToken: String?, refreshToken: String?) /// Clear all stored tokens - func clearAll() + func clearAll() async } \ No newline at end of file diff --git a/Domain/Sources/UseCase/Auth/AuthUseCase.swift b/Domain/Sources/UseCase/Auth/AuthUseCase.swift index 37e1de33..9831968e 100644 --- a/Domain/Sources/UseCase/Auth/AuthUseCase.swift +++ b/Domain/Sources/UseCase/Auth/AuthUseCase.swift @@ -42,7 +42,7 @@ public struct AuthUseCase: AuthUseCaseProtocol { public func deleteUser() async throws -> AuthDeleteStatus { let result = try await repository.delete() - keychainManager.clearAll() + await keychainManager.clearAll() return result } } diff --git a/Features/Profile/Sources/Reducer/ProfileFeature.swift b/Features/Profile/Sources/Reducer/ProfileFeature.swift index 5834c1d0..069ee21b 100644 --- a/Features/Profile/Sources/Reducer/ProfileFeature.swift +++ b/Features/Profile/Sources/Reducer/ProfileFeature.swift @@ -426,7 +426,9 @@ extension ProfileFeature { case .success(let loginData): state.logoutStatus = loginData // Keychain과 AppStorage 모두 정리 - keychainManager.clearAll() + Task { + await keychainManager.clearAll() + } return .send(.delegate(.presentLogin)) case .failure(let error): diff --git a/Features/Splash/Sources/Reducer/SplashFeature.swift b/Features/Splash/Sources/Reducer/SplashFeature.swift index 428f98f9..4ec7f091 100644 --- a/Features/Splash/Sources/Reducer/SplashFeature.swift +++ b/Features/Splash/Sources/Reducer/SplashFeature.swift @@ -281,7 +281,7 @@ private extension SplashFeature { return } - guard let accessToken = keychainManager.loadAccessToken(), + guard let accessToken = await keychainManager.loadAccessToken(), !accessToken.isEmpty else { await send(.async(.checkSession)) return