[Flutter 공부] Flutter와 SQLite.

반응형

안녕하세요, 오늘은 Flutter 앱 개발에서 가장 골치 아픈 부분 중 하나인 데이터 저장과 관리에 대해 이야기해볼게요. API 서버가 죽거나 네트워크 연결이 불안정할 때마다 앱이 먹통이 되는 경험, 다들 해보셨죠? 저도 지난 3년간 Flutter로 여러 프로젝트를 진행하면서 이런 문제로 수없이 야근했습니다. 결국 로컬 데이터베이스의 중요성을 깨닫고 SQLite를 적극 활용하기 시작했는데, 이게 생각보다 훨씬 강력하더군요. 오늘은 제가 실전에서 검증한 Flutter SQLite 활용법을 공유합니다.

SQLite 기본 개념과 Flutter에서의 역할

SQLite는 가볍고 빠른 파일 기반 데이터베이스로, 서버 없이 로컬에서 동작합니다. 대규모 서버 데이터베이스와 달리 설정이 간단하고 별도의 프로세스가 필요 없어 모바일 앱에 딱 맞는 솔루션입니다. 내부적으로는 SQL 쿼리를 사용하지만, Flutter에서는 대부분 Dart 코드로 추상화해서 사용하게 됩니다.

SQLite가 Flutter 앱에서 중요한 이유는 명확합니다. 네트워크 연결 없이도 앱이 정상 작동하게 해주고, 사용자 데이터를 안전하게 저장하며, API 호출 횟수를 줄여 서버 부하와 데이터 사용량을 감소시킵니다. 특히 오프라인 모드 지원이 필요한 앱이나 자주 변경되지 않는 데이터를 다루는 앱에서는 필수적입니다.

Flutter에서 SQLite를 사용할 때 가장 중요한 점은 비동기 처리입니다. 데이터베이스 작업은 메인 스레드에서 실행하면 UI 프리징을 유발할 수 있으므로, 반드시 Future와 async/await를 활용한 비동기 방식으로 구현해야 합니다. 이 점을 무시하면 앱 성능이 현저히 저하됩니다.

Flutter 프로젝트에 SQLite 설정하기

Flutter에서 SQLite를 사용하려면 먼저 패키지 설정이 필요합니다. sqflitepath 패키지는 기본 중의 기본입니다. pubspec.yaml 파일에 다음과 같이 의존성을 추가하세요.

패키지명 버전 용도
sqflite ^2.3.0 Flutter용 SQLite 데이터베이스 기본 기능 제공
path ^1.8.3 파일 시스템 경로 관리
sqflite_common_ffi ^2.3.0+2 데스크톱 개발 및 테스트용(선택사항)
sqlite_viewer ^1.0.5 앱 내에서 DB 내용 확인용(디버깅)

패키지 설치 후, 데이터베이스 인스턴스를 초기화하고 관리할 헬퍼 클래스를 만들어야 합니다. 이 클래스는 싱글톤 패턴으로 구현하는 것이 좋습니다. 데이터베이스 연결은 비용이 크므로, 앱 전체에서 하나의 인스턴스만 사용하는 것이 효율적입니다.

효율적인 데이터베이스 스키마 설계

SQLite는 관계형 데이터베이스이므로 테이블 구조와 관계 정의가 중요합니다. 테이블은 모델 클래스와 일치시키는 것이 좋습니다. 각 모델 클래스에는 toMap()fromMap() 메서드를 구현하여 SQLite와 Dart 객체 간 변환을 처리해야 합니다.

