์ฝ˜ํ…์ธ  ๋Œ€ํ‘œ ์ด๋ฏธ์ง€ - ๐Ÿ” iOS ๋ณด์•ˆ๊ณผ Keychain ยท TouchID ยท FaceID์ƒ์ฒด ์ธ์ฆ ๊ตฌํ˜„ ์™„์ „ ์ •๋ณต

๐Ÿ” iOS ๋ณด์•ˆ๊ณผ Keychain ยท TouchID ยท FaceID
์ƒ์ฒด ์ธ์ฆ ๊ตฌํ˜„ ์™„์ „ ์ •๋ณต

Swift๋กœ ๊ตฌํ˜„ํ•˜๋Š” ์ง„์งœ ๋ณด์•ˆ ์•ฑ ๋งŒ๋“ค๊ธฐ ๐Ÿ›ก๏ธ | ์žฌ๋Šฅ๋„ท ์ง€์‹์ธ์˜ ์ˆฒ

์•ผ ์†”์งํžˆ ๋งํ•ด์„œ... ์•ฑ ๋งŒ๋“ค ๋•Œ ๋ณด์•ˆ ํŒŒํŠธ ์ œ์ผ ๊ท€์ฐฎ์ง€ ์•Š์Œ? ใ…‹ใ…‹ใ…‹ ๊ทผ๋ฐ ์ด๊ฑฐ ์ œ๋Œ€๋กœ ์•ˆ ํ•˜๋ฉด ๋‚˜์ค‘์— ์ง„์งœ ํฐ์ผ ๋‚จ. ์‚ฌ์šฉ์ž ๋ฐ์ดํ„ฐ ํ„ธ๋ฆฌ๋ฉด ๊ทธ๋ƒฅ ์•ฑ ๋งํ•˜๋Š” ๊ฑฐ๊ฑฐ๋“ . ์˜ค๋Š˜์€ iOS ๋ณด์•ˆ์˜ ํ•ต์‹ฌ์ธ Keychain, TouchID, FaceID ์ƒ์ฒด ์ธ์ฆ์„ Swift๋กœ ์–ด๋–ป๊ฒŒ ๊ตฌํ˜„ํ•˜๋Š”์ง€ ์ฐํ•˜๊ฒŒ ํŒŒํ—ค์ณ๋ณผ๊ฒŒ. ์ด๊ฑฐ ์•Œ๋ฉด ์ง„์งœ ์•ฑ ํ€„๋ฆฌํ‹ฐ๊ฐ€ ๋‹ค๋ฅธ ์ฐจ์›์œผ๋กœ ์˜ฌ๋ผ๊ฐ ๐Ÿš€

๐Ÿ’ก ์ด ๊ธ€์˜ ๋Œ€์ƒ: Swift ๊ธฐ์ดˆ๋Š” ์•Œ๊ณ  ์žˆ๊ณ , ๋ณด์•ˆ ๊ธฐ๋Šฅ ๊ตฌํ˜„์— ๊ด€์‹ฌ ์žˆ๋Š” iOS ๊ฐœ๋ฐœ์ž. ์ค‘๊ธ‰ ์ด์ƒ ๋‚ด์šฉ์ด์ง€๋งŒ ์ตœ๋Œ€ํ•œ ์‰ฝ๊ฒŒ ํ’€์–ด๋ณผ๊ฒŒ์š”!

๐Ÿฐ iOS ๋ณด์•ˆ ์•„ํ‚คํ…์ฒ˜ - ์ผ๋‹จ ํฐ ๊ทธ๋ฆผ๋ถ€ํ„ฐ

iOS ๋ณด์•ˆ์ด ์™œ ์ด๋ ‡๊ฒŒ ๊ฐ•๋ ฅํ•œ์ง€ ์•Œ๋ ค๋ฉด ์• ํ”Œ์ด ์„ค๊ณ„ํ•œ ๋ณด์•ˆ ๋ ˆ์ด์–ด๋ฅผ ์ดํ•ดํ•ด์•ผ ํ•ด. ๊ทธ๋ƒฅ "์•”ํ˜ธํ™” ์ž˜ ๋˜์–ด์žˆ์–ด์š”~" ์ˆ˜์ค€์ด ์•„๋‹ˆ๋ผ ํ•˜๋“œ์›จ์–ด๋ถ€ํ„ฐ ์†Œํ”„ํŠธ์›จ์–ด๊นŒ์ง€ ๊ฒน๊ฒน์ด ์Œ“์ธ ๊ตฌ์กฐ์•ผ.

iOS ๋ณด์•ˆ ๋ ˆ์ด์–ด ๊ตฌ์กฐ ๐Ÿ”ฉ ํ•˜๋“œ์›จ์–ด ๋ณด์•ˆ (Secure Enclave) ์ƒ์ฒด์ •๋ณด ์ฒ˜๋ฆฌ ์ „์šฉ ์นฉ ยท ์•”ํ˜ธํ™” ํ‚ค ๊ฒฉ๋ฆฌ ์ €์žฅ ยท ๋ฉ”์ธ ํ”„๋กœ์„ธ์„œ ์ ‘๊ทผ ๋ถˆ๊ฐ€ ๐Ÿ”‘ Keychain Services ๋น„๋ฐ€๋ฒˆํ˜ธ ยท ํ† ํฐ ยท ์ธ์ฆ์„œ ์•ˆ์ „ ์ €์žฅ ยท AES-256 ์•”ํ˜ธํ™” ๐Ÿ‘† Local Authentication (์ƒ์ฒด์ธ์ฆ) TouchID ยท FaceID ยท ๊ธฐ๊ธฐ ์•”ํ˜ธ ์ธ์ฆ ํ”„๋ ˆ์ž„์›Œํฌ ๐Ÿ“ฆ ์•ฑ ์ƒŒ๋“œ๋ฐ•์Šค (App Sandbox) ์•ฑ ๊ฐ„ ๋ฐ์ดํ„ฐ ๊ฒฉ๋ฆฌ ยท ํŒŒ์ผ์‹œ์Šคํ…œ ์ ‘๊ทผ ์ œํ•œ ๐Ÿ“ฑ ์•ฑ ๋ ˆ์ด์–ด (๊ฐœ๋ฐœ์ž ์ฝ”๋“œ) Swift ยท SwiftUI ยท UIKit ๋ณด์•ˆ ๊ตฌํ˜„ โ†‘ ์œ„๋กœ ๊ฐˆ์ˆ˜๋ก ๊ฐœ๋ฐœ์ž ์ ‘๊ทผ ๊ฐ€๋Šฅ ยท ์•„๋ž˜๋กœ ๊ฐˆ์ˆ˜๋ก ํ•˜๋“œ์›จ์–ด ๊ฒฉ๋ฆฌ โ†‘

๋ณด์ด์ง€? ์• ํ”Œ์€ ์ง„์งœ ํ•˜๋“œ์›จ์–ด ๋ ˆ๋ฒจ๋ถ€ํ„ฐ ๋ณด์•ˆ์„ ์„ค๊ณ„ํ–ˆ์–ด. ํŠนํžˆ Secure Enclave๋Š” ๋ฉ”์ธ CPU์™€ ์™„์ „ํžˆ ๋ถ„๋ฆฌ๋œ ๋ณด์•ˆ ์ „์šฉ ์นฉ์ธ๋ฐ, ์—ฌ๊ธฐ์— ์ƒ์ฒด์ •๋ณด๊ฐ€ ์ €์žฅ๋˜๊ณ  ์ฒ˜๋ฆฌ๋ผ. ๊ฐœ๋ฐœ์ž๋„ ์ง์ ‘ ์ ‘๊ทผ ๋ชป ํ•จ. ๊ทธ๋ƒฅ "์ธ์ฆ ์„ฑ๊ณต/์‹คํŒจ" ๊ฒฐ๊ณผ๋งŒ ๋ฐ›๋Š” ๊ฑฐ์•ผ.

๐Ÿง  Secure Enclave๊ฐ€ ๋ญ”๋ฐ? iPhone 5s๋ถ€ํ„ฐ ํƒ‘์žฌ๋œ ํ•˜๋“œ์›จ์–ด ๋ณด์•ˆ ๋ชจ๋“ˆ์ด์•ผ. ARM TrustZone ๊ธฐ๋ฐ˜์œผ๋กœ ๋งŒ๋“ค์–ด์กŒ๊ณ , ๋ฉ”์ธ ํ”„๋กœ์„ธ์„œ์™€ ์™„์ „ํžˆ ๊ฒฉ๋ฆฌ๋œ ๋ณ„๋„์˜ ๋งˆ์ดํฌ๋กœ์ปค๋„์—์„œ ๋™์ž‘ํ•ด. ์ง€๋ฌธ ๋ฐ์ดํ„ฐ, Face ID ์ˆ˜ํ•™์  ํ‘œํ˜„๊ฐ’, ์•”ํ˜ธํ™” ํ‚ค ๋“ฑ์ด ์—ฌ๊ธฐ ์ €์žฅ๋˜๋Š”๋ฐ, ์ด ๋ฐ์ดํ„ฐ๋Š” ์ ˆ๋Œ€ Secure Enclave ๋ฐ–์œผ๋กœ ๋‚˜๊ฐ€์ง€ ์•Š์•„. ํ•ดํ‚นํ•˜๋ ค๋ฉด ๋ฌผ๋ฆฌ์ ์œผ๋กœ ์นฉ์„ ๋œฏ์–ด์•ผ ํ•˜๋Š”๋ฐ... ๊ทธ๊ฒƒ๋„ ์‚ฌ์‹ค์ƒ ๋ถˆ๊ฐ€๋Šฅํ•˜๊ฒŒ ์„ค๊ณ„๋˜์–ด ์žˆ์Œ ใ„ทใ„ท

๐Ÿ—๏ธ Keychain - ๋น„๋ฐ€๋ฒˆํ˜ธ ์ €์žฅ์˜ ์ •์„

UserDefaults์— ๋น„๋ฐ€๋ฒˆํ˜ธ ์ €์žฅํ•˜๋Š” ์‚ฌ๋žŒ... ์ œ๋ฐœ ๊ทธ๋Ÿฌ์ง€ ๋งˆ ๐Ÿ˜ญ UserDefaults๋Š” ๊ทธ๋ƒฅ plist ํŒŒ์ผ์— ํ‰๋ฌธ์œผ๋กœ ์ €์žฅ๋˜๊ฑฐ๋“ . ํƒˆ์˜ฅ ๊ธฐ๊ธฐ์—์„œ ๋ฐ”๋กœ ํ„ธ๋ฆผ. Keychain์ด ๋ฐ”๋กœ ์ด๋Ÿฐ ๋ฏผ๊ฐํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ์•ˆ์ „ํ•˜๊ฒŒ ์ €์žฅํ•˜๋Š” ๊ณณ์ด์•ผ.

