Expo

Expo SecureStore 사용법: 토큰 저장과 생체 인증 처리

2026.04.21·수정 2026.05.12·약 16분

이 글에서 정리하는 내용

expo-secure-store가 왜 필요한지부터 시작해 AsyncStorage와의 차이, iOS와 Android 저장 방식로그인 토큰 저장 흐름, 생체 인증 옵션, keychainAccessible, Android Auto Backup, 운영 단계 주의사항까지 한 번에 정리합니다. 읽고 나면 민감한 값을 어디에 저장해야 하는지, 어떤 옵션을 켜야 하는지, 어떤 한계는 미리 감안해야 하는지 기준이 잡히도록 구성했습니다.

expo-secure-store가 필요한 이유

Expo SecureStore 사용법: 토큰 저장과 생체 인증 처리 핵심 개념을 설명하는 첫 번째 본문 이미지

Expo 앱을 만들다 보면 로그인 토큰, refresh token, 간단한 인증 상태처럼 민감한 문자열을 기기에 저장해야 할 때가 있습니다. 이때 가장 먼저 구분해야 하는 것은 “그냥 저장되는 값”과 “보호가 필요한 값”입니다. 화면 설정, 최근 본 탭, 안내 팝업 여부처럼 노출되어도 상대적으로 위험이 낮은 값은 일반 저장소로도 다룰 수 있지만, 인증 정보는 접근 통제가 가능한 저장소로 분리해서 다루는 편이 안전합니다.

expo-secure-store는 이런 목적으로 쓰는 보안 저장소입니다. 이름만 보면 단순한 key-value 저장소처럼 보이지만, 실제로는 운영체제의 보안 저장 체계와 연결됩니다. 그래서 설치 후 바로 API만 외우기보다, 왜 이 저장소를 써야 하는지와 어떤 데이터를 넣는 것이 맞는지 먼저 정리해 두는 편이 좋습니다.

어떤 데이터를 secure store에 넣어야 할까

// 보안 저장소에 넣기 좋은 값
const secureValues = { accessToken: 'eyJhbGciOi...', refreshToken: 'refresh-token-value', lastLoginProvider: 'google'
}; // 일반 저장소로도 충분한 값
const normalValues = { theme: 'dark', onboardingSeen: 'true', selectedTab: 'home'
};

핵심은 민감도 기준으로 나누는 것입니다. expo-secure-store는 인증과 직접 연결되는 문자열을 저장할 때 우선 검토하고, 단순 UI 상태까지 모두 밀어 넣는 저장소로 쓰지는 않는 편이 관리하기 좋습니다. 특히 큰 데이터나 자주 바뀌는 복잡한 상태를 전부 넣는 방식은 적합하지 않습니다.

플랫폼별 저장 방식 이해하기

expo-secure-store를 이해할 때 많은 분들이 놓치는 부분이 “같은 API라도 운영체제별 동작 기반은 다르다”는 점입니다. Android에서는 SharedPreferences에 값을 저장하고 Android Keystore로 보호하며, iOS에서는 Keychain의 generic password 항목으로 저장합니다. 이 차이 때문에 삭제, 재설치, 접근 가능 시점, 인증 옵션 동작도 미묘하게 달라집니다.

실무에서는 이 차이를 코드보다 먼저 머리에 넣어두는 편이 좋습니다. 그래야 iOS에서 앱을 지웠는데 값이 남아 보이는 상황이나, Android 백업 복원 뒤 복호화 문제가 생기는 상황을 이상 현상으로만 보지 않게 됩니다. expo-secure-store는 앱 재시작과 업데이트를 견디는 저장소로는 적합하지만, 복구 불가능한 핵심 데이터의 유일한 원본으로 두는 저장소는 아닙니다.

설치와 config plugin 예시

