React Native 저장소 아키텍처의 모든 것: 물리적 구조부터 JSI까지

nudge411·2025년 9월 18일
1

ReactNative

목록 보기
8/8
post-thumbnail

"Context, Redux, Zustand 대신 로컬 스토리지를 사용해도 될까요?" 라는 글을 읽고 시작된 기술적 호기심이 React Native 저장소 생태계의 깊은 이해로 이어진 여정을 공유합니다.

📝 작성 방식: 이 글은 개발자와 Claude AI의 기술적 토론을 바탕으로 작성되었으며, 모든 기술적 내용은 공식 문서와 소스 코드를 통해 검증되었습니다.

TL;DR

  • 웹과 모바일의 저장소는 물리적으로 다른 공간에 저장됨 (메모리 vs 디스크)
  • MMKV는 JSI로 브릿지를 우회하여 AsyncStorage 대비 30배 성능 향상
  • JSI는 New Architecture보다 먼저 도입되어 Old Architecture에서도 사용 가능
  • 모바일에서 비동기 저장소는 UI 스레드 보호를 위한 필수 설계

1. 첫 번째 의문: 정말 물리적으로 다른 곳에 저장될까?

가장 기본적인 질문부터 시작해봅시다. localStorage와 React 상태가 정말 다른 곳에 저장되는 걸까요?

저장소의 물리적 구조

답은 입니다. 완전히 다른 저장 공간을 사용합니다.

// 🧠 메모리(RAM)에 저장 - 앱 종료 시 소멸
const [theme, setTheme] = useState('dark');

// 💾 디스크에 저장 - 영구 보존
localStorage.setItem('theme', 'dark');

웹 환경:

  • localStorage: 브라우저 SQLite DB (~/.config/google-chrome/Default/Local Storage/)
  • React 상태: 브라우저 프로세스 메모리(RAM)

React Native:

  • AsyncStorage:
    • iOS: NSUserDefaults (.plist 파일)
    • Android: SQLite (/data/data/<app>/databases/RKStorage)
  • React 상태: 앱 프로세스 메모리(RAM)

이 물리적 차이가 바로 원글에서 언급한 "동기화" 문제의 근본 원인입니다.

2. 두 번째 의문: 그럼 persist는 뭘 하는 거지?

물리적으로 다른 저장소라면, Zustand나 Redux의 persist 설정은 도대체 어떤 마법을 부리는 걸까요?

Persist의 진짜 역할

결론부터 말하면, persist는 두 저장소 간의 자동 동기화 브릿지입니다.

// Zustand persist의 실제 동작
const useStore = create(
  persist(
    (set) => <({
      count: 0,
      increment: () => set((state) => ({ count: state.count + 1 })),
    }),
    { name: 'counter-storage' }
  )
)

// 내부에서 일어나는 일:
// 1. 상태 변경 → 메모리의 Zustand store 업데이트
// 2. JSON.stringify() → localStorage에 저장
// 3. 앱 재시작 → JSON.parse() → 메모리로 복원

브라우저 개발자 도구를 열어보면 실제로 localStorage에 이런 값이 저장된 것을 확인할 수 있습니다:

{"state":{"count":5},"version":0}

JSON 직렬화의 숨겨진 한계

하지만 여기서 새로운 문제가 등장합니다. localStorage는 string만 저장할 수 있는데, 복잡한 객체는 어떻게 처리할까요?

// 저장 전
const complexState = {
  user: { id: 1, name: "김철수" },
  todos: [{ id: 1, text: "할일", createdAt: new Date() }],
  callback: () => console.log("함수")
};

// localStorage에 실제 저장되는 값
// '{"user":{"id":1,"name":"김철수"},"todos":[{"id":1,"text":"할일","createdAt":"2024-01-15T10:30:00.000Z"}]}'

// ❌ 손실되는 것들:
// - 함수: callback
// - Date 객체 → 문자열 변환 (타입 손실)
// - Map, Set 등 특수 객체
// - 순환 참조

