왜 SQLite인가?
모바일 앱에서 데이터를 저장하는 방법은 여러 가지가 있습니다. AsyncStorage는 간단한 키-값 저장에 적합하지만, 구조화된 데이터를 다루기에는 한계가 있습니다. SQLite는 관계형 데이터베이스의 강력함을 로컬 환경에서 제공합니다.
저장 방식 비교
| 항목 | AsyncStorage | expo-sqlite | MMKV |
|---|---|---|---|
| 데이터 구조 | 키-값 (문자열) | 관계형 테이블 | 키-값 (바이너리) |
| 쿼리 기능 | 없음 | SQL 전체 지원 | 없음 |
| 데이터 크기 | 소규모 | 대규모 가능 | 소규모 |
| 관계 표현 | 불가능 | JOIN, FK 지원 | 불가능 |
| 적합한 용도 | 설정, 토큰 | 운동 기록, 일정 | 캐시, 설정 |
환경 설정
expo-sqlite 설치
npx expo install expo-sqlite
Expo SDK 51 이상에서는 expo-sqlite가 새로운 동기 API를 제공합니다. 이 가이드에서는 최신 API를 기준으로 설명합니다.
데이터베이스 초기화
기본 연결
import * as SQLite from 'expo-sqlite';
// 데이터베이스 열기 (없으면 자동 생성)
const db = SQLite.openDatabaseSync('myapp.db');
// WAL 모드 활성화 (성능 향상)
db.execSync('PRAGMA journal_mode = WAL;');
db.execSync('PRAGMA foreign_keys = ON;');
테이블 생성
function initDatabase() {
db.execSync(`
CREATE TABLE IF NOT EXISTS workouts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
date TEXT NOT NULL,
duration INTEGER DEFAULT 0,
memo TEXT,
created_at TEXT DEFAULT (datetime('now', 'localtime'))
);
CREATE TABLE IF NOT EXISTS exercises (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workout_id INTEGER NOT NULL,
name TEXT NOT NULL,
sets INTEGER DEFAULT 0,
reps INTEGER DEFAULT 0,
weight REAL DEFAULT 0,
FOREIGN KEY (workout_id) REFERENCES workouts(id)
ON DELETE CASCADE
);
`);
}
CRUD 작업
Create (데이터 삽입)
function addWorkout(name, date, memo) {
const result = db.runSync(
'INSERT INTO workouts (name, date, memo) VALUES (?, ?, ?)',
[name, date, memo]
);
console.log(`운동 추가됨 (ID: ${result.lastInsertRowId})`);
return result.lastInsertRowId;
}
function addExercise(workoutId, name, sets, reps, weight) {
const result = db.runSync(
`INSERT INTO exercises (workout_id, name, sets, reps, weight)
VALUES (?, ?, ?, ?, ?)`,
[workoutId, name, sets, reps, weight]
);
return result.lastInsertRowId;
}
Read (데이터 조회)
// 전체 운동 기록 조회
function getAllWorkouts() {
return db.getAllSync(
'SELECT * FROM workouts ORDER BY date DESC'
);
}
// 특정 날짜의 운동 조회
function getWorkoutsByDate(date) {
return db.getAllSync(
'SELECT * FROM workouts WHERE date = ?',
[date]
);
}
// 운동에 포함된 종목 조회
function getExercises(workoutId) {
return db.getAllSync(
'SELECT * FROM exercises WHERE workout_id = ? ORDER BY id',
[workoutId]
);
}
// 단일 레코드 조회
function getWorkoutById(id) {
return db.getFirstSync(
'SELECT * FROM workouts WHERE id = ?',
[id]
);
}
Update (데이터 수정)
function updateWorkout(id, name, memo) {
const result = db.runSync(
'UPDATE workouts SET name = ?, memo = ? WHERE id = ?',
[name, memo, id]
);
return result.changes; // 수정된 행 수
}
function updateExercise(id, sets, reps, weight) {
db.runSync(
'UPDATE exercises SET sets = ?, reps = ?, weight = ? WHERE id = ?',
[sets, reps, weight, id]
);
}
Delete (데이터 삭제)
function deleteWorkout(id) {
// CASCADE 설정으로 관련 exercises도 자동 삭제
db.runSync('DELETE FROM workouts WHERE id = ?', [id]);
}
function deleteExercise(id) {
db.runSync('DELETE FROM exercises WHERE id = ?', [id]);
}
React 컴포넌트에서 활용
커스텀 Hook 패턴
import { useState, useEffect, useCallback } from 'react';
function useWorkouts() {
const [workouts, setWorkouts] = useState([]);
const [loading, setLoading] = useState(true);
const refresh = useCallback(() => {
setLoading(true);
const data = getAllWorkouts();
setWorkouts(data);
setLoading(false);
}, []);
useEffect(() => {
initDatabase();
refresh();
}, [refresh]);
const add = useCallback((name, date, memo) => {
addWorkout(name, date, memo);
refresh();
}, [refresh]);
const remove = useCallback((id) => {
deleteWorkout(id);
refresh();
}, [refresh]);
return { workouts, loading, add, remove, refresh };
}
화면에서 사용
import { View, Text, FlatList, TouchableOpacity } from 'react-native';
export default function WorkoutListScreen() {
const { workouts, loading, remove } = useWorkouts();
if (loading) {
return <Text>로딩 중...</Text>;
}
return (
<FlatList
data={workouts}
keyExtractor={(item) => String(item.id)}
renderItem={({ item }) => (
<View style={{ padding: 16, borderBottomWidth: 1, borderColor: '#eee' }}>
<Text style={{ fontSize: 18, fontWeight: 'bold' }}>
{item.name}
</Text>
<Text style={{ color: '#666' }}>{item.date}</Text>
{item.memo && <Text>{item.memo}</Text>}
<TouchableOpacity onPress={() => remove(item.id)}>
<Text style={{ color: 'red', marginTop: 8 }}>삭제</Text>
</TouchableOpacity>
</View>
)}
ListEmptyComponent={
<Text style={{ textAlign: 'center', padding: 40, color: '#999' }}>
운동 기록이 없습니다
</Text>
}
/>
);
}
트랜잭션 처리
여러 작업을 하나의 단위로 묶어 데이터 일관성을 보장합니다.
function addWorkoutWithExercises(name, date, exercises) {
db.execSync('BEGIN TRANSACTION');
try {
const result = db.runSync(
'INSERT INTO workouts (name, date) VALUES (?, ?)',
[name, date]
);
const workoutId = result.lastInsertRowId;
for (const ex of exercises) {
db.runSync(
`INSERT INTO exercises (workout_id, name, sets, reps, weight)
VALUES (?, ?, ?, ?, ?)`,
[workoutId, ex.name, ex.sets, ex.reps, ex.weight]
);
}
db.execSync('COMMIT');
return workoutId;
} catch (error) {
db.execSync('ROLLBACK');
throw error;
}
}
데이터베이스 마이그레이션
앱이 업데이트되면서 테이블 구조가 변경될 수 있습니다. 버전 기반 마이그레이션으로 안전하게 관리합니다.
const CURRENT_DB_VERSION = 3;
function migrateDatabase() {
const result = db.getFirstSync('PRAGMA user_version');
let version = result.user_version;
if (version < 1) {
db.execSync(`
CREATE TABLE IF NOT EXISTS workouts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
date TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now', 'localtime'))
);
`);
version = 1;
}
if (version < 2) {
db.execSync(`
ALTER TABLE workouts ADD COLUMN duration INTEGER DEFAULT 0;
ALTER TABLE workouts ADD COLUMN memo TEXT;
`);
version = 2;
}
if (version < 3) {
db.execSync(`
CREATE TABLE IF NOT EXISTS exercises (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workout_id INTEGER NOT NULL,
name TEXT NOT NULL,
sets INTEGER DEFAULT 0,
reps INTEGER DEFAULT 0,
weight REAL DEFAULT 0,
FOREIGN KEY (workout_id) REFERENCES workouts(id)
ON DELETE CASCADE
);
`);
version = 3;
}
db.execSync(`PRAGMA user_version = ${CURRENT_DB_VERSION}`);
}
통계 쿼리 활용
SQL의 집계 함수를 활용하면 복잡한 통계도 쉽게 구할 수 있습니다.
// 월별 운동 횟수
function getMonthlyStats(year, month) {
const pattern = `${year}-${String(month).padStart(2, '0')}%`;
return db.getFirstSync(
`SELECT
COUNT(*) as total_workouts,
SUM(duration) as total_duration,
AVG(duration) as avg_duration
FROM workouts
WHERE date LIKE ?`,
[pattern]
);
}
// 종목별 최대 중량 기록
function getPersonalRecords() {
return db.getAllSync(`
SELECT
e.name,
MAX(e.weight) as max_weight,
w.date as record_date
FROM exercises e
JOIN workouts w ON e.workout_id = w.id
WHERE e.weight > 0
GROUP BY e.name
ORDER BY e.name
`);
}
// 최근 7일 운동 기록
function getWeeklyHistory() {
return db.getAllSync(`
SELECT date, COUNT(*) as workout_count
FROM workouts
WHERE date >= date('now', '-7 days')
GROUP BY date
ORDER BY date
`);
}
성능 최적화
인덱스 추가
자주 검색하는 컬럼에 인덱스를 생성하면 조회 속도가 크게 향상됩니다.
db.execSync(`
CREATE INDEX IF NOT EXISTS idx_workouts_date
ON workouts(date);
CREATE INDEX IF NOT EXISTS idx_exercises_workout
ON exercises(workout_id);
`);
대량 삽입 최적화
function bulkInsertExercises(workoutId, exercises) {
const stmt = db.prepareSync(
`INSERT INTO exercises (workout_id, name, sets, reps, weight)
VALUES (?, ?, ?, ?, ?)`
);
db.execSync('BEGIN TRANSACTION');
try {
for (const ex of exercises) {
stmt.executeSync([workoutId, ex.name, ex.sets, ex.reps, ex.weight]);
}
db.execSync('COMMIT');
} catch (error) {
db.execSync('ROLLBACK');
throw error;
} finally {
stmt.finalizeSync();
}
}
페이지네이션
데이터가 많을 때 한 번에 모두 불러오지 않고 페이지 단위로 조회합니다.
function getWorkoutsPaginated(page, pageSize = 20) {
const offset = (page - 1) * pageSize;
const data = db.getAllSync(
'SELECT * FROM workouts ORDER BY date DESC LIMIT ? OFFSET ?',
[pageSize, offset]
);
const countResult = db.getFirstSync(
'SELECT COUNT(*) as total FROM workouts'
);
return {
data,
total: countResult.total,
hasMore: offset + pageSize < countResult.total,
};
}
AsyncStorage와 비교 예제
같은 기능을 AsyncStorage로 구현했을 때의 차이를 보면 SQLite의 장점이 명확합니다.
// AsyncStorage: 특정 날짜의 운동을 찾으려면
// 전체 데이터를 불러와서 JavaScript로 필터링해야 함
const all = JSON.parse(await AsyncStorage.getItem('workouts'));
const filtered = all.filter(w => w.date === '2026-02-05');
// SQLite: 데이터베이스 엔진이 직접 필터링
const filtered = db.getAllSync(
'SELECT * FROM workouts WHERE date = ?',
['2026-02-05']
);
데이터가 1000건 이상이면 SQLite의 인덱스 기반 검색이 AsyncStorage의 전체 탐색보다 수십 배 빠릅니다.
결론
SQLite는 모바일 앱에서 구조화된 데이터를 관리하는 가장 강력한 방법입니다. expo-sqlite의 동기 API는 사용이 직관적이고, SQL의 표현력을 그대로 활용할 수 있습니다. 특히 운동 기록, 가계부, 일정 관리처럼 데이터 간 관계가 있는 앱에서 진가를 발휘합니다.
실제로 이 기술을 활용하여 오늘의운동 앱의 전체 데이터 레이어를 구축했으며, 트랜잭션과 마이그레이션을 통해 안정적인 데이터 관리를 구현하고 있습니다.