// 설치 명령과 app.json 예시
// requireAuthentication, Face ID, Android backup 같은 설정은
// 빌드 시점 구성이 필요한 항목이 있을 수 있습니다. // terminal
npx expo install expo-secure-store // app.json
{ "expo": { "plugins": [ [ "expo-secure-store", { "configureAndroidBackup": true, "faceIDPermission": "앱 잠금 해제를 위해 Face ID를 사용합니다." } ] ] }
}

여기서 중요한 점은 런타임 코드만으로 끝나는 라이브러리가 아니라는 점입니다. 특히 생체 인증과 Android 백업 대응은 빌드 설정과 연결되는 부분이 있어서 앱 설정까지 함께 보는 것이 안전합니다.

구분 핵심 특징
Android 앱 삭제 시 값이 보존되지 않으며, Auto Backup 복원 예외를 고려해야 합니다.
iOS Keychain 특성상 같은 bundle ID 재설치에서 값이 남을 수 있어 완전 삭제를 전제로 설계하면 안 됩니다.

기본 사용법과 로그인 토큰 저장 흐름

import * as SecureStore from 'expo-secure-store'; const TOKEN_KEY = 'auth.accessToken';
const REFRESH_TOKEN_KEY = 'auth.refreshToken'; // 토큰 저장
export async function saveSession(accessToken: string, refreshToken: string) { await SecureStore.setItemAsync(TOKEN_KEY, accessToken); await SecureStore.setItemAsync(REFRESH_TOKEN_KEY, refreshToken);
} // 토큰 조회
export async function readSession() { const accessToken = await SecureStore.getItemAsync(TOKEN_KEY); const refreshToken = await SecureStore.getItemAsync(REFRESH_TOKEN_KEY); if (!accessToken || !refreshToken) { return null; } return { accessToken, refreshToken };
} // 로그아웃 시 토큰 삭제
export async function clearSession() { await SecureStore.deleteItemAsync(TOKEN_KEY); await SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY);
}

가장 기본적인 흐름은 저장, 조회, 삭제입니다. 로그인 성공 시 토큰을 저장하고, 앱이 다시 열릴 때 값을 복원하고로그아웃 시 지우는 구조만 먼저 잡아도 실제 인증 흐름의 뼈대가 만들어집니다. 여기서는 동기 함수보다 비동기 함수를 중심으로 두는 편이 좋습니다. 동기 함수는 JS 스레드를 막을 수 있어 화면 반응성에 영향을 줄 수 있기 때문입니다.

또 하나 중요한 점은 저장값이 문자열이라는 것입니다. 객체 전체를 그대로 넣는 것이 아니라 필요한 값만 골라 문자열로 저장하거나, 필요한 경우 JSON 직렬화를 거쳐 저장해야 합니다. 다만 보안 저장소를 작은 민감 정보 중심으로 쓰는 습관이 훨씬 관리하기 쉽습니다.

앱 시작 시 세션 복원 예시

