가계부 앱을 만들다가 앱을 열 때마다 잠금을 걸고 싶어졌다. 민감한 데이터가 있으니 백그라운드에서 돌아왔을 때 지문이나 Face ID로 한 번 확인하는 흐름이다. 직접 지문 API를 붙이려면 iOS는 LocalAuthentication, Android는 BiometricPrompt를 각각 만져야 하는데, Expo에서는 expo-local-authentication 하나로 두 플랫폼이 다 덮인다.
설치
npx expo install expo-local-authentication
iOS는 Face ID를 쓰려면 사용 이유를 Info.plist에 넣어줘야 한다. app.json의 플러그인 설정으로 처리한다.
{
"plugins": [
["expo-local-authentication", {
"faceIDPermission": "앱 잠금 해제를 위해 Face ID를 사용합니다."
}]
]
}
이걸 빠뜨리면 iOS에서 Face ID 프롬프트가 뜨는 순간 앱이 그냥 죽는다. 처음에 원인을 못 찾고 한참 헤맸다.
인증하기 전에 하드웨어부터 확인
바로 인증을 띄우면 안 된다. 기기에 센서가 있는지, 지문/얼굴이 등록돼 있는지를 먼저 봐야 한다. 이 두 가지를 안 거르면 센서 없는 기기에서 그냥 실패로 떨어진다.
import * as LocalAuthentication from 'expo-local-authentication';
async function authenticate() {
const hasHardware = await LocalAuthentication.hasHardwareAsync();
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
if (!hasHardware || !isEnrolled) {
// 생체 인증을 못 쓰는 기기 → PIN 입력 화면으로 폴백
return { success: false, fallback: true };
}
const result = await LocalAuthentication.authenticateAsync({
promptMessage: '앱 잠금 해제',
fallbackLabel: '비밀번호 입력',
disableDeviceFallback: false,
});
return { success: result.success, fallback: false };
}
disableDeviceFallback을 false로 두면 생체 인증이 몇 번 실패했을 때 기기 PIN 입력으로 자동 폴백된다. 손이 젖었거나 마스크를 썼을 때를 생각하면 이걸 막아두는 건 사용성에 안 좋다.
어떤 인증 수단이 있는지 구분하기
버튼 문구를 "지문으로 잠금 해제"로 할지 "Face ID로 잠금 해제"로 할지 정하려면 어떤 센서인지 알아야 한다.
const types = await LocalAuthentication.supportedAuthenticationTypesAsync();
const hasFaceId = types.includes(
LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION
);
const label = hasFaceId ? 'Face ID로 잠금 해제' : '지문으로 잠금 해제';
FINGERPRINT, FACIAL_RECOGNITION, IRIS 세 가지가 있고, 배열로 여러 개가 동시에 올 수도 있다.
실전 팁 하나
authenticateAsync는 앱이 백그라운드로 갔다가 돌아올 때 호출하는 게 보통인데, AppState가 active로 바뀌는 순간마다 무조건 부르면 프롬프트가 겹쳐서 뜬다. 인증 진행 중 플래그를 하나 두고, 이미 떠 있으면 재호출을 막아야 깔끔하다.
정리하면 흐름은 단순하다. 하드웨어·등록 여부 확인 → 인증 → 실패 시 PIN 폴백. 직접 네이티브 두 개를 붙였으면 하루는 걸렸을 일이 30분이면 끝났다. 다만 Face ID 권한 문구를 빠뜨리면 크래시가 나니 그것만 조심하자.