SQLite는 경량화된 데이터베이스 시스템으로, Dart 애플리케이션에서 간단한 데이터베이스 처리를 위해 많이 사용된다. SQLite는 서버가 필요하지 않고 파일 기반으로 동작하기 때문에 모바일, 웹, 데스크탑 애플리케이션에서 활용할 수 있다. Dart는 sqflite라는 패키지를 통해 SQLite 데이터베이스와 상호작용할 수 있다.

SQLite 기본 개념

SQLite는 관계형 데이터베이스로 테이블, 행(row), 열(column) 구조를 갖는다. 각 테이블은 여러 행을 포함하며, 각 행은 여러 열을 가지고 있다. 열은 테이블의 필드(field)를 나타내며, 각 필드에는 고유한 데이터 타입이 있다.

SQLite의 주요 특징은 다음과 같다: - 서버리스(serverless) - 트랜잭션 지원 - ACID(원자성, 일관성, 고립성, 지속성) 속성 보장 - 다양한 데이터 타입 지원 (TEXT, INTEGER, REAL 등)

sqflite 패키지 설치

Dart에서 SQLite를 사용하기 위해서는 sqflite 패키지를 사용한다. 먼저, pubspec.yaml 파일에 sqflitepath 패키지를 추가해야 한다.

dependencies:
  flutter:
    sdk: flutter
  sqflite: ^2.0.0+3
  path: ^1.8.0

이 패키지들을 설치한 후 import를 통해 해당 패키지를 프로젝트에 불러올 수 있다.

import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';

데이터베이스 초기화

SQLite 데이터베이스는 앱 실행 시 한 번만 초기화되고, 이후에는 같은 데이터베이스를 계속해서 사용한다. 데이터베이스를 초기화하는 방법은 다음과 같다.

Future<Database> initDatabase() async {
  return openDatabase(
    join(await getDatabasesPath(), 'example.db'),
    onCreate: (db, version) {
      return db.execute(
        "CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT, age INTEGER)",
      );
    },
    version: 1,
  );
}

위의 코드를 설명하자면:

테이블에 데이터 삽입

데이터 삽입은 insert 메소드를 사용하여 테이블에 데이터를 저장한다.

Future<void> insertUser(Database db, User user) async {
  await db.insert(
    'users',
    user.toMap(),
    conflictAlgorithm: ConflictAlgorithm.replace,
  );
}

데이터 조회

테이블에 저장된 데이터를 조회할 때는 query 메소드를 사용한다.

Future<List<User>> users(Database db) async {
  final List<Map<String, dynamic>> maps = await db.query('users');

  return List.generate(maps.length, (i) {
    return User(
      id: maps[i]['id'],
      name: maps[i]['name'],
      age: maps[i]['age'],
    );
  });
}

위 코드는 데이터베이스에서 모든 행을 가져와 List<User> 형식으로 변환한다.

데이터 업데이트

SQLite 데이터베이스에 저장된 데이터를 수정하려면 update 메소드를 사용한다.

Future<void> updateUser(Database db, User user) async {
  await db.update(
    'users',
    user.toMap(),
    where: "id = ?",
    whereArgs: [user.id],
  );
}

데이터 삭제

데이터 삭제는 delete 메소드를 통해 이루어진다.

Future<void> deleteUser(Database db, int id) async {
  await db.delete(
    'users',
    where: "id = ?",
    whereArgs: [id],
  );
}

비동기 데이터베이스 처리

SQLite와 같은 I/O 작업은 시간이 많이 소요되므로, Dart의 비동기 프로그래밍을 활용하여 처리해야 한다. 비동기 메소드(asyncawait)를 사용하여 데이터베이스 작업이 완료될 때까지 UI가 멈추지 않도록 해야 한다.

예를 들어, 사용자가 데이터를 조회할 때 화면이 멈추지 않고 비동기 작업을 수행하게 하려면 FutureBuilder 위젯을 사용한다.

FutureBuilder<List<User>>(
  future: users(db),
  builder: (context, snapshot) {
    if (snapshot.connectionState == ConnectionState.waiting) {
      return CircularProgressIndicator();
    } else if (snapshot.hasError) {
      return Text('Error: ${snapshot.error}');
    } else {
      final users = snapshot.data ?? [];
      return ListView.builder(
        itemCount: users.length,
        itemBuilder: (context, index) {
          return ListTile(
            title: Text(users[index].name),
            subtitle: Text('Age: ${users[index].age}'),
          );
        },
      );
    }
  },
)

