sungyup's.

react_native / Libraries / 3.1 AsyncStorage

3.1AsyncStorage

React Native의 비동기 영구 저장소 AsyncStorage의 구조와 원리, 한계 및 보안 대안(SecureStore)

TL;DR

추억의 쪽지 시험

웹의 Storage API들을 정리하면서, RN의 대표적 Storage인 AsyncStorage에 대해서도 공부한 내용을 정리해본다.

AsyncStorage

React Native의 비동기 영구 저장소

AsyncStorage는 원래 RN에서 제공했었지만, 현재는 공식 문서에서 지원을 중지했으니 커뮤니티에서 만든 패키지를 사용하라고 하고 있다. 25.10.6일, 글 작성일 시점에서 가장 많이 쓰이는 커뮤니티 패키지는 @react-native-async-storage/async-storage다. 본 포스팅의 내용은 이 패키지를 기준으로 정리한다.

1. 기본 특징

  • localStorage처럼 앱 데이터를 로컬에 저장하지만, 웹이 아니라 네이티브 앱(Android/iOS) 환경에서 작동한다.
  • 비동기적으로 동작해, 앱이 멈추지 않고 I/O 작업을 병렬로 처리할 수 있다.
  • 데이터는 앱을 삭제하기 전까지 유지된다.
  • Android에서 용량 제한은 6MB이다. 여기에, Android의 AsyncStorage는 내부 브리지에서 한번에 읽을 수 있는 데이터 버퍼가 2MB 정도라서, 이것보다 데이터 용량을 적게 유지해야 한다. 만약 더 큰 데이터를 읽어와야 한다면 데이터를 분할해 multiSet이나 multiGet과 같은 API를 사용해 저장하고 조회한다.
    • iOS는 딱히 제한에 대한 구체적인 언급이 없지만, 어쨌든 cross-platform을 위해 RN을 사용한 이상 6MB 이하로 관리하는 것이 좋다.
  • 문자열(string)만 저장할 수 있다. 따라서 JSON.stringify()로 객체나 배열 등의 데이터를 문자열로 직렬화해서 저장(AsyncStorage.setItem())하고, 값을 읽을 때는 꺼내와서(AsyncStorage.getItem()) JSON.parse()로 파싱해 쓴다.
typescript
import AsyncStorage from '@react-native-async-storage/async-storage'; // 저장 await AsyncStorage.setItem('user', JSON.stringify({ name: 'sungyup' })); // 불러오기 const value = await AsyncStorage.getItem('user'); const user = value ? JSON.parse(value) : null; // 삭제 await AsyncStorage.removeItem('user');

2. 데이터 저장 위치

이렇게 저장되는 데이터의 위치는 OS에 따라 다르다.

Android의 경우 SQLite 데이터베이스이다. AsyncStorage는 Android native 측 구현에서 SQLiteOpenHelper를 사용해 /data/data/<패키지명>/databases/RKStorage라는 DB를 만든다. 그리고, RKStorage 데이터베이스에는 catalystLocalStorage라는 테이블이 존재하고, 이 테이블은 아래와 같은 스키마를 가진다:

sql
CREATE TABLE catalystLocalStorage ( key TEXT PRIMARY KEY NOT NULL, value TEXT );

Android에서 모든 AsyncStorage 데이터는 이 SQLite 테이블의 key/value 형태로 저장된다.

iOS는 key 목록을 manifest.json이라는 파일에 저장하고, 각 key의 값은 MD5 해시된 파일명을 가진다. 실제 값은 각 key가 가리키는 해시된 파일명으로 개별 파일에 저장한다. 즉, 예를 들어 manifest.json 파일이 다음과 같다고 하자:

json
{ "user": "4F3D91C0E8B0AB67A1E31F6E2D3E5A14", "theme": "71BFFB14D9EAD1834E0F7D50A7B8E65B" }

그러면 해당 해시의 파일명에 실제 데이터가 들어있다.

bash
cat 4F3D91C0E8B0AB67A1E31F6E2D3E5A14 → {"name":"Sungyup","age":33}

물론 코드를 작성할 땐 RN JS 레벨에서 동일하게 AsyncStorage.getItem/setItem으로 추상화되어 있으므로 이 부분을 신경 쓸 필요는 없다.

3. 비동기 동작 원리

React Native 역시 JavaScript 코드 기반이라 단일 스레드에서 실행되지만, AsyncStorageNative 영역에서 별도의 스레드로 처리되기에 비동기로 동작한다.