스키마 설계 시 주의할 점은 효율적인 인덱싱입니다. 자주 검색하거나 정렬하는 필드에는 인덱스를 추가하되, 남용하면 오히려 성능이 저하됩니다. 또한 SQLite는 데이터 타입이 느슨하므로, 명시적인 타입 지정이 중요합니다.

  1. 스키마 버전 관리: 앱 업데이트 시 데이터베이스 구조가 변경될 수 있으므로, 버전 관리와 마이그레이션 계획을 세워야 합니다. onUpgrade 콜백을 활용하세요.
  2. 정규화 vs 비정규화: 모바일 환경에서는 조인 연산이 비싸므로, 조회 성능을 위해 적절한 비정규화가 필요할 수 있습니다. 그러나 과도한 비정규화는 데이터 일관성 문제를 일으킬 수 있습니다.
  3. NULL 허용 정책: 가능한 NOT NULL 제약조건을 사용하고, 기본값을 설정하여 데이터 무결성을 유지하세요. NULL 값은 인덱싱과 검색 성능에 부정적 영향을 미칩니다.
  4. BLOB 데이터 관리: 이미지 같은 대용량 바이너리 데이터는 파일 시스템에 저장하고 경로만 데이터베이스에 저장하는 것이 효율적입니다. 데이터베이스 크기가 커지면 성능이 저하됩니다.
📝 SQLite 디버깅 팁

실제 앱에서 SQLite 데이터베이스를 디버깅하는 것은 쉽지 않습니다. 개발 중에는 sqlite_viewer 패키지로 앱 내에서 직접 DB를 검사할 수 있지만, 프로덕션 문제를 디버깅하려면 로깅 시스템을 구축해야 합니다. 모든 SQL 쿼리를 로깅하고, 에러 발생 시 DB 상태를 덤프하는 기능을 개발 빌드에 추가하는 것이 좋습니다. 또한 에뮬레이터를 사용할 경우 ADB를 통해 DB 파일을 직접 추출하여 SQLite 브라우저로 분석할 수 있습니다.

기본 CRUD 작업 구현하기

데이터베이스 설계가 완료되면 기본적인 CRUD(Create, Read, Update, Delete) 작업을 구현해야 합니다. 이 작업은 반복적인 코드가 많으므로, 재사용 가능한 저장소(Repository) 패턴으로 구현하는 것이 좋습니다. 각 모델마다 별도의 저장소 클래스를 만들어 데이터 접근 로직을 캡슐화하세요.

특히 sqflite 패키지는 SQL 인젝션 방지를 위해 매개변수화된 쿼리를 지원합니다. db.rawQuery() 대신 db.query()를 사용하고, WHERE 절에 직접 값을 넣지 말고 매개변수로 전달하세요. 이는 보안과 성능 모두에 중요합니다.

모든 데이터베이스 작업은 비동기로 처리해야 합니다. 특히 대량의 데이터를 처리할 때는 트랜잭션을 활용하세요. 트랜잭션은 여러 작업을 원자적으로 실행하여 데이터 일관성을 유지하고 성능을 향상시킵니다. db.transaction() 메서드를 사용하면 모든 작업이 성공하거나 모두 실패하도록 보장할 수 있습니다.

데이터 변경 시 UI 업데이트를 처리하는 방법도 중요합니다. Stream, ChangeNotifier, Provider, Bloc 등 다양한 상태 관리 솔루션과 SQLite를 연동할 수 있습니다. 데이터가 변경될 때마다 알림을 보내는 메커니즘을 구현하여 UI가 자동으로 업데이트되도록 하세요.

고급 쿼리 및 성능 최적화 기법

기본 CRUD 작업을 넘어 복잡한 비즈니스 요구사항을 처리하려면 고급 쿼리 기능이 필요합니다. SQLite는 GROUP BY, JOIN, 서브쿼리 등 대부분의 SQL 기능을 지원합니다. 이런 복잡한 쿼리는 db.rawQuery()를 사용해 직접 SQL을 작성하는 것이 효율적일 수 있습니다.

