모바일 생체 인증: Face ID, Touch ID, 지문 인식 통합 구현

모바일 개발

생체 인증Face IDTouch IDBiometricPrompt보안

이 글은 누구를 위한 것인가

  • 앱 잠금 해제를 Face ID/Touch ID로 구현하고 싶은 팀
  • 금융 앱에서 결제 시 생체 인증을 추가해야 하는 개발자
  • 생체 인증 실패 시 PIN 폴백을 구현해야 하는 엔지니어

들어가며

Face ID 한 번으로 앱을 열고 결제를 승인한다. 사용자 경험은 최고지만 구현 시 인증 실패, 생체 정보 변경, 디바이스 교체 등을 고려해야 한다.

이 글은 bluefoxdev.kr의 모바일 보안 인증 가이드 를 참고하여 작성했습니다.


1. 생체 인증 설계

[인증 흐름 설계]

앱 잠금 해제:
  앱 포그라운드 진입 → 생체 인증 요청
  성공 → 앱 사용
  실패 3회 → PIN 입력
  PIN 입력 → 앱 사용

결제 승인:
  결제 버튼 탭 → 생체 인증 요청 (이유 명시)
  성공 → 결제 처리
  취소/실패 → 결제 취소

[iOS LocalAuthentication]
  LAContext: 인증 컨텍스트
  .biometrics: Face ID 또는 Touch ID
  .deviceOwnerAuthentication: 생체 + 패스코드 폴백
  .deviceOwnerAuthenticationWithBiometrics: 생체만

[Android BiometricPrompt]
  Authenticators.BIOMETRIC_STRONG: 지문, 3D 얼굴, 홍채
  Authenticators.BIOMETRIC_WEAK: 2D 얼굴 포함
  Authenticators.DEVICE_CREDENTIAL: PIN/패턴 폴백

[보안 고려사항]
  생체 정보는 기기에만 저장, 서버 전송 안 됨
  새 지문 등록 시 Keystore 키 무효화
  앱 재설치 시 인증 데이터 초기화
  탈옥/루팅 기기에서 강도 낮은 인증 불허

2. 생체 인증 구현

// iOS Swift - LocalAuthentication
import LocalAuthentication
import SwiftUI

class BiometricAuthManager: ObservableObject {
    @Published var isAuthenticated = false
    @Published var authError: String?
    
    private let context = LAContext()
    
    // 생체 인증 가능 여부 확인
    func biometricType() -> LABiometryType {
        var error: NSError?
        guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
            return .none
        }
        return context.biometryType
    }
    
    func biometricDisplayName() -> String {
        switch biometricType() {
        case .faceID: return "Face ID"
        case .touchID: return "Touch ID"
        case .opticID: return "Optic ID"
        default: return "생체 인증"
        }
    }
    
    // 앱 잠금 해제용 인증
    func authenticate(reason: String, completion: @escaping (Bool, Error?) -> Void) {
        let context = LAContext()
        context.localizedCancelTitle = "취소"
        context.localizedFallbackTitle = "비밀번호 입력"
        
        var error: NSError?
        guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else {
            completion(false, error)
            return
        }
        
        context.evaluatePolicy(
            .deviceOwnerAuthentication,  // 생체 + 패스코드 폴백
            localizedReason: reason,
        ) { success, error in
            DispatchQueue.main.async {
                completion(success, error)
            }
        }
    }
    
    // 결제용 인증 (생체만, 폴백 없음)
    func authenticateForPayment(amount: String, completion: @escaping (Bool) -> Void) {
        let context = LAContext()
        var error: NSError?
        
        guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
            completion(false)
            return
        }
        
        context.evaluatePolicy(
            .deviceOwnerAuthenticationWithBiometrics,
            localizedReason: "\(amount) 결제를 승인합니다",
        ) { success, _ in
            DispatchQueue.main.async { completion(success) }
        }
    }
}

// 앱 잠금 화면
struct AppLockView: View {
    @StateObject private var authManager = BiometricAuthManager()
    @State private var showPINInput = false
    @AppStorage("is_biometric_enabled") private var isBiometricEnabled = true
    
    var onAuthenticated: () -> Void
    
    var body: some View {
        VStack(spacing: 30) {
            Image(systemName: authManager.biometricType() == .faceID ? "faceid" : "touchid")
                .font(.system(size: 60))
                .foregroundColor(.blue)
            
            Text("\(authManager.biometricDisplayName())로 잠금 해제")
                .font(.title2)
            
            if let error = authManager.authError {
                Text(error).foregroundColor(.red).font(.caption)
            }
            
            if isBiometricEnabled {
                Button("잠금 해제") { attemptBiometricAuth() }
                    .buttonStyle(.borderedProminent)
                    .controlSize(.large)
            }
            
            Button("비밀번호 입력") { showPINInput = true }
                .foregroundColor(.secondary)
        }
        .onAppear { attemptBiometricAuth() }
        .sheet(isPresented: $showPINInput) {
            PINInputView(onSuccess: onAuthenticated)
        }
    }
    
    private func attemptBiometricAuth() {
        authManager.authenticate(reason: "앱 잠금을 해제합니다") { success, error in
            if success {
                onAuthenticated()
            } else if let laError = error as? LAError {
                switch laError.code {
                case .userCancel, .appCancel:
                    break
                case .userFallback:
                    showPINInput = true
                default:
                    authManager.authError = "인증에 실패했습니다"
                }
            }
        }
    }
}

struct PINInputView: View {
    var onSuccess: () -> Void
    @State private var pin = ""
    @Environment(\.dismiss) private var dismiss
    
    var body: some View {
        NavigationView {
            VStack {
                SecureField("비밀번호", text: $pin)
                    .textFieldStyle(.roundedBorder)
                    .keyboardType(.numberPad)
                Button("확인") {
                    if verifyPIN(pin) { onSuccess(); dismiss() }
                }
            }
            .padding()
            .navigationTitle("비밀번호 입력")
        }
    }
    
    private func verifyPIN(_ pin: String) -> Bool { pin == "1234" } // 실제 구현
}

마무리

생체 인증 구현의 핵심: iOS는 .deviceOwnerAuthentication(생체+패스코드)으로 폴백을 자동 처리한다. 결제처럼 의도적 행동 확인이 필요하면 생체만 허용하고 취소 시 결제 취소. Android는 BiometricPromptKeystore를 연결해 "인증된 키만 사용 가능"을 보장하면 하드웨어 수준 보안이 된다.