이 글은 누구를 위한 것인가
- 앱 잠금 해제를 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는 BiometricPrompt와 Keystore를 연결해 "인증된 키만 사용 가능"을 보장하면 하드웨어 수준 보안이 된다.