최적화 기법 설명 성능 영향
인덱스 활용 자주 검색하는 필드에 인덱스 생성 조회 속도 크게 향상, 쓰기 속도 소폭 감소
트랜잭션 배치 처리 여러 작업을 하나의 트랜잭션으로 묶기 대량 작업 시 수백 배 성능 향상
LIMIT/OFFSET 사용 페이지네이션으로 대량 데이터 처리 메모리 사용량 감소, UI 응답성 유지
필드 선택적 조회 SELECT * 대신 필요한 컬럼만 조회 데이터 전송량 감소, 메모리 사용 최적화
VACUUM 명령 실행 주기적으로 DB 파일 최적화 파일 크기 감소, 일반 작업 성능 향상

대용량 데이터를 다룰 때는 메모리 사용을 최적화해야 합니다. 전체 결과를 한 번에 로드하는 대신, 커서를 사용하여 필요한 데이터만 점진적으로 로드하는 방법을 고려하세요. 또한 FTS(Full-Text Search) 기능을 활용하면 텍스트 검색 성능을 크게 향상시킬 수 있습니다.

실전 활용 사례와 패턴

SQLite는 다양한 앱 시나리오에서 활용할 수 있습니다. 몇 가지 일반적인 패턴과 실전 사례를 살펴보겠습니다. 이 패턴들은 실제 프로덕션 앱에서 검증된 것들입니다.

  • 오프라인 우선 전략: 모든 데이터 변경을 먼저 로컬 DB에 저장한 후, 네트워크 연결이 가능할 때 서버에 동기화합니다. 이 패턴은 오프라인에서도 앱이 완벽하게 작동하게 해줍니다.
  • 캐싱 레이어: API 응답을 SQLite에 캐싱하여 동일한 요청에 대해 네트워크 호출을 줄입니다. 각 캐시 항목에 타임스탬프를 추가하여 신선도를 관리하세요.
  • 대기열 시스템: 네트워크 작업을 위한 영구 대기열로 SQLite를 사용합니다. 앱이 종료되어도 미완료 작업이 보존되며, 재시작 시 계속 처리할 수 있습니다.
  • 단일 출처 전략: 모든 데이터 접근이 SQLite를 통하도록 하여 일관성을 유지합니다. API 응답은 항상 DB에 저장된 후 UI에 표시됩니다.
  • 변경 추적: 레코드에 버전 필드를 추가하여 변경 사항을 추적합니다. 이를 통해 증분 동기화 및 충돌 해결이 가능해집니다.
  • 데이터 마이그레이션: 앱 업데이트 시 사용자 데이터를 보존하기 위한 마이그레이션 전략입니다. 테이블 구조가 변경되더라도 데이터는 유지됩니다.
📝 백업 및 복구 전략

사용자 데이터는 소중합니다. 앱이 제거되거나 기기가 손상될 경우를 대비해 SQLite 데이터베이스 백업 기능을 구현하세요. 가장 간단한 방법은 데이터베이스 파일을 클라우드 스토리지에 업로드하는 것입니다. 백업 시에는 반드시 암호화를 적용하고, 사용자가 다른 기기에서 데이터를 복원할 수 있도록 명확한 UI를 제공하세요. 클라우드 백업에 동의하지 않는 사용자를 위해 로컬 파일 내보내기 옵션도 함께 제공하는 것이 좋습니다.

📝 SQLite 동시성 문제 해결

Flutter에서 SQLite 사용 시 가장 흔히 발생하는 문제 중 하나는 동시성 문제입니다. 'Database is locked' 오류가 발생한다면, 여러 스레드가 동시에 데이터베이스에 접근하고 있는 것입니다. 이 문제를 해결하기 위해 싱글톤 패턴을 적용하고, 데이터베이스 인스턴스를 중앙에서 관리하세요. 또한 각 Repository 메서드에서 동시 접근을 방지하기 위해 세마포어나 뮤텍스를 사용할 수 있습니다. 가장 간단한 방법은 synchronized 패키지를 사용하여 중요 데이터베이스 작업을 동기화하는 것입니다.

