React Native 앱에서 데이터를 저장할 때 처음엔 대부분 AsyncStorage를 쓴다. 문자열 key-value 저장이라 간단하다. 근데 데이터가 조금만 복잡해져도 문제가 생긴다. 여러 항목을 필터링하거나 정렬하려면 전체를 불러와서 JS에서 처리해야 한다. 네트워크가 없으면 아예 작동을 안 하는 앱도 마찬가지다.
expo-sqlite는 기기에 SQLite 데이터베이스를 만들어준다. 인터넷 연결 없이도 쿼리가 되고, 인덱스도 걸 수 있다.
설치
npx expo install expo-sqlite
Expo SDK 51부터 API가 크게 바뀌었다. 이전 버전이라면 공식 마이그레이션 가이드를 확인하는 게 좋다.
DB 초기화
import * as SQLite from 'expo-sqlite'
const db = SQLite.openDatabaseSync('myapp.db')
export function initDatabase() {
db.execSync(`
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
completed INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
`)
}
openDatabaseSync는 앱 시작 시 한 번만 호출하면 된다. 파일이 없으면 새로 만들고, 있으면 기존 걸 연다. execSync로 스키마를 정의하고, IF NOT EXISTS를 붙여두면 재시작할 때마다 에러 없이 통과한다.
CRUD 작업
type Todo = {
id: number
title: string
completed: number
created_at: string
}
export const todoDB = {
getAll: (): Todo[] => {
return db.getAllSync<Todo>(
'SELECT * FROM todos ORDER BY created_at DESC'
)
},
add: (title: string): void => {
db.runSync('INSERT INTO todos (title) VALUES (?)', [title])
},
toggle: (id: number, completed: boolean): void => {
db.runSync('UPDATE todos SET completed = ? WHERE id = ?', [
completed ? 1 : 0,
id,
])
},
delete: (id: number): void => {
db.runSync('DELETE FROM todos WHERE id = ?', [id])
},
search: (query: string): Todo[] => {
return db.getAllSync<Todo>(
'SELECT * FROM todos WHERE title LIKE ? ORDER BY created_at DESC',
[`%${query}%`]
)
},
}
getAllSync는 쿼리 결과를 배열로 반환하고, runSync는 INSERT/UPDATE/DELETE에 쓴다. Sync 버전이 코드는 단순하지만, 무거운 쿼리라면 getAll/run(Promise 기반)을 쓰는 게 UI 블로킹을 피할 수 있다.
React 컴포넌트에서 사용
import { useState, useEffect } from 'react'
import {
View, Text, TextInput, Button,
FlatList, TouchableOpacity, StyleSheet
} from 'react-native'
import { initDatabase, todoDB } from '@/lib/database'
export default function TodoScreen() {
const [todos, setTodos] = useState<Todo[]>([])
const [input, setInput] = useState('')
useEffect(() => {
initDatabase()
setTodos(todoDB.getAll())
}, [])
const handleAdd = () => {
if (!input.trim()) return
todoDB.add(input.trim())
setTodos(todoDB.getAll())
setInput('')
}
const handleToggle = (id: number, completed: number) => {
todoDB.toggle(id, completed === 0)
setTodos(todoDB.getAll())
}
const handleDelete = (id: number) => {
todoDB.delete(id)
setTodos(todoDB.getAll())
}
return (
<View style={styles.container}>
<View style={styles.inputRow}>
<TextInput
style={styles.input}
value={input}
onChangeText={setInput}
placeholder="할 일 추가"
/>
<Button title="추가" onPress={handleAdd} />
</View>
<FlatList
data={todos}
keyExtractor={(item) => item.id.toString()}
renderItem={({ item }) => (
<TouchableOpacity
style={styles.item}
onPress={() => handleToggle(item.id, item.completed)}
onLongPress={() => handleDelete(item.id)}
>
<Text
style={[
styles.itemText,
item.completed === 1 && styles.completed,
]}
>
{item.title}
</Text>
</TouchableOpacity>
)}
/>
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16 },
inputRow: { flexDirection: 'row', gap: 8, marginBottom: 16 },
input: { flex: 1, borderWidth: 1, borderColor: '#ccc', borderRadius: 8, paddingHorizontal: 12 },
item: { paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: '#eee' },
itemText: { fontSize: 16 },
completed: { textDecorationLine: 'line-through', color: '#aaa' },
})
서버와 동기화할 때
완전 오프라인 앱이 아니라면 로컬 DB를 캐시처럼 쓰는 패턴이 유용하다. 앱 실행 시 서버에서 데이터를 가져와 SQLite에 저장하고, 이후 읽기는 로컬에서 한다. 오프라인 상태에서도 기존 데이터가 보이고, 연결이 되면 백그라운드에서 동기화한다.
import NetInfo from '@react-native-community/netinfo'
async function syncFromServer() {
const state = await NetInfo.fetch()
if (!state.isConnected) return // 오프라인이면 그냥 로컬 데이터 사용
try {
const data = await api.getTodos()
db.execSync('DELETE FROM todos')
for (const todo of data) {
db.runSync(
'INSERT INTO todos (id, title, completed) VALUES (?, ?, ?)',
[todo.id, todo.title, todo.completed ? 1 : 0]
)
}
} catch {
// 서버 에러가 나도 로컬 데이터는 그대로 유지
}
}
AsyncStorage가 단순 설정 저장에는 충분하지만, 목록 데이터가 생기는 순간 SQLite가 훨씬 낫다. 쿼리가 되고, 인덱스를 걸 수 있고, 오프라인에서 그대로 작동한다. Expo 환경에서는 npx expo install expo-sqlite 하나면 바로 시작할 수 있다.