SwiftData + 계정 전환: 로그아웃 시 LocalDB wipe와 Keychain 통일로 데이터 격리 구현

문제: 계정 전환 후 이전 계정 데이터 노출

같은 디바이스에서 다른 계정으로 로그인할 수 있는 앱에서, 로그아웃 시 UserDefaults 토큰만 삭제하면 SwiftData 로컬 DB가 그대로 남습니다. 다음 사용자가 로그인하면 이전 사용자의 자녀 정보와 일정이 화면에 잠깐 노출되는 심각한 프라이버시 버그가 발생했습니다.

해결: 로그아웃 시 4단계 완전 초기화

// AuthService.swift
func logout() async {
    // 1. 서버에 로그아웃 알림 (토큰 무효화)
    try? await APIClient.shared.request("auth/logout", method: "POST")

    // 2. Keychain 전체 삭제
    KeychainManager.deleteAll()

    // 3. UserDefaults 초기화
    let domain = Bundle.main.bundleIdentifier!
    UserDefaults.standard.removePersistentDomain(forName: domain)

    // 4. SwiftData LocalDB wipe
    await MainActor.run {
        do {
            let context = ModelContext.shared
            try context.delete(model: LocalChild.self)
            try context.delete(model: LocalSchedule.self)
            try context.delete(model: LocalAcademy.self)
            try context.save()
        } catch {
            // 실패 시 저장소 파일 직접 삭제
            resetSwiftDataStore()
        }
    }

    // 5. TCA Store 리셋
    await store.send(.logout).finish()
}

Keychain 단일 저장소 통일

초기에는 토큰을 UserDefaults와 Keychain 두 곳에 저장했습니다. 로그아웃 시 한 곳만 삭제하면 다른 곳에 남아있는 문제가 생겼습니다. 해결책은 Keychain 단일 저장소로 통일하는 것입니다.

enum KeychainManager {
    static let service = "com.ivent.app"

    static func save(_ value: String, for key: String) {
        let data = value.data(using: .utf8)!
        let query: [CFString: Any] = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrService: service,
            kSecAttrAccount: key,
            kSecValueData: data
        ]
        SecItemDelete(query as CFDictionary)
        SecItemAdd(query as CFDictionary, nil)
    }

    static func load(for key: String) -> String? {
        let query: [CFString: Any] = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrService: service,
            kSecAttrAccount: key,
            kSecReturnData: true
        ]
        var result: AnyObject?
        SecItemCopyMatching(query as CFDictionary, &result)
        return (result as? Data).flatMap { String(data: $0, encoding: .utf8) }
    }

    static func deleteAll() {
        let query: [CFString: Any] = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrService: service
        ]
        SecItemDelete(query as CFDictionary)
    }
}

userId 기반 데이터 격리 (이중 방어)

@Model class LocalChild {
    var id: String
    var userId: String  // 계정 격리를 위한 필드
    var name: String
}

// 조회 시 항상 userId 필터 적용
let userId = KeychainManager.load(for: "IVENT_USER_ID") ?? ""
let descriptor = FetchDescriptor<LocalChild>(
    predicate: #Predicate { $0.userId == userId }
)

교훈

다중 계정 앱에서 로그아웃은 토큰 삭제가 아닙니다. 로컬 DB, 캐시, UserDefaults, Keychain의 모든 레이어에서 이전 사용자 데이터를 완전히 제거해야 합니다. userId 기반 격리를 이중으로 적용하는 것이 안전합니다.

Leave a Reply

Your email address will not be published. Required fields are marked *