🇺🇸 EN

🔐 Local Authentication 완전정복

Face ID와 Touch ID로 앱을 안전하게! 생체 인증부터 비밀번호 대체까지 완벽 가이드.

⭐ 난이도: ⭐ ⏱️ 예상 시간: 30min 📂 System & Network

✨ Local Authentication이란?

Local Authentication은 Face ID, Touch ID, 기기 암호를 사용하여 사용자를 인증하는 프레임워크입니다. 로그인, 결제 승인, 민감한 정보 접근 시 안전하고 편리한 인증을 제공합니다. 모든 생체 데이터는 기기 내에서만 처리되어 서버로 전송되지 않습니다.

📦 주요 기능

기능 개요
✅ Face ID / Touch ID 인증
✅ 기기 암호 인증 (비밀번호)
✅ 생체 인증 가용성 확인
✅ 인증 실패 처리 및 재시도
✅ 커스텀 인증 UI
✅ 비밀번호 대체 인증

🔍 생체 인증 가용성 확인

BiometryCheck.swift
import LocalAuthentication

func checkBiometryAvailability() -> (Bool, String) {
    let context = LAContext()
    var error: NSError?

    // 생체 인증 가능 여부 확인
    let canEvaluate = context.canEvaluatePolicy(
        .deviceOwnerAuthenticationWithBiometrics,
        error: &error
    )

    if canEvaluate {
        // 생체 인증 타입 확인
        switch context.biometryType {
        case .faceID:
            return (true, "Face ID 사용 가능")
        case .touchID:
            return (true, "Touch ID 사용 가능")
        case .opticID:
            return (true, "Optic ID 사용 가능")
        case .none:
            return (false, "생체 인증 미지원")
        @unknown default:
            return (false, "알 수 없는 타입")
        }
    } else {
        // 오류 처리
        if let error = error {
            switch LAError.Code(rawValue: error.code) {
            case .biometryNotEnrolled:
                return (false, "생체 인증 미등록")
            case .biometryNotAvailable:
                return (false, "생체 인증 미지원")
            case .passcodeNotSet:
                return (false, "기기 암호 미설정")
            default:
                return (false, error.localizedDescription)
            }
        }
        return (false, "알 수 없는 오류")
    }
}

// 사용 예시
let (available, message) = checkBiometryAvailability()
print(message)  // "Face ID 사용 가능"

🔐 기본 생체 인증

BasicAuth.swift
import LocalAuthentication

func authenticateUser() async -> Bool {
    let context = LAContext()
    var error: NSError?

    // 생체 인증 가능 여부 확인
    guard context.canEvaluatePolicy(
        .deviceOwnerAuthenticationWithBiometrics,
        error: &error
    ) else {
        print("생체 인증 불가: \(error?.localizedDescription ?? "")")
        return false
    }

    do {
        // 인증 시도
        let success = try await context.evaluatePolicy(
            .deviceOwnerAuthenticationWithBiometrics,
            localizedReason: "앱에 로그인하려면 인증이 필요합니다"
        )
        return success
    } catch let error {
        // 인증 실패 처리
        if let laError = error as? LAError {
            switch laError.code {
            case .authenticationFailed:
                print("인증 실패 (얼굴/지문 불일치)")
            case .userCancel:
                print("사용자가 취소함")
            case .userFallback:
                print("비밀번호 입력 선택")
            case .systemCancel:
                print("시스템이 취소 (앱 전환 등)")
            case .biometryLockout:
                print("너무 많은 시도로 잠김")
            default:
                print("인증 오류: \(laError.localizedDescription)")
            }
        }
        return false
    }
}

// 사용 예시
let authenticated = await authenticateUser()
if authenticated {
    print("로그인 성공!")
}

🔑 기기 암호 포함 인증

PasscodeAuth.swift
import LocalAuthentication

func authenticateWithPasscode() async -> Bool {
    let context = LAContext()

    // 생체 인증 실패 시 기기 암호 입력 허용
    context.localizedFallbackTitle = "암호 입력"

    do {
        let success = try await context.evaluatePolicy(
            .deviceOwnerAuthentication,  // 생체 인증 + 기기 암호
            localizedReason: "계정에 접근하려면 인증하세요"
        )
        return success
    } catch {
        print("인증 실패: \(error.localizedDescription)")
        return false
    }
}

// 커스텀 취소 버튼 텍스트
func authenticateWithCustomButton() async -> Bool {
    let context = LAContext()
    context.localizedCancelTitle = "나중에"
    context.localizedFallbackTitle = "비밀번호로 로그인"

    do {
        return try await context.evaluatePolicy(
            .deviceOwnerAuthenticationWithBiometrics,
            localizedReason: "민감한 정보에 접근합니다"
        )
    } catch {
        return false
    }
}

📱 SwiftUI 통합

AuthView.swift
import SwiftUI
import LocalAuthentication

