Repository navigation
[Refactor/#465] 토큰 Keychain 이전 및 Realm 데이터 마이그레이션 (1단계) #467
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
e08d31e
[#465] KeychainHelper에 접근 시점 지정과 삭제 추가
Hrepay 4fcc6e9
[#465] 토큰은 Keychain, 사용자 정보는 UserDefaults로 옮기고 기존 Realm 데이터 이전
Hrepay ff46bba
[#465] 토큰 저장 실패와 로그아웃 중 도착한 재발급 결과 처리
Hrepay dfcef45
[#465] 재발급 결과 저장을 원자적으로 처리하고 다른 계정 세션으로 번지지 않게 수정
Hrepay 7e860a0
[#465] 주석을 바뀐 저장소 이름과 동작에 맞게 수정
Hrepay File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
| } | ||
| } | ||
134 changes: 134 additions & 0 deletions
134
EATSSU/App/Sources/Data/LocalDB/LocalStorageMigrator.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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? | ||
| } |
This file was deleted.
Oops, something went wrong.
This file was deleted.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| // | ||
| // 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 | ||
| } | ||
|
|
||
| enum ReplaceResult { | ||
| case saved | ||
| /// 요청 이후 로그아웃·재로그인되어 저장하지 않음 | ||
| case sessionChanged | ||
| case saveFailed | ||
| } | ||
|
|
||
| /// 토큰 두 개를 함께 저장한다. | ||
| /// 하나라도 실패하면 서로 다른 세대의 토큰이 섞이지 않도록 둘 다 지우고 false를 반환한다. | ||
| @discardableResult | ||
| static func save(accessToken: String, refreshToken: String) -> Bool { | ||
| 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 { | ||
| KeychainHelper.delete(forKey: Key.accessToken) | ||
| KeychainHelper.delete(forKey: Key.refreshToken) | ||
| cachedAccessToken = "" | ||
| cachedRefreshToken = "" | ||
| return false | ||
| } | ||
| cachedAccessToken = accessToken | ||
| cachedRefreshToken = refreshToken | ||
| return true | ||
| } | ||
|
|
||
| private static func value(forKey key: String, cache: inout String?) -> String { | ||
| lock.lock() | ||
| defer { lock.unlock() } | ||
|
|
||
| return valueLocked(forKey: key, cache: &cache) | ||
| } | ||
|
|
||
| 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 "" } | ||
| cache = stored | ||
| return stored | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.