๐Ÿ“Œ Keychain์˜ ํ•ต์‹ฌ ๊ฐœ๋…
๊ฐœ๋… ์„ค๋ช…
kSecClass ์ €์žฅํ•  ๋ฐ์ดํ„ฐ ํƒ€์ž… (๋น„๋ฐ€๋ฒˆํ˜ธ, ์ธ์ฆ์„œ, ํ‚ค ๋“ฑ)
kSecAttrService ์•ฑ ์‹๋ณ„์ž (๋ณดํ†ต Bundle ID ์‚ฌ์šฉ)
kSecAttrAccount ๊ณ„์ • ์‹๋ณ„์ž (์‚ฌ์šฉ์ž๋ช…, ์ด๋ฉ”์ผ ๋“ฑ)
kSecValueData ์‹ค์ œ ์ €์žฅํ•  ๋ฐ์ดํ„ฐ (Data ํƒ€์ž…)
kSecAttrAccessible ์–ธ์ œ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•œ์ง€ ์„ค์ • (์ž ๊ธˆ ํ•ด์ œ ํ›„ ๋“ฑ)
๐Ÿ› ๏ธ Keychain ๋ž˜ํผ ํด๋ž˜์Šค ๋งŒ๋“ค๊ธฐ

๋งค๋ฒˆ SecItemAdd, SecItemCopyMatching ์ด๋Ÿฐ C API ์ง์ ‘ ์“ฐ๋ฉด ์ฝ”๋“œ๊ฐ€ ๋„ˆ๋ฌด ์ง€์ €๋ถ„ํ•ด์ง. ๊ทธ๋ž˜์„œ ๋ณดํ†ต ๋ž˜ํผ ํด๋ž˜์Šค๋ฅผ ๋งŒ๋“ค์–ด์„œ ์”€. ์‹ค๋ฌด์—์„œ ๋ฐ”๋กœ ์“ธ ์ˆ˜ ์žˆ๋Š” ์ˆ˜์ค€์œผ๋กœ ๋งŒ๋“ค์–ด๋ณผ๊ฒŒ!

import Foundation
import Security

// MARK: - Keychain ์—๋Ÿฌ ์ •์˜
enum KeychainError: Error, LocalizedError {
    case duplicateEntry
    case itemNotFound
    case invalidData
    case unexpectedStatus(OSStatus)
    
    var errorDescription: String? {
        switch self {
        case .duplicateEntry:
            return "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ํ•ญ๋ชฉ์ž…๋‹ˆ๋‹ค"
        case .itemNotFound:
            return "ํ•ญ๋ชฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"
        case .invalidData:
            return "์œ ํšจํ•˜์ง€ ์•Š์€ ๋ฐ์ดํ„ฐ์ž…๋‹ˆ๋‹ค"
        case .unexpectedStatus(let status):
            return "์˜ˆ์ƒ์น˜ ๋ชปํ•œ ์˜ค๋ฅ˜: \(status)"
        }
    }
}

// MARK: - Keychain ์ ‘๊ทผ ๊ฐ€๋Šฅ ์‹œ์  ์„ค์ •
enum KeychainAccessibility {
    /// ๊ธฐ๊ธฐ ์ž ๊ธˆ ํ•ด์ œ ํ›„ ์ ‘๊ทผ ๊ฐ€๋Šฅ (๊ฐ€์žฅ ์ผ๋ฐ˜์ )
    case whenUnlocked
    /// ๊ธฐ๊ธฐ ์ž ๊ธˆ ํ•ด์ œ ํ›„ ์ ‘๊ทผ ๊ฐ€๋Šฅ + iCloud ๋ฐฑ์—… ์ œ์™ธ
    case whenUnlockedThisDeviceOnly
    /// ์ฒซ ๋ฒˆ์งธ ์ž ๊ธˆ ํ•ด์ œ ํ›„ ์žฌ๋ถ€ํŒ… ์ „๊นŒ์ง€ ์ ‘๊ทผ ๊ฐ€๋Šฅ
    case afterFirstUnlock
    /// ํ•ญ์ƒ ์ ‘๊ทผ ๊ฐ€๋Šฅ (๋น„์ถ”์ฒœ)
    case always
    
    var value: CFString {
        switch self {
        case .whenUnlocked:
            return kSecAttrAccessibleWhenUnlocked
        case .whenUnlockedThisDeviceOnly:
            return kSecAttrAccessibleWhenUnlockedThisDeviceOnly
        case .afterFirstUnlock:
            return kSecAttrAccessibleAfterFirstUnlock
        case .always:
            return kSecAttrAccessibleAlways
        }
    }
}

// MARK: - KeychainManager
final class KeychainManager {
    
    static let shared = KeychainManager()
    private init() {}
    
    private let service = Bundle.main.bundleIdentifier ?? "com.myapp.keychain"
    
    // MARK: - ์ €์žฅ (Create/Update)
    func save(_ value: String, 
              forKey key: String,
              accessibility: KeychainAccessibility = .whenUnlockedThisDeviceOnly) throws {
        
        guard let data = value.data(using: .utf8) else {
            throw KeychainError.invalidData
        }
        
        // ๋จผ์ € ๊ธฐ์กด ํ•ญ๋ชฉ ์‚ญ์ œ ์‹œ๋„ (์—…๋ฐ์ดํŠธ๋ฅผ ์œ„ํ•ด)
        try? delete(forKey: key)
        
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key,
            kSecValueData as String: data,
            kSecAttrAccessible as String: accessibility.value
        ]
        
        let status = SecItemAdd(query as CFDictionary, nil)
        
        guard status == errSecSuccess else {
            if status == errSecDuplicateItem {
                throw KeychainError.duplicateEntry
            }
            throw KeychainError.unexpectedStatus(status)
        }
    }
    
    // MARK: - ์ฝ๊ธฐ (Read)
    func read(forKey key: String) throws -> String {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]
        
        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)
        
        guard status == errSecSuccess else {
            if status == errSecItemNotFound {
                throw KeychainError.itemNotFound
            }
            throw KeychainError.unexpectedStatus(status)
        }
        
        guard let data = result as? Data,
              let string = String(data: data, encoding: .utf8) else {
            throw KeychainError.invalidData
        }
        
        return string
    }
    
    // MARK: - ์‚ญ์ œ (Delete)
    func delete(forKey key: String) throws {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key
        ]
        
        let status = SecItemDelete(query as CFDictionary)
        
        guard status == errSecSuccess || status == errSecItemNotFound else {
            throw KeychainError.unexpectedStatus(status)
        }
    }
    
    // MARK: - ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธ
    func exists(forKey key: String) -> Bool {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]
        
        let status = SecItemCopyMatching(query as CFDictionary, nil)
        return status == errSecSuccess
    }
}
๐Ÿ’ก kSecAttrAccessibleWhenUnlockedThisDeviceOnly๋ฅผ ์“ฐ๋Š” ๊ฒŒ ์™œ ์ข‹๋ƒ๋ฉด, iCloud ๋ฐฑ์—…์—์„œ ์ œ์™ธ๋˜๊ธฐ ๋•Œ๋ฌธ์ด์•ผ. ๋ฏผ๊ฐํ•œ ์ธ์ฆ ํ† ํฐ์ด ํด๋ผ์šฐ๋“œ์— ์˜ฌ๋ผ๊ฐ€๋ฉด ์•ˆ ๋˜์ž–์•„. ๋ณด์•ˆ ๋ฏผ๊ฐ ๋ฐ์ดํ„ฐ๋Š” ํ•ญ์ƒ ThisDeviceOnly ๋ถ™์€ ์˜ต์…˜ ์“ฐ๋Š” ๊ฒŒ ์ •์„์ด์•ผ!
๐ŸŽฏ ์‹ค์ œ ์‚ฌ์šฉ ์˜ˆ์‹œ
// ์‚ฌ์šฉ ์˜ˆ์‹œ - ์ด๋ ‡๊ฒŒ ์“ฐ๋ฉด ๋จ!
class AuthService {
    
    private let tokenKey = "auth_token"
    private let refreshTokenKey = "refresh_token"
    private let keychain = KeychainManager.shared
    
    // ๋กœ๊ทธ์ธ ํ›„ ํ† ํฐ ์ €์žฅ
    func saveTokens(accessToken: String, refreshToken: String) {
        do {
            try keychain.save(accessToken, forKey: tokenKey)
            try keychain.save(refreshToken, forKey: refreshTokenKey)
            print("โœ… ํ† ํฐ ์ €์žฅ ์™„๋ฃŒ!")
        } catch {
            print("โŒ ํ† ํฐ ์ €์žฅ ์‹คํŒจ: \(error.localizedDescription)")
        }
    }
    
    // ์ €์žฅ๋œ ํ† ํฐ ๊ฐ€์ ธ์˜ค๊ธฐ
    func getAccessToken() -> String? {
        return try? keychain.read(forKey: tokenKey)
    }
    
    // ๋กœ๊ทธ์•„์›ƒ ์‹œ ํ† ํฐ ์‚ญ์ œ
    func logout() {
        try? keychain.delete(forKey: tokenKey)
        try? keychain.delete(forKey: refreshTokenKey)
        print("๐Ÿ‘‹ ๋กœ๊ทธ์•„์›ƒ ์™„๋ฃŒ, ํ† ํฐ ์‚ญ์ œ๋จ")
    }
}

์–ด๋•Œ? ์ƒ๊ฐ๋ณด๋‹ค ๋ณ„๊ฑฐ ์—†์ง€? ใ…‹ใ…‹ ๊ทผ๋ฐ ์ด๊ฒŒ ์ง„์งœ ์ค‘์š”ํ•œ ๊ฑฐ์•ผ. ์ด ํŒจํ„ด ํ•˜๋‚˜๋งŒ ์ œ๋Œ€๋กœ ์จ๋„ ์•ฑ ๋ณด์•ˆ ์ˆ˜์ค€์ด ํ™• ์˜ฌ๋ผ๊ฐ.


๐Ÿ‘† TouchID & ๐Ÿ˜Š FaceID - ์ƒ์ฒด ์ธ์ฆ ๊ตฌํ˜„

์ด์ œ ์ง„์งœ ๊ฟ€์žผ ํŒŒํŠธ! ์ƒ์ฒด ์ธ์ฆ์€ LocalAuthentication ํ”„๋ ˆ์ž„์›Œํฌ๋ฅผ ์‚ฌ์šฉํ•ด. ์ด๋ฆ„์ด ์ข€ ์ด์ƒํ•˜๊ฒŒ ๋А๊ปด์งˆ ์ˆ˜ ์žˆ๋Š”๋ฐ, "๋กœ์ปฌ(๊ธฐ๊ธฐ ๋‚ด)์—์„œ ์ธ์ฆํ•œ๋‹ค"๋Š” ๋œป์ด์•ผ. ์„œ๋ฒ„ ํ†ต์‹  ์—†์ด ๊ธฐ๊ธฐ ์ž์ฒด์—์„œ ์ธ์ฆ์ด ์™„๋ฃŒ๋จ.

