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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions EATSSU/App/Sources/Data/LocalDB/AccountStorage.swift
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()
Comment thread
Hrepay marked this conversation as resolved.
UserInfoManager.shared.clear()
// 찜 목록·순서
PartnershipLikeManager.shared.reset()
}
}
134 changes: 134 additions & 0 deletions EATSSU/App/Sources/Data/LocalDB/LocalStorageMigrator.swift
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?
}
63 changes: 0 additions & 63 deletions EATSSU/App/Sources/Data/LocalDB/RealmService.swift

This file was deleted.

27 changes: 0 additions & 27 deletions EATSSU/App/Sources/Data/LocalDB/Token.swift

This file was deleted.

109 changes: 109 additions & 0 deletions EATSSU/App/Sources/Data/LocalDB/TokenStore.swift
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
}
}
Loading
Loading