SQLite 데이터베이스 관리 클래스 구현

다음은 Flutter 앱에서 SQLite 데이터베이스를 초기화하고 관리하는 헬퍼 클래스의 전체 구현 예시입니다. 이 클래스는 싱글톤 패턴을 사용하여 하나의 데이터베이스 연결만 유지하며, 테이블 생성 및 마이그레이션 처리도 포함합니다.

import 'dart:async';
import 'dart:io';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
import 'package:synchronized/synchronized.dart';

class DatabaseHelper {
  static const _databaseName = "my_app.db";
  static const _databaseVersion = 1;

  // 테이블 및 컬럼 이름
  static const tableUsers = 'users';
  static const columnId = 'id';
  static const columnName = 'name';
  static const columnEmail = 'email';
  static const columnCreatedAt = 'created_at';

  // 싱글톤 패턴 구현
  DatabaseHelper._privateConstructor();
  static final DatabaseHelper instance = DatabaseHelper._privateConstructor();

  static Database? _database;
  final _lock = Lock();

  // 데이터베이스 인스턴스 가져오기 (없으면 초기화)
  Future<Database> get database async {
    if (_database != null) return _database!;
    
    // 인스턴스가 없으면 _lock으로 동시 접근 방지
    await _lock.synchronized(() async {
      // double-checking 패턴
      if (_database == null) {
        _database = await _initDatabase();
      }
    });
    
    return _database!;
  }

  // 데이터베이스 초기화 및 열기
  Future<Database> _initDatabase() async {
    // 앱 문서 디렉토리에 데이터베이스 파일 위치 지정
    Directory documentsDirectory = await getApplicationDocumentsDirectory();
    String path = join(documentsDirectory.path, _databaseName);
    
    // 데이터베이스 열기
    return await openDatabase(
      path,
      version: _databaseVersion,
      onCreate: _onCreate,
      onUpgrade: _onUpgrade,
    );
  }

  // 데이터베이스 생성 콜백 (첫 실행 시 호출)
  Future _onCreate(Database db, int version) async {
    // 사용자 테이블 생성
    await db.execute('''
      CREATE TABLE $tableUsers (
        $columnId INTEGER PRIMARY KEY AUTOINCREMENT,
        $columnName TEXT NOT NULL,
        $columnEmail TEXT NOT NULL UNIQUE,
        $columnCreatedAt TEXT NOT NULL
      )
    ''');
    
    // 작업 테이블 생성 (관계 설정 예시)
    await db.execute('''
      CREATE TABLE tasks (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT NOT NULL,
        description TEXT,
        due_date TEXT,
        is_completed INTEGER DEFAULT 0,
        user_id INTEGER,
        FOREIGN KEY (user_id) REFERENCES $tableUsers ($columnId)
      )
    ''');
    
    // 인덱스 생성
    await db.execute(
      'CREATE INDEX tasks_user_id_idx ON tasks (user_id)'
    );
  }

  // 데이터베이스 업그레이드 콜백
  Future _onUpgrade(Database db, int oldVersion, int newVersion) async {
    // 버전별 마이그레이션 로직
    if (oldVersion == 1 && newVersion >= 2) {
      // 버전 1에서 2로 업그레이드하는 경우
      await db.execute('ALTER TABLE tasks ADD COLUMN priority INTEGER DEFAULT 0');
    }
    
    if (oldVersion <= 2 && newVersion >= 3) {
      // 버전 2에서 3으로 업그레이드하는 경우
      await db.execute('CREATE TABLE categories (id INTEGER PRIMARY KEY, name TEXT)');
    }
  }

  // 데이터베이스 완전 리셋 (개발 중에만 사용)
  Future<void> resetDatabase() async {
    Database db = await database;
    await db.execute("DROP TABLE IF EXISTS tasks");
    await db.execute("DROP TABLE IF EXISTS $tableUsers");
    await _onCreate(db, _databaseVersion);
  }

