Etc Programming
Flutter
이론
Sqflite vs Drift

Flutter Drift vs SQFlite: Repository 패턴 적용과 마이그레이션

결론

Drift를 추천합니다. Repository 패턴 적용과 마이그레이션 모두에서 Drift가 더 우수합니다.

Flutter databases overview에서 "Drift has emerged as the go-to choice for type-safe SQL database access. It's actively maintained and has excellent documentation and community support"라고 명시되어 있습니다.

Repository 패턴 적용 비교

Drift의 장점

  1. 자동 추상화 레이어: 코드 생성으로 DAO가 자동 생성되어 Repository처럼 동작합니다
  2. 타입 안정성: 컴파일 타임에 쿼리 오류를 검출합니다
  3. 보일러플레이트 최소화: 수동으로 작성할 코드가 적습니다
// Drift - 테이블 정의만으로 DAO 자동 생성
class Todos extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get title => text()();
  BoolColumn get completed => boolean().withDefault(const Constant(false))();
}
 
// 자동 생성된 DAO 사용
@DriftDatabase(tables: [Todos])
class AppDatabase extends _$AppDatabase {
  AppDatabase() : super(_openConnection());
 
  @override
  int get schemaVersion => 1;
 
  // DAO 메서드가 자동으로 생성됨
  Future<List<Todo>> getAllTodos() => select(todos).get();
  Future<int> insertTodo(TodosCompanion todo) => into(todos).insert(todo);
}
 
// Repository는 단순히 Database를 래핑
class TodoRepository {
  final AppDatabase _db;
  
  TodoRepository(this._db);
  
  Future<List<Todo>> getTodos() => _db.getAllTodos();
  Future<void> addTodo(String title) => 
    _db.insertTodo(TodosCompanion.insert(title: title));
}

Drift 공식 문서 (opens in a new tab)에서 "Drift generates type-safe Dart code from your SQL tables"라고 명시되어 있습니다.

SQFlite의 특징

  1. 직접 제어: SQL을 직접 작성하여 세밀한 제어 가능
  2. 수동 추상화: 모든 추상화 레이어를 직접 작성해야 함
  3. 유연성: 필요한 만큼만 추상화 가능
// SQFlite - 모든 코드를 수동 작성
class TodoDao {
  final Database db;
  
  TodoDao(this.db);
  
  Future<List<Map<String, dynamic>>> getAllTodos() async {
    return await db.query('todos');
  }
  
  Future<int> insertTodo(Map<String, dynamic> todo) async {
    return await db.insert('todos', todo);
  }
  
  // fromMap, toMap 메서드도 수동 작성 필요
  Todo fromMap(Map<String, dynamic> map) {
    return Todo(
      id: map['id'],
      title: map['title'],
      completed: map['completed'] == 1,
    );
  }
  
  Map<String, dynamic> toMap(Todo todo) {
    return {
      'id': todo.id,
      'title': todo.title,
      'completed': todo.completed ? 1 : 0,
    };
  }
}
 
// Repository 구현
class TodoRepository {
  final TodoDao _dao;
  
  TodoRepository(this._dao);
  
  Future<List<Todo>> getTodos() async {
    final maps = await _dao.getAllTodos();
    return maps.map((m) => _dao.fromMap(m)).toList();
  }
  
  Future<void> addTodo(Todo todo) async {
    await _dao.insertTodo(_dao.toMap(todo));
  }
}

Flutter databases comparison (opens in a new tab)에서 "The main drawback of sqflite is you have to write raw SQL, which is something most people don't like"라고 언급되어 있습니다.

비교 요약

항목DriftSQFlite
코드 생성자동수동
타입 안정성컴파일 타임런타임
보일러플레이트최소많음
학습 곡선중간낮음
Repository 통합쉬움보통
유연성높음매우 높음

Drift 마이그레이션 방법

1. 초기 설정

# pubspec.yaml
dependencies:
  drift: ^2.14.0
  sqlite3_flutter_libs: ^0.5.0
  path_provider: ^2.1.0
  path: ^1.8.0
 