모든 persist 시스템이 JSON.stringify/JSON.parse를 사용하기 때문에 이런 한계가 있습니다. 이것이 원글에서 "Zod가 여러분의 가장 친한 친구가 될 것"이라고 한 이유죠.

3. 세 번째 의문: 모바일에서는 왜 비동기일까?

웹에서는 localStorage가 동기적으로 동작하는데, React Native에서는 왜 AsyncStorage라는 비동기 API를 사용할까요? 동기적으로 하면 더 확실한 초기값을 보장할 수 있을 텐데 말이죠.

모바일의 생존 법칙

답은 간단합니다. 모바일에서는 UI 스레드 블로킹이 곧 앱의 죽음이기 때문입니다.

// ❌ 만약 동기라면 이런 참사가...
const syncStorage = {
  getItem: (key) => {
    // 100ms 디스크 I/O 발생
    // → UI 스레드 블로킹
    // → iOS: watchdog가 앱 강제 종료
    // → Android: ANR (Application Not Responding)
  }
};

플랫폼별 제약사항을 보면 이해가 쉬워집니다:

플랫폼제약사항결과
iOSMain thread 5초 이상 블로킹Watchdog가 앱 강제 종료
AndroidUI thread 5초 이상 블로킹ANR 다이얼로그 → 앱 종료
Main thread 블로킹잠깐 멈춤 (사용자가 크게 인지 못함)

웹과 모바일의 근본적인 차이가 여기서 드러납니다. 웹 브라우저는 잠깐 멈춰도 괜찮지만, 모바일 앱은 그럴 수 없죠.

비동기의 대가: 초기값 문제

하지만 비동기 설계는 새로운 도전을 가져왔습니다. 앱 시작 시 저장된 값을 어떻게 보장할까요?

// ❌ 문제 상황
const Screen = () => {
  const [userData, setUserData] = useState(null); // 초기값이 null
  
  useEffect(() => {
    AsyncStorage.getItem('userData').then(setUserData);
  }, []);
  
  // 🚨 userData가 null일 때 뭘 보여줄까?
  return <Text>{userData?.name}</Text>; 
};

이 문제의 해결책들이 현재 React Native 생태계의 표준 패턴이 되었습니다:

// ✅ 해결책 1: Loading State Pattern
const Screen = () => {
  const [isLoading, setIsLoading] = useState(true);
  const [userData, setUserData] = useState(null);

  useEffect(() => {
    AsyncStorage.getItem('userData').then(data => {
      setUserData(data);
      setIsLoading(false);
    });
  }, []);

  if (isLoading) return <LoadingSpinner />;
  return <Text>{userData?.name}</Text>;
};

// ✅ 해결책 2: 앱 레벨 초기화
const App = () => {
  const [isReady, setIsReady] = useState(false);
  
  useEffect(() => {
    Promise.all([
      AsyncStorage.getItem('user'),
      AsyncStorage.getItem('settings'),
      AsyncStorage.getItem('theme')
    ]).then(() => setIsReady(true));
  }, []);
  
  if (!isReady) return <SplashScreen />;
  return <MainApp />;
};

핵심 깨달음: 완벽한 동기화는 불가능합니다. 로딩 상태를 UI로 표현하는 것이 현실적인 해답입니다.

4. 네 번째 의문: MMKV는 어떻게 동기인데도 빠를까?

AsyncStorage가 비동기여야 하는 이유를 이해했다면, 새로운 의문이 생깁니다. MMKV는 동기적 API를 제공하는데 어떻게 UI 블로킹 없이 빠른 성능을 낼 수 있을까요?

React Native 브릿지의 숨겨진 병목

먼저 기존 AsyncStorage의 문제점을 이해해야 합니다:

전통적인 AsyncStorage 흐름:
JS Thread → JSON 직렬화 → Bridge Queue → Native Thread → 파일 I/O
                ↓ (병목지점)                                ↓
UI Thread ← JSON 역직렬화 ← Bridge Queue ← Native Thread ← 결과