  // 트랜잭션 예시
  Future<void> insertUserWithTasks(Map<String, dynamic> user, List<Map<String, dynamic>> tasks) async {
    final db = await database;
    await db.transaction((txn) async {
      int userId = await txn.insert(tableUsers, user);
      
      for (var task in tasks) {
        task['user_id'] = userId;
        await txn.insert('tasks', task);
      }
    });
  }

  // VACUUM 실행 (DB 최적화)
  Future<void> vacuum() async {
    final db = await database;
    await db.execute('VACUUM');
  }

  // 데이터베이스 크기 체크
  Future<String> getDatabaseSize() async {
    Directory documentsDirectory = await getApplicationDocumentsDirectory();
    String path = join(documentsDirectory.path, _databaseName);
    File dbFile = File(path);
    int bytes = await dbFile.length();
    
    if (bytes < 1024) {
      return '$bytes B';
    } else if (bytes < 1048576) {
      return '${(bytes / 1024).toStringAsFixed(2)} KB';
    } else {
      return '${(bytes / 1048576).toStringAsFixed(2)} MB';
    }
  }
}

사용자 모델 및 저장소 구현

다음은 위 데이터베이스 헬퍼 클래스와 함께 사용할 사용자 모델 클래스와 저장소 패턴의 구현 예시입니다. 이 패턴을 통해 데이터 접근 로직을 캡슐화하고 재사용성을 높일 수 있습니다.

// 사용자 모델 클래스
class User {
  final int? id;
  final String name;
  final String email;
  final DateTime createdAt;

  User({
    this.id,
    required this.name,
    required this.email,
    required this.createdAt,
  });

  // Map에서 User 객체 생성 (DB 결과 변환용)
  factory User.fromMap(Map<String, dynamic> map) => User(
    id: map['id'],
    name: map['name'],
    email: map['email'],
    createdAt: DateTime.parse(map['created_at']),
  );

  // User 객체를 Map으로 변환 (DB 저장용)
  Map<String, dynamic> toMap() => {
    if (id != null) 'id': id,
    'name': name,
    'email': email,
    'created_at': createdAt.toIso8601String(),
  };

  // User의 복사본 생성 (불변성 유지)
  User copyWith({
    int? id,
    String? name,
    String? email,
    DateTime? createdAt,
  }) {
    return User(
      id: id ?? this.id,
      name: name ?? this.name,
      email: email ?? this.email,
      createdAt: createdAt ?? this.createdAt,
    );
  }
}

// 사용자 저장소 클래스
class UserRepository {
  final DatabaseHelper _dbHelper = DatabaseHelper.instance;
  final _lock = Lock(); // 동시성 제어용

  // 사용자 추가
  Future<User> insertUser(User user) async {
    final db = await _dbHelper.database;
    final id = await db.insert(
      DatabaseHelper.tableUsers,
      user.toMap(),
      conflictAlgorithm: ConflictAlgorithm.replace,
    );
    return user.copyWith(id: id);
  }

  // 사용자 갱신
  Future<int> updateUser(User user) async {
    final db = await _dbHelper.database;
    return await db.update(
      DatabaseHelper.tableUsers,
      user.toMap(),
      where: '${DatabaseHelper.columnId} = ?',
      whereArgs: [user.id],
    );
  }

  // 사용자 삭제
  Future<int> deleteUser(int id) async {
    final db = await _dbHelper.database;
    return await db.delete(
      DatabaseHelper.tableUsers,
      where: '${DatabaseHelper.columnId} = ?',
      whereArgs: [id],
    );
  }

  // 전체 사용자 조회
  Future<List<User>> getAllUsers() async {
    // 동시 접근 방지
    return await _lock.synchronized(() async {
      final db = await _dbHelper.database;
      final List<Map<String, dynamic>> maps = await db.query(DatabaseHelper.tableUsers);
      return List.generate(maps.length, (i) => User.fromMap(maps[i]));
    });
  }

