๊ถํ์ด ์๋๋ฐ ์ ์๋ผ์?
์ฑ์์ FaceID๋ TouchID ์ธ์ฆ์ ์๋ํ  ๋,
์ผ๋ถ ์ฌ์ฉ์ ํ๊ฒฝ์์ OS ํ์
์ด ๋จ์ง ์๊ฑฐ๋, canEvaluatePolicy๊ฐ ์คํจํ๋ ๊ฒฝํ์ด ์๋ค.
ํนํ ์๋์ ๊ฐ์ ์ํฉ์ด ํผ๋์ ์ค๋ค.
- ์ฌ์ฉ์๊ฐ ์ฒ์์ผ๋ก ์์ฒด ์ธ์ฆ ์ค์ ์ ํ์ง ์์ ์ํ
- ์ฌ์ฉ์๊ฐ ์ค์ ์์ ์์ฒด ์ธ์ฆ์ ๊บผ ๋ ์ํ
- ์ฌ์ฉ์๊ฐ ๊ถํ์ ๊ฑฐ๋ถํ ์ํ
- ๊ธฐ๊ธฐ ์์ฒด์ ์์ฒด ์ ๋ณด(์ง๋ฌธ/์ผ๊ตด)๊ฐ ๋ฑ๋ก๋์ด ์์ง ์์ ์ํ
์ด ์ค ์ด๋ค ๊ฒฝ์ฐ๋  iOS๋ ๊ฐ๋ฐ์์๊ฒ "๊ฑฐ๋ถ๋จ"์ด๋ผ๋ ๋ช
ํํ ์ํ๋ฅผ ์๋ ค์ฃผ์ง ์๋๋ค.
๊ฒฐ๊ตญ, ์ฐ๋ฆฌ๋ OS ์ ์ฑ
 ์์์ ์ํ๋ฅผ "์ถ์ "ํด์ผ ํ๋ค.