문제는 단순히 파일 I/O만이 아니었습니다. JSON 직렬화와 브릿지 통신이 더 큰 오버헤드였죠.

실제 벤치마크를 보면:

  • AsyncStorage: 50-200ms (JSON 직렬화 + 브릿지 오버헤드)
  • MMKV: 0.1-1ms (브릿지 우회 + 메모리 맵핑)

30배 차이가 나는 이유가 여기에 있습니다.

JSI: 게임 체인저의 등장

MMKV의 혁신은 JSI(JavaScript Interface)를 통해 브릿지를 완전히 우회한 것입니다:

// MMKV가 JSI로 C++ 함수를 직접 노출
jsi::Function getString = jsi::Function::createFromHostFunction(
  runtime, 
  jsi::PropNameID::forAscii(runtime, "getString"),
  1,
  [](jsi::Runtime& runtime, const jsi::Value& thisValue, 
     const jsi::Value* arguments, size_t count) -> jsi::Value {
    
    // 브릿지 없이 직접 네이티브 코드 실행
    std::string result = mmkv<->getString(key);
    return jsi::String::createFromUtf8(runtime, result);
  }
);
// 사용자는 이렇게 간단하게
const value = storage.getString('key'); // 동기적이지만 거의 즉시 반환

브릿지를 우회함으로써 JSON 직렬화 오버헤드도, 큐 대기 시간도 모두 사라졌습니다.

MMKV의 다층 최적화

브릿지 우회만이 MMKV의 전부는 아닙니다. 여러 계층에서 최적화가 이뤄졌죠:

1. 메모리 맵핑 (mmap)

일반 파일 I/O:     앱 → 시스템 콜 → 커널 → 디스크 → 메모리 복사
메모리 맵핑:       앱 → 메모리 직접 접근 (디스크가 메모리처럼 작동)

2. Append-Only 구조

  • 수정 시 덮어쓰기 대신 끝에 추가
  • 파일 시스템 fragmentation 최소화
  • Crash-safe 보장

3. Protocol Buffers 직렬화

  • JSON 대비 더 작은 크기
  • 더 빠른 직렬화/역직렬화
  • 스키마 진화 지원

이렇게 여러 최적화가 겹쳐서 "동기적이지만 거의 즉시 반환"이라는 마법 같은 결과를 만들어낸 것입니다.

5. 다섯 번째 의문: JSI는 New Architecture 전용 아닌가?

MMKV가 JSI를 사용한다는 것을 알았을 때, 자연스럽게 떠오르는 질문입니다. JSI는 React Native의 New Architecture에서 나온 기술 아니었나요? 그럼 MMKV는 New Architecture에서만 동작하는 건가요?

시간순으로 정리하는 React Native 진화

이건 많은 개발자들이 가지는 오해입니다. 실제 타임라인을 보면:

연도기술상태
2018JSI 발표 (ReactConf)컨셉 공개
2019-2020JSI 구현React Native 0.60+
2021MMKV 2.0JSI 기반 첫 상용화
2022New Architecture실험적 도입 (0.68)
2024New Architecture기본 활성화 (0.76)

JSI가 New Architecture보다 훨씬 먼저 나왔습니다!

JSI와 New Architecture의 관계

New Architecture = JSI + Fabric + TurboModules

JSI: 저수준 JavaScript-C++ 인터페이스
Fabric: 새로운 렌더링 시스템  
TurboModules: 개선된 네이티브 모듈 시스템

JSI는 New Architecture의 기반 기술 중 하나지만, 독립적으로 Old Architecture에서도 사용 가능한 기술입니다.

실제로 MMKV, Reanimated 2, react-native-quick-crypto 등 많은 JSI 기반 라이브러리들이 Old Architecture 프로젝트에서도 잘 동작합니다.

버전별 호환성의 현실

하지만 여기서 중요한 주의사항이 있습니다:

// MMKV v2.x
{
  "peerDependencies": {
    "react-native": ">=0.60.0" // Old Arch 지원
  }
}

