블로그 목록

Expo SQLite로 오프라인 앱 만들기 — 기기 로컬 DB로 데이터 유지하기

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 하나면 바로 시작할 수 있다.