이 예시는 SQLite 데이터베이스에서 데이터를 가져와 사용자 목록을 표시하는 코드이다.

SQLite에서 트랜잭션 처리

트랜잭션은 여러 SQL 작업을 하나의 작업 단위로 묶어 처리하는 방식이다. 이 방식은 데이터의 일관성을 보장하고, 작업 도중 오류가 발생했을 때 원래 상태로 롤백할 수 있는 기능을 제공한다. 트랜잭션을 사용하면 모든 작업이 성공적으로 완료되거나, 어느 하나라도 실패할 경우 모든 작업을 취소할 수 있다.

트랜잭션은 세 가지 단계로 나뉜다:

  1. 트랜잭션 시작: transaction 메소드 호출
  2. 작업 수행: 여러 데이터베이스 조작 실행
  3. 커밋 또는 롤백: 성공 시 커밋(확정), 실패 시 롤백(취소)
Future<void> performTransaction(Database db) async {
  await db.transaction((txn) async {
    int id1 = await txn.rawInsert(
      'INSERT INTO users(name, age) VALUES(?, ?)',
      ['Alice', 20]);
    int id2 = await txn.rawInsert(
      'INSERT INTO users(name, age) VALUES(?, ?)',
      ['Bob', 25]);
  });
}

이 코드에서 transaction 메소드는 데이터베이스의 트랜잭션을 시작한다. 트랜잭션 블록 안에서 실행된 모든 작업은 성공적으로 완료될 경우에만 데이터베이스에 반영된다.

SQLite 데이터베이스 마이그레이션

데이터베이스를 처음 설계하고 나서 시간이 지나면 테이블 구조를 변경해야 할 경우가 생깁니다. 예를 들어, 새로운 필드를 추가하거나 기존의 필드를 제거해야 할 수 있다. 이때 데이터베이스 마이그레이션이 필요하다.

마이그레이션을 처리하려면 데이터베이스 버전을 증가시키고 onUpgrade 메소드를 구현하여 변경된 구조를 반영해야 한다.

Future<Database> initDatabase() async {
  return openDatabase(
    join(await getDatabasesPath(), 'example.db'),
    onCreate: (db, version) {
      return db.execute(
        "CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT, age INTEGER)",
      );
    },
    onUpgrade: (db, oldVersion, newVersion) {
      if (oldVersion < 2) {
        db.execute("ALTER TABLE users ADD COLUMN email TEXT");
      }
    },
    version: 2,
  );
}

데이터베이스 인덱스 사용

SQLite에서 인덱스는 테이블 내에서 특정 열을 빠르게 검색할 수 있도록 해주는 구조이다. 특히, 많은 데이터를 다루는 애플리케이션에서 인덱스를 사용하면 쿼리 성능을 크게 향상시킬 수 있다.

다음은 name 필드에 대해 인덱스를 생성하는 방법이다:

await db.execute(
  'CREATE INDEX idx_name ON users(name)',
);

인덱스를 사용하면 데이터를 검색할 때 성능이 개선되지만, 인덱스를 너무 많이 사용하면 삽입 및 업데이트 성능이 저하될 수 있으므로 적절한 균형을 유지해야 한다.

SQLite 성능 최적화

SQLite 데이터베이스의 성능을 최적화하기 위해서는 적절한 인덱스 사용, 트랜잭션 적용, 그리고 데이터 압축 등의 방법을 고려해야 한다. 특히, 많은 데이터를 처리할 때는 다음과 같은 최적화 기법을 활용할 수 있다.

예시: 사용자 데이터 관리 애플리케이션

다음은 SQLite를 활용한 간단한 사용자 데이터 관리 애플리케이션의 전체 코드 예시이다.

class User {
  final int id;
  final String name;
  final int age;

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

  Map<String, dynamic> toMap() {
    return {
      'id': id,
      'name': name,
      'age': age,
    };
  }
}

void main() async {
  final db = await initDatabase();

  // 데이터 삽입
  var user = User(id: 0, name: 'John', age: 30);
  await insertUser(db, user);

  // 데이터 조회
  var users = await db.query('users');
  print(users);
}