// MMKV v3.x
{
  "peerDependencies": {
    "react-native": ">=0.74.0" // New Arch 필수
  }
}

MMKV v3부터는 TurboModules를 필수로 요구하여 New Architecture가 필요합니다. Old Architecture 사용자는 v2.x를 사용해야 합니다.

이는 JSI 기술 자체의 한계가 아니라, MMKV 개발팀의 아키텍처 선택입니다.

6. 더 깊이: 네이티브 플랫폼의 저장소 메커니즘

지금까지 React Native 레이어에서의 변화를 살펴봤다면, 이제 그 아래층인 네이티브 플랫폼의 저장소들도 들여다봅시다.

iOS NSUserDefaults의 내부 구조

// NSUserDefaults 내부 구조
// ~/Library/Preferences/com.yourapp.plist 파일에 XML 형태로 저장

<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
    <key>theme</key>
    <string>dark</string>
    <key>user_settings</key>
    <dict>
        <key>notifications</key>
        <true/>
    </dict>
</dict>
</plist>

성능 특성:

  • 첫 로드: ~4ms
  • 메모리 캐시 후: ~0ms
  • Thread-safe 자동 보장

iOS 12+ 중요 변경사항:

// ❌ Deprecated (더 이상 불필요)
[[NSUserDefaults standardUserDefaults] synchronize];

// ✅ 자동으로 비동기 처리됨
[[NSUserDefaults standardUserDefaults] setObject:@"value" forKey:@"key"];

이 변경으로 iOS에서는 수동 동기화 호출이 불필요해졌습니다.

Android SharedPreferences의 최적화

<!-- /data/data/com.yourapp/shared_prefs/preferences.xml -->
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
    <string name="theme">dark</string>
    <boolean name="notifications" value="true" />
</map>

성능 최적화 포인트:

// 동기 vs 비동기 저장
SharedPreferences.Editor editor = prefs.edit();

editor.putString("key", "value");
editor.commit();    // 동기: UI 스레드 블로킹 위험
editor.apply();     // 비동기: 백그라운드에서 처리 (권장)

이런 네이티브 레이어의 특성들이 결국 React Native의 AsyncStorage 설계에도 영향을 미쳤습니다.

7. 웹의 특별한 함정: Storage 이벤트

React Native 얘기를 하다가 웹으로 돌아가는 이유는, 원글에서 언급된 localStorage의 이상한 동작 때문입니다.

예상과 다른 Storage 이벤트

// 현재 탭에서 값 변경
localStorage.setItem('theme', 'dark');

// 다른 탭에서만 이벤트 발생 🤯
window.addEventListener('storage', (event) => {
  console.log('변경됨:', event.key, event.newValue);
  // 변경을 일으킨 탭에서는 절대 실행되지 않음!
});

이 동작은 WHATWG HTML Standard에 명시된 의도된 설계입니다. 크로스 탭 동기화를 위한 것이죠.

하지만 단일 앱에서 상태 관리용으로 localStorage를 사용하려면 문제가 됩니다:

// 해결책 - 수동 이벤트 디스패치
const setStorageWithEvent = (key, value) => {
  localStorage.setItem(key, value);
  
  // 현재 탭에서도 이벤트 발생시키기
  const event = new StorageEvent('storage', {
    key,
    newValue: value,
    oldValue: localStorage.getItem(key),
    storageArea: localStorage,
    url: window.location.href
  });
  
  window.dispatchEvent(event);
};

이런 웹과 모바일의 미묘한 차이들이 크로스 플랫폼 개발을 복잡하게 만드는 요소들입니다.

8. 실무에서의 현명한 선택

지금까지의 여정을 통해 각 저장소 기술의 특성과 한계를 이해했다면, 이제 실무에서 어떻게 활용할지 정리해봅시다.

상황별 저장소 선택 가이드

// 🤔 언제 무엇을 사용할까?

// 1. 임시 UI 상태 → React State
const [isModalOpen, setIsModalOpen] = useState(false);