Apple์ ์ค๊ณ ์ฒ ํ๊ณผ ์ค์  ์ฝ๋
๐คฆ๐ปโโ๏ธ LAContext์ ํ๊ณ
let context = LAContext()
var error: NSError?
context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)์ ๋ฉ์๋๋ '์ฌ์ฉ ๊ฐ๋ฅ ์ฌ๋ถ'๋ง ์๋ ค์ฃผ๋ฉฐ, '๊ถํ ๊ฑฐ๋ถ ์ํ์ธ์ง'๋ ์ ์ ์๋ค.
์ฆ, ์๋ ๋ ์ํฉ์ด ๋์ผํ๊ฒ false๋ฅผ ๋ฐํํ๋ค.
- ์์ฒด ์ธ์ฆ ์์ฒด๊ฐ ๊บผ์ ธ ์๋ ๊ฒฝ์ฐ
- ์ฌ์ฉ์๊ฐ ๊ถํ์ ๊ฑฐ๋ถํ ๊ฒฝ์ฐ
์ด ๋๋ฌธ์ ์ฑ ๋จ์์๋ ์๋์ ๊ฐ์ด ๋ถ๊ธฐ์ฒ๋ฆฌ๋ฅผ ํด์ผํ๋ค.
if context.biometryType == .none {
    print("๊ธฐ๊ธฐ์์ ์์ฒด ์ธ์ฆ์ ์ง์ํ์ง ์์")
} 
else if error != nil {
    print("์ฌ์ฉ ๋ถ๊ฐ โ ์ค์  ์์ธ์ ๋ถ๋ช
ํ")
}
Apple์ด ์ด๋ ๊ฒ ๋ง๋ ์ด์ ?
Apple์ ๋ณด์์์ ์ด์ ๋ก '๊ถํ ๊ฑฐ๋ถ'๋ฅผ ์ฑ์ด ๋ช
ํํ ๊ฐ์งํ์ง ๋ชปํ๋๋ก ์ค๊ณํ๋ค. (healthKit ์ฝ๊ธฐ๊ถํ๋ ์ด๋ผ..๐คฆ๐ปโโ๏ธ)
์ด๋ ๊ณง '์ฌ์ฉ์์ ๊ฑฐ๋ถ ์์ฌ'๋ฅผ ์ฑ์ด ๊ฐ์ ์ ์ผ๋ก ์ถ์ ํ์ง ๋ชปํ๊ฒ ํ๊ธฐ ์ํจ์ด๋ค.
์ฆ, '๊ถํ์ด ์๋ค'๋ ๊ฒ์กฐ์ฐจ ๊ฐ์ธ์ ๋ณด์ ํด๋นํ  ์ ์๋ค๊ณ  ๋ณด๋ ๊ฒ ๊ฐ๋ค.
 
๋ฏผ๊ฐ ํ์ด์ง ๋ณดํธ์ฉ์ผ๋ก ์์ฒด์ธ์ฆ ํ์ฉ
1. ์ํ๋ณ ๋ถ๊ธฐ์ฒ๋ฆฌ
import LocalAuthentication
enum SensitiveAuthRoute {
    case granted                         // ์ ๊ทผ ํ์ฉ
    case cancelled                       // ์ฌ์ฉ์/์์คํ
 ์ทจ์
    case lockedOut                       // ์์ฒด ์ ๊ธ (์ผ์์  ์ฐจ๋จ)
    case notAvailable                    // ๊ธฐ๊ธฐ ๋ฏธ์ง์
    case notEnrolled                     // ๊ธฐ๊ธฐ ๋ฑ๋ก(์ผ๊ตด/์ง๋ฌธ) ์์
    case failed                          // ์ธ์ฆ ์คํจ(๋ถ์ผ์น ๋ฑ)
    case unknown                         // ๊ตฌ๋ถ ๋ถ๊ฐ
}
2. ์ธ์ฆ ๋งค๋์ 
- ๊ถํ '๊ฑฐ๋ถ'๋ฅผ ํ์ ํ๋ ค ํ์ง ๋ง๊ณ , OS๊ฐ ์ฃผ๋ ์ฑ๊ณต/์คํจ + ์๋ฌ์ฝ๋๋ฅผ UX ๋ผ์ฐํธ๋ก ๋ณํ
- ์ ๊ธ(.biometryLockout) ์์๋ ์ฆ์ ํจ์ค์ฝ๋ ๋์ฒด(.deviceOwnerAuthentication)๋ก ์ ํ
final class SensitiveAuthManager {
    private var lastAttemptAt: Date?
    private let minInterval: TimeInterval = 1.0
    @MainActor
    func requireAuthentication(reason: String = "๋ฏผ๊ฐ ์ ๋ณด ์ ๊ทผ์ ์ํด ๋ณธ์ธ ํ์ธ์ด ํ์ํฉ๋๋ค.") async -> SensitiveAuthRoute {
        // 0) ๊ณผ๋ํ ์ฌ์์ฒญ ๋ฐฉ์ง
        if let last = lastAttemptAt, Date().timeIntervalSince(last) < minInterval {
            return .cancelled
        }
        lastAttemptAt = Date()
        let ctx = LAContext()
        ctx.localizedFallbackTitle = "์ํธ ์
๋ ฅ"
        var err: NSError?
        let canBio = ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &err)
        // 1) ์ธ์ฆ ๊ฐ๋ฅ ์ฌ๋ถ ๋ถ๊ธฐ
        if !canBio {
            if let la = err.map({ LAError(_nsError: $0) }) {
                switch la.code {
                case .biometryNotAvailable:
                    return .notAvailable
                case .biometryNotEnrolled:
                    return .notEnrolled
                case .biometryLockout:
                    return await evaluateWithPasscode(context: ctx, reason: reason)
                default:
                    return .unknown
                }
            }
            return .unknown
        }
        // 2) ์์ฒด ์ธ์ฆ ์ํ
        let bioResult = await evaluateBiometrics(context: ctx, reason: reason)
        switch bioResult {
        case .granted:
            return .granted
        case .lockedOut:
            return await evaluateWithPasscode(context: ctx, reason: reason)
        case .cancelled, .failed, .unknown:
            return bioResult
        }
    }
    private func evaluateBiometrics(context: LAContext, reason: String) async -> SensitiveAuthRoute {
        await withCheckedContinuation { cont in
            context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
                                   localizedReason: reason) { success, error in
                if success { cont.resume(returning: .granted); return }
                guard let e = error as NSError? else { cont.resume(returning: .unknown); return }
                let la = LAError(_nsError: e)
                switch la.code {
                case .userCancel, .systemCancel, .appCancel:
                    cont.resume(returning: .cancelled)
                case .authenticationFailed:
                    cont.resume(returning: .failed)
                case .biometryLockout:
                    cont.resume(returning: .lockedOut)
                default:
                    cont.resume(returning: .unknown)
                }
            }
        }
    }
    private func evaluateWithPasscode(context: LAContext, reason: String) async -> SensitiveAuthRoute {
        await withCheckedContinuation { cont in
            context.evaluatePolicy(.deviceOwnerAuthentication,
                                   localizedReason: reason) { success, error in
                if success { cont.resume(returning: .granted); return }
                guard let e = error as NSError? else { cont.resume(returning: .unknown); return }
                let la = LAError(_nsError: e)
                switch la.code {
                case .userCancel, .systemCancel, .appCancel:
                    cont.resume(returning: .cancelled)
                case .authenticationFailed:
                    cont.resume(returning: .failed)
                default:
                    cont.resume(returning: .unknown)
                }
            }
        }
    }
}
3. ๋ฏผ๊ฐ ํ๋ฉด ์ง์
 ๊ฐ์ด๋(UIKit)