์ƒ์ฒด ์ธ์ฆ ๋™์ž‘ ํ๋ฆ„ ๐Ÿ‘† TouchID ํ๋ฆ„ ์•ฑ์—์„œ ์ธ์ฆ ์š”์ฒญ โ†“ ํ™ˆ๋ฒ„ํŠผ ์ง€๋ฌธ ์„ผ์„œ ํ™œ์„ฑํ™” โ†“ Secure Enclave์—์„œ ์ง€๋ฌธ ๋Œ€์กฐ โ†“ ์„ฑ๊ณต/์‹คํŒจ ๊ฒฐ๊ณผ๋งŒ ์•ฑ์— ์ „๋‹ฌ ๐Ÿ˜Š FaceID ํ๋ฆ„ ์•ฑ์—์„œ ์ธ์ฆ ์š”์ฒญ โ†“ TrueDepth ์นด๋ฉ”๋ผ 3D ์Šค์บ” โ†“ ์‹ ๊ฒฝ๋ง ๋ชจ๋ธ๋กœ ์–ผ๊ตด ์ˆ˜ํ•™์  ๋ถ„์„ โ†“ ์„ฑ๊ณต/์‹คํŒจ ๊ฒฐ๊ณผ๋งŒ ์•ฑ์— ์ „๋‹ฌ โšก ๋‘ ๋ฐฉ์‹ ๋ชจ๋‘ ์ƒ์ฒด ๋ฐ์ดํ„ฐ๋Š” ์ ˆ๋Œ€ ์•ฑ์— ์ „๋‹ฌ๋˜์ง€ ์•Š์Œ
๐Ÿ“‹ Info.plist ์„ค์ • ํ•„์ˆ˜!

FaceID๋ฅผ ์‚ฌ์šฉํ•˜๋ ค๋ฉด Info.plist์— ๋ฐ˜๋“œ์‹œ ๊ถŒํ•œ ์„ค๋ช…์„ ์ถ”๊ฐ€ํ•ด์•ผ ํ•ด. ์•ˆ ํ•˜๋ฉด ์•ฑ์ด ํฌ๋ž˜์‹œ ๋‚จ ใ…‹ใ…‹

<!-- Info.plist์— ์ถ”๊ฐ€ -->
<key>NSFaceIDUsageDescription</key>
<string>์•ˆ์ „ํ•œ ๋กœ๊ทธ์ธ์„ ์œ„ํ•ด Face ID๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค</string>
โš ๏ธ TouchID๋Š” Info.plist ์„ค์ • ๋ถˆํ•„์š”ํ•˜์ง€๋งŒ, FaceID๋Š” ๋ฐ˜๋“œ์‹œ NSFaceIDUsageDescription ํ‚ค๋ฅผ ์ถ”๊ฐ€ํ•ด์•ผ ํ•ด. ์—†์œผ๋ฉด ์•ฑ ์‹ฌ์‚ฌ์—์„œ๋„ ๋ฆฌ์  ๋‹นํ•จ!
๐Ÿ”ง BiometricAuthManager ๊ตฌํ˜„
import LocalAuthentication
import Foundation

// MARK: - ์ƒ์ฒด ์ธ์ฆ ํƒ€์ž…
enum BiometricType {
    case none
    case touchID
    case faceID
    case opticID  // Apple Vision Pro์šฉ (iOS 17+)
    
    var displayName: String {
        switch self {
        case .none: return "์ƒ์ฒด ์ธ์ฆ ์—†์Œ"
        case .touchID: return "Touch ID"
        case .faceID: return "Face ID"
        case .opticID: return "Optic ID"
        }
    }
    
    var iconName: String {
        switch self {
        case .none: return "lock.fill"
        case .touchID: return "touchid"
        case .faceID: return "faceid"
        case .opticID: return "opticid"
        }
    }
}

// MARK: - ์ธ์ฆ ๊ฒฐ๊ณผ
enum AuthResult {
    case success
    case failure(AuthError)
}

// MARK: - ์ธ์ฆ ์—๋Ÿฌ
enum AuthError: Error, LocalizedError {
    case biometryNotAvailable      // ์ƒ์ฒด ์ธ์ฆ ํ•˜๋“œ์›จ์–ด ์—†์Œ
    case biometryNotEnrolled       // ์ƒ์ฒด ์ •๋ณด ๋ฏธ๋“ฑ๋ก
    case biometryLockout           // ๋„ˆ๋ฌด ๋งŽ์€ ์‹คํŒจ๋กœ ์ž ๊น€
    case userCancel                // ์‚ฌ์šฉ์ž๊ฐ€ ์ทจ์†Œ
    case userFallback              // ๋น„๋ฐ€๋ฒˆํ˜ธ ์ž…๋ ฅ ์„ ํƒ
    case systemCancel              // ์‹œ์Šคํ…œ์ด ์ทจ์†Œ (์ „ํ™” ์ˆ˜์‹  ๋“ฑ)
    case authenticationFailed      // ์ธ์ฆ ์‹คํŒจ
    case unknown(Error)            // ๊ธฐํƒ€ ์—๋Ÿฌ
    
    var errorDescription: String? {
        switch self {
        case .biometryNotAvailable:
            return "์ด ๊ธฐ๊ธฐ๋Š” ์ƒ์ฒด ์ธ์ฆ์„ ์ง€์›ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค"
        case .biometryNotEnrolled:
            return "์ƒ์ฒด ์ •๋ณด๊ฐ€ ๋“ฑ๋ก๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ์„ค์ •์—์„œ ๋“ฑ๋กํ•ด์ฃผ์„ธ์š”"
        case .biometryLockout:
            return "๋„ˆ๋ฌด ๋งŽ์€ ์‹คํŒจ๋กœ ์ƒ์ฒด ์ธ์ฆ์ด ์ž ๊ฒผ์Šต๋‹ˆ๋‹ค. ๊ธฐ๊ธฐ ์•”ํ˜ธ๋ฅผ ์‚ฌ์šฉํ•ด์ฃผ์„ธ์š”"
        case .userCancel:
            return "์ธ์ฆ์ด ์ทจ์†Œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค"
        case .userFallback:
            return "๋น„๋ฐ€๋ฒˆํ˜ธ ์ž…๋ ฅ์„ ์„ ํƒํ–ˆ์Šต๋‹ˆ๋‹ค"
        case .systemCancel:
            return "์‹œ์Šคํ…œ์— ์˜ํ•ด ์ธ์ฆ์ด ์ทจ์†Œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค"
        case .authenticationFailed:
            return "์ธ์ฆ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค"
        case .unknown(let error):
            return "์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜: \(error.localizedDescription)"
        }
    }
}

// MARK: - BiometricAuthManager
@MainActor
final class BiometricAuthManager: ObservableObject {
    
    static let shared = BiometricAuthManager()
    private init() {}
    
    // MARK: - ํ˜„์žฌ ๊ธฐ๊ธฐ์˜ ์ƒ์ฒด ์ธ์ฆ ํƒ€์ž… ํ™•์ธ
    var biometricType: BiometricType {
        let context = LAContext()
        var error: NSError?
        
        guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, 
                                        error: &error) else {
            return .none
        }
        
        switch context.biometryType {
        case .touchID:
            return .touchID
        case .faceID:
            return .faceID
        case .opticID:
            return .opticID
        case .none:
            return .none
        @unknown default:
            return .none
        }
    }
    
    // MARK: - ์ƒ์ฒด ์ธ์ฆ ๊ฐ€๋Šฅ ์—ฌ๋ถ€ ํ™•์ธ
    var isBiometricAvailable: Bool {
        let context = LAContext()
        var error: NSError?
        return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, 
                                         error: &error)
    }
    
    // MARK: - ์ƒ์ฒด ์ธ์ฆ ์‹คํ–‰ (async/await ๋ฒ„์ „)
    func authenticate(reason: String = "๋ณธ์ธ ํ™•์ธ์„ ์œ„ํ•ด ์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค") async -> AuthResult {
        let context = LAContext()
        context.localizedCancelTitle = "์ทจ์†Œ"
        context.localizedFallbackTitle = "๋น„๋ฐ€๋ฒˆํ˜ธ ์‚ฌ์šฉ"
        
        // ์ƒ์ฒด ์ธ์ฆ ๊ฐ€๋Šฅ ์—ฌ๋ถ€ ๋จผ์ € ์ฒดํฌ
        var error: NSError?
        guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, 
                                        error: &error) else {
            return .failure(mapLAError(error))
        }
        
        do {
            let success = try await context.evaluatePolicy(
                .deviceOwnerAuthenticationWithBiometrics,
                localizedReason: reason
            )
            return success ? .success : .failure(.authenticationFailed)
        } catch let laError as LAError {
            return .failure(mapLAError(laError as NSError))
        } catch {
            return .failure(.unknown(error))
        }
    }
    
    // MARK: - ๊ธฐ๊ธฐ ์•”ํ˜ธ ํฌํ•จ ์ธ์ฆ (์ƒ์ฒด ์‹คํŒจ ์‹œ ์•”ํ˜ธ๋กœ ํด๋ฐฑ)
    func authenticateWithFallback(reason: String = "๋ณธ์ธ ํ™•์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค") async -> AuthResult {
        let context = LAContext()
        context.localizedCancelTitle = "์ทจ์†Œ"
        
        var error: NSError?
        guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else {
            return .failure(mapLAError(error))
        }
        
        do {
            let success = try await context.evaluatePolicy(
                .deviceOwnerAuthentication,  // ๊ธฐ๊ธฐ ์•”ํ˜ธ ํฌํ•จ!
                localizedReason: reason
            )
            return success ? .success : .failure(.authenticationFailed)
        } catch let laError as LAError {
            return .failure(mapLAError(laError as NSError))
        } catch {
            return .failure(.unknown(error))
        }
    }
    
    // MARK: - LAError ๋งคํ•‘
    private func mapLAError(_ error: NSError?) -> AuthError {
        guard let error = error else { return .authenticationFailed }
        
        switch LAError.Code(rawValue: error.code) {
        case .biometryNotAvailable:
            return .biometryNotAvailable
        case .biometryNotEnrolled:
            return .biometryNotEnrolled
        case .biometryLockout:
            return .biometryLockout
        case .userCancel:
            return .userCancel
        case .userFallback:
            return .userFallback
        case .systemCancel:
            return .systemCancel
        case .authenticationFailed:
            return .authenticationFailed
        default:
            return .unknown(error)
        }
    }
}
๐Ÿ’ก LAPolicy ๋‘ ๊ฐ€์ง€ ์ฐจ์ด์ ! .deviceOwnerAuthenticationWithBiometrics โ†’ ์ƒ์ฒด ์ธ์ฆ๋งŒ ์‚ฌ์šฉ. ์‹คํŒจํ•ด๋„ ๊ธฐ๊ธฐ ์•”ํ˜ธ๋กœ ์ž๋™ ์ „ํ™˜ ์•ˆ ๋จ.
.deviceOwnerAuthentication โ†’ ์ƒ์ฒด ์ธ์ฆ + ๊ธฐ๊ธฐ ์•”ํ˜ธ ํด๋ฐฑ. ์ƒ์ฒด ์‹คํŒจ ์‹œ ์ž๋™์œผ๋กœ ์•”ํ˜ธ ์ž…๋ ฅ์ฐฝ ๋œธ.
๋ฑ…ํ‚น ์•ฑ์ฒ˜๋Ÿผ ๋ณด์•ˆ์ด ์ค‘์š”ํ•œ ๊ฒฝ์šฐ์—” ์ „์ž, ์ผ๋ฐ˜ ์•ฑ ์ž ๊ธˆ ํ•ด์ œ์—” ํ›„์ž๊ฐ€ ๋” UX ์ข‹์•„!

๐Ÿ”— Keychain + ์ƒ์ฒด ์ธ์ฆ ์—ฐ๋™ - ์ง„์งœ ์‹ค์ „ ํŒจํ„ด

