Flutter 로컬 데이터베이스

Flutter 앱 개발 시 로컬 데이터베이스는 필수적인 요소입니다. 다양한 선택지 중에서도 Hive는 한때 많은 개발자에게 사랑받았지만, 최근 업데이트가 뜸해지면서 새로운 대안을 찾는 개발자들이 늘고 있습니다.

(위 이미지를 보면 Hive의 최신 버전 업데이트가 23개월 전인 것을 확인할 수 있습니다. 이는 개발자 입장에서 불안 요소가 될 수 있습니다.)

이에 대한 강력한 대안으로 떠오르는 것이 바로 **ObjectBox**입니다. ObjectBoxHive의 장점을 뛰어넘는 다양한 기능과 뛰어난 성능을 자랑합니다.


1. ObjectBox 란?

ObjectBox는 순수 Dart로 작성된 객체 데이터베이스(Object-DB)로, SQLite 대비 최대 10배 빠른 성능을 제공하는 임베디드 NoSQL 데이터베이스입니다. ACID(Atomicity, Consistency, Isolation, Durability)를 준수하여 데이터의 신뢰성을 보장하며, 복잡한 SQL 쿼리 없이도 객체 지향적으로 데이터를 다룰 수 있습니다.

주요 특징

  • 객체 모델링: @Entity 어노테이션을 사용하여 Dart 클래스를 데이터베이스 엔티티로 쉽게 변환합니다.
  • 고성능: 데이터 모델이 평면화되어 있어 빠른 읽기/쓰기 성능을 제공합니다.
  • 타입 안전 쿼리: QueryBuilder를 통해 컴파일 시점에 오류를 잡아내는 타입 안전한 쿼리를 작성할 수 있습니다.
  • 반응형 스트림: 데이터 변경을 감지하여 UI를 실시간으로 업데이트하는 Stream 기능을 지원합니다.
  • 확장성: 필요에 따라 데이터 동기화, 벡터 검색과 같은 고급 기능으로 확장할 수 있습니다.
구분요약비고
데이터 모델@Entity 클래스로 정의, @Id() 자동 증가 기본값Null-safety 완벽 지원
저장소(Store)Store 객체 1개가 DB 파일과 연동openStore() 헬퍼 함수 사용 권장
Boxstore.box<T>()로 얻는 엔티티 단위 DAOCRUD, 쿼리, 트랜잭션 담당
쿼리타입 안전 QueryBuilderfindAsync()로 격리된 Isolate 실행 가능
관계(Link)ToOne, ToMany 내장, LAZY 로딩 지원양방향 관계는 Backlink 사용
반응형query.watch() 또는 box.watch()StreamUI 자동 갱신에 용이
코드 생성build_runnerobjectbox.g.dart 생성변경 시마다 재실행 필요

2. 엔티티(Entity) 정의하기

ObjectBox를 사용하려면 먼저 데이터베이스에 저장할 엔티티 클래스를 정의해야 합니다.

import 'package:objectbox/objectbox.dart';

()
class Person {
  ()
  int id = 0;
  String firstName;
  String lastName;

  Person({required this.firstName, required this.lastName});
}
  • @Entity(): 해당 클래스를 데이터베이스 엔티티로 지정합니다.
  • @Id(): 엔티티의 기본 키(Primary Key)를 지정합니다. id0일 때 put() 호출 시 자동으로 할당됩니다.
  • ObjectBoxint, double, bool, String, DateTime, Uint8List와 같은 원시 타입을 지원하며, ToOneToMany를 통해 다른 엔티티와의 관계도 모델링할 수 있습니다.

3. 코드 생성

엔티티를 정의한 후에는 다음 명령어를 실행하여 필요한 파일을 생성해야 합니다.

flutter pub run build_runner build --delete-conflicting-outputs

이 명령어를 실행하면 objectbox.g.dartobjectbox-model.json 파일이 생성됩니다. objectbox.g.dart는 쿼리 및 데이터베이스 작업을 위한 중요한 도우미 클래스를 포함합니다.

TIP: build_runner 실행 중 오류(78 error)가 발생하면 objectbox_generator의 버전을 최신으로 업데이트하거나 Flutter 캐시를 삭제해 보세요.


4. Store 초기화

ObjectBox를 사용하기 위해서는 Store를 초기화해야 합니다. Store는 데이터베이스 파일과 연결되는 핵심 객체입니다.

import 'objectbox.g.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;

late final Store store;

Future<void> initStore() async {
    final dir = await getApplicationSupportDirectory();
    store = await openStore(directory: p.join(dir.path, 'person_db'));
}