import { useEffect, useState } from 'react';
import * as SecureStore from 'expo-secure-store'; const TOKEN_KEY = 'auth.accessToken'; export function useSessionBootstrap() { const [isReady, setIsReady] = useState(false); const [token, setToken] = useState<string | null>(null); useEffect(() => { async function bootstrap() { // 기기에서 secure store 사용 가능 여부를 먼저 확인합니다. const available = await SecureStore.isAvailableAsync(); if (!available) { setIsReady(true); return; } // 저장된 토큰을 읽어 초기 인증 상태를 구성합니다. const savedToken = await SecureStore.getItemAsync(TOKEN_KEY); setToken(savedToken); setIsReady(true); } bootstrap(); }, []); return { isReady, token, isLoggedIn: Boolean(token) };
}

앱 시작 시 세션을 복원하는 로직은 대개 이 형태에서 출발합니다. 먼저 사용 가능 여부를 확인하고, 저장된 토큰을 읽어 로그인 상태를 구성합니다. 이 방식은 Expo Router 보호 라우트, Context API, 상태 관리 라이브러리와도 자연스럽게 연결됩니다. 핵심은 앱 첫 진입 순간에 “토큰이 아직 안 읽힌 상태”와 “토큰이 없어서 비로그인인 상태”를 구분하는 것입니다.

여기서 한 가지 더 기억할 점은 expo-secure-store에 값이 있다고 해서 그 세션이 서버 기준으로도 유효하다고 단정하면 안 된다는 점입니다. 앱 시작 직후 저장된 토큰을 읽은 뒤에는 보통 만료 여부 확인, refresh 시도, 사용자 정보 재조회 같은 후속 검증을 붙입니다. 로컬 저장소는 진입 속도를 높여주지만, 최종 인증 상태의 원본은 여전히 서버가 쥐고 있다고 보는 편이 안전합니다.

실무에서 자주 막히는 보안 옵션

Expo SecureStore 사용법: 토큰 저장과 생체 인증 처리 적용 흐름을 설명하는 두 번째 본문 이미지

기본 저장까지는 비교적 단순하지만, 실무에서는 그다음부터가 더 중요합니다. 대표적인 포인트가 requireAuthentication, keychainAccessible, 실기기 테스트, Android 백업 제외입니다. 이 항목들은 API 이름만 읽으면 쉬워 보이지만, 실제로는 UX와 운영 이슈를 같이 만듭니다.

생체 인증 가능 여부를 먼저 확인하는 예시

import * as SecureStore from 'expo-secure-store'; export function canProtectWithBiometrics() { // 기기가 충분히 안전한 생체 인증을 지원하는지 확인합니다. return SecureStore.canUseBiometricAuthentication();
}

expo-secure-store에서 requireAuthentication을 쓰기 전에는 기기에서 생체 인증 보호를 실제로 사용할 수 있는지 확인하는 편이 좋습니다. 특히 Expo Go에서는 생체 인증이 가능한 환경에서 requireAuthentication이 제대로 지원되지 않으므로, release 빌드나 development build 기준으로 확인하는 편이 맞습니다.

생체 인증이 필요한 값을 따로 저장하는 예시

import * as SecureStore from 'expo-secure-store'; const SECRET_NOTE_KEY = 'secret.note'; export async function saveProtectedValue(value: string) { // requireAuthentication을 켜면 읽기 시점에 인증이 필요할 수 있습니다. await SecureStore.setItemAsync(SECRET_NOTE_KEY, value, { requireAuthentication: true, authenticationPrompt: '민감한 정보를 확인하려면 인증이 필요합니다.' });
} export async function readProtectedValue() { // 생체 정보가 변경되면 기존 값이 무효화될 수 있습니다. return SecureStore.getItemAsync(SECRET_NOTE_KEY, { requireAuthentication: true, authenticationPrompt: '저장된 비밀 메모를 확인합니다.' });
}

requireAuthentication은 이름 그대로 사용자의 인증을 요구하는 옵션입니다. 다만 “무조건 켜면 더 좋다”로 보면 곤란합니다. Android에서는 모든 작업에서 인증이 필요하게 동작하고, iOS에서는 새 값을 처음 저장할 때보다 기존 값을 읽거나 갱신할 때 인증이 걸리는 쪽에 가깝습니다. 또 생체 정보가 바뀌면 기존 값이 무효화될 수 있으므로, access token 전체를 매 앱 실행마다 생체 인증으로 묶을지, 특정 민감 화면 진입 시에만 별도 값을 보호할지 먼저 결정하는 편이 좋습니다.

keychainAccessible은 iOS에서 저장값이 언제 접근 가능한지를 정하는 옵션입니다. 기본값은 SecureStore.WHEN_UNLOCKED이며, 기기가 잠금 해제된 동안만 값을 읽을 수 있습니다. 백그라운드 작업이나 재부팅 직후 첫 잠금 해제 전 접근이 필요하다면 AFTER_FIRST_UNLOCK 계열을 검토할 수 있고, 패스코드가 반드시 걸린 기기에서만 더 엄격하게 다루고 싶다면 WHEN_PASSCODE_SET_THIS_DEVICE_ONLY를 생각할 수 있습니다. 다만 이 옵션은 보안 수준과 접근 가능 시점을 바꾸는 설정이므로, 막연히 강한 값을 고르기보다 앱이 실제로 언제 값을 읽는지 먼저 따져야 합니다.

Android 쪽에서는 Auto Backup도 같이 봐야 합니다. expo-secure-store 데이터는 복원 후 복호화할 수 없기 때문에 백업 대상에서 제외되어야 합니다. 기본 config plugin을 쓰면 이 부분을 자동으로 처리하지만, 커스텀 backup_rules 또는 data-extraction-rules를 직접 쓰고 있다면 shared preferences를 직접 exclude하고 configureAndroidBackup을 false로 맞춰 중복 설정을 피하는 편이 안전합니다.

정리

expo-secure-store는 Expo 앱에서 민감한 문자열을 저장할 때 가장 먼저 검토할 수 있는 기본 보안 저장소입니다. 하지만 단순한 local storage 대체품으로 보면 금방 한계에 부딪힙니다. Android와 iOS의 저장 기반이 다르고, 재설치와 백업 동작이 다르며, 생체 인증 옵션도 플랫폼별 차이가 있기 때문입니다.

실무에서는 다음 기준으로 기억하면 정리가 쉽습니다. 첫째, access token과 refresh token처럼 민감한 값은 expo-secure-store를 우선 검토합니다. 둘째, 모든 상태를 여기 넣지 말고 작은 민감 정보 중심으로 씁니다. 셋째, requireAuthentication은 보안과 UX를 같이 비교해서 선택합니다. 넷째, iOS 재설치 지속성과 Android 백업 제외 설정은 초반부터 감안합니다. 다섯째, 저장된 값만 믿지 말고 서버 기준 인증 상태 확인을 함께 둡니다. 이 기준까지 잡아두면 저장 전략이 훨씬 덜 흔들립니다.

많이 받는 질문

Q. expo-secure-store와 AsyncStorage는 무엇이 가장 다를까요?
가장 큰 차이는 용도입니다. AsyncStorage는 일반 앱 상태 저장에 가깝고, expo-secure-store는 인증 정보처럼 보호가 필요한 문자열 저장에 더 적합합니다. 두 저장소는 경쟁 관계라기보다 역할 분담 관계로 보는 편이 맞습니다.

Q. access token과 refresh token을 둘 다 저장해도 될까요?
가능합니다. 다만 서버 정책과 함께 봐야 합니다. 일반적으로는 만료 시간이 짧은 access token과 재발급용 refresh token을 분리해서 관리하고로그아웃이나 토큰 만료 처리 시 함께 정리하는 흐름으로 설계합니다.

Q. 동기 API보다 비동기 API를 먼저 쓰는 이유가 있나요?
있습니다. 동기 API는 JavaScript 스레드를 막을 수 있어서, 특히 requireAuthentication이 걸린 상황에서는 인증이 끝날 때까지 화면 반응이 멈춘 것처럼 보일 수 있습니다. 일반적인 앱 코드에서는 setItemAsync와 getItemAsync를 우선 쓰는 편이 안전합니다.

Q. 앱을 삭제하면 secure store 값도 항상 같이 사라지나요?
항상 그렇지는 않습니다. Android는 앱 삭제 시 보존되지 않는 쪽으로 이해하면 되고, iOS는 Keychain 특성상 같은 bundle ID 재설치에서 값이 남을 수 있습니다

같이 읽으면 좋은 글

이 글이 마음에 드세요?

RSS 피드를 구독하세요!

“Expo SecureStore 사용법: 토큰 저장과 생체 인증 처리”에 대한 1개의 생각

댓글 남기기