์ž ์ด์ œ ์ง„์งœ ํ•ต์‹ฌ์ด์•ผ. Keychain๊ณผ ์ƒ์ฒด ์ธ์ฆ์„ ์—ฐ๋™ํ•˜๋ฉด ํ›จ์”ฌ ๊ฐ•๋ ฅํ•œ ๋ณด์•ˆ์„ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ์–ด. kSecAccessControl์„ ์‚ฌ์šฉํ•˜๋ฉด "์ƒ์ฒด ์ธ์ฆ ์„ฑ๊ณต ์‹œ์—๋งŒ Keychain ํ•ญ๋ชฉ์— ์ ‘๊ทผ ๊ฐ€๋Šฅ"ํ•˜๊ฒŒ ๋งŒ๋“ค ์ˆ˜ ์žˆ๊ฑฐ๋“ . ์ด๊ฒŒ ์ง„์งœ ํ”„๋กœ ๋ ˆ๋ฒจ ๊ตฌํ˜„์ž„ ใ„ทใ„ท

๐Ÿ” ์ƒ์ฒด ์ธ์ฆ ๋ณดํ˜ธ Keychain ์•„์ดํ…œ
import Foundation
import Security
import LocalAuthentication

// MARK: - ์ƒ์ฒด ์ธ์ฆ ์—ฐ๋™ Keychain ์ €์žฅ
final class SecureBiometricKeychain {
    
    static let shared = SecureBiometricKeychain()
    private init() {}
    
    private let service = Bundle.main.bundleIdentifier ?? "com.myapp"
    
    // MARK: - ์ƒ์ฒด ์ธ์ฆ ๋ณดํ˜ธ ํ•ญ๋ชฉ ์ €์žฅ
    // ์ด ํ•ญ๋ชฉ์€ ์ƒ์ฒด ์ธ์ฆ ์„ฑ๊ณต ์‹œ์—๋งŒ ์ฝ์„ ์ˆ˜ ์žˆ์Œ!
    func saveWithBiometricProtection(
        value: String,
        forKey key: String
    ) throws {
        guard let data = value.data(using: .utf8) else {
            throw KeychainError.invalidData
        }
        
        // ์ƒ์ฒด ์ธ์ฆ ์ ‘๊ทผ ์ œ์–ด ์„ค์ •
        var error: Unmanaged<CFError>?
        guard let accessControl = SecAccessControlCreateWithFlags(
            kCFAllocatorDefault,
            kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
            .biometryCurrentSet,  // ํ˜„์žฌ ๋“ฑ๋ก๋œ ์ƒ์ฒด ์ •๋ณด๋กœ๋งŒ ์ ‘๊ทผ ๊ฐ€๋Šฅ
            &error
        ) else {
            throw KeychainError.invalidData
        }
        
        // ๊ธฐ์กด ํ•ญ๋ชฉ ์‚ญ์ œ
        let deleteQuery: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key
        ]
        SecItemDelete(deleteQuery as CFDictionary)
        
        // ์ƒˆ ํ•ญ๋ชฉ ์ €์žฅ
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key,
            kSecValueData as String: data,
            kSecAttrAccessControl as String: accessControl
        ]
        
        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else {
            throw KeychainError.unexpectedStatus(status)
        }
    }
    
    // MARK: - ์ƒ์ฒด ์ธ์ฆ ํ›„ ํ•ญ๋ชฉ ์ฝ๊ธฐ
    func readWithBiometricAuth(
        forKey key: String,
        reason: String = "์ €์žฅ๋œ ์ •๋ณด์— ์ ‘๊ทผํ•˜๋ ค๋ฉด ์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค"
    ) throws -> String {
        
        // LAContext๋ฅผ ์ง์ ‘ ์ „๋‹ฌํ•ด์„œ ์ธ์ฆ ์ด์œ  ์ปค์Šคํ„ฐ๋งˆ์ด์ง•
        let context = LAContext()
        context.localizedReason = reason
        
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne,
            kSecUseAuthenticationContext as String: context  // ์ธ์ฆ ์ปจํ…์ŠคํŠธ ์ฃผ์ž…!
        ]
        
        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)
        
        guard status == errSecSuccess else {
            if status == errSecItemNotFound {
                throw KeychainError.itemNotFound
            }
            // errSecUserCanceled: ์‚ฌ์šฉ์ž๊ฐ€ ์ทจ์†Œ
            // errSecAuthFailed: ์ธ์ฆ ์‹คํŒจ
            throw KeychainError.unexpectedStatus(status)
        }
        
        guard let data = result as? Data,
              let string = String(data: data, encoding: .utf8) else {
            throw KeychainError.invalidData
        }
        
        return string
    }
}

// MARK: - .biometryCurrentSet vs .biometryAny ์ฐจ์ด์ 
/*
 .biometryCurrentSet:
 - ํ˜„์žฌ ๋“ฑ๋ก๋œ ์ƒ์ฒด ์ •๋ณด๋กœ๋งŒ ์ ‘๊ทผ ๊ฐ€๋Šฅ
 - ์ƒˆ ์ง€๋ฌธ/์–ผ๊ตด ์ถ”๊ฐ€ํ•˜๋ฉด ๊ธฐ์กด ํ•ญ๋ชฉ ์ ‘๊ทผ ๋ถˆ๊ฐ€ (๋ณด์•ˆ ๊ฐ•ํ™”)
 - ๋ฑ…ํ‚น, ๊ฒฐ์ œ ์•ฑ์— ๊ถŒ์žฅ
 
 .biometryAny:
 - ์–ด๋–ค ์ƒ์ฒด ์ •๋ณด๋กœ๋„ ์ ‘๊ทผ ๊ฐ€๋Šฅ
 - ์ƒˆ ์ง€๋ฌธ ์ถ”๊ฐ€ํ•ด๋„ ๊ธฐ์กด ํ•ญ๋ชฉ ์ ‘๊ทผ ๊ฐ€๋Šฅ
 - ์ผ๋ฐ˜ ์•ฑ ์ž ๊ธˆ์— ์ ํ•ฉ
*/
๐Ÿ“ฑ SwiftUI์—์„œ ์™„์„ฑ๋œ ์ธ์ฆ ๋ทฐ ๊ตฌํ˜„
import SwiftUI
import LocalAuthentication

// MARK: - ์ธ์ฆ ์ƒํƒœ
enum AuthenticationState {
    case unauthenticated
    case authenticating
    case authenticated
    case failed(String)
}

// MARK: - ์ธ์ฆ ๋ทฐ๋ชจ๋ธ
@MainActor
class AuthViewModel: ObservableObject {
    
    @Published var authState: AuthenticationState = .unauthenticated
    @Published var isAuthenticated = false
    
    private let biometricManager = BiometricAuthManager.shared
    private let keychain = KeychainManager.shared
    
    // ์ƒ์ฒด ์ธ์ฆ ํƒ€์ž… (UI์—์„œ ์•„์ด์ฝ˜ ํ‘œ์‹œ์šฉ)
    var biometricType: BiometricType {
        biometricManager.biometricType
    }
    
    // MARK: - ์ƒ์ฒด ์ธ์ฆ ์‹คํ–‰
    func authenticate() async {
        authState = .authenticating
        
        let result = await biometricManager.authenticate(
            reason: "์•ฑ์— ์ ‘๊ทผํ•˜๋ ค๋ฉด \(biometricManager.biometricType.displayName) ์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค"
        )
        
        switch result {
        case .success:
            authState = .authenticated
            isAuthenticated = true
            // ์ธ์ฆ ์„ฑ๊ณต ํ›„ Keychain์—์„œ ํ† ํฐ ๊ฐ€์ ธ์˜ค๊ธฐ
            loadUserData()
            
        case .failure(let error):
            switch error {
            case .userCancel:
                authState = .unauthenticated  // ์ทจ์†Œ๋Š” ์—๋Ÿฌ ์•„๋‹˜
            case .biometryLockout:
                // ์ž ๊ธˆ ์ƒํƒœ๋ฉด ๊ธฐ๊ธฐ ์•”ํ˜ธ๋กœ ํด๋ฐฑ
                await authenticateWithDevicePasscode()
            default:
                authState = .failed(error.localizedDescription ?? "์ธ์ฆ ์‹คํŒจ")
            }
        }
    }
    
    // MARK: - ๊ธฐ๊ธฐ ์•”ํ˜ธ๋กœ ์ธ์ฆ (ํด๋ฐฑ)
    func authenticateWithDevicePasscode() async {
        let result = await biometricManager.authenticateWithFallback(
            reason: "๊ธฐ๊ธฐ ์•”ํ˜ธ๋กœ ์ธ์ฆํ•ด์ฃผ์„ธ์š”"
        )
        
        switch result {
        case .success:
            authState = .authenticated
            isAuthenticated = true
        case .failure(let error):
            authState = .failed(error.localizedDescription ?? "์ธ์ฆ ์‹คํŒจ")
        }
    }
    
    // MARK: - ์ธ์ฆ ํ›„ ๋ฐ์ดํ„ฐ ๋กœ๋“œ
    private func loadUserData() {
        if let token = try? keychain.read(forKey: "auth_token") {
            print("โœ… ํ† ํฐ ๋กœ๋“œ ์„ฑ๊ณต: \(token.prefix(10))...")
        }
    }
    
    // MARK: - ๋กœ๊ทธ์•„์›ƒ
    func logout() {
        isAuthenticated = false
        authState = .unauthenticated
    }
}

// MARK: - ์ธ์ฆ ํ™”๋ฉด ๋ทฐ
struct AuthenticationView: View {
    
    @StateObject private var viewModel = AuthViewModel()
    
    var body: some View {
        ZStack {
            // ๋ฐฐ๊ฒฝ
            LinearGradient(
                colors: [Color(hex: "#f0f4ff"), Color(hex: "#f5f0ff")],
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            )
            .ignoresSafeArea()
            
            if viewModel.isAuthenticated {
                // ์ธ์ฆ ์„ฑ๊ณต ํ™”๋ฉด
                MainContentView()
                    .transition(.opacity.combined(with: .scale))
            } else {
                // ์ธ์ฆ ํ™”๋ฉด
                VStack(spacing: 32) {
                    Spacer()
                    
                    // ์•ฑ ์•„์ด์ฝ˜ ์˜์—ญ
                    ZStack {
                        Circle()
                            .fill(Color(hex: "#4a6cf7").opacity(0.1))
                            .frame(width: 120, height: 120)
                        
                        Image(systemName: "lock.shield.fill")
                            .font(.system(size: 50))
                            .foregroundColor(Color(hex: "#4a6cf7"))
                    }
                    
                    VStack(spacing: 8) {
                        Text("๋ณธ์ธ ํ™•์ธ")
                            .font(.title.bold())
                            .foregroundColor(.primary)
                        
                        Text("์•ฑ์— ์ ‘๊ทผํ•˜๋ ค๋ฉด ์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค")
                            .font(.subheadline)
                            .foregroundColor(.secondary)
                            .multilineTextAlignment(.center)
                    }
                    
                    // ์ธ์ฆ ์ƒํƒœ ํ‘œ์‹œ
                    authStateView
                    
                    Spacer()
                    
                    // ์ธ์ฆ ๋ฒ„ํŠผ
                    VStack(spacing: 12) {
                        Button(action: {
                            Task { await viewModel.authenticate() }
                        }) {
                            HStack(spacing: 12) {
                                Image(systemName: viewModel.biometricType.iconName)
                                    .font(.title3)
                                Text("\(viewModel.biometricType.displayName)๋กœ ์ธ์ฆ")
                                    .font(.headline)
                            }
                            .foregroundColor(.white)
                            .frame(maxWidth: .infinity)
                            .padding(.vertical, 16)
                            .background(
                                LinearGradient(
                                    colors: [Color(hex: "#4a6cf7"), Color(hex: "#6a3de8")],
                                    startPoint: .leading,
                                    endPoint: .trailing
                                )
                            )
                            .cornerRadius(14)
                        }
                        .disabled(viewModel.authState == .authenticating)
                        
                        // ๊ธฐ๊ธฐ ์•”ํ˜ธ ์‚ฌ์šฉ ๋ฒ„ํŠผ
                        Button("๊ธฐ๊ธฐ ์•”ํ˜ธ ์‚ฌ์šฉ") {
                            Task { await viewModel.authenticateWithDevicePasscode() }
                        }
                        .font(.subheadline)
                        .foregroundColor(Color(hex: "#4a6cf7"))
                    }
                    .padding(.horizontal, 32)
                    .padding(.bottom, 48)
                }
                .animation(.spring(response: 0.4), value: viewModel.authState)
            }
        }
    }
    
