왜 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의 표현력을 그대로 활용할 수 있습니다. 특히 운동 기록, 가계부, 일정 관리처럼 데이터 간 관계가 있는 앱에서 진가를 발휘합니다.

실제로 이 기술을 활용하여 오늘의운동 앱의 전체 데이터 레이어를 구축했으며, 트랜잭션과 마이그레이션을 통해 안정적인 데이터 관리를 구현하고 있습니다.