
"Context, Redux, Zustand 대신 로컬 스토리지를 사용해도 될까요?" 라는 글을 읽고 시작된 기술적 호기심이 React Native 저장소 생태계의 깊은 이해로 이어진 여정을 공유합니다.
📝 작성 방식: 이 글은 개발자와 Claude AI의 기술적 토론을 바탕으로 작성되었으며, 모든 기술적 내용은 공식 문서와 소스 코드를 통해 검증되었습니다.
가장 기본적인 질문부터 시작해봅시다. localStorage와 React 상태가 정말 다른 곳에 저장되는 걸까요?
답은 예입니다. 완전히 다른 저장 공간을 사용합니다.
// 🧠 메모리(RAM)에 저장 - 앱 종료 시 소멸
const [theme, setTheme] = useState('dark');
// 💾 디스크에 저장 - 영구 보존
localStorage.setItem('theme', 'dark');
웹 환경:
~/.config/google-chrome/Default/Local Storage/)React Native:
/data/data/<app>/databases/RKStorage)이 물리적 차이가 바로 원글에서 언급한 "동기화" 문제의 근본 원인입니다.
물리적으로 다른 저장소라면, Zustand나 Redux의 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}
하지만 여기서 새로운 문제가 등장합니다. 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가 여러분의 가장 친한 친구가 될 것"이라고 한 이유죠.
웹에서는 localStorage가 동기적으로 동작하는데, React Native에서는 왜 AsyncStorage라는 비동기 API를 사용할까요? 동기적으로 하면 더 확실한 초기값을 보장할 수 있을 텐데 말이죠.
답은 간단합니다. 모바일에서는 UI 스레드 블로킹이 곧 앱의 죽음이기 때문입니다.
// ❌ 만약 동기라면 이런 참사가...
const syncStorage = {
getItem: (key) => {
// 100ms 디스크 I/O 발생
// → UI 스레드 블로킹
// → iOS: watchdog가 앱 강제 종료
// → Android: ANR (Application Not Responding)
}
};
플랫폼별 제약사항을 보면 이해가 쉬워집니다:
| 플랫폼 | 제약사항 | 결과 |
|---|---|---|
| iOS | Main thread 5초 이상 블로킹 | Watchdog가 앱 강제 종료 |
| Android | UI 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로 표현하는 것이 현실적인 해답입니다.
AsyncStorage가 비동기여야 하는 이유를 이해했다면, 새로운 의문이 생깁니다. MMKV는 동기적 API를 제공하는데 어떻게 UI 블로킹 없이 빠른 성능을 낼 수 있을까요?
먼저 기존 AsyncStorage의 문제점을 이해해야 합니다:
전통적인 AsyncStorage 흐름:
JS Thread → JSON 직렬화 → Bridge Queue → Native Thread → 파일 I/O
↓ (병목지점) ↓
UI Thread ← JSON 역직렬화 ← Bridge Queue ← Native Thread ← 결과
문제는 단순히 파일 I/O만이 아니었습니다. JSON 직렬화와 브릿지 통신이 더 큰 오버헤드였죠.
실제 벤치마크를 보면:
30배 차이가 나는 이유가 여기에 있습니다.
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의 전부는 아닙니다. 여러 계층에서 최적화가 이뤄졌죠:
1. 메모리 맵핑 (mmap)
일반 파일 I/O: 앱 → 시스템 콜 → 커널 → 디스크 → 메모리 복사
메모리 맵핑: 앱 → 메모리 직접 접근 (디스크가 메모리처럼 작동)
2. Append-Only 구조
3. Protocol Buffers 직렬화
이렇게 여러 최적화가 겹쳐서 "동기적이지만 거의 즉시 반환"이라는 마법 같은 결과를 만들어낸 것입니다.
MMKV가 JSI를 사용한다는 것을 알았을 때, 자연스럽게 떠오르는 질문입니다. JSI는 React Native의 New Architecture에서 나온 기술 아니었나요? 그럼 MMKV는 New Architecture에서만 동작하는 건가요?
이건 많은 개발자들이 가지는 오해입니다. 실제 타임라인을 보면:
| 연도 | 기술 | 상태 |
|---|---|---|
| 2018 | JSI 발표 (ReactConf) | 컨셉 공개 |
| 2019-2020 | JSI 구현 | React Native 0.60+ |
| 2021 | MMKV 2.0 | JSI 기반 첫 상용화 |
| 2022 | New Architecture | 실험적 도입 (0.68) |
| 2024 | New Architecture | 기본 활성화 (0.76) |
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 개발팀의 아키텍처 선택입니다.
지금까지 React Native 레이어에서의 변화를 살펴봤다면, 이제 그 아래층인 네이티브 플랫폼의 저장소들도 들여다봅시다.
// 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>
성능 특성:
iOS 12+ 중요 변경사항:
// ❌ Deprecated (더 이상 불필요)
[[NSUserDefaults standardUserDefaults] synchronize];
// ✅ 자동으로 비동기 처리됨
[[NSUserDefaults standardUserDefaults] setObject:@"value" forKey:@"key"];
이 변경으로 iOS에서는 수동 동기화 호출이 불필요해졌습니다.
<!-- /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 설계에도 영향을 미쳤습니다.
React Native 얘기를 하다가 웹으로 돌아가는 이유는, 원글에서 언급된 localStorage의 이상한 동작 때문입니다.
// 현재 탭에서 값 변경
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);
};
이런 웹과 모바일의 미묘한 차이들이 크로스 플랫폼 개발을 복잡하게 만드는 요소들입니다.
지금까지의 여정을 통해 각 저장소 기술의 특성과 한계를 이해했다면, 이제 실무에서 어떻게 활용할지 정리해봅시다.
// 🤔 언제 무엇을 사용할까?
// 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));
| 저장소 | 읽기 속도 | 쓰기 속도 | 동기/비동기 | 추천 용도 |
|---|---|---|---|---|
| AsyncStorage | 50-200ms | 100-300ms | 비동기 | 일반적인 설정 저장 |
| MMKV v2 | 0.1-1ms | 0.5-2ms | 동기 | 고성능 필요 시 (Old Arch) |
| MMKV v3 | 0.05-0.5ms | 0.2-1ms | 동기 | 고성능 필요 시 (New Arch) |
| SQLite | 1-10ms | 5-50ms | 비동기 | 복잡한 쿼리, 관계형 데이터 |
// 에러 처리를 포함한 안전한 래퍼
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 상태 관리" 질문에서 시작해서, 우리는 다음과 같은 깊이 있는 이해에 도달했습니다:
// 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가 아니라, 왜 그런 설계 결정이 내려졌는지, 어떤 문제를 해결하려고 하는지를 이해하면 새로운 기술이 나와도 빠르게 적응할 수 있습니다.
"왜?"를 끝까지 물어보는 습관이 더 나은 개발자, 더 나은 아키텍처를 만듭니다.
📋 작성 정보