    // MARK: - ์ƒํƒœ๋ณ„ ๋ทฐ
    @ViewBuilder
    private var authStateView: some View {
        switch viewModel.authState {
        case .authenticating:
            ProgressView()
                .scaleEffect(1.5)
                .tint(Color(hex: "#4a6cf7"))
            
        case .failed(let message):
            HStack(spacing: 8) {
                Image(systemName: "exclamationmark.circle.fill")
                    .foregroundColor(.red)
                Text(message)
                    .font(.caption)
                    .foregroundColor(.red)
            }
            .padding(.horizontal, 20)
            .multilineTextAlignment(.center)
            
        default:
            EmptyView()
        }
    }
}

โšก ๊ณ ๊ธ‰ ํŒจํ„ด - ์‹ค๋ฌด์—์„œ ์“ฐ๋Š” ํ…Œํฌ๋‹‰๋“ค
๐Ÿ”„ ํ† ํฐ ์ž๋™ ๊ฐฑ์‹  + Keychain ์—ฐ๋™

์‹ค๋ฌด์—์„œ ์ง„์งœ ๋งŽ์ด ์“ฐ๋Š” ํŒจํ„ด์ด์•ผ. ์•ฑ ์‹คํ–‰ ์‹œ Keychain์—์„œ ํ† ํฐ ๊บผ๋‚ด์„œ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌํ•˜๊ณ , ๋งŒ๋ฃŒ๋์œผ๋ฉด ์ž๋™ ๊ฐฑ์‹ ํ•˜๋Š” ํ”Œ๋กœ์šฐ!

// MARK: - ํ† ํฐ ๋ชจ๋ธ
struct AuthTokens: Codable {
    let accessToken: String
    let refreshToken: String
    let expiresAt: Date
    
    var isExpired: Bool {
        return Date() >= expiresAt
    }
    
    var isNearExpiry: Bool {
        // ๋งŒ๋ฃŒ 5๋ถ„ ์ „๋ถ€ํ„ฐ ๊ฐฑ์‹  ์‹œ๋„
        return Date() >= expiresAt.addingTimeInterval(-300)
    }
}

// MARK: - ํ† ํฐ ๋งค๋‹ˆ์ €
@MainActor
class TokenManager: ObservableObject {
    
    static let shared = TokenManager()
    private init() {}
    
    private let keychain = KeychainManager.shared
    private let tokenKey = "auth_tokens_v2"
    
    @Published var isLoggedIn = false
    
    // MARK: - ํ† ํฐ ์ €์žฅ (JSON ์ง๋ ฌํ™”)
    func saveTokens(_ tokens: AuthTokens) throws {
        let encoder = JSONEncoder()
        encoder.dateEncodingStrategy = .iso8601
        
        let data = try encoder.encode(tokens)
        guard let jsonString = String(data: data, encoding: .utf8) else {
            throw KeychainError.invalidData
        }
        
        try keychain.save(jsonString, forKey: tokenKey)
        isLoggedIn = true
    }
    
    // MARK: - ํ† ํฐ ๋กœ๋“œ ๋ฐ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ
    func loadValidTokens() async throws -> AuthTokens {
        guard let jsonString = try? keychain.read(forKey: tokenKey) else {
            throw KeychainError.itemNotFound
        }
        
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601
        
        guard let data = jsonString.data(using: .utf8),
              let tokens = try? decoder.decode(AuthTokens.self, from: data) else {
            throw KeychainError.invalidData
        }
        
        // ๋งŒ๋ฃŒ ์ž„๋ฐ• ์‹œ ์ž๋™ ๊ฐฑ์‹ 
        if tokens.isNearExpiry {
            return try await refreshTokens(refreshToken: tokens.refreshToken)
        }
        
        // ์™„์ „ ๋งŒ๋ฃŒ ์‹œ ๋กœ๊ทธ์•„์›ƒ
        if tokens.isExpired {
            logout()
            throw KeychainError.itemNotFound
        }
        
        return tokens
    }
    
    // MARK: - ํ† ํฐ ๊ฐฑ์‹ 
    private func refreshTokens(refreshToken: String) async throws -> AuthTokens {
        // ์‹ค์ œ API ํ˜ธ์ถœ๋กœ ํ† ํฐ ๊ฐฑ์‹ 
        // let newTokens = try await APIClient.shared.refreshToken(refreshToken)
        // try saveTokens(newTokens)
        // return newTokens
        
        // ์˜ˆ์‹œ์šฉ ๋”๋ฏธ ๋ฐ˜ํ™˜
        let newTokens = AuthTokens(
            accessToken: "new_access_token",
            refreshToken: refreshToken,
            expiresAt: Date().addingTimeInterval(3600)
        )
        try saveTokens(newTokens)
        return newTokens
    }
    
    // MARK: - ๋กœ๊ทธ์•„์›ƒ
    func logout() {
        try? keychain.delete(forKey: tokenKey)
        isLoggedIn = false
    }
}
๐Ÿ›ก๏ธ ์ƒ์ฒด ์ธ์ฆ ๋ณ€๊ฒฝ ๊ฐ์ง€

์ด๊ฑฐ ์ง„์งœ ์ค‘์š”ํ•œ๋ฐ ๋งŽ์ด๋“ค ๋ชจ๋ฆ„. ์‚ฌ์šฉ์ž๊ฐ€ ์ƒˆ ์ง€๋ฌธ์„ ์ถ”๊ฐ€ํ•˜๊ฑฐ๋‚˜ Face ID๋ฅผ ์žฌ๋“ฑ๋กํ•˜๋ฉด ๋ณด์•ˆ ์œ„ํ˜‘์ด ๋  ์ˆ˜ ์žˆ์–ด. ์ด๊ฑธ ๊ฐ์ง€ํ•˜๋Š” ๋ฐฉ๋ฒ•์ด ์žˆ์Œ!

// MARK: - ์ƒ์ฒด ์ธ์ฆ ๋ณ€๊ฒฝ ๊ฐ์ง€
class BiometricChangeDetector {
    
    private let userDefaults = UserDefaults.standard
    private let biometricStateKey = "biometric_domain_state"
    
    // MARK: - ํ˜„์žฌ ์ƒ์ฒด ์ƒํƒœ ์ €์žฅ
    func saveBiometricState() {
        let context = LAContext()
        var error: NSError?
        
        guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, 
                                        error: &error) else { return }
        
        // domainState๋Š” ์ƒ์ฒด ์ •๋ณด๊ฐ€ ๋ณ€๊ฒฝ๋  ๋•Œ๋งˆ๋‹ค ๋ฐ”๋€Œ๋Š” Data
        if let domainState = context.evaluatedPolicyDomainState {
            userDefaults.set(domainState, forKey: biometricStateKey)
        }
    }
    
    // MARK: - ์ƒ์ฒด ์ •๋ณด ๋ณ€๊ฒฝ ์—ฌ๋ถ€ ํ™•์ธ
    func hasBiometricChanged() -> Bool {
        let context = LAContext()
        var error: NSError?
        
        guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, 
                                        error: &error) else { return false }
        
        guard let currentState = context.evaluatedPolicyDomainState,
              let savedState = userDefaults.data(forKey: biometricStateKey) else {
            // ์ €์žฅ๋œ ์ƒํƒœ ์—†์œผ๋ฉด ์ฒ˜์Œ ์‹คํ–‰ โ†’ ์ €์žฅํ•˜๊ณ  ๋ณ€๊ฒฝ ์—†์Œ์œผ๋กœ ์ฒ˜๋ฆฌ
            saveBiometricState()
            return false
        }
        
        // ์ƒํƒœ๊ฐ€ ๋‹ค๋ฅด๋ฉด ์ƒ์ฒด ์ •๋ณด ๋ณ€๊ฒฝ๋จ!
        if currentState != savedState {
            print("โš ๏ธ ์ƒ์ฒด ์ธ์ฆ ์ •๋ณด๊ฐ€ ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!")
            // ๋ณด์•ˆ ์กฐ์น˜: ์ €์žฅ๋œ ๋ฏผ๊ฐ ๋ฐ์ดํ„ฐ ์‚ญ์ œ ๋˜๋Š” ์žฌ์ธ์ฆ ์š”๊ตฌ
            return true
        }
        
        return false
    }
    
    // MARK: - ์•ฑ ์‹คํ–‰ ์‹œ ์ฒดํฌ ๋กœ์ง
    func checkAndHandleBiometricChange(onChanged: () -> Void) {
        if hasBiometricChanged() {
            // ์ƒ์ฒด ์ •๋ณด ๋ณ€๊ฒฝ ๊ฐ์ง€ โ†’ ๋ณด์•ˆ ์กฐ์น˜
            onChanged()
            // ์ƒˆ ์ƒํƒœ ์ €์žฅ
            saveBiometricState()
        }
    }
}

// ์‚ฌ์šฉ ์˜ˆ์‹œ
let detector = BiometricChangeDetector()
detector.checkAndHandleBiometricChange {
    // ์ƒ์ฒด ์ •๋ณด ๋ณ€๊ฒฝ๋จ โ†’ ์žฌ๋กœ๊ทธ์ธ ์š”๊ตฌ
    print("๐Ÿ”’ ๋ณด์•ˆ์„ ์œ„ํ•ด ๋‹ค์‹œ ๋กœ๊ทธ์ธํ•ด์ฃผ์„ธ์š”")
    TokenManager.shared.logout()
}
๐Ÿ’ก evaluatedPolicyDomainState๋Š” ๊ธฐ๊ธฐ์— ๋“ฑ๋ก๋œ ์ƒ์ฒด ์ •๋ณด์˜ ํ•ด์‹œ๊ฐ’ ๊ฐ™์€ ๊ฑฐ์•ผ. ์ง€๋ฌธ์ด ์ถ”๊ฐ€๋˜๊ฑฐ๋‚˜ ์‚ญ์ œ๋˜๋ฉด ์ด ๊ฐ’์ด ๋ฐ”๋€œ. ์ด๊ฑธ ์ €์žฅํ•ด๋’€๋‹ค๊ฐ€ ๋น„๊ตํ•˜๋ฉด ๋ณ€๊ฒฝ ๊ฐ์ง€ ๊ฐ€๋Šฅ! ๋ฑ…ํ‚น ์•ฑ์—์„œ ํ•„์ˆ˜๋กœ ์“ฐ๋Š” ๊ธฐ๋ฒ•์ด์•ผ.