getApplicationSupportDirectory()를 통해 플랫폼에 맞는 안전한 디렉터리 경로를 얻고, openStore()를 호출하여 Store를 초기화합니다.


5. CRUD (Create, Read, Update, Delete)

Box 객체를 통해 엔티티에 대한 CRUD 작업을 수행할 수 있습니다. Box는 특정 엔티티 타입에 대한 DAO(Data Access Object) 역할을 합니다.

1. Box 가져오기

final personBox = store.box<Person>();

2. CRUD 작업

// C: Create
final id = personBox.put(Person(firstName: 'Jane', lastName: 'Doe'));

// R: Read
final jane = personBox.get(id);

// U: Update
jane!.lastName = 'Black';
personBox.put(jane);

// D: Delete
personBox.remove(jane.id);

성능 팁: 대량의 데이터를 삽입할 때는 putMany(list)를 사용하면 개별 put() 호출보다 훨씬 빠릅니다.


6. 쿼리 (Query)

ObjectBoxQueryBuilder를 사용하여 타입 안전한 쿼리를 작성할 수 있습니다.

final query = personBox
  .query(Person_.lastName.equals('Black') & 
         Person_.firstName.startsWith('J'))
  .order(Person_.id, flags: Order.descending)
  .build();

final results = query.find(); // List<Person>
query.close(); // 리소스 해제
  • 조건: AND 연산은 &로, OR 연산은 |로 표현합니다.
  • 정렬: order() 함수를 사용해 정렬 순서를 지정할 수 있습니다.
  • 비동기 쿼리: findAsync()를 사용하면 쿼리를 별도의 Isolate(백그라운드 스레드)에서 실행하여 UI 스레드의 부하를 줄일 수 있습니다.

7. 관계(Relations) 설정

ObjectBox는 엔티티 간의 관계를 쉽게 설정할 수 있도록 ToOneToMany를 지원합니다.

1. ToOne (1:1 또는 N:1 관계)

()
class Order {
  int id = 0;
  final customer = ToOne<Customer>();
}

()
class Customer {
  int id = 0;
  String name;
}

// 사용 예
final customerObj = Customer(name: 'Jane Doe');
final order = Order();
order.customer.target = customerObj; // 관계 설정
store.box<Order>().put(order); // Order와 Customer 모두 저장

2. ToMany & Backlink (N:M 관계)

()
class Tag {
  int id = 0;
  String name;
  ('tags')
  final tasks = ToMany<Task>();
}

()
class Task {
  int id = 0;
  String title;
  final tags = ToMany<Tag>();
}

@Backlink('tags')를 사용하면 Tag 엔티티에서 Task 엔티티로의 역참조 컬렉션이 자동으로 생성됩니다.


8. 반응형 스트림

ObjectBoxwatch() 메서드를 사용하면 데이터 변경을 Stream으로 받아 UI를 실시간으로 업데이트할 수 있습니다.

final query = personBox.query().build();
final subscription = query.watch(triggerImmediately: true)
  .listen((Query<Person> q) {
    final list = q.find();
    setState(() => persons = list);
  });
  • triggerImmediately: true 옵션은 초기 데이터를 즉시 스트림으로 보냅니다.
  • subscription.cancel()을 호출하면 스트림 구독이 취소되고 Query 리소스가 자동으로 해제됩니다.

9. 트랜잭션 및 동시성

ObjectBox는 여러 데이터베이스 작업을 하나의 트랜잭션으로 묶어 원자성을 보장할 수 있습니다.

store.runInTransaction(TxMode.write, () {
  for (final p in persons) {
    personBox.put(p);
  }
});

runInTransactionAsync()는 CPU 집약적인 작업을 별도의 Isolate에서 실행하여 앱의 반응성을 유지하는 데 도움이 됩니다.


10. 스키마 마이그레이션

ObjectBox는 엔티티 스키마 변경 시 마이그레이션을 지원합니다.

  • 필드 추가: @Id() 값을 유지하고 새 필드를 추가한 후 build_runner를 다시 실행하면 자동으로 마이그레이션됩니다.
  • 필드 삭제: 2단계 절차를 권장합니다.
    1. 먼저 필드를 주석 처리하고 build_runner를 실행한 후 앱을 배포합니다.
    2. 이후 코드에서 해당 필드를 완전히 제거하고 다시 빌드합니다. (바로 삭제 시 ID 재사용으로 인한 충돌 가능성 방지)
profile
꾸준히, 의미있는 사이드 프로젝트 경험과 문제해결 과정을 기록하기 위한 공간입니다.

0개의 댓글