Flutter 앱에서 생체 인증(지문, Face ID 등)을 어떻게 구현하나요?
질문
Flutter 앱에서 지문 인식이나 Face ID와 같은 생체 인증을 구현하는 방법과 모범 사례에 대해 설명해주세요.
답변
Flutter 앱에서 생체 인증(지문, Face ID 등)을 구현하면 사용자 인증 과정을 간소화하고 보안을 강화할 수 있습니다. iOS와 Android 모두 지원하는 생체 인증 구현 방법을 단계별로 살펴보겠습니다.
1. 생체 인증 구현 기본 원리
Flutter에서 생체 인증을 구현하기 위해서는 각 플랫폼의 고유한 인증 시스템과 통합해야 합니다:
- iOS: Touch ID 또는 Face ID
- Android: 지문 인식, 안면 인식, 또는 기타 생체 인식
일반적인 구현 과정은 다음과 같습니다:
- 기기가 생체 인증을 지원하는지 확인
- 사용 가능한 생체 인증 유형 확인
- 사용자에게 인증 프롬프트 표시
- 인증 결과 처리
2. local_auth 패키지 사용하기
Flutter에서 생체 인증을 구현하는 가장 간단한 방법은 local_auth
패키지를 사용하는 것입니다:
# pubspec.yaml
dependencies:
local_auth: ^2.1.6
local_auth_android: ^1.0.31
local_auth_ios: ^1.1.3
기본 사용법은 다음과 같습니다:
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:local_auth/local_auth.dart';
import 'package:local_auth/error_codes.dart' as auth_error;
class BiometricAuthExample extends StatefulWidget {
@override
_BiometricAuthExampleState createState() => _BiometricAuthExampleState();
}
class _BiometricAuthExampleState extends State<BiometricAuthExample> {
final LocalAuthentication auth = LocalAuthentication();
bool _canCheckBiometrics = false;
List<BiometricType> _availableBiometrics = [];
String _authorized = '인증되지 않음';
bool _isAuthenticating = false;
@override
void initState() {
super.initState();
_checkBiometrics();
_getAvailableBiometrics();
}
Future<void> _checkBiometrics() async {
bool canCheckBiometrics;
try {
canCheckBiometrics = await auth.canCheckBiometrics;
} on PlatformException catch (e) {
canCheckBiometrics = false;
print(e);
}
if (!mounted) return;
setState(() {
_canCheckBiometrics = canCheckBiometrics;
});
}
Future<void> _getAvailableBiometrics() async {
List<BiometricType> availableBiometrics;
try {
availableBiometrics = await auth.getAvailableBiometrics();
} on PlatformException catch (e) {
availableBiometrics = <BiometricType>[];
print(e);
}
if (!mounted) return;
setState(() {
_availableBiometrics = availableBiometrics;
});
}
Future<void> _authenticate() async {
bool authenticated = false;
try {
setState(() {
_isAuthenticating = true;
_authorized = '인증 중...';
});
authenticated = await auth.authenticate(
localizedReason: '앱에 접근하기 위해 생체 인증을 사용하세요',
options: const AuthenticationOptions(
stickyAuth: true,
biometricOnly: true,
));
setState(() {
_isAuthenticating = false;
});
} on PlatformException catch (e) {
print(e);
setState(() {
_isAuthenticating = false;
_authorized = '인증 오류: ${e.message}';
});
return;
}
if (!mounted) return;
setState(() {
_authorized = authenticated ? '인증 성공!' : '인증 실패';
});
}
Future<void> _cancelAuthentication() async {
await auth.stopAuthentication();
setState(() => _isAuthenticating = false);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('생체 인증 데모'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text('생체 인식 사용 가능: $_canCheckBiometrics\n'),
Text('사용 가능한 생체 인증 유형: $_availableBiometrics\n'),
Text('인증 상태: $_authorized\n'),
if (_isAuthenticating)
ElevatedButton(
onPressed: _cancelAuthentication,
child: Text('인증 취소'),
)
else
Column(
children: [
ElevatedButton(
onPressed: _authenticate,
child: Text('생체 인증 시작'),
),
],
),
],
),
),
);
}
}
3. 플랫폼별 설정
3.1 Android 설정
Android에서 생체 인증을 사용하려면 AndroidManifest.xml
파일에 권한을 추가해야 합니다:
<!-- android/app/src/main/AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<!-- Android 9.0 이하를 지원하려면 다음 권한도 추가 -->
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
<!-- 나머지 매니페스트 내용... -->
</manifest>
3.2 iOS 설정
iOS에서는 Info.plist
파일에 생체 인증 사용 이유를 명시해야 합니다:
<!-- ios/Runner/Info.plist -->
<dict>
<key>NSFaceIDUsageDescription</key>
<string>앱 로그인을 위해 Face ID 접근 권한이 필요합니다</string>
<!-- 나머지 Info.plist 내용... -->
</dict>
4. 오류 처리 및 사용자 경험 개선
생체 인증은 다양한 오류 상황에 대비해야 합니다:
Future<bool> authenticateWithBiometrics() async {
try {
final bool canAuthenticateWithBiometrics = await auth.canCheckBiometrics;
final bool canAuthenticate = canAuthenticateWithBiometrics || await auth.isDeviceSupported();
if (!canAuthenticate) {
// 기기가 생체 인증을 지원하지 않음
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('생체 인증 사용 불가'),
content: Text('이 기기에서는 생체 인증을 사용할 수 없습니다. 다른 인증 방법을 사용해주세요.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('확인'),
),
],
),
);
return false;
}
final bool authenticated = await auth.authenticate(
localizedReason: '앱 로그인을 위해 생체 인식을 사용합니다',
options: const AuthenticationOptions(
stickyAuth: true,
biometricOnly: false,
),
);
return authenticated;
} on PlatformException catch (e) {
// 오류 코드 기반 처리
switch (e.code) {
case auth_error.notAvailable:
showErrorMessage('기기에서 생체 인증을 사용할 수 없습니다.');
break;
case auth_error.notEnrolled:
showErrorMessage('기기에 등록된 생체 정보가 없습니다. 설정에서 등록해주세요.');
break;
case auth_error.lockedOut:
showErrorMessage('너무 많은 시도로 일시적으로 잠겼습니다. 잠시 후 다시 시도해주세요.');
break;
case auth_error.permanentlyLockedOut:
showErrorMessage('너무 많은 시도로 생체 인증이 비활성화되었습니다. 기기 비밀번호를 입력해주세요.');
break;
default:
showErrorMessage('생체 인증 중 오류가 발생했습니다: ${e.message}');
}
return false;
}
}
void showErrorMessage(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
5. 다양한 인증 시나리오 구현
5.1 앱 시작 시 인증
앱을 시작할 때 생체 인증을 요구하려면:
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: AuthGate(),
);
}
}
class AuthGate extends StatefulWidget {
@override
_AuthGateState createState() => _AuthGateState();
}
class _AuthGateState extends State<AuthGate> {
final LocalAuthentication auth = LocalAuthentication();
bool _isAuthenticated = false;
@override
void initState() {
super.initState();
_authenticate();
}
Future<void> _authenticate() async {
final authenticated = await authenticateWithBiometrics();
setState(() {
_isAuthenticated = authenticated;
});
}
@override
Widget build(BuildContext context) {
if (_isAuthenticated) {
return HomePage();
} else {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text('앱을 사용하려면 인증이 필요합니다'),
SizedBox(height: 20),
ElevatedButton(
onPressed: () async {
await _authenticate();
},
child: Text('인증하기'),
),
],
),
),
);
}
}
}
5.2 민감한 기능에 대한 선택적 인증
특정 민감한 기능에만 생체 인증을 적용할 수 있습니다:
class PaymentScreen extends StatelessWidget {
final PaymentService _paymentService = PaymentService();
Future<void> _makePayment(BuildContext context, double amount) async {
final LocalAuthentication auth = LocalAuthentication();
try {
final bool authenticated = await auth.authenticate(
localizedReason: '결제를 완료하기 위해 생체 인증을 사용하세요',
options: const AuthenticationOptions(
stickyAuth: true,
biometricOnly: false,
),
);
if (authenticated) {
// 결제 처리
await _paymentService.processPayment(amount);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('결제가 완료되었습니다')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('인증 실패로 결제가 취소되었습니다')),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('결제 처리 중 오류가 발생했습니다: $e')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('결제')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('결제 금액: \$100.00', style: TextStyle(fontSize: 20)),
SizedBox(height: 30),
ElevatedButton(
onPressed: () => _makePayment(context, 100.00),
child: Text('결제하기'),
),
],
),
),
);
}
}
6. 보안 강화 및 대체 인증 방법
6.1 생체 인증과 PIN/비밀번호 조합
보안을 강화하기 위해 생체 인증에 실패할 경우 PIN이나 비밀번호를 대체 인증 방법으로 제공할 수 있습니다:
Future<bool> authenticateUser() async {
final LocalAuthentication auth = LocalAuthentication();
try {
// 먼저 생체 인증 시도
final bool canUseBiometrics = await auth.canCheckBiometrics;
if (canUseBiometrics) {
final bool authenticated = await auth.authenticate(
localizedReason: '생체 인식으로 로그인하세요',
options: const AuthenticationOptions(
biometricOnly: true, // 생체 인증만 허용
),
);
if (authenticated) {
return true;
}
// 생체 인증 실패 시 사용자에게 PIN 인증 선택권 제공
final bool usePinAuth = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('생체 인증 실패'),
content: Text('PIN으로 인증하시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text('취소'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text('PIN 사용'),
),
],
),
) ?? false;
if (usePinAuth) {
return await Navigator.of(context).push<bool>(
MaterialPageRoute(builder: (context) => PinAuthScreen()),
) ?? false;
}
} else {
// 생체 인증을 사용할 수 없는 경우 PIN 인증 화면으로 바로 이동
return await Navigator.of(context).push<bool>(
MaterialPageRoute(builder: (context) => PinAuthScreen()),
) ?? false;
}
} catch (e) {
print('인증 오류: $e');
// 오류 발생 시 PIN 인증 제안
final bool usePinAuth = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('생체 인증 오류'),
content: Text('PIN으로 인증하시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text('취소'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text('PIN 사용'),
),
],
),
) ?? false;
if (usePinAuth) {
return await Navigator.of(context).push<bool>(
MaterialPageRoute(builder: (context) => PinAuthScreen()),
) ?? false;
}
}
return false;
}
6.2 생체 인증 사용 설정
사용자가 앱 내에서 생체 인증 사용을 선택할 수 있게 하는 설정 화면:
class SecuritySettingsScreen extends StatefulWidget {
@override
_SecuritySettingsScreenState createState() => _SecuritySettingsScreenState();
}
class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
final LocalAuthentication auth = LocalAuthentication();
bool _isBiometricAvailable = false;
bool _useBiometric = false;
List<BiometricType> _availableBiometrics = [];
final _secureStorage = FlutterSecureStorage();
@override
void initState() {
super.initState();
_checkBiometrics();
_loadSettings();
}
Future<void> _checkBiometrics() async {
final canCheckBiometrics = await auth.canCheckBiometrics;
final availableBiometrics = await auth.getAvailableBiometrics();
setState(() {
_isBiometricAvailable = canCheckBiometrics;
_availableBiometrics = availableBiometrics;
});
}
Future<void> _loadSettings() async {
final useBiometric = await _secureStorage.read(key: 'use_biometric');
setState(() {
_useBiometric = useBiometric == 'true';
});
}
Future<void> _toggleBiometric(bool value) async {
if (value && _isBiometricAvailable) {
// 생체 인증 활성화 전에 한 번 더 확인
final authenticated = await auth.authenticate(
localizedReason: '생체 인증 설정을 위해 확인해주세요',
options: const AuthenticationOptions(
stickyAuth: true,
biometricOnly: true,
),
);
if (authenticated) {
await _secureStorage.write(key: 'use_biometric', value: 'true');
setState(() {
_useBiometric = true;
});
} else {
// 인증 실패 시 스위치 상태 원복
setState(() {
_useBiometric = false;
});
}
} else {
await _secureStorage.write(key: 'use_biometric', value: 'false');
setState(() {
_useBiometric = false;
});
}
}
String _getBiometricTypeName() {
if (_availableBiometrics.contains(BiometricType.face)) {
return 'Face ID';
} else if (_availableBiometrics.contains(BiometricType.fingerprint)) {
return '지문 인식';
} else {
return '생체 인증';
}
}
@override
Widget build(BuildContext context) {
final biometricName = _getBiometricTypeName();
return Scaffold(
appBar: AppBar(title: Text('보안 설정')),
body: ListView(
children: [
SwitchListTile(
title: Text('앱 잠금 사용'),
subtitle: Text('앱 실행 시 인증 필요'),
value: true, // 항상 필요하다고 가정
onChanged: null, // 비활성화(항상 켜짐)
),
Divider(),
SwitchListTile(
title: Text('$biometricName 사용'),
subtitle: Text(_isBiometricAvailable
? '앱 잠금 해제에 $biometricName 사용'
: '이 기기에서는 사용할 수 없습니다'),
value: _useBiometric,
onChanged: _isBiometricAvailable ? _toggleBiometric : null,
),
Divider(),
ListTile(
title: Text('PIN 변경'),
subtitle: Text('앱 잠금 해제용 PIN 변경'),
trailing: Icon(Icons.chevron_right),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => ChangePinScreen()),
);
},
),
],
),
);
}
}
7. 생체 인증 최적화 및 모범 사례
7.1 인증 상태 캐싱
빈번한 인증 요청을 방지하기 위해 인증 상태를 일정 시간 동안 캐싱할 수 있습니다:
class AuthenticationService {
final LocalAuthentication _auth = LocalAuthentication();
DateTime? _lastAuthTime;
final int _authValidityDuration = 5; // 인증 유효 시간(분)
bool get isAuthValid {
if (_lastAuthTime == null) return false;
final difference = DateTime.now().difference(_lastAuthTime!);
return difference.inMinutes < _authValidityDuration;
}
Future<bool> authenticate() async {
// 이미 유효한 인증이 있으면 즉시 성공 반환
if (isAuthValid) return true;
final authenticated = await _auth.authenticate(
localizedReason: '계속하려면 생체 인증을 사용하세요',
options: const AuthenticationOptions(
stickyAuth: true,
biometricOnly: false,
),
);
if (authenticated) {
_lastAuthTime = DateTime.now();
}
return authenticated;
}
void invalidateAuth() {
_lastAuthTime = null;
}
}
7.2 앱 백그라운드/포그라운드 전환 처리
앱이 백그라운드로 갔다가 돌아올 때 추가 인증을 요구할 수 있습니다:
class SecureHomePage extends StatefulWidget {
@override
_SecureHomePageState createState() => _SecureHomePageState();
}
class _SecureHomePageState extends State<SecureHomePage> with WidgetsBindingObserver {
final AuthenticationService _authService = AuthenticationService();
bool _isAuthenticated = false;
late AppLifecycleState _lastLifecycleState;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_authenticate();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed &&
_lastLifecycleState == AppLifecycleState.paused) {
// 앱이 백그라운드에서 포그라운드로 돌아옴
// 인증 상태 무효화 및 재인증 요청
_authService.invalidateAuth();
_authenticate();
}
_lastLifecycleState = state;
}
Future<void> _authenticate() async {
final authenticated = await _authService.authenticate();
setState(() {
_isAuthenticated = authenticated;
});
}
@override
Widget build(BuildContext context) {
if (!_isAuthenticated) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('인증이 필요합니다'),
SizedBox(height: 20),
ElevatedButton(
onPressed: _authenticate,
child: Text('인증하기'),
),
],
),
),
);
}
return Scaffold(
appBar: AppBar(title: Text('보안 홈')),
body: Center(
child: Text('인증된 콘텐츠'),
),
);
}
}
8. 생체 인증 디버깅 및 테스트
8.1 에뮬레이터/시뮬레이터에서 테스트
Android 에뮬레이터에서 지문 인식 테스트:
- 에뮬레이터에서 설정 > 보안 > 지문 > 지문 추가로 이동
- 지문 설정 완료 후 Android Studio의 에뮬레이터 제어판에서 '...' 버튼 클릭
- 'Fingerprint' 탭에서 지문 인식 시뮬레이션 가능
iOS 시뮬레이터에서 Face ID/Touch ID 테스트:
- 메뉴에서 Hardware > Face ID 또는 Touch ID > Enrolled 선택
- 인증 필요 시 Hardware > Face ID/Touch ID > Matching 또는 Non-matching 선택
8.2 생체 인증 로깅 및 디버깅
개발 중 생체 인증 문제 디버깅을 위한 로깅:
Future<bool> authenticateWithLogging() async {
try {
debugPrint('생체 인증 프로세스 시작...');
// 생체 인증 지원 확인
final bool canCheckBiometrics = await auth.canCheckBiometrics;
final bool canAuthenticate = canCheckBiometrics || await auth.isDeviceSupported();
debugPrint('생체 인증 가능 여부: $canAuthenticate');
if (!canAuthenticate) {
debugPrint('기기가 생체 인증을 지원하지 않음');
return false;
}
// 사용 가능한 생체 인증 유형 확인
final List<BiometricType> availableBiometrics = await auth.getAvailableBiometrics();
debugPrint('사용 가능한 생체 인증 유형: $availableBiometrics');
// 인증 시도
debugPrint('생체 인증 프롬프트 표시 중...');
final bool authenticated = await auth.authenticate(
localizedReason: '앱 로그인을 위해 생체 인식을 사용합니다',
options: const AuthenticationOptions(
stickyAuth: true,
biometricOnly: false,
),
);
debugPrint('인증 결과: ${authenticated ? "성공" : "실패"}');
return authenticated;
} catch (e) {
debugPrint('생체 인증 중 오류 발생: $e');
return false;
}
}
결론
Flutter에서 생체 인증을 구현하는 것은 local_auth
패키지를 통해 비교적 간단하게 할 수 있습니다. 효과적인 생체 인증 구현을 위해서는 다음 사항을 고려하세요:
- 사용자 경험: 생체 인증 실패 시 대체 인증 방법 제공
- 보안: 적절한 인증 유효 시간 설정 및 중요 작업 시 재인증 요구
- 오류 처리: 다양한 오류 상황에 대비한 명확한 사용자 안내
- 접근성: 생체 인증을 사용할 수 없는 사용자를 위한 대체 인증 수단 제공
생체 인증은 사용자 경험과 보안 사이의 균형을 맞추는 좋은 방법입니다. 올바르게 구현하면 사용자에게 편리하면서도 안전한 인증 경험을 제공할 수 있습니다.