๐Ÿงช ์‹œ๋ฎฌ๋ ˆ์ดํ„ฐ์—์„œ ํ…Œ์ŠคํŠธํ•˜๋Š” ๋ฒ•

์‹ค๊ธฐ๊ธฐ ์—†์–ด๋„ ์‹œ๋ฎฌ๋ ˆ์ดํ„ฐ์—์„œ ์ƒ์ฒด ์ธ์ฆ ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•ด! ์ด๊ฑฐ ๋ชจ๋ฅด๋Š” ์‚ฌ๋žŒ ์ƒ๊ฐ๋ณด๋‹ค ๋งŽ๋”๋ผ๊ณ  ใ…‹ใ…‹

๐Ÿ“ฑ Xcode ์‹œ๋ฎฌ๋ ˆ์ดํ„ฐ ์ƒ์ฒด ์ธ์ฆ ํ…Œ์ŠคํŠธ ๋ฐฉ๋ฒ•
1
์‹œ๋ฎฌ๋ ˆ์ดํ„ฐ ์‹คํ–‰ ํ›„ ์ƒ๋‹จ ๋ฉ”๋‰ด Features ํด๋ฆญ
2
Face ID ๋˜๋Š” Touch ID ์„ ํƒ โ†’ Enrolled ์ฒดํฌ (์ƒ์ฒด ์ •๋ณด ๋“ฑ๋ก ์ƒํƒœ๋กœ ์„ค์ •)
3
์•ฑ์—์„œ ์ธ์ฆ ์š”์ฒญ ์‹œ Features โ†’ Face ID โ†’ Matching Face ํด๋ฆญ (์ธ์ฆ ์„ฑ๊ณต)
4
์‹คํŒจ ํ…Œ์ŠคํŠธ๋Š” Features โ†’ Face ID โ†’ Non-matching Face ํด๋ฆญ
๐Ÿ”ฌ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ ์ž‘์„ฑ
import XCTest
@testable import MyApp

// MARK: - Keychain ํ…Œ์ŠคํŠธ
final class KeychainManagerTests: XCTestCase {
    
    var sut: KeychainManager!
    let testKey = "test_key_\(UUID().uuidString)"
    
    override func setUp() {
        super.setUp()
        sut = KeychainManager.shared
    }
    
    override func tearDown() {
        // ํ…Œ์ŠคํŠธ ํ›„ ์ •๋ฆฌ
        try? sut.delete(forKey: testKey)
        super.tearDown()
    }
    
    // MARK: - ์ €์žฅ ํ…Œ์ŠคํŠธ
    func test_save_shouldSucceed() throws {
        // Given
        let testValue = "test_password_123"
        
        // When
        XCTAssertNoThrow(try sut.save(testValue, forKey: testKey))
        
        // Then
        let retrieved = try sut.read(forKey: testKey)
        XCTAssertEqual(retrieved, testValue)
    }
    
    // MARK: - ์—†๋Š” ํ‚ค ์ฝ๊ธฐ ํ…Œ์ŠคํŠธ
    func test_read_nonExistentKey_shouldThrowItemNotFound() {
        // Given
        let nonExistentKey = "non_existent_\(UUID().uuidString)"
        
        // When & Then
        XCTAssertThrowsError(try sut.read(forKey: nonExistentKey)) { error in
            XCTAssertEqual(error as? KeychainError, KeychainError.itemNotFound)
        }
    }
    
    // MARK: - ์‚ญ์ œ ํ…Œ์ŠคํŠธ
    func test_delete_shouldRemoveItem() throws {
        // Given
        try sut.save("test_value", forKey: testKey)
        XCTAssertTrue(sut.exists(forKey: testKey))
        
        // When
        try sut.delete(forKey: testKey)
        
        // Then
        XCTAssertFalse(sut.exists(forKey: testKey))
    }
    
    // MARK: - ์—…๋ฐ์ดํŠธ ํ…Œ์ŠคํŠธ
    func test_save_existingKey_shouldUpdate() throws {
        // Given
        let originalValue = "original"
        let updatedValue = "updated"
        try sut.save(originalValue, forKey: testKey)
        
        // When
        try sut.save(updatedValue, forKey: testKey)
        
        // Then
        let retrieved = try sut.read(forKey: testKey)
        XCTAssertEqual(retrieved, updatedValue)
    }
}

โš ๏ธ ๋ณด์•ˆ ์ฒดํฌ๋ฆฌ์ŠคํŠธ - ์ด๊ฒƒ๋งŒ์€ ๊ผญ!

์žฌ๋Šฅ๋„ท์—์„œ iOS ๊ฐœ๋ฐœ ๊ด€๋ จ ์žฌ๋Šฅ์„ ๊ฑฐ๋ž˜ํ•˜๋‹ค ๋ณด๋ฉด ๋ณด์•ˆ ๊ตฌํ˜„ ๊ด€๋ จ ์˜๋ขฐ๊ฐ€ ๊ฝค ๋งŽ์ด ๋“ค์–ด์˜จ๋‹ค๊ณ  ํ•ด. ํด๋ผ์ด์–ธํŠธํ•œํ…Œ "๋ณด์•ˆ ์ž˜ ๋์–ด์š”~" ํ•˜๋ ค๋ฉด ์ด ์ฒดํฌ๋ฆฌ์ŠคํŠธ๋Š” ๊ธฐ๋ณธ์œผ๋กœ ํ†ต๊ณผํ•ด์•ผ ํ•จ โœ…

๐Ÿ›ก๏ธ iOS ๋ณด์•ˆ ๊ตฌํ˜„ ์ฒดํฌ๋ฆฌ์ŠคํŠธ โœ“ ๋ฏผ๊ฐ ๋ฐ์ดํ„ฐ๋Š” Keychain์—๋งŒ ์ €์žฅ UserDefaults, ํŒŒ์ผ์‹œ์Šคํ…œ ์‚ฌ์šฉ ๊ธˆ์ง€ โœ“ ThisDeviceOnly ์ ‘๊ทผ์„ฑ ์„ค์ • iCloud ๋ฐฑ์—…์—์„œ ๋ฏผ๊ฐ ๋ฐ์ดํ„ฐ ์ œ์™ธ โœ“ ์ƒ์ฒด ์ธ์ฆ ๋ณ€๊ฒฝ ๊ฐ์ง€ ๊ตฌํ˜„ domainState ๋น„๊ต๋กœ ์ƒˆ ์ง€๋ฌธ ์ถ”๊ฐ€ ๊ฐ์ง€ โœ“ NSFaceIDUsageDescription ์„ค์ • Info.plist ๊ถŒํ•œ ์„ค๋ช… ํ•„์ˆ˜ โœ“ ์ธ์ฆ ์‹คํŒจ ํšŸ์ˆ˜ ์ œํ•œ 5ํšŒ ์‹คํŒจ ์‹œ ๊ธฐ๊ธฐ ์•”ํ˜ธ ์š”๊ตฌ โœ— ํ•˜๋“œ์ฝ”๋”ฉ๋œ ๋น„๋ฐ€๋ฒˆํ˜ธ/ํ‚ค ์‚ฌ์šฉ ์†Œ์Šค์ฝ”๋“œ์— API ํ‚ค ์ง์ ‘ ์ž…๋ ฅ ์ ˆ๋Œ€ ๊ธˆ์ง€ โœ— ์ƒ์ฒด ๋ฐ์ดํ„ฐ ์ง์ ‘ ์ €์žฅ ์‹œ๋„ ์ง€๋ฌธ/์–ผ๊ตด ์ด๋ฏธ์ง€๋Š” ์ ˆ๋Œ€ ์•ฑ์—์„œ ์ ‘๊ทผ ๋ถˆ๊ฐ€ โœ— kSecAttrAccessibleAlways ์‚ฌ์šฉ ์ž ๊ธˆ ์ƒํƒœ์—์„œ๋„ ์ ‘๊ทผ ๊ฐ€๋Šฅ โ†’ ๋ณด์•ˆ ์ทจ์•ฝ โœ— ์ธ์ฆ ๊ฒฐ๊ณผ๋งŒ ๋ฏฟ๊ณ  ์„œ๋ฒ„ ๊ฒ€์ฆ ์ƒ๋žต ํด๋ผ์ด์–ธํŠธ ์ธ์ฆ์€ ์„œ๋ฒ„ ๊ฒ€์ฆ๊ณผ ๋ณ‘ํ–‰ํ•ด์•ผ โœ— ํƒˆ์˜ฅ ๊ฐ์ง€ ์—†์ด ๋ณด์•ˆ ๊ธฐ๋Šฅ ์˜์กด ํƒˆ์˜ฅ ๊ธฐ๊ธฐ์—์„œ Keychain ์šฐํšŒ ๊ฐ€๋Šฅ โœ… ์™ผ์ชฝ์€ ํ•ด์•ผ ํ•  ๊ฒƒ ยท โŒ ์˜ค๋ฅธ์ชฝ์€ ํ•˜๋ฉด ์•ˆ ๋˜๋Š” ๊ฒƒ ์ด ์ฒดํฌ๋ฆฌ์ŠคํŠธ ํ†ต๊ณผํ•˜๋ฉด ์•ฑ์Šคํ† ์–ด ์‹ฌ์‚ฌ๋„ ํ›จ์”ฌ ์ˆ˜์›”ํ•ด์ง
๐Ÿ” ํƒˆ์˜ฅ ๊ฐ์ง€ (Jailbreak Detection)

ํƒˆ์˜ฅ๋œ ๊ธฐ๊ธฐ์—์„œ๋Š” Keychain ๋ณด์•ˆ์ด ์šฐํšŒ๋  ์ˆ˜ ์žˆ์–ด. ์™„๋ฒฝํ•œ ํƒˆ์˜ฅ ๊ฐ์ง€๋Š” ๋ถˆ๊ฐ€๋Šฅํ•˜์ง€๋งŒ, ๊ธฐ๋ณธ์ ์ธ ์ฒดํฌ๋Š” ํ•ด๋‘๋Š” ๊ฒŒ ์ข‹์•„.

// MARK: - ํƒˆ์˜ฅ ๊ฐ์ง€ (๊ธฐ๋ณธ ์ˆ˜์ค€)
struct JailbreakDetector {
    
    static func isJailbroken() -> Bool {
        #if targetEnvironment(simulator)
        return false  // ์‹œ๋ฎฌ๋ ˆ์ดํ„ฐ๋Š” ํ•ญ์ƒ false
        #else
        return checkSuspiciousFiles() || 
               checkSandboxViolation() || 
               checkDynamicLibraries()
        #endif
    }
    