dev_dependencies:
  drift_dev: ^2.14.0
  build_runner: ^2.4.0

2. 스키마 변경 및 마이그레이션 프로세스

// STEP 1: schemaVersion 증가
@DriftDatabase(tables: [Todos])
class AppDatabase extends _$AppDatabase {
  AppDatabase() : super(_openConnection());
 
  @override
  int get schemaVersion => 2; // 1에서 2로 증가
 
  @override
  MigrationStrategy get migration => MigrationStrategy(
    onUpgrade: (migrator, from, to) async {
      if (from == 1) {
        // 마이그레이션 로직
        await migrator.addColumn(todos, todos.dueDate);
      }
    },
  );
}

3. 자동 마이그레이션 도구 사용

# STEP 1: 스키마 덤프 (각 버전마다 실행)
dart run drift_dev schema dump lib/database/app_database.dart db_schemas/
 
# STEP 2: 마이그레이션 코드 생성
dart run drift_dev schema steps db_schemas/ lib/database/migration.dart

생성된 migration.dart 파일:

// 자동 생성됨
import 'package:drift/drift.dart';
import 'package:drift_dev/api/migrations.dart';
 
class GeneratedMigrations extends GeneratedHelper {
  @override
  int get schemaVersion => 2;
  
  @override
  Future<void> onCreate(Migrator m) async {
    await m.createTable(todos);
  }
  
  @override
  Future<void> onUpgrade(Migrator m, int from, int to) async {
    if (from == 1) {
      await m.addColumn(todos, todos.dueDate);
    }
  }
}

4. 생성된 마이그레이션 적용

@DriftDatabase(tables: [Todos])
class AppDatabase extends _$AppDatabase {
  AppDatabase() : super(_openConnection());
 
  @override
  int get schemaVersion => 2;
 
  @override
  MigrationStrategy get migration => MigrationStrategy(
    onUpgrade: (migrator, from, to) async {
      // 생성된 마이그레이션 사용
      await GeneratedMigrations().onUpgrade(migrator, from, to);
    },
  );
}

Drift Migrations Documentation (opens in a new tab)에서 "After changing your database schema and running the make-migrations command, Drift generates a database.steps.dart file"이라고 설명되어 있습니다.

5. 복잡한 마이그레이션 예시

@override
MigrationStrategy get migration => MigrationStrategy(
  onUpgrade: (migrator, from, to) async {
    if (from < 2) {
      // 컬럼 추가
      await migrator.addColumn(todos, todos.dueDate);
    }
    
    if (from < 3) {
      // 새 테이블 생성
      await migrator.createTable(categories);
      
      // 데이터 이전
      await customStatement(
        'INSERT INTO categories (name) SELECT DISTINCT category FROM todos'
      );
    }
    
    if (from < 4) {
      // 컬럼 이름 변경 (SQLite는 직접 지원 안함)
      await migrator.alterTable(
        TableMigration(
          todos,
          columnTransformer: {
            todos.title: todos.title.dartCast<String>(),
            todos.description: const CustomExpression('old_desc'),
          },
        ),
      );
    }
  },
);

6. 마이그레이션 테스트

import 'package:drift_dev/api/migrations.dart';
import 'package:test/test.dart';
 
void main() {
  late SchemaVerifier verifier;
 
  setUpAll(() {
    verifier = SchemaVerifier(GeneratedHelper());
  });
 
  test('schema v1 to v2 migration', () async {
    final connection = await verifier.startAt(1);
    final db = AppDatabase(connection);
    
    // v1에서 데이터 삽입
    await db.into(db.todos).insert(
      TodosCompanion.insert(title: 'Test'),
    );
    
    // v2로 마이그레이션
    final migratedDb = AppDatabase(
      await verifier.migrateAndValidate(db, 2),
    );
    
    // 데이터 검증
    final todos = await migratedDb.select(migratedDb.todos).get();
    expect(todos.length, 1);
    expect(todos.first.title, 'Test');
  });
}

Drift Migration Guide (opens in a new tab)에서 "Drift provides tooling and APIs to safely change database schemas, including command-line tools and testing utilities"라고 명시되어 있습니다.

