React Native에서 Keychain을 사용한 생체 인증 로그인
Source: Dev.to
TL;DR – 생체 인증은 별도의 “로그인” API가 아니라, 저장된 데이터를 보호하는 Keychain(iOS) / Keystore(Android) 위에 있는 게이트입니다.
연결해야 할 세 가지 요소는:
- Keychain – 안전한 저장소.
- Biometric prompt – 보호된 값을 읽을 때 나타나는 OS 게이트.
- Your auth code – 결과를 가지고 수행하는 작업(예: 액세스 토큰 가져오기).
react‑native‑keychain은 첫 번째와 두 번째 부분을 대신 처리합니다. 이 글에서는 세 가지를 모두 연결하는 방법을 보여줍니다.
1. Mental Model – Biometric = Gate on Storage
- Keychain (iOS) / Keystore (Android) 은 하드웨어 보안 저장소를 기반으로 하는 키/값 저장소입니다.
react-native-keychain은setGenericPassword와getGenericPassword를 통해 이를 노출합니다.- 생체인식 프롬프트는 별도의 API 호출이 아니라 보호된 값을 읽을 때 발생하는 부수 효과입니다.
핵심 포인트: 생체인식 보호와 함께 저장된 값을 요청하면, OS가 해당 값을 반환할지 여부를 결정합니다(필요 시 사용자에게 프롬프트를 표시).
2. 두 개의 네임스페이스 (서비스)
| Service | What it stores | Protection |
|---|---|---|
| access‑token | 모든 요청에 사용되는 API 액세스 토큰 | 잠금 해제 – 생체 인증 프롬프트가 없음. |
| biometric‑gate | 사용자가 존재함을 증명하기 위해만 사용되는 장치별 임의 문자열(또는 작은 값) | 생체 인증 보호 – 사용자에게 프롬프트가 표시됨. |
왜 별도로 구분하나요?
- 백그라운드 작업(예: 푸시 알림 핸들러)은 Face ID 프롬프트를 표시하지 않고도 액세스 토큰이 필요합니다.
- 생체 인증이 적용된 값은 사용자가 UI와 적극적으로 상호작용할 때만 읽힙니다.
3. 값 저장하기
import * as Keychain from 'react-native-keychain';
/* 1️⃣ Unlocked access‑token */
await Keychain.setGenericPassword('access', accessToken, {
service: 'access-token', // {
if (Platform.OS === 'android') {
return value.substring(0, 30).replace(/[^a-zA-Z0-9]/g, '');
}
return value;
};
생체 인증으로 보호되는 값만 이 트리밍이 필요합니다; 액세스 토큰은 길이에 제한이 없습니다.
Source: …
7. 프롬프트 오류 처리
getGenericPassword는 플랫폼 및 로케일에 따라 다른 message를 가진 Error를 발생시킵니다.
| 플랫폼 | 오류 식별 방법 |
|---|---|
| iOS | 심볼릭 오류 이름 (예: LAErrorUserCancel) – 언어에 관계없이 안정적. |
| Android | 숫자 오류 코드 (예: code: 103). |
| Fallback | 영어 메시지 (구버전 OS용). |
구분해야 할 세 가지 오류 카테고리
-
사용자 취소 – 사용자가 “취소” 버튼이나 뒤로 가기 버튼을 탭함.
조치: 프롬프트를 조용히 닫고, UI를 표시하지 않으며 오류 로그도 남기지 않음. -
생체 인증 불일치 – 얼굴/지문이 인식되지 않음.
조치: “다시 시도”를 표시하거나 비밀번호 입력으로 전환. -
기타 – 하드웨어 사용 불가, 생체 인증 미등록, 라이브러리 상태 예외 등.
조치: 진단을 위해 로그를 남기고, 필요에 따라 일반 오류 UI를 표시.
헬퍼 함수 (TypeScript)
/** iOS & Android: 사용자가 생체 인증 프롬프트를 취소했는지 감지 */
function isBiometricUserCanceled(error: Error): boolean {
const msg = error.message;
return (
/code:\s*1[03][,\s]/i.test(msg) || // Android 103 / 101
/laerrorusercanceled/i.test(msg) || // iOS symbolic name
/user canceled the operation/i.test(msg) // fallback English
);
}
/** 생체 인증 불일치 (잘못된 지문/얼굴) 감지 */
function isBiometricMismatch(error: Error): boolean {
const msg = error.message;
return (
/code:\s*7[,\s]/i.test(msg) || // Android 7x series
/the user name or passphrase you entered is not correct/i.test(msg) // iOS fallback
);
}
팁: 헬퍼 함수는 하나의 파일에 모아두고
getGenericPassword를 호출하는 곳마다 import해서 사용하세요.
8. 전체 흐름 요약 (의사코드)
import * as Keychain from 'react-native-keychain';
import { isBiometricUserCanceled, isBiometricMismatch } from './biometricHelpers';
async function signInWithBiometrics() {
try {
// 1️⃣ Prompt the user (biometric‑gated value)
await Keychain.getGenericPassword({ service: 'biometric-gate' });
// 2️⃣ If we get here, biometric succeeded → fetch the unlocked token
const { password: token } = await Keychain.getGenericPassword({
service: 'access-token',
});
// 3️⃣ Use the token (e.g., attach to API calls)
await api.fetchUserData(token);
} catch (error) {
if (isBiometricUserCanceled(error)) {
// User dismissed the prompt – do nothing
return;
}
if (isBiometricMismatch(error)) {
// Show a retry UI or fallback to password login
showRetryOrPasswordScreen();
return;
}
// Anything else → log & show generic error
console.error('Biometric auth error:', error);
showGenericError();
}
}
9. 요점
| ✅ 기억할 점 |
|---|
| 생체인식 = 저장 게이트 – 별도의 “로그인” 호출이 없습니다. |
| 액세스 토큰은 잠금 해제된 상태로 유지하고; 존재 증명은 생체인식으로 보호합니다. |
iOS에서는 더 강력한 보안을 위해 BIOMETRY_CURRENT_SET을 사용하고; Android에서는 편의를 위해 BIOMETRY_ANY을 사용합니다. |
| Android에서 암호화 크기 오류를 방지하려면 게이트된 값을 30자 이하의 영숫자로 저장합니다. |
| 오류는 오류 코드 / 심볼 이름으로 처리하고, 영문 메시지 문자열로 처리하지 않습니다. |
| UI 기반 흐름(프롬프트)과 백그라운드 흐름(토큰 가져오기)을 분리합니다. |
With this pattern you get:
- 불필요한 프롬프트 없이 안전한 생체인식 게이팅.
- iOS와 Android 전반에 걸쳐 예측 가능한 동작.
- 모든 로케일에서 작동하는 깨끗한 오류 처리.
즐거운 코딩 되세요! 🚀
Prompt
7은 ERROR_BIOMETRIC_AUTHENTICATION_FAILED (인식되지 않음)
10은 ERROR_USER_CANCELED
13은 ERROR_NEGATIVE_BUTTON (사용자가 “취소” 버튼을 탭함)
iOS 이름은 LAError에서 가져옵니다. LAErrorUserCanceled는 심볼 이름이기 때문에 로케일에 독립적입니다. 정규식의 영어 대체값은 해당 심볼이 브리지된 오류에 나타나지 않는 오래된 iOS 버전을 잡아냅니다.
경험 법칙
- 먼저 코드와 매치하고, 그 다음에 메시지를 매치합니다.
- 매치되지 않은 모든 것은 “실제 오류” 버킷으로 처리합니다.
이렇게 하면 크래시 리포터가 유용하게 유지됩니다. 취소와 매치 실패는 사용자 행동이며, 예상치 못한 경우에만 주의를 기울이면 됩니다.
몇 가지 의견 (힘들게 배운 교훈)
-
생체인식으로 보호된 값을 존재 증명으로만 사용하십시오.
- 백그라운드 흐름은 프롬프트 없이 액세스 토큰이 필요합니다.
- 토큰은 잠금 해제된 서비스에 저장하고, 생체인식은 별도 서비스에 보관하십시오.
- 이러한 분리가 백그라운드가 제대로 동작하게 만드는 핵심입니다.
-
저장된 값을 불투명하게 유지하십시오.
- 메타데이터를 Keychain 키나 값에 넣으려 하지 마세요.
- 사용자나 세션에 대한 메타데이터가 필요하면, 서비스 이름을 키로 하여 AsyncStorage에 저장하십시오.
- Keychain 값은 마이그레이션이 까다롭지만, AsyncStorage는 그렇지 않습니다.
-
구조화된 태그로 생체인식 오류를 기록하십시오.
- 포함 항목: 작업, OS, 오류 클래스.
- 실제 사용자의 생체인식 로그인이 처음 실패할 때, 어떤 클래스인지 알고 싶습니다.
- 두 번째에는, 같은 클래스인지 확인하고 싶습니다.
-
배포 전에 실제 하드웨어에서 테스트하십시오.
- iOS 시뮬레이터의 “Match Touch ID” 메뉴는 정상 흐름 테스트에 충분합니다.
- 실제 기기에서 나타나는 취소 및 불일치 오류 형태는 재현되지 않습니다.
조각들이 어떻게 맞춰지는지
- Keychain = 하드웨어 기반 키/값 저장소.
- Biometrics = 검색 시 게이트.
두 서비스는 백그라운드 읽기 시 프롬프트가 뜨는 것을 방지합니다. accessControl 및 accessible을 다음을 기준으로 선택하세요:
- 무효화가 얼마나 엄격해야 하는지.
- 값이 얼마나 이식 가능해야 하는지.
오류는 현지화된 메시지가 아니라 코드로 매칭하세요. 실제 기기에서 테스트하세요.
Bottom line
올바르게 구현하면 “Face ID로 로그인”은 사용자가 다음 상황에서도 계속 작동하는 한 번 탭하는 경험입니다:
- 지문을 변경했을 때.
- 기기를 교체했을 때.
- 새로 고침 중에 불안정한 네트워크에 접속했을 때.
대부분의 작업은 라이브러리에 있는 것이 아니라, 그 주변의 작은 결정들에 있습니다.