// 2. 컴포넌트 간 공유 상태 → Context/Zustand/Redux  
const theme = useContext(ThemeContext);

// 3. 영속성이 필요한 설정 → Persist + 상태관리
const useSettingsStore = create(persist(...));

// 4. 고성능이 필요한 경우 → MMKV
import { MMKV } from 'react-native-mmkv';
const storage = new MMKV();

// 5. 폼 백업, 임시 저장 → 직접 AsyncStorage/localStorage
AsyncStorage.setItem('form_backup', JSON.stringify(formData));

성능 벤치마크로 보는 현실

저장소읽기 속도쓰기 속도동기/비동기추천 용도
AsyncStorage50-200ms100-300ms비동기일반적인 설정 저장
MMKV v20.1-1ms0.5-2ms동기고성능 필요 시 (Old Arch)
MMKV v30.05-0.5ms0.2-1ms동기고성능 필요 시 (New Arch)
SQLite1-10ms5-50ms비동기복잡한 쿼리, 관계형 데이터

안전한 AsyncStorage 사용법

// 에러 처리를 포함한 안전한 래퍼
const safeAsyncStorage = {
  async getItem(key: string): Promise<string | null> {
    try {
      const value = await AsyncStorage.getItem(key);
      return value ? JSON.parse(value) : null;
    } catch (error) {
      console.error('AsyncStorage getItem error:', error);
      return null; // 기본값 반환
    }
  },

  async setItem(key: string, value: any): Promise<boolean> {
    try {
      await AsyncStorage.setItem(key, JSON.stringify(value));
      return true;
    } catch (error) {
      if (error.name === 'QuotaExceededError') {
        // 5MB 제한 초과 시 오래된 데이터 정리
        await this.clearOldData();
      }
      console.error('AsyncStorage setItem error:', error);
      return false;
    }
  }
};

마무리: 기술 선택의 지혜

여정의 교훈들

간단해 보였던 "로컬 스토리지 vs 상태 관리" 질문에서 시작해서, 우리는 다음과 같은 깊이 있는 이해에 도달했습니다:

  1. 저장소는 목적이 다르다: 상태 관리(메모리) ≠ 데이터 영속성(디스크)
  2. 플랫폼마다 제약이 다르다: 웹의 관대함 vs 모바일의 엄격함
  3. 기술의 진화는 문제 해결 과정이다: JSI → MMKV → 성능 혁신
  4. 새로운 기술도 호환성을 고려해야 한다: Old vs New Architecture

2025년 권장 아키텍처

// React Native 프로젝트 권장 구성
export const storageStack = {
  // 상태 관리
  global: 'Zustand with persist',
  local: 'React useState/useReducer',
  
  // 고성능 저장소  
  keyValue: 'MMKV v2 (Old Arch) / v3 (New Arch)',
  
  // 복잡한 데이터
  database: 'SQLite with react-native-sqlite-storage',
  
  // 크로스 플랫폼 호환
  fallback: 'AsyncStorage (웹은 localStorage)'
};

끝나지 않는 진화

기술은 계속 진화합니다. React Native의 New Architecture가 기본이 되고, JSI가 더 널리 활용되며, 새로운 저장소 솔루션들이 등장할 것입니다.

중요한 것은 기술의 본질을 이해하는 것입니다. 표면적인 API가 아니라, 왜 그런 설계 결정이 내려졌는지, 어떤 문제를 해결하려고 하는지를 이해하면 새로운 기술이 나와도 빠르게 적응할 수 있습니다.

"왜?"를 끝까지 물어보는 습관이 더 나은 개발자, 더 나은 아키텍처를 만듭니다.


📋 작성 정보

  • 작성 과정: 개발자의 연속적인 기술 질문과 Claude AI의 답변을 통한 협업
  • 검증 방식: 공식 문서, GitHub 소스 코드, 벤치마크 데이터 기반 팩트 체크
  • 업데이트: 2024년 12월 기준 최신 정보 반영
profile
잊기 위한 기록을 합니다.

0개의 댓글