From e08d31e7be122652e88e2764b79159cd485f6059 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:39:03 +0900 Subject: [PATCH 1/5] =?UTF-8?q?[#465]=20KeychainHelper=EC=97=90=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=20=EC=8B=9C=EC=A0=90=20=EC=A7=80=EC=A0=95?= =?UTF-8?q?=EA=B3=BC=20=EC=82=AD=EC=A0=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Data/LocalDB/RealmService.swift | 63 ------------------- EATSSU/App/Sources/Data/LocalDB/Token.swift | 27 -------- .../Utility/Helper/KeychainHelper.swift | 21 ++++++- 3 files changed, 19 insertions(+), 92 deletions(-) delete mode 100644 EATSSU/App/Sources/Data/LocalDB/RealmService.swift delete mode 100644 EATSSU/App/Sources/Data/LocalDB/Token.swift diff --git a/EATSSU/App/Sources/Data/LocalDB/RealmService.swift b/EATSSU/App/Sources/Data/LocalDB/RealmService.swift deleted file mode 100644 index 75047606..00000000 --- a/EATSSU/App/Sources/Data/LocalDB/RealmService.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// RealmService.swift -// EatSSU-iOS -// -// Created by 박윤빈 on 2023/05/27. -// - -import Realm -import RealmSwift - -class RealmService { - static let shared = RealmService() - - private init() { - let realm = try! Realm() - print("Realm Location: ", realm.configuration.fileURL ?? "cannot find location.") - } - - func addToken(accessToken: String, refreshToken: String) { - let realm = try! Realm() - let token = Token(accessToken: accessToken, refreshToken: refreshToken) - let existingToken = realm.objects(Token.self) - - try! realm.write { - realm.delete(existingToken) - realm.add(token) - } - } - - func getToken() -> String { - let realm = try! Realm() - let token = realm.objects(Token.self) - return token.last?.accessToken ?? "" - } - - func getRefreshToken() -> String { - let realm = try! Realm() - let token = realm.objects(Token.self) - return token.last?.refreshToken ?? "" - } - - func isAccessTokenPresent() -> Bool { - return getToken() != "" - } - - func resetDB() { - let realm = try! Realm() - try! realm.write { - realm.deleteAll() - } - // 계정 단위 상태도 함께 초기화 (찜 목록·순서) - PartnershipLikeManager.shared.reset() - } - - func deleteAll(_ objectType: (some Object).Type) { - let realm = try! Realm() - let objects = realm.objects(objectType) - try! realm.write { - realm.delete(objects) - print("Successfully deleted all objects of type \(objectType)") - } - } -} diff --git a/EATSSU/App/Sources/Data/LocalDB/Token.swift b/EATSSU/App/Sources/Data/LocalDB/Token.swift deleted file mode 100644 index a3c35b87..00000000 --- a/EATSSU/App/Sources/Data/LocalDB/Token.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// Token.swift -// EatSSU-iOS -// -// Created by 박윤빈 on 2023/05/27. -// - -import Realm -import RealmSwift - -/// 나중에 -class Token: Object { - @Persisted(primaryKey: true) var _id: ObjectId - - @Persisted var accessToken = String() - @Persisted var refreshToken = String() - - override static func primaryKey() -> String? { - "token" - } - - convenience init(accessToken: String, refreshToken: String) { - self.init() - self.accessToken = accessToken - self.refreshToken = refreshToken - } -} diff --git a/EATSSU/App/Sources/Utility/Helper/KeychainHelper.swift b/EATSSU/App/Sources/Utility/Helper/KeychainHelper.swift index 82f1e55f..993c0b7c 100644 --- a/EATSSU/App/Sources/Utility/Helper/KeychainHelper.swift +++ b/EATSSU/App/Sources/Utility/Helper/KeychainHelper.swift @@ -16,8 +16,13 @@ enum KeychainHelper { private static let service = Bundle.main.bundleIdentifier ?? "com.eatssu.keychain" /// 값을 저장한다. 같은 key가 있으면 덮어쓴다. + /// - Parameter accessibility: 기기 잠금 상태에 따른 접근 가능 시점 (기본값: 첫 잠금 해제 이후) @discardableResult - static func save(_ value: String, forKey key: String) -> Bool { + static func save( + _ value: String, + forKey key: String, + accessibility: CFString = kSecAttrAccessibleAfterFirstUnlock + ) -> Bool { guard let data = value.data(using: .utf8) else { return false } let query: [String: Any] = [ @@ -29,7 +34,7 @@ enum KeychainHelper { var attributes = query attributes[kSecValueData as String] = data - attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + attributes[kSecAttrAccessible as String] = accessibility return SecItemAdd(attributes as CFDictionary, nil) == errSecSuccess } @@ -52,4 +57,16 @@ enum KeychainHelper { } return value } + + /// 값을 삭제한다. 없는 key여도 성공으로 본다. + @discardableResult + static func delete(forKey key: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecAttrService as String: service + ] + let status = SecItemDelete(query as CFDictionary) + return status == errSecSuccess || status == errSecItemNotFound + } } From 4fcc6e97b64a4d698b437b3426656418a3f947ca Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:39:03 +0900 Subject: [PATCH 2/5] =?UTF-8?q?[#465]=20=ED=86=A0=ED=81=B0=EC=9D=80=20Keyc?= =?UTF-8?q?hain,=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=EB=8A=94=20UserDefaults=EB=A1=9C=20=EC=98=AE=EA=B8=B0=EA=B3=A0?= =?UTF-8?q?=20=EA=B8=B0=EC=A1=B4=20Realm=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=9D=B4=EC=A0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Data/LocalDB/AccountStorage.swift | 18 +++ .../Data/LocalDB/LocalStorageMigrator.swift | 134 ++++++++++++++++++ .../App/Sources/Data/LocalDB/TokenStore.swift | 72 ++++++++++ .../App/Sources/Data/LocalDB/UserInfo.swift | 55 ++----- .../Data/LocalDB/UserInfoManager.swift | 102 +++++++------ .../Network/Foundation/AuthInterceptor.swift | 4 +- .../Foundation/AuthenticationManager.swift | 5 +- .../Network/Foundation/TokenManager.swift | 4 +- .../Network/Foundation/TokenRefresher.swift | 2 +- .../Data/Network/Router/ReissueRouter.swift | 2 +- .../ViewController/LoginViewController.swift | 27 ++-- .../SetNickNameViewController.swift | 15 +- .../HomeRestaurantViewController.swift | 2 +- .../MainMapViewController+Network.swift | 21 ++- .../MainMapViewController.swift | 8 +- .../ViewController/MyPageViewController.swift | 3 +- .../UserWithdrawViewController.swift | 3 +- .../ViewController/ReviewViewController.swift | 6 +- .../CustomTabBarContainerController.swift | 4 +- .../Utility/Application/AppDelegate.swift | 17 +-- .../Utility/Application/SceneDelegate.swift | 4 +- .../Sources/Utility/Literal/TextLiteral.swift | 2 +- 22 files changed, 334 insertions(+), 176 deletions(-) create mode 100644 EATSSU/App/Sources/Data/LocalDB/AccountStorage.swift create mode 100644 EATSSU/App/Sources/Data/LocalDB/LocalStorageMigrator.swift create mode 100644 EATSSU/App/Sources/Data/LocalDB/TokenStore.swift diff --git a/EATSSU/App/Sources/Data/LocalDB/AccountStorage.swift b/EATSSU/App/Sources/Data/LocalDB/AccountStorage.swift new file mode 100644 index 00000000..95340016 --- /dev/null +++ b/EATSSU/App/Sources/Data/LocalDB/AccountStorage.swift @@ -0,0 +1,18 @@ +// +// AccountStorage.swift +// EATSSU +// +// Created by 황상환 on 9/27/26. +// + +import Foundation + +/// 계정 단위로 로컬에 남는 상태를 한 번에 정리한다 (로그아웃·탈퇴·세션 만료). +enum AccountStorage { + static func reset() { + TokenStore.clear() + UserInfoManager.shared.clear() + // 찜 목록·순서 + PartnershipLikeManager.shared.reset() + } +} diff --git a/EATSSU/App/Sources/Data/LocalDB/LocalStorageMigrator.swift b/EATSSU/App/Sources/Data/LocalDB/LocalStorageMigrator.swift new file mode 100644 index 00000000..88e08cb1 --- /dev/null +++ b/EATSSU/App/Sources/Data/LocalDB/LocalStorageMigrator.swift @@ -0,0 +1,134 @@ +// +// LocalStorageMigrator.swift +// EATSSU +// +// Created by 황상환 on 9/27/26. +// + +import UIKit + +import RealmSwift + +/// 앱 시작 시 로컬 저장소를 준비한다. 토큰·사용자 정보를 읽기 전에 호출해야 한다. +/// +/// 1. Realm에 남아 있는 토큰·사용자 정보를 Keychain·UserDefaults로 한 번 옮긴다 (업데이트 사용자) +/// 2. 재설치 후 Keychain에 남은 이전 설치의 토큰을 지운다 (Keychain은 앱을 지워도 남는다) +/// +/// Realm 의존성을 제거할 때는 1번과 Legacy 모델만 지우고 2번은 유지한다. +enum LocalStorageMigrator { + + private static let didPrepareKey = "LocalStorageMigrator.didPrepare" + + static func prepareIfNeeded(defaults: UserDefaults = .standard) { + guard !defaults.bool(forKey: didPrepareKey) else { return } + // 첫 잠금 해제 전 백그라운드 실행(푸시 등)에서는 UserDefaults·Keychain·Realm 파일을 읽을 수 없어 + // 신규 설치로 오판할 수 있으므로 다음 실행으로 미룬다 + guard UIApplication.shared.isProtectedDataAvailable else { return } + + let realmConfiguration = legacyRealmConfiguration() + let hasLegacyRealm = realmConfiguration.fileURL + .map { FileManager.default.fileExists(atPath: $0.path) } ?? false + + if hasLegacyRealm { + // 한 번만 시도한다. 실패한 채로 재시도하면, 그사이 새로 로그인한 토큰을 + // 나중에 옛 토큰으로 덮어쓸 수 있어 실패 시에는 다시 로그인하게 둔다 + if !migrateFromRealm(configuration: realmConfiguration) { + TokenStore.clear() + UserInfoManager.shared.clear() + } + _ = try? Realm.deleteFiles(for: realmConfiguration) + } else { + TokenStore.clear() + } + + defaults.set(true, forKey: didPrepareKey) + } + + // MARK: - Realm → Keychain·UserDefaults + + private struct LegacyData { + var accessToken: String? + var refreshToken: String? + var userInfo: UserInfo? + } + + /// 기존 AppDelegate 설정과 같은 스키마(version 1)로 기존 default.realm을 연다 + private static func legacyRealmConfiguration() -> Realm.Configuration { + var configuration = Realm.Configuration.defaultConfiguration + configuration.schemaVersion = 1 + configuration.objectTypes = [LegacyToken.self, LegacyUserInfo.self] + return configuration + } + + /// Realm에 남은 토큰·사용자 정보를 읽는다. 파일을 열 수 없으면 nil + private static func readLegacyData(configuration: Realm.Configuration) -> LegacyData? { + autoreleasepool { + let realm: Realm + do { + realm = try Realm(configuration: configuration) + } catch { + print("[LocalStorageMigrator] Realm 열기 실패: \(error)") + return nil + } + + var data = LegacyData() + // 기존 RealmService와 같은 기준: 토큰은 마지막 것, 사용자 정보는 첫 번째 것 + if let token = realm.objects(LegacyToken.self).last, !token.accessToken.isEmpty { + data.accessToken = token.accessToken + data.refreshToken = token.refreshToken + } + if let legacyUserInfo = realm.objects(LegacyUserInfo.self).first { + data.userInfo = UserInfo( + nickname: legacyUserInfo.nickname, + accountType: legacyUserInfo.accountTypeRaw.flatMap(UserInfo.AccountType.init(rawValue:)), + collegeId: legacyUserInfo.collegeId, + collegeName: legacyUserInfo.collegeName, + departmentId: legacyUserInfo.departmentId, + departmentName: legacyUserInfo.departmentName + ) + } + return data + } + } + + /// 옮길 데이터가 없거나 모두 옮겼으면 true + private static func migrateFromRealm(configuration: Realm.Configuration) -> Bool { + guard let data = readLegacyData(configuration: configuration) else { return false } + + if let accessToken = data.accessToken, let refreshToken = data.refreshToken { + guard TokenStore.save(accessToken: accessToken, refreshToken: refreshToken) else { + print("[LocalStorageMigrator] Keychain 저장 실패") + return false + } + } + if let userInfo = data.userInfo { + UserInfoManager.shared.save(userInfo) + } + print("[LocalStorageMigrator] 이전 완료 (토큰: \(data.accessToken != nil), 사용자 정보: \(data.userInfo != nil))") + return true + } +} + +// MARK: - Legacy Realm Models + +// 기존 Realm 파일을 읽기 위한 모델. 테이블 이름과 속성을 기존 Token·UserInfo와 똑같이 유지해야 한다. + +final class LegacyToken: Object { + override class func _realmObjectName() -> String { "Token" } + + @Persisted(primaryKey: true) var _id: ObjectId + @Persisted var accessToken = String() + @Persisted var refreshToken = String() +} + +final class LegacyUserInfo: Object { + override class func _realmObjectName() -> String { "UserInfo" } + + @Persisted(primaryKey: true) var id: String = UUID().uuidString + @Persisted var nickname: String = "" + @Persisted var accountTypeRaw: String? + @Persisted var collegeId: Int? + @Persisted var collegeName: String? + @Persisted var departmentId: Int? + @Persisted var departmentName: String? +} diff --git a/EATSSU/App/Sources/Data/LocalDB/TokenStore.swift b/EATSSU/App/Sources/Data/LocalDB/TokenStore.swift new file mode 100644 index 00000000..bb342054 --- /dev/null +++ b/EATSSU/App/Sources/Data/LocalDB/TokenStore.swift @@ -0,0 +1,72 @@ +// +// TokenStore.swift +// EATSSU +// +// Created by 황상환 on 9/27/26. +// + +import Foundation +import Security + +/// 로그인 토큰을 Keychain에 저장한다. +/// 요청마다 읽히므로 메모리에 캐시하고, 여러 스레드(네트워크 인터셉터·메인)에서 접근하므로 잠금으로 보호한다. +enum TokenStore { + + private enum Key { + static let accessToken = "auth.accessToken" + static let refreshToken = "auth.refreshToken" + } + + /// 백업·기기 이전으로 다른 기기에 복원되지 않도록 이 기기에만 보관 + private static let accessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + + private static let lock = NSLock() + nonisolated(unsafe) private static var cachedAccessToken: String? + nonisolated(unsafe) private static var cachedRefreshToken: String? + + static var accessToken: String { + value(forKey: Key.accessToken, cache: &cachedAccessToken) + } + + static var refreshToken: String { + value(forKey: Key.refreshToken, cache: &cachedRefreshToken) + } + + static var hasAccessToken: Bool { + !accessToken.isEmpty + } + + /// 토큰 두 개를 함께 저장한다. 하나라도 실패하면 false. + @discardableResult + static func save(accessToken: String, refreshToken: String) -> Bool { + lock.lock() + defer { lock.unlock() } + + let isAccessSaved = KeychainHelper.save(accessToken, forKey: Key.accessToken, accessibility: accessibility) + let isRefreshSaved = KeychainHelper.save(refreshToken, forKey: Key.refreshToken, accessibility: accessibility) + cachedAccessToken = isAccessSaved ? accessToken : nil + cachedRefreshToken = isRefreshSaved ? refreshToken : nil + return isAccessSaved && isRefreshSaved + } + + static func clear() { + lock.lock() + defer { lock.unlock() } + + KeychainHelper.delete(forKey: Key.accessToken) + KeychainHelper.delete(forKey: Key.refreshToken) + cachedAccessToken = "" + cachedRefreshToken = "" + } + + private static func value(forKey key: String, cache: inout String?) -> String { + lock.lock() + defer { lock.unlock() } + + if let cache { return cache } + // 읽기 실패(첫 잠금 해제 전 등)는 캐시하지 않아, 잠금 해제 후 다시 읽을 수 있게 한다 + guard let stored = KeychainHelper.read(forKey: key) else { return "" } + cache = stored + return stored + } +} diff --git a/EATSSU/App/Sources/Data/LocalDB/UserInfo.swift b/EATSSU/App/Sources/Data/LocalDB/UserInfo.swift index 911ef37f..64a7e1ca 100644 --- a/EATSSU/App/Sources/Data/LocalDB/UserInfo.swift +++ b/EATSSU/App/Sources/Data/LocalDB/UserInfo.swift @@ -5,53 +5,18 @@ // Created by 박윤빈 on 2023/08/02. // -import Realm -import RealmSwift +import Foundation -class UserInfo: Object { - @Persisted(primaryKey: true) var id: String = UUID().uuidString - @Persisted var nickname: String = "" - @Persisted private var accountTypeRaw: String? - @Persisted var collegeId: Int? - @Persisted var collegeName: String? - @Persisted var departmentId: Int? - @Persisted var departmentName: String? +/// 로그인한 사용자의 로컬 프로필. UserInfoManager가 UserDefaults에 저장한다. +struct UserInfo: Codable, Equatable { + var nickname: String = "" + var accountType: AccountType? + var collegeId: Int? + var collegeName: String? + var departmentId: Int? + var departmentName: String? - var accountType: AccountType? { - get { - guard let rawValue = accountTypeRaw else { return nil } - return AccountType(rawValue: rawValue) - } - set { - accountTypeRaw = newValue?.rawValue - } - } - - convenience init(accountType: AccountType) { - self.init() - self.accountType = accountType - } - - func updateUserInfo(nickname: String, collegeId: Int?, collegeName: String?, departmentId: Int?, departmentName: String?) { - self.nickname = nickname - self.collegeId = collegeId - self.collegeName = collegeName - self.departmentId = departmentId - self.departmentName = departmentName - } - - func updateNickname(_ nickname: String) { - self.nickname = nickname - } - - func updateDepartment(collegeId: Int?, collegeName: String?, departmentId: Int?, departmentName: String?) { - self.collegeId = collegeId - self.collegeName = collegeName - self.departmentId = departmentId - self.departmentName = departmentName - } - - enum AccountType: String { + enum AccountType: String, Codable { case apple = "Apple" case kakao = "Kakao" } diff --git a/EATSSU/App/Sources/Data/LocalDB/UserInfoManager.swift b/EATSSU/App/Sources/Data/LocalDB/UserInfoManager.swift index bf2c7820..0695d1ff 100644 --- a/EATSSU/App/Sources/Data/LocalDB/UserInfoManager.swift +++ b/EATSSU/App/Sources/Data/LocalDB/UserInfoManager.swift @@ -5,71 +5,69 @@ // Created by 최지우 on 9/19/24. // -import RealmSwift +import Foundation -class UserInfoManager { +/// 사용자 프로필을 UserDefaults에 JSON으로 저장한다. +/// 값 몇 개뿐이라 DB 없이 통째로 읽고 쓴다. +final class UserInfoManager { static let shared = UserInfoManager() - private init() {} - private var realm: Realm { - do { - return try Realm() - } catch { - fatalError("Realm을 초기화하는데 실패했습니다: \(error)") - } + private let defaults: UserDefaults + private let storageKey = "localUserInfo" + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func getCurrentUserInfo() -> UserInfo? { + guard let data = defaults.data(forKey: storageKey) else { return nil } + return try? JSONDecoder().decode(UserInfo.self, from: data) } + /// 로그인 직후 새 프로필을 만든다. 이전 프로필은 대체된다. + @discardableResult func createUserInfo(accountType: UserInfo.AccountType) -> UserInfo { let userInfo = UserInfo(accountType: accountType) - do { - try realm.write { - realm.add(userInfo) - } - return userInfo - } catch { - print("UserInfo 생성 중 오류 발생: \(error)") - return userInfo - } + save(userInfo) + return userInfo } - - func updateUserInfo(for userInfo: UserInfo, nickname: String, collegeId: Int?, collegeName: String?, departmentId: Int?, departmentName: String?) { - do { - try realm.write { - userInfo.updateUserInfo(nickname: nickname, - collegeId: collegeId, - collegeName: collegeName, - departmentId: departmentId, - departmentName: departmentName) - } - } catch { - print("사용자 정보 업데이트 중 오류 발생: \(error)") - } + + func save(_ userInfo: UserInfo) { + guard let data = try? JSONEncoder().encode(userInfo) else { return } + defaults.set(data, forKey: storageKey) } - func updateNickname(for userInfo: UserInfo, nickname: String) { - do { - try realm.write { - userInfo.updateNickname(nickname) // UserInfo.swift에 있는 함수 호출 - } - } catch { - print("닉네임 정보 업데이트 중 오류 발생: \(error)") + func updateUserInfo(nickname: String, collegeId: Int?, collegeName: String?, departmentId: Int?, departmentName: String?) { + update { + $0.nickname = nickname + $0.collegeId = collegeId + $0.collegeName = collegeName + $0.departmentId = departmentId + $0.departmentName = departmentName } } - - func updateDepartment(for userInfo: UserInfo, collegeId: Int?, collegeName: String?, departmentId: Int?, departmentName: String?) { - do { - try realm.write { - userInfo.updateDepartment(collegeId: collegeId, - collegeName: collegeName, - departmentId: departmentId, - departmentName: departmentName) - } - } catch { - print("학과 정보 업데이트 중 오류 발생: \(error)") + + func updateNickname(_ nickname: String) { + update { $0.nickname = nickname } + } + + func updateDepartment(collegeId: Int?, collegeName: String?, departmentId: Int?, departmentName: String?) { + update { + $0.collegeId = collegeId + $0.collegeName = collegeName + $0.departmentId = departmentId + $0.departmentName = departmentName } } - - func getCurrentUserInfo() -> UserInfo? { - realm.objects(UserInfo.self).first + + func clear() { + defaults.removeObject(forKey: storageKey) + } + + /// 프로필이 있을 때만 수정한다 (로그인 전에는 무시) + private func update(_ change: (inout UserInfo) -> Void) { + guard var userInfo = getCurrentUserInfo() else { return } + change(&userInfo) + save(userInfo) } } diff --git a/EATSSU/App/Sources/Data/Network/Foundation/AuthInterceptor.swift b/EATSSU/App/Sources/Data/Network/Foundation/AuthInterceptor.swift index e58a5b17..b0f61db7 100644 --- a/EATSSU/App/Sources/Data/Network/Foundation/AuthInterceptor.swift +++ b/EATSSU/App/Sources/Data/Network/Foundation/AuthInterceptor.swift @@ -35,7 +35,7 @@ final class AuthInterceptor: RequestInterceptor { if request.requiresToken { request.headers.add( name: "Authorization", - value: "Bearer \(RealmService.shared.getToken())" + value: "Bearer \(TokenStore.accessToken)" ) } @@ -61,7 +61,7 @@ final class AuthInterceptor: RequestInterceptor { } // 비로그인 상태면 재발급할 토큰이 없음 - guard !RealmService.shared.getRefreshToken().isEmpty else { + guard !TokenStore.refreshToken.isEmpty else { return completion(.doNotRetryWithError(error)) } diff --git a/EATSSU/App/Sources/Data/Network/Foundation/AuthenticationManager.swift b/EATSSU/App/Sources/Data/Network/Foundation/AuthenticationManager.swift index 8ea5389b..05dcb3a8 100644 --- a/EATSSU/App/Sources/Data/Network/Foundation/AuthenticationManager.swift +++ b/EATSSU/App/Sources/Data/Network/Foundation/AuthenticationManager.swift @@ -6,7 +6,6 @@ // import Foundation -import RealmSwift enum AuthResult { case authenticated @@ -41,7 +40,7 @@ final class AuthenticationManager { // resetDB는 메인 전용 상태(찜 목록)도 비우므로 메인에서 실행 await MainActor.run { AnalyticsIdentityManager.reset() - RealmService.shared.resetDB() + AccountStorage.reset() } return .sessionExpired } catch { @@ -51,6 +50,6 @@ final class AuthenticationManager { } private func hasStoredToken() -> Bool { - !RealmService.shared.getToken().isEmpty + !TokenStore.accessToken.isEmpty } } diff --git a/EATSSU/App/Sources/Data/Network/Foundation/TokenManager.swift b/EATSSU/App/Sources/Data/Network/Foundation/TokenManager.swift index 85adaafc..ff6f7ece 100644 --- a/EATSSU/App/Sources/Data/Network/Foundation/TokenManager.swift +++ b/EATSSU/App/Sources/Data/Network/Foundation/TokenManager.swift @@ -20,7 +20,7 @@ final class TokenManager { /// accessToken 디코딩하여 만료 여부 판단 func isTokenExpiringSoon() -> Bool { - let token = RealmService.shared.getToken() + let token = TokenStore.accessToken guard let payload = decodePayload(token: token) else { return true // 디코딩 실패 시 만료로 간주 } @@ -32,7 +32,7 @@ final class TokenManager { /// 재발급이 필요하면 TokenRefresher로 실행 func refreshIfNeeded() async { // 비로그인 상태면 재발급할 토큰이 없다 - guard !RealmService.shared.getRefreshToken().isEmpty else { return } + guard !TokenStore.refreshToken.isEmpty else { return } if isTokenExpiringSoon() { do { diff --git a/EATSSU/App/Sources/Data/Network/Foundation/TokenRefresher.swift b/EATSSU/App/Sources/Data/Network/Foundation/TokenRefresher.swift index e894bb13..5ac8b39d 100644 --- a/EATSSU/App/Sources/Data/Network/Foundation/TokenRefresher.swift +++ b/EATSSU/App/Sources/Data/Network/Foundation/TokenRefresher.swift @@ -39,7 +39,7 @@ actor TokenRefresher { do { let data = try await performReissuance() - RealmService.shared.addToken( + TokenStore.save( accessToken: data.accessToken, refreshToken: data.refreshToken ) diff --git a/EATSSU/App/Sources/Data/Network/Router/ReissueRouter.swift b/EATSSU/App/Sources/Data/Network/Router/ReissueRouter.swift index ebb65ddd..355a8df8 100644 --- a/EATSSU/App/Sources/Data/Network/Router/ReissueRouter.swift +++ b/EATSSU/App/Sources/Data/Network/Router/ReissueRouter.swift @@ -41,7 +41,7 @@ extension ReissueRouter: TargetType, AccessTokenAuthorizable { var headers: [String: String]? { switch self { default: - let refreshToken = RealmService.shared.getRefreshToken() + let refreshToken = TokenStore.refreshToken return ["Content-Type": "application/json", "Authorization": "Bearer \(refreshToken)"] } diff --git a/EATSSU/App/Sources/Presentation/Auth/ViewController/LoginViewController.swift b/EATSSU/App/Sources/Presentation/Auth/ViewController/LoginViewController.swift index 5d3208ef..5c8a5dd3 100644 --- a/EATSSU/App/Sources/Presentation/Auth/ViewController/LoginViewController.swift +++ b/EATSSU/App/Sources/Presentation/Auth/ViewController/LoginViewController.swift @@ -11,7 +11,6 @@ import UIKit import Firebase import KakaoSDKUser import Moya -import RealmSwift import SnapKit final class LoginViewController: BaseViewController { @@ -38,7 +37,7 @@ final class LoginViewController: BaseViewController { logScreenView(screenID: FirebaseScreenID.Login.log3) // 로그인 화면 진입 시 로컬 데이터 초기화 (데이터 불일치 방지) - RealmService.shared.resetDB() + AccountStorage.reset() configureFirebaseRemoteConfig() showLastLoginTooltipIfNeeded() @@ -108,7 +107,7 @@ final class LoginViewController: BaseViewController { } private func hasStoredToken() -> Bool { - !RealmService.shared.getToken().isEmpty + !TokenStore.accessToken.isEmpty } private func changeIntoHomeViewController() { @@ -124,17 +123,13 @@ final class LoginViewController: BaseViewController { private func handleNicknameCheck(info: MyInfoResponse) { if let nickname = info.nickname { // 사용자의 닉네임을 업데이트하고 홈 화면으로 이동 - if let currentUserInfo = UserInfoManager.shared.getCurrentUserInfo() { - UserInfoManager.shared.updateUserInfo( - for: currentUserInfo, - nickname: nickname, - collegeId: info.collegeId, - collegeName: info.collegeName, - departmentId: info.departmentId, - departmentName: info.departmentName - ) - - } + UserInfoManager.shared.updateUserInfo( + nickname: nickname, + collegeId: info.collegeId, + collegeName: info.collegeName, + departmentId: info.departmentId, + departmentName: info.departmentName + ) // 로그인 성공 + 유저 정보 채운 뒤 식별 AnalyticsIdentityManager.identify() changeIntoHomeViewController() @@ -146,9 +141,9 @@ final class LoginViewController: BaseViewController { } } - /// 토큰을 Realm에 저장하고, 디버깅 로그를 출력한다. + /// 토큰을 Keychain에 저장하고, 디버깅 로그를 출력한다. private func storeTokensAndPrintDebugLogs(accessToken: String, refreshToken: String) { - RealmService.shared.addToken(accessToken: accessToken, refreshToken: refreshToken) + TokenStore.save(accessToken: accessToken, refreshToken: refreshToken) #if DEBUG print("⭐️⭐️ 토큰 저장 성공 ⭐️⭐️", accessToken) #endif diff --git a/EATSSU/App/Sources/Presentation/Auth/ViewController/SetNickNameViewController.swift b/EATSSU/App/Sources/Presentation/Auth/ViewController/SetNickNameViewController.swift index 37905f5d..a9ad2c1f 100644 --- a/EATSSU/App/Sources/Presentation/Auth/ViewController/SetNickNameViewController.swift +++ b/EATSSU/App/Sources/Presentation/Auth/ViewController/SetNickNameViewController.swift @@ -312,9 +312,7 @@ extension SetNickNameViewController { ) { result in switch result { case .success: - if let user = UserInfoManager.shared.getCurrentUserInfo() { - UserInfoManager.shared.updateNickname(for: user, nickname: nickname) - } + UserInfoManager.shared.updateNickname(nickname) completion(true) case .failure: @@ -374,13 +372,10 @@ extension SetNickNameViewController { switch result { case .success: print("학과 등록 성공: ID \(departmentInfo.id)") - if let user = UserInfoManager.shared.getCurrentUserInfo() { - UserInfoManager.shared.updateDepartment(for: user, - collegeId: collegeInfo.id, - collegeName: collegeInfo.name, - departmentId: departmentInfo.id, - departmentName: departmentInfo.name) - } + UserInfoManager.shared.updateDepartment(collegeId: collegeInfo.id, + collegeName: collegeInfo.name, + departmentId: departmentInfo.id, + departmentName: departmentInfo.name) // 학과 정보 갱신 후 유저 속성 재식별 AnalyticsIdentityManager.identify() completion(true) diff --git a/EATSSU/App/Sources/Presentation/Home/ViewController/HomeRestaurantViewController.swift b/EATSSU/App/Sources/Presentation/Home/ViewController/HomeRestaurantViewController.swift index f3c80ffa..afe8d29c 100644 --- a/EATSSU/App/Sources/Presentation/Home/ViewController/HomeRestaurantViewController.swift +++ b/EATSSU/App/Sources/Presentation/Home/ViewController/HomeRestaurantViewController.swift @@ -271,7 +271,7 @@ extension HomeRestaurantViewController: UITableViewDataSource { } private func handleMenuTap(section: Int, menuIndex: Int) { - if RealmService.shared.isAccessTokenPresent() == false { + if !TokenStore.hasAccessToken { presentLoginAlert() return } diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift index bd2a2b36..d119c282 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift @@ -194,21 +194,18 @@ extension MainMapViewController { self.currentDepartmentId = department.departmentId self.currentCollegeId = department.collegeId - // Realm 데이터도 함께 동기화하여 서버-클라이언트 불일치 방지 - if let userInfo = UserInfoManager.shared.getCurrentUserInfo() { - UserInfoManager.shared.updateDepartment( - for: userInfo, - collegeId: department.collegeId, - collegeName: department.collegeName, - departmentId: department.departmentId, - departmentName: department.departmentName - ) - } + // 로컬 사용자 정보도 함께 동기화하여 서버-클라이언트 불일치 방지 + UserInfoManager.shared.updateDepartment( + collegeId: department.collegeId, + collegeName: department.collegeName, + departmentId: department.departmentId, + departmentName: department.departmentName + ) case .failure(let error): - // 일시적 조회 실패를 '학과 없음'으로 오판하지 않도록 기존 값(없으면 Realm 저장값)을 유지한다 + // 일시적 조회 실패를 '학과 없음'으로 오판하지 않도록 기존 값(없으면 로컬 저장값)을 유지한다 print("학과 조회 실패: \(error.localizedDescription)") - self.seedDepartmentFromRealmIfNeeded() + self.seedDepartmentFromLocalIfNeeded() } completion?() diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift index 5a9b2c34..bdbec989 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift @@ -120,11 +120,11 @@ final class MainMapViewController: BaseViewController { self.currentTab = (mode == .standaloneGoodPrice) ? .goodPrice : .partnership super.init(nibName: nil, bundle: nil) // 서버 조회 전에도 로그인 시 저장된 학과로 판정할 수 있게 미리 채운다 - seedDepartmentFromRealmIfNeeded() + seedDepartmentFromLocalIfNeeded() } - /// 학과 정보가 비어 있으면 Realm 저장값으로 채운다 (서버 조회 실패 시 폴백) - func seedDepartmentFromRealmIfNeeded() { + /// 학과 정보가 비어 있으면 로컬 저장값으로 채운다 (서버 조회 실패 시 폴백) + func seedDepartmentFromLocalIfNeeded() { guard currentDepartmentName == nil, let userInfo = UserInfoManager.shared.getCurrentUserInfo(), let name = userInfo.departmentName, !name.isEmpty else { return } @@ -221,7 +221,7 @@ final class MainMapViewController: BaseViewController { if currentTab != .partnership { switchTab(to: .partnership) } - // 학과 확인이 끝난 뒤 판단한다 (Realm에 없어도 서버 조회로 학과가 확인될 수 있음) + // 학과 확인이 끝난 뒤 판단한다 (로컬에 없어도 서버 조회로 학과가 확인될 수 있음) pendingDetailStore = store returnsToLikeTab = true updateLikeReturnButton() diff --git a/EATSSU/App/Sources/Presentation/MyPage/ViewController/MyPageViewController.swift b/EATSSU/App/Sources/Presentation/MyPage/ViewController/MyPageViewController.swift index bd014db7..2863ef00 100644 --- a/EATSSU/App/Sources/Presentation/MyPage/ViewController/MyPageViewController.swift +++ b/EATSSU/App/Sources/Presentation/MyPage/ViewController/MyPageViewController.swift @@ -11,7 +11,6 @@ import WebKit import KakaoSDKCommon import KakaoSDKTalk import Moya -import Realm import SnapKit final class MyPageViewController: BaseViewController { @@ -116,7 +115,7 @@ final class MyPageViewController: BaseViewController { style: .default, handler: { _ in AnalyticsIdentityManager.reset() - RealmService.shared.resetDB() + AccountStorage.reset() let loginViewController = LoginViewController() if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, diff --git a/EATSSU/App/Sources/Presentation/MyPage/ViewController/UserWithdrawViewController.swift b/EATSSU/App/Sources/Presentation/MyPage/ViewController/UserWithdrawViewController.swift index 05c4b392..a5d635c9 100644 --- a/EATSSU/App/Sources/Presentation/MyPage/ViewController/UserWithdrawViewController.swift +++ b/EATSSU/App/Sources/Presentation/MyPage/ViewController/UserWithdrawViewController.swift @@ -8,7 +8,6 @@ import UIKit import Moya -import Realm import SnapKit final class UserWithdrawViewController: BaseViewController { @@ -142,7 +141,7 @@ extension UserWithdrawViewController { ) { result in switch result { case .success: - RealmService.shared.resetDB() + AccountStorage.reset() let loginViewController = LoginViewController() if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let keyWindow = windowScene.windows.first(where: { $0.isKeyWindow }) diff --git a/EATSSU/App/Sources/Presentation/Review/ViewController/ReviewViewController.swift b/EATSSU/App/Sources/Presentation/Review/ViewController/ReviewViewController.swift index 436cd378..6e10d131 100644 --- a/EATSSU/App/Sources/Presentation/Review/ViewController/ReviewViewController.swift +++ b/EATSSU/App/Sources/Presentation/Review/ViewController/ReviewViewController.swift @@ -78,7 +78,7 @@ final class ReviewViewController: BaseViewController { /// 번역 기능 노출 여부 (현재 서버가 EN 번역만 지원 + 번역 API는 인증 필수) private var isTranslationAvailable: Bool { AppLanguageManager.shared.currentLanguage.isServerTranslationSupported - && RealmService.shared.isAccessTokenPresent() + && TokenStore.hasAccessToken } // MARK: - UI Components @@ -321,7 +321,7 @@ final class ReviewViewController: BaseViewController { /// 리뷰 작성 버튼 탭 처리 (로그인 체크 포함) func userTapReviewButton() { - if RealmService.shared.isAccessTokenPresent() { + if TokenStore.hasAccessToken { ReviewAnalyticsManager.shared.logWriteReviewV2(restaurantName: restaurantName) DispatchQueue.global().async { DispatchQueue.main.async { [self] in @@ -547,7 +547,7 @@ extension ReviewViewController: UITableViewDataSource { for: indexPath ) as? ReviewEmptyViewCell ?? ReviewEmptyViewCell() - if RealmService.shared.getToken() == "" { + if TokenStore.accessToken == "" { cell.configure(isTokenExist: false) } else { cell.configure(isTokenExist: true) diff --git a/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift b/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift index d374efa7..52606301 100644 --- a/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift +++ b/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift @@ -104,7 +104,7 @@ final class CustomTabBarContainerController: UITabBarController { @discardableResult public func setTab(index: Int) -> Bool { guard index < tabViewControllers.count, let tab = Tab(rawValue: index) else { return false } - if tab.requiresLogin, RealmService.shared.isAccessTokenPresent() == false { + if tab.requiresLogin, !TokenStore.hasAccessToken { presentLoginAlert() return false } @@ -252,7 +252,7 @@ extension CustomTabBarContainerController: UITabBarControllerDelegate { return true } - if selectedTab.requiresLogin, RealmService.shared.isAccessTokenPresent() == false { + if selectedTab.requiresLogin, !TokenStore.hasAccessToken { presentLoginAlert() return false } diff --git a/EATSSU/App/Sources/Utility/Application/AppDelegate.swift b/EATSSU/App/Sources/Utility/Application/AppDelegate.swift index e3ef1f60..14f96acc 100644 --- a/EATSSU/App/Sources/Utility/Application/AppDelegate.swift +++ b/EATSSU/App/Sources/Utility/Application/AppDelegate.swift @@ -13,14 +13,14 @@ import FirebaseMessaging import KakaoSDKCommon import NMapsMap import PostHog -import RealmSwift @main class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate { // MARK: - UIApplicationDelegate Methods func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - configureRealm() + // 토큰·사용자 정보를 읽기 전에 로컬 저장소 준비 (Realm → Keychain 이전 포함) + LocalStorageMigrator.prepareIfNeeded() setupNotificationPermissions() startNetworkMonitoring() configureFirebase() @@ -114,19 +114,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD userInfo["fcm_options"] != nil } - private func configureRealm() { - let config = Realm.Configuration( - // 데이터베이스의 버전을 설정 - 구조를 변경할 때마다 이 숫자를 1씩 증가 - schemaVersion: 1, - migrationBlock: { migration, oldSchemaVersion in - if oldSchemaVersion < 1 { - // UserInfo에 새 속성들이 추가된 경우, Realm이 자동으로 처리 - } - } - ) - Realm.Configuration.defaultConfiguration = config - } - /// 푸시 알림 권한을 요청하고 설정을 처리합니다. private func setupNotificationPermissions() { _Concurrency.Task { diff --git a/EATSSU/App/Sources/Utility/Application/SceneDelegate.swift b/EATSSU/App/Sources/Utility/Application/SceneDelegate.swift index b52e54cd..8d8f03e9 100644 --- a/EATSSU/App/Sources/Utility/Application/SceneDelegate.swift +++ b/EATSSU/App/Sources/Utility/Application/SceneDelegate.swift @@ -404,11 +404,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // 스플래시 중이면 시작 인증 흐름이 로그인 화면으로 보낸다 guard hasFinishedLaunchAuthentication else { return } // 동시에 실패한 요청들이 각각 이벤트를 보내므로 첫 번째만 처리 - guard !RealmService.shared.getToken().isEmpty else { return } + guard !TokenStore.accessToken.isEmpty else { return } // 로그아웃과 같은 기준으로 계정 상태 정리 AnalyticsIdentityManager.reset() - RealmService.shared.resetDB() + AccountStorage.reset() transitionToLogin(withMessage: TextLiteral.Common.sessionExpired) } } diff --git a/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift b/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift index a57d532d..d6d6a739 100644 --- a/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift +++ b/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift @@ -1271,7 +1271,7 @@ enum TextLiteral { // MARK: - Academic /// 서버가 내려주는 한국어 단과대/학과명을 현재 앱 언어 표기로 변환 - /// 서버 응답·Realm 저장값은 한국어 원본을 유지하고 표시 시점에만 사용한다. + /// 서버 응답·로컬 저장값은 한국어 원본을 유지하고 표시 시점에만 사용한다. /// 한국어도 ko.lproj 값을 사용하므로 표기(가운뎃점·공백 등)는 strings 파일이 기준이 된다. /// 등록되지 않은 이름은 서버 원본을 그대로 반환한다. enum Academic { From ff46bba75f7e4df164c7fa5a64d248b8e560663b Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:50:01 +0900 Subject: [PATCH 3/5] =?UTF-8?q?[#465]=20=ED=86=A0=ED=81=B0=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=8B=A4=ED=8C=A8=EC=99=80=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EC=A4=91=20=EB=8F=84=EC=B0=A9=ED=95=9C=20?= =?UTF-8?q?=EC=9E=AC=EB=B0=9C=EA=B8=89=20=EA=B2=B0=EA=B3=BC=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../App/Sources/Data/LocalDB/TokenStore.swift | 16 ++++++++++++---- .../Network/Foundation/TokenRefresher.swift | 16 ++++++++++++---- .../ViewController/LoginViewController.swift | 19 ++++++++++++------- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/EATSSU/App/Sources/Data/LocalDB/TokenStore.swift b/EATSSU/App/Sources/Data/LocalDB/TokenStore.swift index bb342054..e613fb01 100644 --- a/EATSSU/App/Sources/Data/LocalDB/TokenStore.swift +++ b/EATSSU/App/Sources/Data/LocalDB/TokenStore.swift @@ -36,7 +36,8 @@ enum TokenStore { !accessToken.isEmpty } - /// 토큰 두 개를 함께 저장한다. 하나라도 실패하면 false. + /// 토큰 두 개를 함께 저장한다. + /// 하나라도 실패하면 서로 다른 세대의 토큰이 섞이지 않도록 둘 다 지우고 false를 반환한다. @discardableResult static func save(accessToken: String, refreshToken: String) -> Bool { lock.lock() @@ -44,9 +45,16 @@ enum TokenStore { let isAccessSaved = KeychainHelper.save(accessToken, forKey: Key.accessToken, accessibility: accessibility) let isRefreshSaved = KeychainHelper.save(refreshToken, forKey: Key.refreshToken, accessibility: accessibility) - cachedAccessToken = isAccessSaved ? accessToken : nil - cachedRefreshToken = isRefreshSaved ? refreshToken : nil - return isAccessSaved && isRefreshSaved + guard isAccessSaved && isRefreshSaved else { + KeychainHelper.delete(forKey: Key.accessToken) + KeychainHelper.delete(forKey: Key.refreshToken) + cachedAccessToken = "" + cachedRefreshToken = "" + return false + } + cachedAccessToken = accessToken + cachedRefreshToken = refreshToken + return true } static func clear() { diff --git a/EATSSU/App/Sources/Data/Network/Foundation/TokenRefresher.swift b/EATSSU/App/Sources/Data/Network/Foundation/TokenRefresher.swift index 5ac8b39d..5be7a9a6 100644 --- a/EATSSU/App/Sources/Data/Network/Foundation/TokenRefresher.swift +++ b/EATSSU/App/Sources/Data/Network/Foundation/TokenRefresher.swift @@ -12,6 +12,8 @@ import Moya enum TokenRefresherError: Error { case emptyResult case sessionExpired + /// 재발급 도중 로그아웃·재로그인되어 결과를 버림 (세션 만료 안내 대상 아님) + case discarded } actor TokenRefresher { @@ -38,11 +40,17 @@ actor TokenRefresher { } do { + let requestedRefreshToken = TokenStore.refreshToken let data = try await performReissuance() - TokenStore.save( - accessToken: data.accessToken, - refreshToken: data.refreshToken - ) + + // 재발급을 기다리는 사이 로그아웃·재로그인됐다면 이전 계정 토큰을 되살리지 않는다 + guard TokenStore.refreshToken == requestedRefreshToken else { + throw TokenRefresherError.discarded + } + // 저장에 실패하면 토큰이 비므로 재발급 실패와 같게 로그인 화면으로 보낸다 + guard TokenStore.save(accessToken: data.accessToken, refreshToken: data.refreshToken) else { + throw TokenRefresherError.sessionExpired + } #if DEBUG print("⭐️⭐️ 재발급 완료 ⭐️⭐️ – 새 accessToken:", data.accessToken) #endif diff --git a/EATSSU/App/Sources/Presentation/Auth/ViewController/LoginViewController.swift b/EATSSU/App/Sources/Presentation/Auth/ViewController/LoginViewController.swift index 5c8a5dd3..82a811d0 100644 --- a/EATSSU/App/Sources/Presentation/Auth/ViewController/LoginViewController.swift +++ b/EATSSU/App/Sources/Presentation/Auth/ViewController/LoginViewController.swift @@ -142,11 +142,16 @@ final class LoginViewController: BaseViewController { } /// 토큰을 Keychain에 저장하고, 디버깅 로그를 출력한다. - private func storeTokensAndPrintDebugLogs(accessToken: String, refreshToken: String) { - TokenStore.save(accessToken: accessToken, refreshToken: refreshToken) + /// 저장에 실패하면 안내 후 false를 반환해 이후 로그인 흐름을 멈춘다. + private func storeTokensAndPrintDebugLogs(accessToken: String, refreshToken: String) -> Bool { + guard TokenStore.save(accessToken: accessToken, refreshToken: refreshToken) else { + showToast(message: TextLiteral.Common.errorOccured, type: .danger) + return false + } #if DEBUG print("⭐️⭐️ 토큰 저장 성공 ⭐️⭐️", accessToken) #endif + return true } // MARK: - 액션 메서드 @@ -228,7 +233,7 @@ extension LoginViewController { let refreshToken = data.refreshToken // 토큰을 로컬에 저장 - storeTokensAndPrintDebugLogs(accessToken: accessToken, refreshToken: refreshToken) + guard storeTokensAndPrintDebugLogs(accessToken: accessToken, refreshToken: refreshToken) else { return } // 로컬 매니저에 유저 정보 생성 _ = UserInfoManager.shared.createUserInfo(accountType: accountType) @@ -264,8 +269,8 @@ extension LoginViewController { #if DEBUG print("Kakao login success") #endif - storeTokensAndPrintDebugLogs(accessToken: signData.accessToken, - refreshToken: signData.refreshToken) + guard storeTokensAndPrintDebugLogs(accessToken: signData.accessToken, + refreshToken: signData.refreshToken) else { return } _ = UserInfoManager.shared.createUserInfo(accountType: .kakao) UserDefaults.standard.set(UserInfo.AccountType.kakao.rawValue, forKey: TextLiteral.Auth.lastLoginProviderKey) AnalyticsService.logEvent("complete_login", parameters: ["method": "kakao"]) @@ -294,8 +299,8 @@ extension LoginViewController { #if DEBUG print("Apple 로그인 성공") #endif - storeTokensAndPrintDebugLogs(accessToken: signData.accessToken, - refreshToken: signData.refreshToken) + guard storeTokensAndPrintDebugLogs(accessToken: signData.accessToken, + refreshToken: signData.refreshToken) else { return } _ = UserInfoManager.shared.createUserInfo(accountType: .apple) UserDefaults.standard.set(UserInfo.AccountType.apple.rawValue, forKey: TextLiteral.Auth.lastLoginProviderKey) AnalyticsService.logEvent("complete_login", parameters: ["method": "apple"]) From dfcef45edda92b3a35d02ab67dc93686b0f5e0f9 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:03:15 +0900 Subject: [PATCH 4/5] =?UTF-8?q?[#465]=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=EC=A0=80=EC=9E=A5=EC=9D=84=20=EC=9B=90?= =?UTF-8?q?=EC=9E=90=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20=EB=8B=A4=EB=A5=B8=20=EA=B3=84=EC=A0=95=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=EC=9C=BC=EB=A1=9C=20=EB=B2=88=EC=A7=80?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../App/Sources/Data/LocalDB/TokenStore.swift | 47 +++++++++++++++---- .../Network/Foundation/TokenRefresher.swift | 27 +++++++---- 2 files changed, 57 insertions(+), 17 deletions(-) diff --git a/EATSSU/App/Sources/Data/LocalDB/TokenStore.swift b/EATSSU/App/Sources/Data/LocalDB/TokenStore.swift index e613fb01..e2155a65 100644 --- a/EATSSU/App/Sources/Data/LocalDB/TokenStore.swift +++ b/EATSSU/App/Sources/Data/LocalDB/TokenStore.swift @@ -36,6 +36,13 @@ enum TokenStore { !accessToken.isEmpty } + enum ReplaceResult { + case saved + /// 요청 이후 로그아웃·재로그인되어 저장하지 않음 + case sessionChanged + case saveFailed + } + /// 토큰 두 개를 함께 저장한다. /// 하나라도 실패하면 서로 다른 세대의 토큰이 섞이지 않도록 둘 다 지우고 false를 반환한다. @discardableResult @@ -43,6 +50,34 @@ enum TokenStore { lock.lock() defer { lock.unlock() } + return saveLocked(accessToken: accessToken, refreshToken: refreshToken) + } + + /// 재발급 결과를 저장한다. 요청할 때의 refreshToken이 그대로일 때만 저장하며, + /// 비교와 저장을 한 잠금 안에서 처리해 그 사이 로그아웃이 끼어들지 못하게 한다. + static func replace(expectedRefreshToken: String, accessToken: String, refreshToken: String) -> ReplaceResult { + lock.lock() + defer { lock.unlock() } + + guard valueLocked(forKey: Key.refreshToken, cache: &cachedRefreshToken) == expectedRefreshToken else { + return .sessionChanged + } + return saveLocked(accessToken: accessToken, refreshToken: refreshToken) ? .saved : .saveFailed + } + + static func clear() { + lock.lock() + defer { lock.unlock() } + + KeychainHelper.delete(forKey: Key.accessToken) + KeychainHelper.delete(forKey: Key.refreshToken) + cachedAccessToken = "" + cachedRefreshToken = "" + } + + // MARK: - lock을 잡은 상태에서만 호출 + + private static func saveLocked(accessToken: String, refreshToken: String) -> Bool { let isAccessSaved = KeychainHelper.save(accessToken, forKey: Key.accessToken, accessibility: accessibility) let isRefreshSaved = KeychainHelper.save(refreshToken, forKey: Key.refreshToken, accessibility: accessibility) guard isAccessSaved && isRefreshSaved else { @@ -57,20 +92,14 @@ enum TokenStore { return true } - static func clear() { + private static func value(forKey key: String, cache: inout String?) -> String { lock.lock() defer { lock.unlock() } - KeychainHelper.delete(forKey: Key.accessToken) - KeychainHelper.delete(forKey: Key.refreshToken) - cachedAccessToken = "" - cachedRefreshToken = "" + return valueLocked(forKey: key, cache: &cache) } - private static func value(forKey key: String, cache: inout String?) -> String { - lock.lock() - defer { lock.unlock() } - + private static func valueLocked(forKey key: String, cache: inout String?) -> String { if let cache { return cache } // 읽기 실패(첫 잠금 해제 전 등)는 캐시하지 않아, 잠금 해제 후 다시 읽을 수 있게 한다 guard let stored = KeychainHelper.read(forKey: key) else { return "" } diff --git a/EATSSU/App/Sources/Data/Network/Foundation/TokenRefresher.swift b/EATSSU/App/Sources/Data/Network/Foundation/TokenRefresher.swift index 5be7a9a6..212dbcbb 100644 --- a/EATSSU/App/Sources/Data/Network/Foundation/TokenRefresher.swift +++ b/EATSSU/App/Sources/Data/Network/Foundation/TokenRefresher.swift @@ -39,16 +39,22 @@ actor TokenRefresher { isRefreshing = false } + let requestedRefreshToken = TokenStore.refreshToken do { - let requestedRefreshToken = TokenStore.refreshToken let data = try await performReissuance() - // 재발급을 기다리는 사이 로그아웃·재로그인됐다면 이전 계정 토큰을 되살리지 않는다 - guard TokenStore.refreshToken == requestedRefreshToken else { + switch TokenStore.replace( + expectedRefreshToken: requestedRefreshToken, + accessToken: data.accessToken, + refreshToken: data.refreshToken + ) { + case .saved: + break + case .sessionChanged: + // 재발급을 기다리는 사이 로그아웃·재로그인됐다면 이전 계정 토큰을 되살리지 않는다 throw TokenRefresherError.discarded - } - // 저장에 실패하면 토큰이 비므로 재발급 실패와 같게 로그인 화면으로 보낸다 - guard TokenStore.save(accessToken: data.accessToken, refreshToken: data.refreshToken) else { + case .saveFailed: + // 저장에 실패하면 토큰이 비므로 재발급 실패와 같게 로그인 화면으로 보낸다 throw TokenRefresherError.sessionExpired } #if DEBUG @@ -58,10 +64,15 @@ actor TokenRefresher { waitingContinuations.forEach { $0.resume() } waitingContinuations.removeAll() } catch { + // 그사이 다른 계정으로 로그인했다면, 이전 계정 요청의 실패(401 등)가 + // 새 계정의 세션 만료로 번지지 않게 한다 + let currentRefreshToken = TokenStore.refreshToken + let isAnotherSession = !currentRefreshToken.isEmpty && currentRefreshToken != requestedRefreshToken + let propagatedError: Error = isAnotherSession ? TokenRefresherError.discarded : error // 대기 중인 모든 요청에 실패 전파 - waitingContinuations.forEach { $0.resume(throwing: error) } + waitingContinuations.forEach { $0.resume(throwing: propagatedError) } waitingContinuations.removeAll() - throw error + throw propagatedError } } From 7e860a04cd7c6ad913ac71a98022ff3d4495f3ba Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:44:34 +0900 Subject: [PATCH 5/5] =?UTF-8?q?[#465]=20=EC=A3=BC=EC=84=9D=EC=9D=84=20?= =?UTF-8?q?=EB=B0=94=EB=80=90=20=EC=A0=80=EC=9E=A5=EC=86=8C=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=EA=B3=BC=20=EB=8F=99=EC=9E=91=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Data/Network/Foundation/AuthenticationManager.swift | 2 +- EATSSU/App/Sources/Data/Network/Foundation/TokenRefresher.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/EATSSU/App/Sources/Data/Network/Foundation/AuthenticationManager.swift b/EATSSU/App/Sources/Data/Network/Foundation/AuthenticationManager.swift index 05dcb3a8..1fcd4e2e 100644 --- a/EATSSU/App/Sources/Data/Network/Foundation/AuthenticationManager.swift +++ b/EATSSU/App/Sources/Data/Network/Foundation/AuthenticationManager.swift @@ -37,7 +37,7 @@ final class AuthenticationManager { return .authenticated } catch TokenRefresherError.sessionExpired { // 3. refreshToken까지 만료되면 로그아웃과 같은 기준으로 계정 상태 정리 - // resetDB는 메인 전용 상태(찜 목록)도 비우므로 메인에서 실행 + // AccountStorage.reset은 메인 전용 상태(찜 목록)도 비우므로 메인에서 실행 await MainActor.run { AnalyticsIdentityManager.reset() AccountStorage.reset() diff --git a/EATSSU/App/Sources/Data/Network/Foundation/TokenRefresher.swift b/EATSSU/App/Sources/Data/Network/Foundation/TokenRefresher.swift index 212dbcbb..19a4d1c8 100644 --- a/EATSSU/App/Sources/Data/Network/Foundation/TokenRefresher.swift +++ b/EATSSU/App/Sources/Data/Network/Foundation/TokenRefresher.swift @@ -54,7 +54,7 @@ actor TokenRefresher { // 재발급을 기다리는 사이 로그아웃·재로그인됐다면 이전 계정 토큰을 되살리지 않는다 throw TokenRefresherError.discarded case .saveFailed: - // 저장에 실패하면 토큰이 비므로 재발급 실패와 같게 로그인 화면으로 보낸다 + // 저장에 실패하면 토큰이 비므로 재발급 실패(세션 만료)로 처리한다 throw TokenRefresherError.sessionExpired } #if DEBUG