- ๋ฏผ๊ฐ ํ๋ฉด ์ง์
 ์ง์ ์ ํธ์ถ
- ๋ผ์ฐํธ๋ณ๋ก ์ค์  ์ด๋ ์ ๋, ์ฑ PIN ๋์ฒด, ์ผ์ ์ฌ์๋, ์ ๊ทผ ์ฐจ๋จ ๋ฑ์ ๊ฒฐ์ 
@MainActor
func guardSensitiveScreenAccess(presenting: UIViewController, onGranted: () -> Void) async {
    let manager = SensitiveAuthManager()
    switch await manager.requireAuthentication(reason: "๊ฑด๊ฐ ์ ๋ณด ์ด๋์ ์ํด ๋ณธ์ธ ํ์ธ์ด ํ์ํฉ๋๋ค.") {
    case .granted:
        onGranted()
    case .notEnrolled:
        let alert = UIAlertController(
            title: "์์ฒด ์ ๋ณด ๋ฏธ๋ฑ๋ก",
            message: "Face ID/Touch ID๊ฐ ๋ฑ๋ก๋์ด ์์ง ์์ต๋๋ค. ์ค์ ์์ ๋ฑ๋ก ํ ๋ค์ ์๋ํ์ธ์.",
            preferredStyle: .alert
        )
        alert.addAction(UIAlertAction(title: "์ทจ์", style: .cancel))
        alert.addAction(UIAlertAction(title: "์ค์ ์ผ๋ก ์ด๋", style: .default) { _ in
            if let url = URL(string: UIApplication.openSettingsURLString) {
                UIApplication.shared.open(url)
            }
        })
        presenting.present(alert, animated: true)
    case .notAvailable:
        // ๊ธฐ๊ธฐ ๋ฏธ์ง์ โ ์ฑ ์์ฒด PIN/ํจํด ๋ฑ ๋์ฒด ์ธ์ฆ ๋ฃจํธ๋ก ์ ํ
        presentAppPINFlow(from: presenting)
    case .lockedOut:
        // ๊ณผ๋ค ์คํจ โ ์ผ์  ์๊ฐ ํ ์ฌ์๋ ์๋ด(๋๋ ์ด๋ฏธ passcode ๋์ฒด์์ ์คํจํ ๊ฒฝ์ฐ)
        showToast("์ ์ ํ ๋ค์ ์๋ํ์ธ์.") // UI ๊ตฌ์ฑ์ ๋ง๊ฒ ์ฒ๋ฆฌ
    case .cancelled:
        // ์ฌ์ฉ์๊ฐ ์ทจ์ โ ์๋ฌด ๋์ ์์ด ๋ณต๊ท
        break
    case .failed:
        // ๋ถ์ผ์น โ ์ฌ์๋/๋์ฒด์๋จ ์ ์
        showToast("์ธ์ฆ์ ์คํจํ์ต๋๋ค. ๋ค์ ์๋ํ๊ฑฐ๋ ๋ค๋ฅธ ์ธ์ฆ ์๋จ์ ์ด์ฉํ์ธ์.")
    case .unknown:
        // ์์ธ โ ์์ ํ๊ฒ ์ฐจ๋จ
        showToast("์ธ์ฆ์ ์งํํ  ์ ์์ต๋๋ค.")
    }
}
๋ง๋ฌด๋ฆฌ
iOS๋ ์์ฒด์ธ์ฆ์ ๋ํด ๊ฐ๋ฐ์๊ฐ ์์๋ก '๊ถํ ํ์
'์ ๋์ธ ์ ์๋ค.
์์คํ
 ํ์
์ evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, โฆ)๋ฅผ ํธ์ถํ์ ๋ โ์ต์ด 1ํโ ์๋์ผ๋ก ๋
ธ์ถ๋๋ค. (์ฑ ์ญ์  ์ ๊น์ง)
๊ทธ๋์ 'ํ์ฉํด๋ฌ๋ผ๋ ์๋ด ํ์
'์ ์ฑ ์์ฒด UI๋ก ๋จผ์  ๋ณด์ฌ์ฃผ๊ณ , ์ฌ์ฉ์๊ฐ ๋์ํ๋ฉด evaluatePolicy๋ฅผ ํธ์ถํด์ OS ํ์
์ ์ ๋ํ๋ ๋ฐฉ์์ด ์ ์์ผ ๊ฒ ๊ฐ๋ค. ๋ํ, ์ฑ ๊ธฐ๋ฅ์ ๋ง๊ฒ info.plist์ NSFaceIDUsageDescription(FaceID ์ด์  ๋ฌธ๊ตฌ)๋ฅผ ๋ฐ๋์ ์ถ๊ฐํด์ผํ๋ค.
์ฌ์ค ๊ธฐํ์ '๊ถํ ๊ฑฐ๋ถ/ํ์ฉ' ์ํ์ ๋ฐ๋ผ์ ์ฌ์ฉ์์๊ฒ ๊ถํ์ ํ์ฉํด๋ฌ๋ผ๋ ๋ฌธ๊ตฌ๋ฅผ ๋์์ผํ๋ ๊ฒฝ์ฐ๊ฐ ์๋๋ผ๋ฉด ๊ตฌํ์ ์์ด์ ํฐ ๋ฌธ์ ๋ ์์ ๊ฒ ๊ฐ๋ค.
๋ง์ฝ ๊ตฌํ์ ํ์ํ๋ค๋ฉด, '์ฑ๊ณต / ์คํจ / ์ธ์ฆ ๋ถ๊ฐ๋ฅ' ์ด๋ ๊ฒ 3๊ฐ์ง ๊ฒฝ์ฐ๋ก ๋๋๊ณ  '์คํจ'์ธ ๊ฒฝ์ฐ์๋ ์ํธ ๋ฐ PIN ์
๋ ฅ ํน์ ์ฑ ๋ด์ ๊ฐํธ๋น๋ฐ๋ฒํธ ์
๋ ฅ ๊ธฐ๋ฅ์ผ๋ก Fallback ์ฒ๋ฆฌํ๋ฉด ๋๋ค. '์ธ์ฆ ๋ถ๊ฐ๋ฅ'์ธ ๊ฒฝ์ฐ์๋ ๊ถํ ๊ฑฐ๋ถ ์ํ์ผ ์ ์์ผ๋, ์ค์ ์ฐฝ์ผ๋ก ์ด๋์ํฌ ์ ์๋ ํ์
์ ๋์์ฃผ๊ณ , ๊ถํ์ ํ์ฉํ  ์ ์๋๋ก ์ ๋ํ๋ฉด ๋๋ค.
์์ ๊ฐ์ ๊ตฌํ ๋ฐฉ์์ด ์ ์์ ์๋์ง๋ง, ์ฌ๋ฌ ๋ฉ์ด์  ์ฑ๋ค์ ๋ฒค์น๋งํน ํด๋ณด๋ ์์ ๊ฐ์ด ์ฒ๋ฆฌํ๊ณ  ์์๋ค.
์ ๋งคํ๋ฉด ๊ทธ๋ฅ ๋ฉ์ด์  ์ฑ๊ณผ ๋์ผํ๊ฒ ๊ตฌํํ๋๊ฒ ์ข์ ๊ฒ ๊ฐ๋ค. ๋ฌด์กฐ๊ฑด ๋ฐ๋ผํ์ ๋ง์ธ๋๊ฐ ์๋๋ผ ์ฌ์ฉ์ ์
์ฅ์์๋ ์ต์ํ UX์ผํ
๋ :)
'๐' ์นดํ ๊ณ ๋ฆฌ์ ๋ค๋ฅธ ๊ธ
| [Swift] Privacy Masking(Privacy Screen) ๊ตฌํํ๊ธฐ (0) | 2025.10.19 | 
|---|---|
| [iOS] New Requirement for Apps Using Sign in with Apple for Account Creation (0) | 2025.10.15 | 
| [Swift] ์ฑ ์ฒซ ์คํ ์ ํด์ผ ํ ์ด๊ธฐํ ์์  ์ ๋ฆฌ(AppDelagate vs SceneDelegate) (0) | 2025.10.05 | 
| [Swift] SplashView - ์คํ๋์ ํ๋ฉด (0) | 2025.09.28 | 
| [Swift] KeychainManager (0) | 2025.09.21 |