SQFlite 마이그레이션 방법

1. 초기 설정

# pubspec.yaml
dependencies:
  sqflite: ^2.3.0
  path_provider: ^2.1.0
  path: ^1.8.0

2. 기본 마이그레이션 구조

class DatabaseProvider {
  static final DatabaseProvider instance = DatabaseProvider._();
  static Database? _database;
  
  DatabaseProvider._();
  
  Future<Database> get database async {
    if (_database != null) return _database!;
    _database = await _initDatabase();
    return _database!;
  }
  
  Future<Database> _initDatabase() async {
    final dbPath = await getDatabasesPath();
    final path = join(dbPath, 'app_database.db');
    
    return await openDatabase(
      path,
      version: 3, // 현재 버전
      onCreate: _onCreate,
      onUpgrade: _onUpgrade,
    );
  }
  
  Future<void> _onCreate(Database db, int version) async {
    // 최신 스키마로 생성
    await db.execute('''
      CREATE TABLE todos (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT NOT NULL,
        completed INTEGER NOT NULL DEFAULT 0,
        due_date TEXT,
        category_id INTEGER,
        FOREIGN KEY (category_id) REFERENCES categories(id)
      )
    ''');
    
    await db.execute('''
      CREATE TABLE categories (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL UNIQUE
      )
    ''');
  }
  
  Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
    // 버전별 마이그레이션
    if (oldVersion < 2) {
      await db.execute('ALTER TABLE todos ADD COLUMN due_date TEXT');
    }
    
    if (oldVersion < 3) {
      await db.execute('''
        CREATE TABLE categories (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          name TEXT NOT NULL UNIQUE
        )
      ''');
      
      await db.execute(
        'ALTER TABLE todos ADD COLUMN category_id INTEGER'
      );
      
      // 데이터 이전
      await db.execute('''
        INSERT INTO categories (name) 
        SELECT DISTINCT category FROM todos WHERE category IS NOT NULL
      ''');
    }
  }
}

SQFlite migration example (opens in a new tab)에서 공식 마이그레이션 패턴을 제공합니다.

3. sqflite_migration_plan 패키지 사용

dependencies:
  sqflite_migration_plan: ^3.0.0
import 'package:sqflite_migration_plan/sqflite_migration_plan.dart';
 
class DatabaseProvider {
  Future<Database> _initDatabase() async {
    final dbPath = await getDatabasesPath();
    final path = join(dbPath, 'app_database.db');
    
    return await openDatabaseWithMigration(
      path,
      MigrationConfig(
        initializationScript: [
          '''CREATE TABLE todos (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            title TEXT NOT NULL
          )'''
        ],
        migrationScripts: [
          // 버전 2 마이그레이션
          '''ALTER TABLE todos ADD COLUMN completed INTEGER NOT NULL DEFAULT 0''',
          
          // 버전 3 마이그레이션
          '''CREATE TABLE categories (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            name TEXT NOT NULL
          )''',
          '''ALTER TABLE todos ADD COLUMN category_id INTEGER''',
        ],
      ),
    );
  }
}

sqflite_migration_plan (opens in a new tab)은 "flexible migrations (upgrade and downgrade) for Flutter sqflite databases"를 제공합니다.

4. 파일 기반 마이그레이션 (sqflite_migrate)

dependencies:
  sqflite_migrate: ^1.0.0
assets/
  migrations/
    001_initial.sql
    002_add_due_date.sql
    003_add_categories.sql
-- assets/migrations/001_initial.sql
-- UP
CREATE TABLE todos (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  title TEXT NOT NULL
);
 
-- DOWN
DROP TABLE todos;
-- assets/migrations/002_add_due_date.sql
-- UP
ALTER TABLE todos ADD COLUMN due_date TEXT;
 
-- DOWN
ALTER TABLE todos DROP COLUMN due_date;
import 'package:sqflite_migrate/sqflite_migrate.dart';
 