즉, AsyncStorage.setItem(key, value)를 실행하면 RN에선 Native Bridge를 통해 native 코드에 명령을 보낸다. 명령을 받은 native 모듈은 백그라운드 스레드에서 파일 시스템에 데이터를 쓴다. 저장이 완료되면 native에서 JS 쪽으로 완료 신호를 보내고, JS는 Promise로 resolve한다.

이런 구조이기 때문에 RN에서 AsyncStorage를 통해 저장을 해도 UI가 멈추지 않고 병렬적으로 작업이 가능하다.

4. 주의점

주의해야할 점도 있다. 우선, 별도의 처리가 없다면 앱이 강제 종료될 경우 비동기 저장 중 데이터가 유실될 수 있다. 또, 단순 key/value만 저장하는 것이므로 검색이나 필터링 기능은 기대할 수 없다. 또, 암호화를 제공하지 않아 민감정보는 SecureStore(Keychain/Keystore)를 사용해 저장해야 한다.

SecureStore

SecureStoreOS가 제공하는 보안 저장소(Keystore/Keychain)에 접근해 키-값을 저장/조회하는 추상화이다. React Native에선 구현을 위해 라이브러리를 사용한다.

AndroidAndroid Keystore로 암호화된 저장을 한다. 라이브러리(react-native-encrypted-storage 등)를 통해 구현한다. OS의 Keystore로 관리되는 키로 암호화해, 내부 저장소(EncryptedSharedPreferences 또는 DataStore)에 보관한다. 비밀 키는 내보낼 수 없고, 보안 하드웨어(StrongBox) 또는 보안 소프트웨어 TEE에서 연산된다.

iOSKeychain Services(암호화된 데이터베이스)를 사용한다. react-native-keychain을 통해 구현한다. iOS에서 값들은 keychain 서비스를 통해 kSecClassGenericPassword라는 class(일반 문자열 데이터를 저장할 때 쓰는 가장 일반적인 보안 항목 class)로 저장된다.

react-native-keychain은 iOS에는 Keychain, Android에는 Keystore를 지원한다고 하는데 Android에선 이슈가 종종 있다고해서 Android는 앞서 언급한 react-native-encrypted-storage 같은 다른 라이브러리로, iOS는 react-native-keychain으로 암호화된 키-값 쌍을 저장하는 로직을 구현하곤 한다.

앞서 링크한 react-native-keychain 공식 문서에는 사용법 관련해서 딱 하나의 예시가 나오는데, 아래의 코드다. 키(username)-값(password)와 service 명('service-key')만 있으면 암호화(Keychain.setGenericPassword) 및 값 가져오기(Keychain.getGenericPassword)가 모두 가능하다. service 외에 옵션으로 storage도 넣을 수 있다. 여기서 GenericPassword는 앞서 언급한 keychain의 일반 문자열 데이터를 저장할 때 쓰는 가장 일반적인 보안 항목 class인 kSecClassGenericPassword의 그것이다.

javascript
import * as Keychain from 'react-native-keychain'; async () => { const username = 'zuck'; const password = 'poniesRgr8'; // Store the credentials await Keychain.setGenericPassword(username, password, {service: 'service_key'}); try { // Retrieve the credentials const credentials = await Keychain.getGenericPassword({service: 'service_key'}); if (credentials) { console.log( 'Credentials successfully loaded for user ' + credentials.username ); } else { console.log('No credentials stored'); } } catch (error) { console.error("Failed to access Keychain", error); } // Reset the stored credentials await Keychain.resetGenericPassword({service: 'service_key'}); };

암호화는 로그인 후 발급받는 Access Token이나, Access Token 만료 시 새 토큰 재발급을 위한 Refresh Token을 저장할 때 유용하다. 예를 들면, 아래와 같이 코드를 작성할 수 있다.

typescript
export const signIn = async (data: SignInParams): Promise<SignInResponse> => { const options = { { url: "/auth/login/email", method: 'POST' }, data, }; try { const result = await axios.request(options); if (result) { axios.defaults.headers.common['X-Access-Token'] = result.data.data.accessToken; await setSecureValue('accessToken', result.data.data.accessToken); await setSecureValue('refreshToken', result.data.data.refreshToken); } return result.data; } catch (error) { console.error('SignIn Error!'); throw error as ApiError; } };