Keychain과 Keystore: 장기 인증 정보는 왜 일반 저장소와 분리하는가
refresh token을 암호화되지 않은 일반 저장소에 남긴 실패를 재현하고, iOS Keychain과 Android Keystore 기반 저장의 서로 다른 보호 구조와 검증 경계를 익힌다.
목차
표준·구현·측정·해석 표시는 무엇인가요?
- 표준웹 표준이나 언어 명세가 정한 동작
- 구현특정 기술이나 브라우저가 실제로 구현한 동작
- 측정명시한 환경에서 직접 실행해 관찰한 결과
- 해석앞선 근거에서 도출한 설계 판단
- 미확인아직 공식 근거나 재현 결과를 확인하지 못한 내용
30초 요약
프로세스가 끝난 뒤에도 값이 남는 영속성과 저장된 원문을 운영체제 보호 아래 두는 보안 저장은 같은 요구가 아니다.
일반 영속 저장소 앱 설정·캐시·사용자 초안처럼 비밀이 아닌 값
iOS Keychain 작은 비밀값 자체를 암호화된 Keychain 항목으로 저장
Android Keystore token을 보호할 암호화 키를 추출하기 어렵게 관리
앱 실행 중 허용된 조회 뒤 받은 token 원문을 다시 노출하지 않음반복해서 기억할 문장은 이것이다.
장기 인증 정보는 일반 저장소에서 분리하고, 플랫폼 보안 경계에서 읽은 뒤에도 원문 복사본을 늘리지 않는다.
이 글은 어디에 저장하고 어떻게 증명할지만 다룬다. 저장한 앱 인증으로 WebView session을 다시 만드는 절차는 19편에서 이어 간다.
이 글을 관통하는 상황: refresh token을 일반 저장소에 넣었다
17편에서 네이티브 API용 token을 WebView page에 복사하지 않았다. 하지만 앱 프로세스가 다시 시작돼도 로그인을 이어 가려고 refresh token을 Async Storage에 그대로 저장했다고 하자.
이 글의 demo-refresh-token-not-a-secret은 권한이 없는 고정 문자열이다. 실제 token, 사용자 정보나 운영
서버 endpoint는 코드·화면·log에 넣지 않는다. 읽기 성공 여부만 표시하고 값 자체는 출력하지 않는다.
실행 기준선을 만든다
12~17편에서 사용한 React Native 0.86.2 WebBoundaryApp 루트에서 다음 버전을 정확히 설치한다.
npm install @react-native-async-storage/[email protected] [email protected]
cd ios
bundle exec pod install
cd ..react-native-keychain은 네이티브 코드가 있는 의존성이다. 설치 뒤 Fast Refresh만 하지 말고 Android와
iOS 앱을 각각 다시 빌드해 시작한다.
npm run android
npm run ios코드에서 service는 저장한 자격 정보 묶음을 구분하는 이름이다. iOS의
WHEN_UNLOCKED_THIS_DEVICE_ONLY는 기기가 잠금 해제된 동안에만 읽고 다른 기기로 옮기지 않는 조건이다.
Android의 SECURE_SOFTWARE는 암호화 키를 Android Keystore에 두되 특정 보안 하드웨어까지 필수로
요구하지 않는 단계다. 아래 코드는 플랫폼마다 해당하는 옵션만 선택한다.
App.tsx를 다음 완결 코드로 교체한다. USE_SECURE_STORAGE = false가 잘못된 기준선이다.
import React, { useState } from 'react'
import {
Button,
Platform,
SafeAreaView,
StyleSheet,
Text,
View,
} from 'react-native'
import AsyncStorage from '@react-native-async-storage/async-storage'
import * as Keychain from 'react-native-keychain'
const USE_SECURE_STORAGE = false
const DEMO_REFRESH_TOKEN = 'demo-refresh-token-not-a-secret'
const GENERAL_KEY = '@auth/refresh-token'
const SECURE_SERVICE = 'com.example.rnwebboundary.refresh-token'
const SECURE_SET_OPTIONS =
Platform.OS === 'ios'
? {
service: SECURE_SERVICE,
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
}
: {
service: SECURE_SERVICE,
securityLevel: Keychain.SECURITY_LEVEL.SECURE_SOFTWARE,
}
type Evidence = {
generalHasPlaintext: boolean
secureHasCredential: boolean
}
async function inspectStorage(): Promise<Evidence> {
const [generalValue, secureValue] = await Promise.all([
AsyncStorage.getItem(GENERAL_KEY),
Keychain.getGenericPassword({ service: SECURE_SERVICE }),
])
return {
generalHasPlaintext: generalValue === DEMO_REFRESH_TOKEN,
secureHasCredential:
secureValue !== false &&
secureValue.username === 'refresh-token' &&
secureValue.password === DEMO_REFRESH_TOKEN,
}
}
export default function App() {
const [status, setStatus] = useState('아직 실행하지 않음')
const [evidence, setEvidence] = useState<Evidence | null>(null)
const [busy, setBusy] = useState(false)
async function run(action: () => Promise<void>) {
setBusy(true)
setStatus('실행 중')
try {
await action()
} catch {
setStatus('저장소 작업 오류')
} finally {
setBusy(false)
}
}
async function resetFixture() {
await run(async () => {
await AsyncStorage.removeItem(GENERAL_KEY)
await Keychain.resetGenericPassword({ service: SECURE_SERVICE })
setEvidence(await inspectStorage())
setStatus('두 저장소 초기화 완료')
})
}
async function saveFixture() {
await run(async () => {
if (USE_SECURE_STORAGE) {
const saved = await Keychain.setGenericPassword(
'refresh-token',
DEMO_REFRESH_TOKEN,
SECURE_SET_OPTIONS,
)
if (saved === false) {
throw new Error('secure storage write failed')
}
const verified = await Keychain.getGenericPassword({
service: SECURE_SERVICE,
})
if (
verified === false ||
verified.username !== 'refresh-token' ||
verified.password !== DEMO_REFRESH_TOKEN
) {
throw new Error('secure storage verification failed')
}
await AsyncStorage.removeItem(GENERAL_KEY)
} else {
await AsyncStorage.setItem(GENERAL_KEY, DEMO_REFRESH_TOKEN)
}
setEvidence(await inspectStorage())
setStatus('fixture 저장 완료')
})
}
async function readFixture() {
await run(async () => {
setEvidence(await inspectStorage())
setStatus('저장소 다시 읽기 완료')
})
}
return (
<SafeAreaView style={styles.screen}>
<Text style={styles.title}>장기 인증 정보 저장 경계</Text>
<Text>mode: {USE_SECURE_STORAGE ? 'secure' : 'general'}</Text>
<Text>상태: {status}</Text>
<Text>
일반 저장소에 원문 있음:{' '}
{evidence === null
? '확인 전'
: evidence.generalHasPlaintext
? '예'
: '아니오'}
</Text>
<Text>
보안 저장소 재조회:{' '}
{evidence?.secureHasCredential ? '성공' : '없음'}
</Text>
<View style={styles.actions}>
<Button
title="두 저장소 초기화"
disabled={busy}
onPress={() => void resetFixture()}
/>
<Button
title="고정 fixture 저장"
disabled={busy}
onPress={() => void saveFixture()}
/>
<Button
title="저장소 다시 읽기"
disabled={busy}
onPress={() => void readFixture()}
/>
</View>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 16, gap: 12 },
title: { fontSize: 22, fontWeight: '700' },
actions: { gap: 8 },
})실행 전에 예측한다.
두 저장소 초기화뒤고정 fixture 저장을 누르면 어느 저장소에서 고정 문자열이 다시 읽힐까?- Reload 뒤
저장소 다시 읽기를 눌러도 같은 결과가 남을까? - 앱 전용 API로만 읽는 값이면 자동으로 암호화됐다고 결론 내려도 될까?
처음에는 다음 순서로 확인한다.
두 저장소 초기화를 눌러 이전 실행의 값을 없앤다.고정 fixture 저장을 누른다.- 개발자 메뉴 Reload 또는 앱 재시작을 한다.
저장소 다시 읽기를 누른다.
mode: general
상태: 저장소 다시 읽기 완료
일반 저장소에 원문 있음: 예
보안 저장소 재조회: 없음이 관찰은 외부 공격자가 파일을 꺼냈다는 증거가 아니다. 고정 refresh token 원문이 공식 문서상 암호화되지 않은 일반 저장소에 배치됐고, 앱 코드가 같은 문자열을 다시 읽었다는 저장 위치 증거다.
영속성과 비밀값 보호는 다른 요구다
React state 현재 JavaScript 실행의 메모리 값
일반 영속 저장소 다음 실행에도 필요한 비밀이 아닌 데이터
인증 정보 보안 저장소 다음 실행에도 필요한 작은 비밀값
backend token 만료·폐기와 실제 권한 판단Async Storage가 잘못된 도구라는 뜻은 아니다. 배송 메모, 화면 설정처럼 암호화 요구가 없는 값을 프로세스 종료 뒤 복원하는 데 적합하다. 문제는 “다음 실행에도 남아야 한다”는 이유만으로 refresh token까지 같은 저장소에 넣은 분류다.
같은 JavaScript API 뒤의 플랫폼 구현은 다르다
암호화 키는 읽을 수 있는 원문을 다른 모양으로 바꾸고 다시 되돌릴 때 사용하는 비밀 재료다. 원문을 암호화해 만든 읽기 어려운 결과를 암호문이라고 하며, 되돌리는 계산을 복호화라고 한다. 암호 연산은 암호화·복호화·서명처럼 이 키로 수행하는 계산을 뜻한다.
이 글의 설정도 플랫폼별 의미가 다르다.
| 설정 | iOS | Android |
|---|---|---|
WHEN_UNLOCKED_THIS_DEVICE_ONLY | 잠금 해제 중에만 읽고 다른 기기로 옮기지 않는 Keychain 접근 조건 | 사용하지 않음 |
SECURE_SOFTWARE | 사용하지 않음 | 암호화 키를 암호화된 데이터와 분리해 Android Keystore에 둘 것을 요구 |
service | Keychain 항목을 구분하는 서비스 이름 | 암호화된 자격 정보 묶음을 구분하는 이름 |
SECURE_SOFTWARE의 software는 token을 암호화하지 않는다는 뜻이 아니다. Android Keystore를 쓰되 특정
보안 하드웨어까지 요구하지 않는 단계다. 실제 하드웨어 보호가 제품 요구사항이면 기기가 지원하는 보안
능력을 따로 확인하고 지원 실기기에서 검증해야 한다.
실패 코드를 보안 저장 경계로 교체한다
첫 실패를 확인한 뒤 USE_SECURE_STORAGE만 true로 바꾼다.
const USE_SECURE_STORAGE = true코드 교체 뒤에는 이전 일반 저장소 원문이 남아 있으면 결과가 섞인다. 다음 순서를 그대로 반복한다.
- 앱을 다시 빌드해 시작하고
두 저장소 초기화를 누른다. 고정 fixture 저장을 누른다.- 개발자 메뉴 Reload 또는 앱 재시작을 한다.
저장소 다시 읽기를 누른다.
mode: secure
상태: 저장소 다시 읽기 완료
일반 저장소에 원문 있음: 아니오
보안 저장소 재조회: 성공교정이 증명한 것은 두 가지다.
- 일반 Async Storage key에 refresh token 원문이 남지 않는다.
- 앱을 새로 실행해도 지정한 플랫폼 보안 저장 API로 고정 credential을 다시 읽는다.
실제 저장 위치를 일반 저장소에서 보안 저장소로 옮길 때도 순서가 중요하다. 기존 일반 저장소 값을 먼저 지우지 않는다. 보안 저장소에 쓰고, 같은 service에서 다시 읽어 값이 일치하는지 확인한 뒤에만 기존 원문을 삭제한다. 중간에 앱이 끝나도 다음 실행에서 이 순서를 다시 수행할 수 있게 만들어야 한다.
마지막으로 두 저장소 초기화를 한 번 더 눌러 로컬 logout 대역의 삭제도 확인한다.
mode: secure
상태: 두 저장소 초기화 완료
일반 저장소에 원문 있음: 아니오
보안 저장소 재조회: 없음이 결과는 두 로컬 저장소의 fixture 값이 삭제됐다는 증거다. 실제 logout에서는 backend에도 refresh token 폐기를 요청하고, 다음 refresh 요청이 거절되는지 별도 통합 검증해야 한다.
이 결과만으로 파일 추출 저항성, 보안 하드웨어, 생체 인증이나 실제 서버 token 유효성을 증명하지는 않는다.
보안 저장소는 실행 중 원문을 없애지 않는다
좋은 사용 API 요청 직전에 읽고 Authorization header에만 사용
나쁜 복사 React state·전역 변수·분석 event·오류 log에 장기 보관
나쁜 전송 URL query·WebView 주입·page message에 원문 전달
삭제 경계 logout에서 로컬 항목 삭제와 backend token 폐기를 각각 확인JavaScript 문자열을 메모리에서 물리적으로 즉시 지웠다고 보장하기는 어렵다. 그래서 원문을 오래 들고 있는 객체와 state를 만들지 않고, 필요한 요청 가까이에서 읽으며, 오류 메시지에도 값을 포함하지 않는 구조가 중요하다. 보안 저장소는 서버의 만료·폐기·권한 확인도 대신하지 않는다.
설치·재설치·logout을 같은 삭제로 보지 않는다
Android와 iOS의 삭제·백업·기기 이전 정책은 같지 않다. 따라서 다음 사건을 별도로 검증한다.
logout 지정 service의 로컬 항목 삭제 + backend refresh token 폐기
앱 재설치 플랫폼 정책에 따른 잔존 여부 관찰, logout 대용으로 사용하지 않음
기기 이전 iOS accessible·동기화 정책과 Android 백업·키 가용성 확인
token 만료 저장값이 있어도 서버 거절 시 삭제하고 다시 인증삭제 성공 UI만 보지 말고 getGenericPassword({ service })가 false인지, 다음 refresh 요청이 서버에서
거절되는지 각각 확인한다. 로컬 삭제와 서버 권한 폐기는 서로 대신하지 않는다.
가상 환경과 실기기에서 증명하는 범위를 나눈다
| 관찰 | Android emulator | iOS Simulator | 지원 실기기 |
|---|---|---|---|
| set/get/reset API 흐름 | 확인 | 확인 | 회귀 확인 |
| Reload 뒤 재조회 | 확인 | 확인 | 회귀 확인 |
| 일반 저장소 원문 없음 | 확인 | 확인 | 회귀 확인 |
| 실제 보안 하드웨어 수준 | 증명하지 않음 | 증명하지 않음 | 요구 단계와 기기가 지원하는 보안 능력 확인 |
| 생체 인증·기기 잠금 정책 | 제한된 가상 기능 | 제한된 가상 기능 | 실제 잠금·취소·등록 변경 검증 |
이 글의 fixture에는 생체 인증을 걸지 않았다. 사용자 확인을 요구하는 접근 제어를 제품에 추가한다면 성공뿐 아니라 취소, 잠금, 생체 정보 변경, 지원하지 않는 기기와 복구 경로까지 별도 실기기 시나리오로 닫는다.
pull request에서 달라져야 할 행동
장기 인증 정보 저장 코드를 검토할 때 다음 표를 먼저 채운다.
값 access token / refresh token / 비밀이 아닌 사용자 상태
필요 수명 메모리만 / 앱 재실행까지 / 서버에서 재발급 가능
일반 저장소 복사본 key·저장 위치 이전 코드·개발용 데이터 내보내기(debug export)·backup에 남는가
iOS Keychain service, accessible, 항목을 공유할 앱 범위인 access group, 동기화 정책
Android 암호문 위치, Keystore key, 요구한 키 보호 단계, 기기가 지원하는 보안 능력
실행 중 state·log·URL·WebView에 원문 복사본이 생기는가
삭제 logout, 계정 전환, 서버 폐기, 재설치·기기 이전
검증 set/get/reset, 재실행, 오류, 실제 기기 보안 능력검토자는 다음을 확인한다.
- 일반 설정·캐시 저장소에 장기 credential 원문을 넣지 않는가?
- iOS Keychain 값과 Android Keystore 키의 역할 차이를 문서화했는가?
- 서비스 이름과 계정 전환 시 덮어쓰기·삭제 범위가 명확한가?
- 저장 실패와 조회 실패를 로그인 성공으로 처리하지 않는가?
- 조회한 원문을 React state, log, URL이나 WebView로 다시 복사하지 않는가?
- logout의 로컬 삭제와 backend 폐기를 각각 검증하는가?
- simulator·emulator 통과를 하드웨어 보호 증거로 과장하지 않는가?
한 장으로 다시 보기
문제 장기 refresh token 원문이 암호화되지 않은 일반 저장소에 남음
오판 영속 저장이면 민감한 값도 자동으로 보호됨
관찰 Reload 뒤 Async Storage에서 같은 고정 문자열을 다시 읽음
원인 데이터 수명과 비밀값 보호 요구를 한 저장 경계로 합침
행동 일반 저장소 복사본 제거 + 플랫폼 보안 저장 API로 이동
검증 일반 저장소 없음 + secure service 재조회 + 로컬 삭제, 서버 폐기는 별도 확인
플랫폼 iOS는 Keychain 항목, Android는 Keystore 키로 암호문 보호
경계 저장 중 보호는 실행 중 원문·서버 권한·하드웨어 수준을 대신하지 않음Keychain과 Keystore를 쓰는 이유는 “보안”이라는 이름 때문이 아니다. 어떤 값이 오래 남아야 하는지 먼저 분류하고, 플랫폼이 제공하는 암호화·접근 조건에 맡길 부분과 앱이 실행 중 책임질 부분을 나누기 위해서다.
스스로 확인할 질문
- Async Storage의 영속성과 인증 정보 보안 저장소의 보호는 어떻게 다른가?
- iOS Keychain과 Android Keystore에서 token 원문과 암호화 키의 위치는 어떻게 다른가?
getGenericPassword가 성공한 뒤에도 원문 노출 위험이 남는 이유는 무엇인가?- Reload·재설치·logout을 같은 삭제 증거로 보면 안 되는 이유는 무엇인가?
- emulator·Simulator의 성공만으로 어떤 보안 요구를 증명할 수 없는가?
Active recall
기억에서 꺼내 보기
답을 쓰지 않아도 됩니다. 먼저 머릿속으로 답한 뒤 펼쳐서 정답·이유·흔한 오해를 비교하세요.
01프로세스 종료 뒤에도 refresh token이 필요하므로 Async Storage 같은 일반 영속 저장소에 두면 될까?
정답
안 된다. Async Storage 3.1.1은 영속하지만 암호화되지 않은 저장소다. 장기 인증 정보는 일반 설정·캐시와 분리해 플랫폼 보안 저장 경계를 사용한다.
관련 설명 다시 읽기왜 그런가
오래 남는다는 성질과 민감한 원문을 보호한다는 성질은 서로 다르다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
02Android Keystore도 iOS Keychain처럼 refresh token 문자열 자체를 항목으로 저장할까?
정답
같은 구조가 아니다. 이 글의 라이브러리는 iOS에서 값을 Keychain 항목으로 저장하지만, Android에서는 Keystore가 암호화 키를 맡고 그 키로 암호화한 값을 앱 전용 저장소에 둔다.
관련 설명 다시 읽기왜 그런가
공통 JavaScript API 뒤의 비밀값 저장 위치와 암호화 키 저장 위치를 구분해야 한다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
03보안 저장소에서 읽은 refresh token은 JavaScript 메모리에서도 암호문으로 남을까?
정답
아니다. 앱이 API 요청에 사용하려면 허용된 시점에 원문 문자열을 받는다. state·log·URL·WebView에 복사하지 않고 필요한 요청에만 짧게 사용해야 한다.
관련 설명 다시 읽기왜 그런가
저장 중 보호와 실행 중 원문 취급은 서로 다른 경계다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
04Android emulator와 iOS Simulator에서 저장·재조회가 성공하면 실제 기기의 보안 하드웨어와 사용자 인증도 검증된 걸까?
정답
아니다. 가상 환경은 API 흐름과 오류 처리를 확인하고, 하드웨어 보호·생체 인증·잠금 상태 정책이 요구사항이면 지원 실기기에서 별도 확인한다.
관련 설명 다시 읽기왜 그런가
기능 호출 성공과 특정 보안 능력 보장은 같은 증거가 아니다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
출처와 검증 범위
아래 날짜는 링크를 마지막으로 열어 본 날입니다. 문서 상단의 검증일은 글의 설명과 적용 범위를 다시 확인한 날입니다.
- React Native Async StorageReact Native Async Storage · 공식 문서 · 확인 2026-08-09
- Using Async StorageReact Native Async Storage · 공식 문서 · 확인 2026-08-09
- Async Storage ChangelogReact Native Async Storage · 공식 문서 · 확인 2026-08-09
- Keychain servicesApple Developer · 공식 문서 · 확인 2026-08-09
- Keychain itemsApple Developer · 공식 문서 · 확인 2026-08-09
- Android Keystore systemAndroid Developers · 공식 문서 · 확인 2026-08-09
- react-native-keychain 10.0.0react-native-keychain · 공식 문서 · 확인 2026-08-09
- react-native-keychain 10.0.0 releasereact-native-keychain · 공식 문서 · 확인 2026-08-09
- react-native-keychain 10.0.0 - Platform value storagereact-native-keychain · 공식 문서 · 확인 2026-08-09
- react-native-keychain 10.0.0 - Data persistencereact-native-keychain · 공식 문서 · 확인 2026-08-09
- react-native-keychain 10.0.0 - setGenericPasswordreact-native-keychain · 공식 문서 · 확인 2026-08-09
- react-native-keychain 10.0.0 - getGenericPasswordreact-native-keychain · 공식 문서 · 확인 2026-08-09
- react-native-keychain 10.0.0 - resetGenericPasswordreact-native-keychain · 공식 문서 · 확인 2026-08-09
- react-native-keychain 10.0.0 - SetOptionsreact-native-keychain · 공식 문서 · 확인 2026-08-09
- react-native-keychain 10.0.0 - ACCESSIBLEreact-native-keychain · 공식 문서 · 확인 2026-08-09
- react-native-keychain 10.0.0 - SECURITY_LEVELreact-native-keychain · 공식 문서 · 확인 2026-08-09
- Fast RefreshReact Native · 공식 문서 · 확인 2026-08-09
- Debugging — Accessing the Dev MenuReact Native · 공식 문서 · 확인 2026-08-09
- Extended controls, settings, and helpAndroid Developers · 공식 문서 · 확인 2026-08-09
- Running your app on simulated or physical devicesApple Developer · 공식 문서 · 확인 2026-08-09