class DatabaseProvider {
  Future<Database> _initDatabase() async {
    final dbPath = await getDatabasesPath();
    final path = join(dbPath, 'app_database.db');
    
    final db = await openDatabase(path);
    
    await migrate(
      db,
      migrationsPath: 'assets/migrations',
    );
    
    return db;
  }
}

sqflite_migrate (opens in a new tab)는 "file-based migrations with UP and DOWN sections"을 지원합니다.

5. Repository 패턴과 통합

// DAO
class TodoDao {
  final Database db;
  
  TodoDao(this.db);
  
  Future<List<Todo>> getAll() async {
    final List<Map<String, dynamic>> maps = await db.query('todos');
    return List.generate(maps.length, (i) => Todo.fromMap(maps[i]));
  }
  
  Future<int> insert(Todo todo) async {
    return await db.insert('todos', todo.toMap());
  }
  
  Future<int> update(Todo todo) async {
    return await db.update(
      'todos',
      todo.toMap(),
      where: 'id = ?',
      whereArgs: [todo.id],
    );
  }
  
  Future<int> delete(int id) async {
    return await db.delete(
      'todos',
      where: 'id = ?',
      whereArgs: [id],
    );
  }
}
 
// Repository
abstract class TodoRepository {
  Future<List<Todo>> getTodos();
  Future<void> addTodo(Todo todo);
  Future<void> updateTodo(Todo todo);
  Future<void> deleteTodo(int id);
}
 
class TodoRepositoryImpl implements TodoRepository {
  final TodoDao _dao;
  
  TodoRepositoryImpl(this._dao);
  
  @override
  Future<List<Todo>> getTodos() => _dao.getAll();
  
  @override
  Future<void> addTodo(Todo todo) async {
    await _dao.insert(todo);
  }
  
  @override
  Future<void> updateTodo(Todo todo) async {
    await _dao.update(todo);
  }
  
  @override
  Future<void> deleteTodo(int id) async {
    await _dao.delete(id);
  }
}

마이그레이션 Best Practices

공통 원칙

  1. 버전 관리: 항상 마이그레이션을 순차적으로 적용
  2. 데이터 백업: 중요한 데이터는 마이그레이션 전 백업
  3. 테스트: 각 마이그레이션 단계를 테스트
  4. 롤백 계획: 문제 발생 시 대응 방안 준비

SQFlite best practices (opens in a new tab)에서 "Always add new migrations to the end of the list, never in the middle"라고 강조합니다.

Drift 권장사항

  • 스키마 변경 시 drift_dev schema dump 실행하여 스키마 히스토리 유지
  • 자동 생성된 마이그레이션 코드 검토 후 사용
  • 복잡한 마이그레이션은 customStatement 활용
  • 테스트 코드로 마이그레이션 검증

SQFlite 권장사항

  • 마이그레이션 SQL을 별도 파일로 관리
  • 버전별로 명확한 주석 작성
  • onUpgrade에서 모든 버전 케이스 처리
  • 앱 재시작 필요 (Hot Reload 작동 안함)

SQFlite migration guide (opens in a new tab)에서 "You will have to restart your app when you change your application schema; Flutter Hot-reload won't work"이라고 명시되어 있습니다.

성능 고려사항

  • Drift는 약간의 ORM 오버헤드가 있지만 대부분의 앱에서 무시할 수 있는 수준입니다
  • 한 개발자가 SQFlite에서 Drift로 마이그레이션 후 약 0.1초의 성능 저하를 보고했으나, 타입 안정성과 개발 생산성이 더 중요한 고려사항입니다

Performance discussion (opens in a new tab)에서 실제 마이그레이션 경험이 공유되었습니다.

언제 SQFlite를 선택할까?

다음 경우에는 SQFlite를 고려할 수 있습니다:

  1. 극한의 성능 최적화: 밀리초 단위 최적화가 필요한 경우
  2. 복잡한 SQL 쿼리: Drift가 지원하지 않는 고급 SQL 기능 사용
  3. 코드 생성 회피: 빌드 프로세스에 코드 생성을 추가하고 싶지 않은 경우
  4. 기존 SQFlite 프로젝트: 이미 잘 구축된 SQFlite 프로젝트가 있는 경우

출처