    // ํƒˆ์˜ฅ ๊ด€๋ จ ํŒŒ์ผ ์กด์žฌ ์—ฌ๋ถ€
    private static func checkSuspiciousFiles() -> Bool {
        let suspiciousPaths = [
            "/Applications/Cydia.app",
            "/Library/MobileSubstrate/MobileSubstrate.dylib",
            "/bin/bash",
            "/usr/sbin/sshd",
            "/etc/apt",
            "/private/var/lib/apt/",
            "/usr/bin/ssh"
        ]
        
        return suspiciousPaths.contains { path in
            FileManager.default.fileExists(atPath: path)
        }
    }
    
    // ์ƒŒ๋“œ๋ฐ•์Šค ์™ธ๋ถ€ ์“ฐ๊ธฐ ์‹œ๋„
    private static func checkSandboxViolation() -> Bool {
        let testPath = "/private/jailbreak_test_\(UUID().uuidString)"
        do {
            try "test".write(toFile: testPath, atomically: true, encoding: .utf8)
            try FileManager.default.removeItem(atPath: testPath)
            return true  // ์“ฐ๊ธฐ ์„ฑ๊ณต = ํƒˆ์˜ฅ๋จ
        } catch {
            return false  // ์“ฐ๊ธฐ ์‹คํŒจ = ์ •์ƒ
        }
    }
    
    // ์˜์‹ฌ์Šค๋Ÿฌ์šด ๋™์  ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์ฒดํฌ
    private static func checkDynamicLibraries() -> Bool {
        let suspiciousLibs = ["SubstrateLoader", "CydiaSubstrate", "SSLKillSwitch"]
        
        for i in 0..<_dyld_image_count() {
            if let imageName = _dyld_get_image_name(i) {
                let name = String(cString: imageName)
                if suspiciousLibs.contains(where: { name.contains($0) }) {
                    return true
                }
            }
        }
        return false
    }
}

// ์‚ฌ์šฉ ์˜ˆ์‹œ
if JailbreakDetector.isJailbroken() {
    // ํƒˆ์˜ฅ ๊ธฐ๊ธฐ์—์„œ ์•ฑ ์‹คํ–‰ ์ œํ•œ
    print("โš ๏ธ ๋ณด์•ˆ ์œ„ํ˜‘์ด ๊ฐ์ง€๋˜์—ˆ์Šต๋‹ˆ๋‹ค")
    // ์•ฑ ์ข…๋ฃŒ ๋˜๋Š” ๊ธฐ๋Šฅ ์ œํ•œ
}
โš ๏ธ ํƒˆ์˜ฅ ๊ฐ์ง€์˜ ํ•œ๊ณ„: ์œ„ ์ฝ”๋“œ๋Š” ๊ธฐ๋ณธ์ ์ธ ํƒˆ์˜ฅ ๊ฐ์ง€๋งŒ ๊ฐ€๋Šฅํ•ด. ๊ณ ๊ธ‰ ํƒˆ์˜ฅ ๋„๊ตฌ๋“ค์€ ์ด๋Ÿฐ ๊ฐ์ง€๋ฅผ ์šฐํšŒํ•˜๋Š” ๊ธฐ๋Šฅ์ด ์žˆ์–ด. ์™„๋ฒฝํ•œ ๋ณด์•ˆ์„ ์›ํ•œ๋‹ค๋ฉด ์„œ๋ฒ„ ์‚ฌ์ด๋“œ ๊ฒ€์ฆ์„ ๋ฐ˜๋“œ์‹œ ๋ณ‘ํ–‰ํ•ด์•ผ ํ•ด!

๐ŸŽฏ ์‹ค์ „ ์‹œ๋‚˜๋ฆฌ์˜ค - ๋ฑ…ํ‚น ์•ฑ ์ˆ˜์ค€ ์ธ์ฆ ํ”Œ๋กœ์šฐ

์ด์ œ ๋ฐฐ์šด ๊ฑธ ๋‹ค ํ•ฉ์ณ์„œ ์‹ค์ œ ๋ฑ…ํ‚น ์•ฑ ์ˆ˜์ค€์˜ ์ธ์ฆ ํ”Œ๋กœ์šฐ๋ฅผ ๋งŒ๋“ค์–ด๋ณผ๊ฒŒ. ์žฌ๋Šฅ๋„ท์—์„œ ๊ธˆ์œต ์•ฑ ๊ฐœ๋ฐœ ์˜๋ขฐ ๋ฐ›์•˜์„ ๋•Œ ์ด ์ •๋„ ๊ตฌํ˜„ํ•˜๋ฉด ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์ง„์งœ ๋งŒ์กฑํ•  ๊ฑฐ์•ผ ใ…‹ใ…‹

// MARK: - ์™„์„ฑ๋œ ๋ณด์•ˆ ์ธ์ฆ ๋งค๋‹ˆ์ €
@MainActor
final class SecurityManager: ObservableObject {
    
    static let shared = SecurityManager()
    private init() {
        setupInitialState()
    }
    
    // MARK: - ์ƒํƒœ
    @Published var isAuthenticated = false
    @Published var requiresReauth = false
    
    private let keychain = KeychainManager.shared
    private let biometric = BiometricAuthManager.shared
    private let changeDetector = BiometricChangeDetector()
    private let tokenManager = TokenManager.shared
    
    // ๋งˆ์ง€๋ง‰ ์ธ์ฆ ์‹œ๊ฐ„ (์„ธ์…˜ ํƒ€์ž„์•„์›ƒ์šฉ)
    private var lastAuthTime: Date?
    private let sessionTimeout: TimeInterval = 300  // 5๋ถ„
    
    // MARK: - ์ดˆ๊ธฐ ์„ค์ •
    private func setupInitialState() {
        // ํƒˆ์˜ฅ ๊ฐ์ง€
        if JailbreakDetector.isJailbroken() {
            print("๐Ÿšจ ํƒˆ์˜ฅ ๊ธฐ๊ธฐ ๊ฐ์ง€ - ๋ณด์•ˆ ๊ธฐ๋Šฅ ์ œํ•œ")
            // ์‹ค์ œ ์•ฑ์—์„œ๋Š” ์—ฌ๊ธฐ์„œ ์•ฑ ์ข…๋ฃŒ ๋˜๋Š” ๊ธฐ๋Šฅ ์ œํ•œ
        }
        
        // ์ƒ์ฒด ์ธ์ฆ ๋ณ€๊ฒฝ ๊ฐ์ง€
        changeDetector.checkAndHandleBiometricChange {
            print("โš ๏ธ ์ƒ์ฒด ์ธ์ฆ ๋ณ€๊ฒฝ ๊ฐ์ง€ - ์žฌ๋กœ๊ทธ์ธ ํ•„์š”")
            self.tokenManager.logout()
        }
    }
    
    // MARK: - ์•ฑ ์‹คํ–‰ ์‹œ ์ธ์ฆ ํ”Œ๋กœ์šฐ
    func performAppLaunchAuth() async {
        // 1. ์„ธ์…˜ ํƒ€์ž„์•„์›ƒ ์ฒดํฌ
        if let lastAuth = lastAuthTime,
           Date().timeIntervalSince(lastAuth) < sessionTimeout {
            isAuthenticated = true
            return
        }
        
        // 2. ์ €์žฅ๋œ ํ† ํฐ ํ™•์ธ
        guard let _ = try? await tokenManager.loadValidTokens() else {
            // ํ† ํฐ ์—†์Œ โ†’ ๋กœ๊ทธ์ธ ํ™”๋ฉด์œผ๋กœ
            isAuthenticated = false
            return
        }
        
        // 3. ์ƒ์ฒด ์ธ์ฆ ์‹คํ–‰
        await performBiometricAuth()
    }
    
    // MARK: - ์ƒ์ฒด ์ธ์ฆ ์‹คํ–‰
    func performBiometricAuth() async {
        let result = await biometric.authenticate(
            reason: "์•ฑ์— ์•ˆ์ „ํ•˜๊ฒŒ ์ ‘๊ทผํ•˜๊ธฐ ์œ„ํ•ด ์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค"
        )
        
        switch result {
        case .success:
            isAuthenticated = true
            lastAuthTime = Date()
            changeDetector.saveBiometricState()  // ์ธ์ฆ ์„ฑ๊ณต ์‹œ ์ƒํƒœ ์—…๋ฐ์ดํŠธ
            
        case .failure(let error):
            handleAuthFailure(error)
        }
    }
    
    // MARK: - ์ธ์ฆ ์‹คํŒจ ์ฒ˜๋ฆฌ
    private func handleAuthFailure(_ error: AuthError) {
        switch error {
        case .biometryLockout:
            // ์ƒ์ฒด ์ž ๊ธˆ โ†’ ๊ธฐ๊ธฐ ์•”ํ˜ธ๋กœ ํด๋ฐฑ
            Task { await performDevicePasscodeAuth() }
            
        case .biometryNotEnrolled:
            // ์ƒ์ฒด ๋ฏธ๋“ฑ๋ก โ†’ ๊ธฐ๊ธฐ ์•”ํ˜ธ ์‚ฌ์šฉ
            Task { await performDevicePasscodeAuth() }
            
        case .userCancel:
            // ์ทจ์†Œ โ†’ ์ธ์ฆ ํ™”๋ฉด ์œ ์ง€
            isAuthenticated = false
            
        default:
            isAuthenticated = false
            requiresReauth = true
        }
    }
    
    // MARK: - ๊ธฐ๊ธฐ ์•”ํ˜ธ ์ธ์ฆ
    private func performDevicePasscodeAuth() async {
        let result = await biometric.authenticateWithFallback(
            reason: "๊ธฐ๊ธฐ ์•”ํ˜ธ๋กœ ์ธ์ฆํ•ด์ฃผ์„ธ์š”"
        )
        
        switch result {
        case .success:
            isAuthenticated = true
            lastAuthTime = Date()
        case .failure:
            isAuthenticated = false
        }
    }
    
    // MARK: - ์•ฑ ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ง„์ž… ์‹œ
    func handleAppBackground() {
        // ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ง„์ž… ์‹œ ์ธ์ฆ ์ƒํƒœ ์œ ์ง€ (ํƒ€์ž„์•„์›ƒ ์ „๊นŒ์ง€)
        // ๋ฏผ๊ฐํ•œ ํ™”๋ฉด์€ ๋ธ”๋Ÿฌ ์ฒ˜๋ฆฌ ํ•„์š”
    }
    
    // MARK: - ์•ฑ ํฌ๊ทธ๋ผ์šด๋“œ ๋ณต๊ท€ ์‹œ
    func handleAppForeground() async {
        // ์„ธ์…˜ ํƒ€์ž„์•„์›ƒ ์ฒดํฌ
        if let lastAuth = lastAuthTime,
           Date().timeIntervalSince(lastAuth) >= sessionTimeout {
            isAuthenticated = false
            await performBiometricAuth()
        }
    }
    
    // MARK: - ๋กœ๊ทธ์•„์›ƒ
    func logout() {
        tokenManager.logout()
        isAuthenticated = false
        lastAuthTime = nil
    }
}

// MARK: - App ๋ ˆ๋ฒจ์—์„œ ์‚ฌ์šฉ
@main
struct MySecureApp: App {
    
    @StateObject private var securityManager = SecurityManager.shared
    @Environment(\.scenePhase) private var scenePhase
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(securityManager)
                .onChange(of: scenePhase) { _, newPhase in
                    switch newPhase {
                    case .background:
                        securityManager.handleAppBackground()
                    case .active:
                        Task { await securityManager.handleAppForeground() }
                    default:
                        break
                    }
                }
                .task {
                    await securityManager.performAppLaunchAuth()
                }
        }
    }
}