struct AuthenticationView: View {
    @State private var isAuthenticated = false
    @State private var showError = false
    @State private var errorMessage = ""

    var body: some View {
        VStack(spacing: 20) {
            if isAuthenticated {
                // 인증 후 화면
                VStack(spacing: 16) {
                    Image(systemName: "checkmark.circle.fill")
                        .font(.system(size: 80))
                        .foregroundStyle(.green)

                    Text("인증 성공")
                        .font(.title)
                        .bold()

                    Text("안전하게 로그인되었습니다")
                        .foregroundStyle(.secondary)

                    Button("로그아웃") {
                        isAuthenticated = false
                    }
                    .buttonStyle(.borderedProminent)
                }
            } else {
                // 로그인 화면
                VStack(spacing: 16) {
                    Image(systemName: "faceid")
                        .font(.system(size: 80))
                        .foregroundStyle(.blue)

                    Text("생체 인증 필요")
                        .font(.title)
                        .bold()

                    Text("Face ID로 안전하게 로그인하세요")
                        .foregroundStyle(.secondary)

                    Button("인증하기") {
                        Task {
                            await authenticate()
                        }
                    }
                    .buttonStyle(.borderedProminent)
                }
            }
        }
        .padding()
        .alert("인증 실패", isPresented: $showError) {
            Button("확인", role: .cancel) { }
        } message: {
            Text(errorMessage)
        }
        .onAppear {
            // 앱 시작 시 자동 인증 시도
            Task {
                await authenticate()
            }
        }
    }

    func authenticate() async {
        let context = LAContext()
        var error: NSError?

        guard context.canEvaluatePolicy(
            .deviceOwnerAuthentication,
            error: &error
        ) else {
            errorMessage = error?.localizedDescription ?? "인증 불가"
            showError = true
            return
        }

        do {
            let success = try await context.evaluatePolicy(
                .deviceOwnerAuthentication,
                localizedReason: "앱 접근을 위해 인증이 필요합니다"
            )

            await MainActor.run {
                isAuthenticated = success
            }
        } catch let error {
            await MainActor.run {
                errorMessage = error.localizedDescription
                showError = true
            }
        }
    }
}

🔒 실전 예제: 민감 정보 보호

SecureDataView.swift
import SwiftUI
import LocalAuthentication

struct SecureDataView: View {
    @State private var isUnlocked = false
    @State private var sensitiveData = "****-****-****-1234"

    var body: some View {
        List {
            Section("계좌 정보") {
                HStack {
                    Text("계좌번호")
                    Spacer()
                    Text(isUnlocked ? "1234-5678-9012-3456" : "****-****-****-3456")
                        .font(.system(.body, design: .monospaced))
                }

                HStack {
                    Text("잔액")
                    Spacer()
                    Text(isUnlocked ? "1,234,567원" : "*******원")
                }
            }

            Section {
                Button(isUnlocked ? "정보 숨기기" : "정보 보기") {
                    if isUnlocked {
                        isUnlocked = false
                    } else {
                        Task {
                            await authenticate()
                        }
                    }
                }
            }
        }
        .navigationTitle("내 계좌")
    }

    func authenticate() async {
        let context = LAContext()

        do {
            let success = try await context.evaluatePolicy(
                .deviceOwnerAuthentication,
                localizedReason: "민감한 계좌 정보를 보려면 인증하세요"
            )

            if success {
                await MainActor.run {
                    withAnimation {
                        isUnlocked = true
                    }
                }
            }
        } catch {
            print("인증 실패")
        }
    }
}

⏱️ 인증 컨텍스트 재사용

ContextReuse.swift
import LocalAuthentication

class AuthenticationManager {
    private let context = LAContext()

    init() {
        // 인증 유효 시간 설정 (기본 10초)
        context.touchIDAuthenticationAllowableReuseDuration = 30  // 30초
    }

    func authenticate(reason: String) async -> Bool {
        do {
            // 30초 내 재인증 시 생체 인증 생략
            return try await context.evaluatePolicy(
                .deviceOwnerAuthenticationWithBiometrics,
                localizedReason: reason
            )
        } catch {
            return false
        }
    }

    func invalidate() {
        // 컨텍스트 무효화 (즉시 재인증 필요)
        context.invalidate()
    }
}

// 사용 예시
let authManager = AuthenticationManager()

// 첫 인증 (생체 인증 필요)
let success1 = await authManager.authenticate(reason: "결제 승인")

// 30초 내 재인증 (생체 인증 생략)
let success2 = await authManager.authenticate(reason: "추가 결제")

// 로그아웃 시 컨텍스트 무효화
authManager.invalidate()

🎯 고급 기능: 도메인 상태

DomainState.swift
import LocalAuthentication

class BiometricStateMonitor {
    private var domainState: Data?