  // ID로 사용자 조회
  Future<User?> getUserById(int id) async {
    final db = await _dbHelper.database;
    final List<Map<String, dynamic>> maps = await db.query(
      DatabaseHelper.tableUsers,
      where: '${DatabaseHelper.columnId} = ?',
      whereArgs: [id],
    );
    
    if (maps.isNotEmpty) {
      return User.fromMap(maps.first);
    }
    return null;
  }

  // 이메일로 사용자 검색
  Future<User?> getUserByEmail(String email) async {
    final db = await _dbHelper.database;
    final List<Map<String, dynamic>> maps = await db.query(
      DatabaseHelper.tableUsers,
      where: '${DatabaseHelper.columnEmail} = ?',
      whereArgs: [email],
    );
    
    if (maps.isNotEmpty) {
      return User.fromMap(maps.first);
    }
    return null;
  }

  // 사용자 페이지네이션 (예: 10명씩 로드)
  Future<List<User>> getUsersPaginated(int page, int pageSize) async {
    final db = await _dbHelper.database;
    final List<Map<String, dynamic>> maps = await db.query(
      DatabaseHelper.tableUsers,
      orderBy: '${DatabaseHelper.columnName} ASC',
      limit: pageSize,
      offset: page * pageSize,
    );
    
    return List.generate(maps.length, (i) => User.fromMap(maps[i]));
  }

  // 사용자 검색 (이름에 특정 문자열 포함)
  Future<List<User>> searchUsersByName(String query) async {
    final db = await _dbHelper.database;
    final List<Map<String, dynamic>> maps = await db.query(
      DatabaseHelper.tableUsers,
      where: '${DatabaseHelper.columnName} LIKE ?',
      whereArgs: ['%$query%'],
    );
    
    return List.generate(maps.length, (i) => User.fromMap(maps[i]));
  }
}

마치며

지금까지 Flutter에서 SQLite를 활용한 효율적인 로컬 데이터베이스 구현 방법에 대해 알아봤습니다. SQLite는 단순한 로컬 저장소 이상의 가치를 제공합니다. 오프라인 지원, 데이터 캐싱, 성능 최적화 등 다양한 이점으로 앱의 품질을 한 단계 높여줍니다.

실제 프로젝트에서는 위에서 다룬 코드 예제를 기반으로 앱의 요구사항에 맞게 확장하면 됩니다. 특히 저장소 패턴은 확장성과 유지보수성을 크게 향상시키므로 적극 도입하는 것이 좋습니다. 또한 Drift(이전의 Moor)나 Floor 같은 고수준 SQLite 래퍼 라이브러리를 사용하면 보일러플레이트 코드를 줄이고 타입 안전성을 높일 수 있습니다.

데이터베이스 설계는 앱 개발에서 가장 중요한 부분 중 하나입니다. 처음에는 시간이 조금 더 걸리더라도 스키마를 제대로 설계하고 마이그레이션 계획을 세우는 것이 장기적으로 득이 됩니다. API만 의존하던 앱에 로컬 데이터베이스를 추가하면 사용자 경험이 획기적으로 개선됩니다. 네트워크 연결이 불안정한 상황에서도 안정적으로 작동하는 앱을 만들어 사용자 만족도를 높이세요.

마지막으로, 데이터베이스 성능 이슈는 대부분 앱이 성장한 후에 발생합니다. 초기에는 기본 구조에 집중하고, 실제 성능 문제가 확인된 후에 최적화하는 것이 효율적입니다. 하지만 트랜잭션 사용, 인덱스 설정, 쿼리 최적화 같은 기본적인 성능 관련 패턴은 처음부터 적용하는 것이 좋습니다. 비록 당장 성능 문제가 보이지 않더라도, 나중에 큰 차이를 만들어낼 것입니다.

Designed by JB FACTORY