๐Ÿ“Š ์„ฑ๋Šฅ & ๋ฒ ์ŠคํŠธ ํ”„๋ž™ํ‹ฐ์Šค ์ •๋ฆฌ
โšก Keychain ์„ฑ๋Šฅ ์ตœ์ ํ™”
๐Ÿ”‘ Keychain ์ ‘๊ทผ์€ ๋ฉ”์ธ ์Šค๋ ˆ๋“œ ๋ธ”๋กœํ‚น ์ฃผ์˜! Keychain ์ฝ๊ธฐ/์“ฐ๊ธฐ๋Š” ๋™๊ธฐ ์ž‘์—…์ด๊ณ  ์ƒ๊ฐ๋ณด๋‹ค ๋А๋ฆด ์ˆ˜ ์žˆ์–ด. ํŠนํžˆ ์ƒ์ฒด ์ธ์ฆ์ด ํ•„์š”ํ•œ ํ•ญ๋ชฉ์€ ๋” ๋А๋ฆผ. ๋ฐ˜๋“œ์‹œ ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์Šค๋ ˆ๋“œ์—์„œ ์‹คํ–‰ํ•˜๊ณ  ๊ฒฐ๊ณผ๋ฅผ ๋ฉ”์ธ ์Šค๋ ˆ๋“œ๋กœ ๊ฐ€์ ธ์™€์•ผ ํ•ด.
// โœ… ์˜ฌ๋ฐ”๋ฅธ ๋ฐฉ๋ฒ• - ๋ฐฑ๊ทธ๋ผ์šด๋“œ์—์„œ Keychain ์ ‘๊ทผ
func loadTokenAsync() async throws -> String {
    return try await Task.detached(priority: .userInitiated) {
        try KeychainManager.shared.read(forKey: "auth_token")
    }.value
}

// โŒ ์ž˜๋ชป๋œ ๋ฐฉ๋ฒ• - ๋ฉ”์ธ ์Šค๋ ˆ๋“œ์—์„œ ์ง์ ‘ ์ ‘๊ทผ
// let token = try KeychainManager.shared.read(forKey: "auth_token")
// โ†’ UI ๋ธ”๋กœํ‚น ๋ฐœ์ƒ ๊ฐ€๋Šฅ!
๐ŸŽฏ ์ž์ฃผ ํ•˜๋Š” ์‹ค์ˆ˜ TOP 5
์ˆœ์œ„ ์‹ค์ˆ˜ ์˜ฌ๋ฐ”๋ฅธ ๋ฐฉ๋ฒ•
1์œ„ UserDefaults์— ํ† ํฐ ์ €์žฅ ๋ฐ˜๋“œ์‹œ Keychain ์‚ฌ์šฉ
2์œ„ Info.plist์— NSFaceIDUsageDescription ๋ˆ„๋ฝ FaceID ์‚ฌ์šฉ ์ „ ๋ฐ˜๋“œ์‹œ ์ถ”๊ฐ€
3์œ„ ์ธ์ฆ ๊ฒฐ๊ณผ๋งŒ ๋ฏฟ๊ณ  ์„œ๋ฒ„ ๊ฒ€์ฆ ์ƒ๋žต ์„œ๋ฒ„์—์„œ ํ† ํฐ ์žฌ๊ฒ€์ฆ ํ•„์ˆ˜
4์œ„ kSecAttrAccessibleAlways ์‚ฌ์šฉ WhenUnlockedThisDeviceOnly ์‚ฌ์šฉ
5์œ„ ๋ฉ”์ธ ์Šค๋ ˆ๋“œ์—์„œ Keychain ๋™๊ธฐ ์ ‘๊ทผ ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์Šค๋ ˆ๋“œ์—์„œ async ์ฒ˜๋ฆฌ
๐Ÿ“ฆ ์ถ”์ฒœ ์„œ๋“œํŒŒํ‹ฐ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ
๐Ÿ” KeychainAccess Swift SPM ์ง€์› โ˜… 7.5k+
๊ฐ€์žฅ ์ธ๊ธฐ ์žˆ๋Š” Keychain ๋ž˜ํผ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ. ์ง๊ด€์ ์ธ API๋กœ Keychain ์ž‘์—…์„ ๋”•์…”๋„ˆ๋ฆฌ์ฒ˜๋Ÿผ ์‰ฝ๊ฒŒ ์ฒ˜๋ฆฌ ๊ฐ€๋Šฅ. ์ง์ ‘ ๊ตฌํ˜„์ด ๋ถ€๋‹ด์Šค๋Ÿฌ์šฐ๋ฉด ์ด๊ฑฐ ์“ฐ๋Š” ๊ฒƒ๋„ ์ข‹์€ ์„ ํƒ์ด์•ผ.
๐Ÿ›ก๏ธ Valet Swift Square ์ œ์ž‘ โ˜… 4k+
Square(๋ธ”๋ก)์—์„œ ๋งŒ๋“  Keychain ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ. ์ƒ์ฒด ์ธ์ฆ ์—ฐ๋™์ด ํŠนํžˆ ์ž˜ ๋˜์–ด ์žˆ๊ณ , ๊ณต์œ  Keychain(์•ฑ ๊ทธ๋ฃน) ์ง€์›๋„ ํ›Œ๋ฅญํ•ด.
๐Ÿ”’ BiometricAuthentication Swift ๊ฒฝ๋Ÿ‰
LocalAuthentication ๋ž˜ํผ. ์ƒ์ฒด ์ธ์ฆ๋งŒ ํ•„์š”ํ•  ๋•Œ ๊ฐ„๋‹จํ•˜๊ฒŒ ์“ธ ์ˆ˜ ์žˆ๋Š” ๊ฒฝ๋Ÿ‰ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ.

๐Ÿš€ ๋งˆ๋ฌด๋ฆฌ - ๋ณด์•ˆ์€ ์„ ํƒ์ด ์•„๋‹Œ ํ•„์ˆ˜!

์ž ์˜ค๋Š˜ ์ง„์งœ ๋งŽ์€ ๊ฑธ ๋‹ค๋ค˜๋Š”๋ฐ ํ•ต์‹ฌ๋งŒ ์ •๋ฆฌํ•˜๋ฉด:

๐ŸŽฏ ์˜ค๋Š˜์˜ ํ•ต์‹ฌ ์š”์•ฝ 1. Keychain = ๋ฏผ๊ฐ ๋ฐ์ดํ„ฐ ์ €์žฅ์˜ ์œ ์ผํ•œ ์ •๋‹ต. UserDefaults๋Š” ์ ˆ๋Œ€ ์•ˆ ๋จ.
2. LocalAuthentication = TouchID/FaceID ๊ตฌํ˜„์˜ ๊ณต์‹ ํ”„๋ ˆ์ž„์›Œํฌ. ์ƒ์ฒด ๋ฐ์ดํ„ฐ๋Š” ์•ฑ์— ์ ˆ๋Œ€ ์•ˆ ์˜ด.
3. Secure Enclave = ํ•˜๋“œ์›จ์–ด ๋ ˆ๋ฒจ ๋ณด์•ˆ. ๊ฐœ๋ฐœ์ž๊ฐ€ ์ง์ ‘ ๊ฑด๋“œ๋ฆด ์ˆ˜ ์—†๊ณ  ๊ฑด๋“œ๋ฆด ํ•„์š”๋„ ์—†์Œ.
4. kSecAccessControl = Keychain + ์ƒ์ฒด ์ธ์ฆ ์—ฐ๋™์˜ ํ•ต์‹ฌ. ์ƒ์ฒด ์ธ์ฆ ์„ฑ๊ณต ์‹œ์—๋งŒ Keychain ์ ‘๊ทผ ๊ฐ€๋Šฅ.
5. ์ƒ์ฒด ๋ณ€๊ฒฝ ๊ฐ์ง€ = evaluatedPolicyDomainState๋กœ ์ƒˆ ์ง€๋ฌธ/์–ผ๊ตด ์ถ”๊ฐ€ ๊ฐ์ง€ ๊ฐ€๋Šฅ.
6. ์„œ๋ฒ„ ๊ฒ€์ฆ ๋ณ‘ํ–‰ = ํด๋ผ์ด์–ธํŠธ ์ธ์ฆ๋งŒ ๋ฏฟ์œผ๋ฉด ์•ˆ ๋จ. ํ•ญ์ƒ ์„œ๋ฒ„์—์„œ ์žฌ๊ฒ€์ฆ!

iOS ๋ณด์•ˆ์€ ์ฒ˜์Œ์—” ๋ณต์žกํ•ด ๋ณด์ด์ง€๋งŒ, ์• ํ”Œ์ด ์ด๋ฏธ ์—„์ฒญ๋‚œ ์ธํ”„๋ผ๋ฅผ ๋งŒ๋“ค์–ด๋†จ์–ด. ์šฐ๋ฆฌ๋Š” ๊ทธ๊ฑธ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์‚ฌ์šฉํ•˜๊ธฐ๋งŒ ํ•˜๋ฉด ๋จ. ์˜ค๋Š˜ ๋ฐฐ์šด ํŒจํ„ด๋“ค ์ž˜ ์ตํ˜€๋‘๋ฉด ์–ด๋–ค ์•ฑ์„ ๋งŒ๋“ค์–ด๋„ ๋ณด์•ˆ ๊ฑฑ์ •์€ ์—†์„ ๊ฑฐ์•ผ ๐Ÿ’ช

๊ทธ๋ฆฌ๊ณ  ์ด๋Ÿฐ iOS ๋ณด์•ˆ ๊ตฌํ˜„ ๊ด€๋ จ ๋„์›€์ด ํ•„์š”ํ•˜๋‹ค๋ฉด ์žฌ๋Šฅ๋„ท์—์„œ iOS ๊ฐœ๋ฐœ ์ „๋ฌธ๊ฐ€๋ฅผ ์ฐพ์•„๋ณด๋Š” ๊ฒƒ๋„ ์ข‹์€ ๋ฐฉ๋ฒ•์ด์•ผ. ๋ณด์•ˆ ๊ตฌํ˜„์€ ํ•œ ๋ฒˆ ์ž˜๋ชป๋˜๋ฉด ๋‚˜์ค‘์— ๊ณ ์น˜๊ธฐ ์—„์ฒญ ํž˜๋“ค๊ฑฐ๋“ . ์ฒ˜์Œ๋ถ€ํ„ฐ ์ œ๋Œ€๋กœ ํ•˜๋Š” ๊ฒŒ ์ตœ๊ณ ์•ผ! ๐Ÿ”

๐Ÿ”๐Ÿ›ก๏ธ๐Ÿ‘†๐Ÿ˜Š๐Ÿ”‘โšก๐Ÿš€โœ…
๋Œ“๊ธ€ ์ž‘์„ฑ

์ด ๊ธ€์— ๋Œ€ํ•œ ์—ฌ๋Ÿฌ๋ถ„์˜ ์ƒ๊ฐ์„ ๋“ค๋ ค์ฃผ์„ธ์š”

๋Œ“๊ธ€ 0
๋Œ“๊ธ€์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.

์•„์ง ๋Œ“๊ธ€์ด ์—†์Šต๋‹ˆ๋‹ค.