    func saveBiometricState() {
        let context = LAContext()
        var error: NSError?

        guard context.canEvaluatePolicy(
            .deviceOwnerAuthenticationWithBiometrics,
            error: &error
        ) else {
            return
        }

        // 현재 생체 인증 상태 저장
        domainState = context.evaluatedPolicyDomainState
        UserDefaults.standard.set(domainState, forKey: "BiometricState")
    }

    func hasBiometricChanged() -> Bool {
        let context = LAContext()
        var error: NSError?

        guard context.canEvaluatePolicy(
            .deviceOwnerAuthenticationWithBiometrics,
            error: &error
        ) else {
            return true
        }

        // 저장된 상태와 비교
        guard let savedState = UserDefaults.standard.data(forKey: "BiometricState") else {
            return true
        }

        let currentState = context.evaluatedPolicyDomainState
        return savedState != currentState
    }

    func handleBiometricChange() {
        if hasBiometricChanged() {
            print("⚠️ 생체 인증 정보가 변경되었습니다!")
            print("보안을 위해 다시 로그인하세요.")

            // 앱 데이터 초기화 또는 재로그인 요구
            logoutUser()
            saveBiometricState()
        }
    }

    func logoutUser() {
        // 로그아웃 로직
        UserDefaults.standard.set(false, forKey: "IsLoggedIn")
    }
}

// 사용 예시
let monitor = BiometricStateMonitor()
monitor.saveBiometricState()  // 로그인 시

// 앱 시작 시 확인
monitor.handleBiometricChange()

💡 HIG 가이드라인

HIG 권장사항
✅ DO
1. 명확한 이유 제공
   - "결제를 승인하려면 Face ID가 필요합니다"
   - 사용자에게 왜 인증이 필요한지 명확히 설명

2. 대체 수단 제공
   - 생체 인증 실패 시 기기 암호 허용
   - .deviceOwnerAuthentication 정책 사용

3. 적절한 타이밍
   - 민감한 작업 직전에만 요청
   - 너무 자주 요청하지 않기

4. 오류 처리
   - 인증 실패 시 명확한 피드백
   - 재시도 옵션 제공

❌ DON'T
1. 앱 시작마다 인증 요구 (불필요한 경우)
2. 생체 데이터를 서버로 전송
3. 인증 없이도 기본 기능은 사용 가능하게
4. "Touch ID"라고 명시 (기기별로 다름)

🔧 실무 활용 팁

실전 패턴
import LocalAuthentication

// 1. 인증 헬퍼 클래스
class BiometricAuth {
    static let shared = BiometricAuth()
    private init() {}

    var biometricType: LABiometryType {
        let context = LAContext()
        _ = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
        return context.biometryType
    }

    var biometricName: String {
        switch biometricType {
        case .faceID: return "Face ID"
        case .touchID: return "Touch ID"
        case .opticID: return "Optic ID"
        case .none: return "생체 인증"
        @unknown default: return "생체 인증"
        }
    }

    func authenticate(reason: String) async throws -> Bool {
        let context = LAContext()
        return try await context.evaluatePolicy(
            .deviceOwnerAuthentication,
            localizedReason: reason
        )
    }
}

// 2. SwiftUI에서 사용
struct SettingsView: View {
    @AppStorage("useBiometric") private var useBiometric = false

    var body: some View {
        Form {
            Section("보안") {
                Toggle("\(BiometricAuth.shared.biometricName) 사용", isOn: $useBiometric)
            }
        }
    }
}

// 3. 결제 승인 예시
func processPayment(amount: Int) async -> Bool {
    do {
        let authenticated = try await BiometricAuth.shared.authenticate(
            reason: "\(amount)원 결제를 승인하시겠습니까?"
        )

        if authenticated {
            // 결제 처리
            await performPaymentTransaction(amount)
            return true
        }
    } catch {
        print("결제 취소됨")
    }
    return false
}

func performPaymentTransaction(_ amount: Int) async {
    // 실제 결제 API 호출
}

🔐 Info.plist 설정

Info.plist
<!-- Face ID 사용 시 필수 -->
<key>NSFaceIDUsageDescription</key>
<string>안전한 로그인을 위해 Face ID를 사용합니다</string>

<!-- 또는 한국어로 -->
<key>NSFaceIDUsageDescription</key>
<string>계정 보안을 위해 Face ID 인증이 필요합니다</string>

💡 Local Authentication 핵심
✅ 모든 생체 데이터는 기기 내에서만 처리
✅ Secure Enclave에 안전하게 저장
✅ 생체 인증 실패 시 기기 암호 대체 수단 제공
✅ 앱은 인증 성공/실패만 확인 가능
✅ 실제 생체 데이터는 앱에서 접근 불가

📦 학습 자료

💻
GitHub 프로젝트
📖
Apple 공식 문서
🎨
HIG 인증 가이드

📎 Apple 공식 자료

📘 공식 문서 🎬 WWDC 세션