React Native에서 Keychain을 사용한 생체 인증 로그인

발행: (2026년 5월 7일 AM 11:57 GMT+9)
12 분 소요
원문: Dev.to

Source: Dev.to

TL;DR – 생체 인증은 별도의 “로그인” API가 아니라, 저장된 데이터를 보호하는 Keychain(iOS) / Keystore(Android) 위에 있는 게이트입니다.
연결해야 할 세 가지 요소는:

  1. Keychain – 안전한 저장소.
  2. Biometric prompt – 보호된 값을 읽을 때 나타나는 OS 게이트.
  3. Your auth code – 결과를 가지고 수행하는 작업(예: 액세스 토큰 가져오기).

react‑native‑keychain은 첫 번째와 두 번째 부분을 대신 처리합니다. 이 글에서는 세 가지를 모두 연결하는 방법을 보여줍니다.

1. Mental Model – Biometric = Gate on Storage

  • Keychain (iOS) / Keystore (Android) 은 하드웨어 보안 저장소를 기반으로 하는 키/값 저장소입니다.
  • react-native-keychainsetGenericPasswordgetGenericPassword를 통해 이를 노출합니다.
  • 생체인식 프롬프트는 별도의 API 호출이 아니라 보호된 값을 읽을 때 발생하는 부수 효과입니다.

핵심 포인트: 생체인식 보호와 함께 저장된 값을 요청하면, OS가 해당 값을 반환할지 여부를 결정합니다(필요 시 사용자에게 프롬프트를 표시).

2. 두 개의 네임스페이스 (서비스)

ServiceWhat it storesProtection
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용).

구분해야 할 세 가지 오류 카테고리

  1. 사용자 취소 – 사용자가 “취소” 버튼이나 뒤로 가기 버튼을 탭함.
    조치: 프롬프트를 조용히 닫고, UI를 표시하지 않으며 오류 로그도 남기지 않음.

  2. 생체 인증 불일치 – 얼굴/지문이 인식되지 않음.
    조치: “다시 시도”를 표시하거나 비밀번호 입력으로 전환.

  3. 기타 – 하드웨어 사용 불가, 생체 인증 미등록, 라이브러리 상태 예외 등.
    조치: 진단을 위해 로그를 남기고, 필요에 따라 일반 오류 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 버전을 잡아냅니다.

경험 법칙

  1. 먼저 코드와 매치하고, 그 다음에 메시지를 매치합니다.
  2. 매치되지 않은 모든 것은 “실제 오류” 버킷으로 처리합니다.

이렇게 하면 크래시 리포터가 유용하게 유지됩니다. 취소와 매치 실패는 사용자 행동이며, 예상치 못한 경우에만 주의를 기울이면 됩니다.

몇 가지 의견 (힘들게 배운 교훈)

  1. 생체인식으로 보호된 값을 존재 증명으로만 사용하십시오.

    • 백그라운드 흐름은 프롬프트 없이 액세스 토큰이 필요합니다.
    • 토큰은 잠금 해제된 서비스에 저장하고, 생체인식은 별도 서비스에 보관하십시오.
    • 이러한 분리가 백그라운드가 제대로 동작하게 만드는 핵심입니다.
  2. 저장된 값을 불투명하게 유지하십시오.

    • 메타데이터를 Keychain 키나 값에 넣으려 하지 마세요.
    • 사용자나 세션에 대한 메타데이터가 필요하면, 서비스 이름을 키로 하여 AsyncStorage에 저장하십시오.
    • Keychain 값은 마이그레이션이 까다롭지만, AsyncStorage는 그렇지 않습니다.
  3. 구조화된 태그로 생체인식 오류를 기록하십시오.

    • 포함 항목: 작업, OS, 오류 클래스.
    • 실제 사용자의 생체인식 로그인이 처음 실패할 때, 어떤 클래스인지 알고 싶습니다.
    • 두 번째에는, 같은 클래스인지 확인하고 싶습니다.
  4. 배포 전에 실제 하드웨어에서 테스트하십시오.

    • iOS 시뮬레이터의 “Match Touch ID” 메뉴는 정상 흐름 테스트에 충분합니다.
    • 실제 기기에서 나타나는 취소불일치 오류 형태는 재현되지 않습니다.

조각들이 어떻게 맞춰지는지

  • Keychain = 하드웨어 기반 키/값 저장소.
  • Biometrics = 검색 시 게이트.

두 서비스는 백그라운드 읽기 시 프롬프트가 뜨는 것을 방지합니다. accessControlaccessible을 다음을 기준으로 선택하세요:

  • 무효화가 얼마나 엄격해야 하는지.
  • 값이 얼마나 이식 가능해야 하는지.

오류는 현지화된 메시지가 아니라 코드로 매칭하세요. 실제 기기에서 테스트하세요.

Bottom line

올바르게 구현하면 “Face ID로 로그인”은 사용자가 다음 상황에서도 계속 작동하는 한 번 탭하는 경험입니다:

  • 지문을 변경했을 때.
  • 기기를 교체했을 때.
  • 새로 고침 중에 불안정한 네트워크에 접속했을 때.

대부분의 작업은 라이브러리에 있는 것이 아니라, 그 주변의 작은 결정들에 있습니다.

0 조회
Back to Blog

관련 글

더 보기 »