데이터베이스 실전 가이드

데이터베이스의 핵심 개념과 실무 활용

Flutter중급
6시간
3개 항목
학습 진행률0 / 3 (0%)

학습 항목

1. Flutter
Drift|Moor|로컬|데이터베이스|구축하기
퀴즈튜토리얼
2. Flutter
Flutter|SQLite|데이터베이스|완벽|가이드
퀴즈튜토리얼
3. Go
GORM|데이터베이스|ORM|Go|완벽가이드
퀴즈튜토리얼
1 / 3

이미지 로딩 중...

Drift Moor 로컬 데이터베이스 구축하기 - 슬라이드 1/13

Drift와 Moor로 Flutter 로컬 데이터베이스 구축하기

Flutter 앱에서 Drift(구 Moor)를 사용하여 강력한 로컬 데이터베이스를 구축하는 방법을 배웁니다. SQLite 기반의 타입 안전한 데이터베이스 관리부터 복잡한 쿼리 작성까지, 실무에서 바로 활용할 수 있는 완벽 가이드입니다.


목차

  1. Drift_소개와_설정
  2. 테이블_정의하기
  3. DAO_패턴_구현
  4. CRUD_작업_마스터하기
  5. 복잡한_쿼리_작성
  6. 마이그레이션_관리
  7. 트랜잭션_처리
  8. 스트림_활용

1. Drift_소개와_설정

시작하며

여러분이 Flutter 앱을 개발하면서 사용자 데이터를 로컬에 저장해야 할 때, 어떤 방법을 사용하시나요? SharedPreferences는 간단하지만 복잡한 데이터 구조를 다루기 어렵고, 직접 SQLite를 사용하면 SQL 쿼리 문자열을 다루다 오타로 인한 런타임 에러를 마주하게 됩니다.

실제 프로덕션 앱에서는 수백 개의 테이블과 수천 줄의 쿼리 코드를 관리해야 합니다. 이때 타입 안전성이 보장되지 않으면 디버깅에 엄청난 시간을 소모하게 되고, 리팩토링은 거의 불가능해집니다.

바로 이럴 때 필요한 것이 Drift(구 Moor)입니다. Dart의 강력한 타입 시스템을 활용하여 컴파일 타임에 오류를 잡아내고, 코드 생성을 통해 보일러플레이트를 최소화하며, 리액티브 프로그래밍을 지원하여 UI와 데이터베이스를 완벽하게 동기화할 수 있습니다.

개요

간단히 말해서, Drift는 Flutter와 Dart를 위한 강력한 타입 안전 SQL 데이터베이스 라이브러리입니다. 기존 SQLite를 직접 사용할 때는 문자열로 쿼리를 작성하고 결과를 Map으로 받아서 수동으로 파싱해야 했습니다.

예를 들어, 사용자 목록을 가져오는 작업에서 컬럼 이름을 잘못 입력하면 런타임에서야 에러를 발견하게 됩니다. Drift를 사용하면 IDE의 자동완성과 함께 타입 안전한 코드를 작성할 수 있어 이런 문제가 컴파일 타임에 해결됩니다.

Drift의 핵심 특징은 세 가지입니다. 첫째, 코드 생성을 통한 타입 안전성으로 SQL 쿼리 결과가 자동으로 Dart 클래스로 변환됩니다.

둘째, Stream 기반의 리액티브 쿼리로 데이터가 변경되면 UI가 자동으로 업데이트됩니다. 셋째, 플러그형 아키텍처로 웹, 모바일, 데스크톱 모든 플랫폼에서 동작합니다.

이러한 특징들이 개발 생산성을 극대적으로 향상시키고 버그를 사전에 방지합니다.

코드 예제

// 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

// 코드 생성 실행 명령어
// flutter pub run build_runner build

설명

이것이 하는 일: Drift는 SQLite 데이터베이스를 Dart의 타입 시스템과 완벽하게 통합하여 안전하고 효율적인 데이터베이스 작업을 가능하게 합니다. 첫 번째로, 필요한 패키지들을 설치합니다.

drift는 핵심 라이브러리이고, sqlite3_flutter_libs는 각 플랫폼에 맞는 SQLite 네이티브 라이브러리를 제공합니다. path_providerpath는 데이터베이스 파일의 저장 위치를 결정하는 데 사용됩니다.

이 조합은 안드로이드, iOS, 웹, 데스크톱 모든 플랫폼에서 동작합니다. 그 다음으로, drift_devbuild_runner를 개발 의존성으로 추가합니다.

이들은 여러분이 작성한 테이블 정의를 바탕으로 실제 데이터베이스 접근 코드를 자동 생성합니다. 테이블 구조를 변경하면 build_runner를 실행하여 관련된 모든 코드가 자동으로 업데이트되므로, 수동으로 파싱 코드를 작성할 필요가 없습니다.

마지막으로, build_runner build 명령어를 실행하면 코드 생성이 시작됩니다. 이 과정에서 .g.dart 파일들이 생성되며, 이 파일들에는 테이블 정의, 쿼리 빌더, 데이터 클래스 등이 포함됩니다.

개발 중에는 build_runner watch를 사용하여 파일이 변경될 때마다 자동으로 코드를 재생성할 수도 있습니다. 여러분이 이 설정을 완료하면 타입 안전한 데이터베이스 작업, IDE의 완벽한 자동완성 지원, 컴파일 타임 오류 검출을 얻을 수 있습니다.

특히 대규모 프로젝트에서 팀원들과 협업할 때 타입 시스템이 API 계약 역할을 하여 의사소통 비용을 크게 줄여줍니다.

실전 팁

💡 개발 중에는 flutter pub run build_runner watch를 사용하여 파일 변경 시 자동으로 코드를 재생성하세요. 매번 수동으로 빌드 명령어를 실행하는 번거로움을 없앨 수 있습니다.

💡 코드 생성 파일(.g.dart)은 .gitignore에 추가하지 마세요. 팀원들이 빌드 환경 차이로 인한 문제를 겪을 수 있으며, CI/CD 파이프라인에서도 일관된 빌드를 보장할 수 있습니다.

💡 Drift 버전을 업데이트할 때는 CHANGELOG를 꼼꼼히 확인하세요. 주요 버전 업데이트 시 마이그레이션 가이드가 제공되며, 이를 따르지 않으면 기존 데이터베이스가 손상될 수 있습니다.

💡 웹 플랫폼에서는 drift/web.dart를 사용하여 IndexedDB 기반으로 동작합니다. 모바일과 동일한 API를 사용하지만 내부 구현이 다르므로, 대용량 데이터 처리 시 성능 테스트가 필요합니다.


2. 테이블_정의하기

시작하며

여러분이 앱에서 할 일 목록을 관리하는 기능을 만든다고 생각해보세요. 각 할 일은 제목, 설명, 완료 여부, 생성 날짜 등의 정보를 가지고 있을 것입니다.

이런 데이터 구조를 어떻게 정의하고 관리할까요? 기존 SQLite에서는 긴 CREATE TABLE 문자열을 작성하고, 컬럼 타입을 문자열로 지정하며, Dart 클래스와 별도로 관리해야 했습니다.

테이블 구조를 변경할 때마다 SQL 문, 파싱 코드, 데이터 클래스를 모두 수정해야 하는 번거로움이 있었죠. Drift에서는 Dart 클래스로 테이블을 정의하면 코드 생성을 통해 필요한 모든 코드가 자동으로 만들어집니다.

타입 안전성을 보장하면서도 간결한 코드로 복잡한 스키마를 관리할 수 있습니다.

개요

간단히 말해서, Drift의 테이블은 Dart 클래스를 확장하여 정의하며, 각 필드는 메서드로 선언됩니다. 실제 개발 현장에서는 테이블 간의 관계(외래 키), 인덱스, 제약 조건 등을 정의해야 합니다.

예를 들어, 사용자 테이블과 게시글 테이블이 있을 때 게시글은 작성자(사용자)를 참조해야 하고, 이메일은 유일해야 하며, 특정 컬럼에는 인덱스를 걸어 조회 성능을 높여야 합니다. 기존에는 복잡한 SQL 문자열을 작성하고 테스트해야 했다면, Drift에서는 Dart의 타입 시스템을 활용하여 선언적으로 정의할 수 있습니다.

IDE가 자동완성을 제공하고 컴파일러가 오류를 잡아주므로 훨씬 안전합니다. Drift 테이블의 핵심 특징은 다음과 같습니다.

첫째, 강타입 컬럼 정의로 각 필드의 타입이 명확합니다. 둘째, 제약 조건을 메서드 체이닝으로 간결하게 표현합니다.

셋째, 코드 생성을 통해 데이터 클래스와 컴패니언 클래스가 자동으로 생성됩니다. 이러한 특징들이 스키마 관리를 훨씬 쉽고 안전하게 만들어줍니다.

코드 예제

import 'package:drift/drift.dart';

// 할 일 테이블 정의
class Todos extends Table {
  // 자동 증가하는 기본 키
  IntColumn get id => integer().autoIncrement()();

  // 필수 텍스트 필드 (최대 128자)
  TextColumn get title => text().withLength(min: 1, max: 128)();

  // 선택적 텍스트 필드
  TextColumn get description => text().nullable()();

  // 완료 여부 (기본값: false)
  BoolColumn get isCompleted => boolean().withDefault(const Constant(false))();

  // 생성 날짜 (기본값: 현재 시간)
  DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}

설명

이것이 하는 일: Dart 클래스로 데이터베이스 테이블의 구조를 정의하면, Drift가 실제 SQL 테이블과 Dart 데이터 클래스를 자동으로 생성합니다. 첫 번째로, Table 클래스를 상속하는 클래스를 만듭니다.

클래스 이름은 복수형으로 짓는 것이 관례이며(Todos, Users 등), 이 이름이 실제 SQL 테이블 이름이 됩니다. 각 getter는 테이블의 컬럼을 나타내며, 반환 타입이 컬럼의 데이터 타입을 결정합니다.

IntColumn, TextColumn, BoolColumn, DateTimeColumn 등 다양한 타입을 지원합니다. 그 다음으로, 각 컬럼에 제약 조건을 추가합니다.

autoIncrement()는 자동으로 증가하는 기본 키를 만들고, withLength()는 텍스트 길이를 제한하며, nullable()은 NULL 값을 허용합니다. withDefault()는 값이 제공되지 않을 때 사용할 기본값을 지정합니다.

Constant(false)는 상수 값을, currentDateAndTime은 현재 시간을 기본값으로 사용합니다. 코드 생성 후에는 Todo라는 데이터 클래스(단수형)와 TodosCompanion이라는 삽입/수정용 클래스가 자동으로 생성됩니다.

Todo 클래스는 불변 객체로 데이터베이스에서 조회한 결과를 담고, TodosCompanion은 새 데이터를 삽입하거나 기존 데이터를 수정할 때 사용됩니다. Companion 클래스는 Value.absent()를 통해 특정 필드를 선택적으로 업데이트할 수 있게 해줍니다.

여러분이 이렇게 테이블을 정의하면 타입 안전한 데이터 모델, SQL 문 자동 생성, IDE의 완벽한 자동완성 지원을 얻게 됩니다. 특히 테이블 구조를 변경할 때 관련된 모든 코드가 자동으로 업데이트되므로, 대규모 리팩토링도 안전하게 수행할 수 있습니다.

실전 팁

💡 테이블 이름은 복수형으로, 생성되는 데이터 클래스는 단수형으로 자동 지정됩니다. @DataClassName('TodoItem')을 사용하여 생성되는 클래스 이름을 커스터마이징할 수 있습니다.

💡 외래 키는 references() 메서드로 정의합니다. 예: integer().references(Users, #id)()로 Users 테이블의 id를 참조할 수 있으며, CASCADE 옵션도 지정 가능합니다.

💡 인덱스는 @TableIndex 어노테이션으로 정의합니다. 자주 검색하는 컬럼에 인덱스를 추가하면 조회 성능이 크게 향상되지만, 삽입/수정 성능은 약간 저하됩니다.

💡 TextColumn의 경우 withLength()로 길이 제한을 명시하면 데이터베이스 레벨에서 검증이 이루어집니다. 프론트엔드 검증만으로는 부족한 경우 유용합니다.

💡 JSON 타입 컬럼이 필요하면 커스텀 타입 컨버터를 만들 수 있습니다. Dart 객체를 JSON 문자열로 저장하고 자동으로 직렬화/역직렬화할 수 있어 복잡한 데이터 구조 저장에 유용합니다.


3. DAO_패턴_구현

시작하며

여러분의 앱이 점점 커지면서 여러 화면에서 동일한 데이터베이스 작업을 수행해야 하는 상황이 생깁니다. 할 일 목록 화면, 통계 화면, 검색 화면에서 모두 Todos 테이블에 접근해야 한다면, 각 화면마다 쿼리 코드를 중복해서 작성하시겠습니까?

이런 접근 방식은 코드 중복을 야기하고, 쿼리 로직이 변경될 때 모든 곳을 찾아서 수정해야 하는 유지보수 악몽을 만들어냅니다. 또한 비즈니스 로직과 데이터 접근 로직이 섞여서 테스트도 어려워집니다.

DAO(Data Access Object) 패턴을 사용하면 데이터 접근 로직을 한 곳에 모아서 관리할 수 있습니다. Drift는 DAO 패턴을 일급 시민으로 지원하여, 테이블별로 전문화된 데이터 접근 계층을 쉽게 만들 수 있습니다.

개요

간단히 말해서, DAO는 특정 테이블이나 테이블 그룹에 대한 모든 데이터베이스 작업을 캡슐화하는 클래스입니다. 실무에서는 단순한 CRUD뿐만 아니라 복잡한 비즈니스 로직이 포함된 쿼리가 필요합니다.

예를 들어, "완료되지 않은 할 일을 마감일 순으로 가져오기", "특정 카테고리의 할 일 통계 계산하기" 같은 작업들입니다. 이런 로직들을 DAO에 메서드로 정의하면 코드 재사용성이 높아지고 테스트가 쉬워집니다.

기존에는 데이터베이스 접근 코드가 UI 위젯이나 비즈니스 로직에 흩어져 있었다면, DAO를 사용하면 명확한 계층 분리가 이루어집니다. UI는 DAO의 메서드만 호출하고, 실제 SQL이나 쿼리 최적화는 DAO 내부에서 처리됩니다.

DAO의 핵심 특징은 다음과 같습니다. 첫째, @DriftAccessor 어노테이션으로 어떤 테이블에 접근할지 선언합니다.

둘째, 데이터베이스 인스턴스를 자동으로 주입받아 쿼리를 실행할 수 있습니다. 셋째, 여러 테이블을 조합한 복잡한 쿼리도 한 곳에서 관리할 수 있습니다.

이러한 특징들이 코드의 구조와 유지보수성을 크게 개선합니다.

코드 예제

import 'package:drift/drift.dart';

part 'todo_dao.g.dart'; // 코드 생성 파일

@DriftAccessor(tables: [Todos])
class TodoDao extends DatabaseAccessor<MyDatabase> with _$TodoDaoMixin {
  // 데이터베이스 인스턴스를 받는 생성자
  TodoDao(MyDatabase db) : super(db);

  // 모든 할 일을 스트림으로 반환 (실시간 업데이트)
  Stream<List<Todo>> watchAllTodos() => select(todos).watch();

  // 완료되지 않은 할 일만 조회
  Stream<List<Todo>> watchActiveTodos() {
    return (select(todos)..where((t) => t.isCompleted.equals(false))).watch();
  }

  // 할 일 추가
  Future<int> addTodo(TodosCompanion todo) => into(todos).insert(todo);
}

설명

이것이 하는 일: DAO 클래스는 테이블에 대한 모든 데이터베이스 작업을 중앙집중화하여 코드 재사용성을 높이고 유지보수를 쉽게 만듭니다. 첫 번째로, @DriftAccessor 어노테이션으로 이 DAO가 접근할 테이블들을 선언합니다.

여기서는 Todos 테이블만 사용하지만, 여러 테이블을 배열로 지정할 수도 있습니다. part 지시문은 코드 생성 파일을 포함시키며, with _$TodoDaoMixin은 생성된 믹스인을 추가합니다.

이 믹스인에는 쿼리 빌더를 위한 편의 메서드들이 포함되어 있습니다. 그 다음으로, 실제 쿼리 메서드들을 정의합니다.

watchAllTodos()Stream<List<Todo>>를 반환하는데, 이는 데이터가 변경될 때마다 새로운 결과를 자동으로 방출합니다. select(todos)는 SELECT 쿼리를 시작하고, watch()는 이를 스트림으로 변환합니다.

UI에서 StreamBuilder와 함께 사용하면 데이터베이스 변경이 자동으로 화면에 반영됩니다. watchActiveTodos()는 조건을 추가하는 방법을 보여줍니다.

where() 메서드는 필터를 적용하며, 람다 함수 내에서 타입 안전한 조건을 작성할 수 있습니다. t.isCompleted.equals(false)는 SQL의 WHERE is_completed = false로 변환됩니다.

여러 조건을 &(AND)나 |(OR)로 결합할 수도 있습니다. addTodo() 메서드는 새 데이터를 삽입합니다.

into(todos).insert()는 INSERT 문을 생성하고, TodosCompanion 객체를 받아서 데이터를 저장합니다. 반환값은 삽입된 행의 ID입니다.

이런 식으로 DAO에 메서드를 추가하면 앱 전체에서 일관된 방식으로 데이터에 접근할 수 있습니다. 여러분이 DAO 패턴을 적용하면 코드 중복 제거, 쉬운 단위 테스트 작성, 명확한 책임 분리를 얻을 수 있습니다.

특히 복잡한 비즈니스 로직이 있는 대규모 앱에서는 필수적인 패턴입니다.

실전 팁

💡 DAO는 기능별로 분리하는 것이 좋습니다. 예를 들어, UserDao, TodoDao, SettingsDao처럼 도메인별로 나누면 코드 탐색이 쉬워집니다.

💡 복잡한 쿼리는 DAO 내부에 private 헬퍼 메서드로 분리하세요. _buildActiveQuery() 같은 메서드를 만들어 쿼리 로직을 재사용할 수 있습니다.

💡 테스트를 위해 DAO를 인터페이스로 추상화할 수 있습니다. 실제 구현과 Mock 구현을 쉽게 교체할 수 있어 단위 테스트가 빨라집니다.

💡 여러 테이블을 조합하는 복잡한 쿼리도 DAO에 두세요. Join이 필요한 경우 @DriftAccessor(tables: [Todos, Categories])처럼 여러 테이블을 선언하고 한 DAO에서 관리할 수 있습니다.

💡 트랜잭션이 필요한 복잡한 작업은 DAO 메서드로 캡슐화하세요. 여러 테이블을 동시에 수정하는 작업을 하나의 메서드로 제공하면 데이터 일관성을 보장할 수 있습니다.


4. CRUD_작업_마스터하기

시작하며

여러분이 데이터베이스를 다룰 때 가장 기본이 되는 작업은 무엇일까요? 바로 데이터를 생성(Create)하고, 조회(Read)하고, 수정(Update)하고, 삭제(Delete)하는 CRUD 작업입니다.

이 네 가지 작업을 완벽히 마스터하면 대부분의 앱 기능을 구현할 수 있습니다. 실무에서는 단순히 데이터를 저장하는 것뿐만 아니라 조건부 업데이트, 일괄 삭제, 트랜잭션 내에서의 복잡한 작업 등이 필요합니다.

기존 SQLite를 직접 사용하면 SQL 문자열을 작성하고 결과를 파싱하는 번거로운 과정을 거쳐야 했습니다. Drift는 타입 안전한 쿼리 빌더를 제공하여 CRUD 작업을 직관적이고 안전하게 만들어줍니다.

각 작업은 메서드 체이닝으로 표현되며, 컴파일러가 타입 오류를 잡아주므로 런타임 에러를 크게 줄일 수 있습니다.

개요

간단히 말해서, Drift의 CRUD 작업은 타입 안전한 쿼리 빌더 API를 통해 수행되며, Future나 Stream으로 결과를 받습니다. 실무에서는 다양한 시나리오가 있습니다.

예를 들어, 사용자가 할 일을 추가하면 데이터베이스에 저장(Create)하고, 목록 화면에서 모든 할 일을 불러오며(Read), 완료 버튼을 누르면 상태를 업데이트하고(Update), 삭제 버튼을 누르면 데이터를 제거(Delete)합니다. 각 작업은 성공 여부를 확인하고 UI에 반영해야 합니다.

기존에는 각 작업마다 SQL 문자열을 작성하고 파라미터 바인딩을 하며 결과를 Map에서 파싱해야 했다면, Drift에서는 강타입 API를 사용하여 간결하고 안전하게 작성할 수 있습니다. IDE의 자동완성이 모든 메서드와 컬럼을 제안해주므로 개발 속도도 빨라집니다.

CRUD 작업의 핵심 특징은 다음과 같습니다. 첫째, Companion 클래스를 사용하여 부분 업데이트가 가능합니다.

둘째, 쿼리 빌더의 메서드 체이닝으로 복잡한 조건을 표현할 수 있습니다. 셋째, Future 기반의 비동기 작업으로 UI 블로킹 없이 처리됩니다.

이러한 특징들이 견고하고 유지보수하기 쉬운 코드를 만들어줍니다.

코드 예제

// Create - 새 할 일 추가
Future<int> createTodo(String title, String? description) async {
  final newTodo = TodosCompanion(
    title: Value(title),
    description: Value(description),
    // isCompleted와 createdAt은 기본값 사용
  );
  return await into(todos).insert(newTodo);
}

// Read - ID로 할 일 조회
Future<Todo?> getTodoById(int id) async {
  return await (select(todos)..where((t) => t.id.equals(id))).getSingleOrNull();
}

// Update - 할 일 상태 변경
Future<bool> updateTodoStatus(int id, bool isCompleted) async {
  return await (update(todos)..where((t) => t.id.equals(id)))
      .write(TodosCompanion(isCompleted: Value(isCompleted)));
}

// Delete - 할 일 삭제
Future<int> deleteTodo(int id) async {
  return await (delete(todos)..where((t) => t.id.equals(id))).go();
}

설명

이것이 하는 일: 각 CRUD 작업은 특화된 쿼리 빌더를 사용하여 데이터베이스를 조작하며, 타입 시스템이 모든 작업의 정확성을 보장합니다. 첫 번째로, Create 작업은 TodosCompanion 객체를 생성하여 into().insert()로 삽입합니다.

Companion 클래스는 각 필드를 Value 타입으로 감싸서 어떤 필드에 값을 제공할지 명시적으로 지정할 수 있게 해줍니다. 기본값이 있는 필드는 생략할 수 있으며, 이 경우 데이터베이스에 정의된 기본값이 사용됩니다.

insert() 메서드는 삽입된 행의 ID를 반환하므로, 이를 저장해두면 나중에 해당 데이터를 참조할 수 있습니다. 그 다음으로, Read 작업은 select() 메서드로 시작합니다.

where() 절을 추가하여 조건을 지정하고, getSingleOrNull()로 단일 결과를 가져옵니다. 결과가 없으면 null을 반환하므로 안전하게 처리할 수 있습니다.

여러 결과를 가져올 때는 get()을 사용하여 List<Todo>를 받거나, watch()를 사용하여 실시간 업데이트되는 Stream<List<Todo>>를 받을 수 있습니다. Update 작업은 update() 메서드로 시작하여 where() 절로 대상을 지정하고, write()로 변경할 필드를 전달합니다.

여기서도 Companion 클래스를 사용하여 변경하고 싶은 필드만 지정할 수 있습니다. write() 메서드는 영향받은 행의 수를 반환하므로, 이를 통해 업데이트 성공 여부를 확인할 수 있습니다.

반환값이 true면 최소 한 행이 업데이트된 것입니다. 마지막으로, Delete 작업은 delete() 메서드로 시작하여 where() 절로 삭제할 대상을 지정하고, go()로 실행합니다.

반환값은 삭제된 행의 수입니다. where 절을 생략하면 모든 데이터가 삭제되므로 주의해야 합니다.

실수를 방지하기 위해 항상 조건을 명시하는 습관을 들이세요. 여러분이 이 CRUD 패턴을 익히면 거의 모든 데이터베이스 작업을 안전하게 수행할 수 있습니다.

타입 시스템이 컴파일 타임에 오류를 잡아주므로 런타임 버그가 크게 줄어들며, 코드 리뷰 시 의도가 명확히 드러나 팀 협업도 원활해집니다.

실전 팁

💡 insertReturning() 메서드를 사용하면 삽입된 전체 행을 즉시 받을 수 있습니다. ID뿐만 아니라 생성 시간 같은 자동 생성 필드도 함께 필요할 때 유용합니다.

💡 일괄 삽입은 batch() API를 사용하세요. batch((b) => b.insertAll(todos, data))로 여러 행을 한 번에 삽입하면 성능이 크게 향상됩니다.

💡 조건부 업데이트 시 where() 절을 잘못 작성하면 의도치 않은 데이터가 변경될 수 있습니다. 개발 중에는 로그로 영향받은 행 수를 확인하세요.

💡 getSingle() vs getSingleOrNull(): 전자는 결과가 없으면 예외를 던지고, 후자는 null을 반환합니다. 존재가 보장되지 않은 쿼리에는 후자를 사용하세요.

💡 복잡한 업데이트는 먼저 조회한 후 수정하는 것보다 직접 UPDATE 문을 실행하는 것이 효율적입니다. customUpdate() 메서드로 복잡한 SQL 표현식도 사용할 수 있습니다.


5. 복잡한_쿼리_작성

시작하며

여러분이 앱의 통계 화면을 만든다고 상상해보세요. 사용자별 할 일 완료율, 카테고리별 할 일 개수, 최근 7일간 활동 추이 같은 정보를 보여주려면 어떻게 해야 할까요?

단순한 SELECT 문으로는 해결할 수 없는 복잡한 쿼리가 필요합니다. 실무에서는 여러 테이블을 조인하거나, 집계 함수를 사용하거나, 서브쿼리를 작성해야 하는 경우가 많습니다.

예를 들어, 사용자 테이블과 할 일 테이블을 조인하여 "각 사용자가 생성한 완료되지 않은 할 일 개수"를 가져오는 것은 일반적인 요구사항입니다. Drift는 SQL의 강력한 기능들을 타입 안전한 Dart API로 제공합니다.

Join, Group By, Having, Order By 등 복잡한 쿼리 구조를 메서드 체이닝으로 표현할 수 있으며, 컴파일러가 타입 오류를 잡아줍니다.

개요

간단히 말해서, Drift는 Join, 집계 함수, 서브쿼리 등 SQL의 고급 기능을 타입 안전한 쿼리 빌더로 제공합니다. 실무에서는 관계형 데이터베이스의 진가가 복잡한 쿼리에서 발휘됩니다.

예를 들어, 전자상거래 앱에서 "최근 한 달간 가장 많이 팔린 카테고리별 상위 5개 상품"을 찾거나, SNS 앱에서 "팔로우하는 사용자의 최근 게시글 중 좋아요 10개 이상인 것"을 찾는 쿼리는 여러 테이블의 데이터를 조합해야 합니다. 기존에는 복잡한 SQL 문자열을 작성하고 결과를 Map의 중첩 구조로 파싱해야 했다면, Drift에서는 타입 안전한 조인 빌더를 사용하여 각 테이블의 데이터를 명확히 분리해서 받을 수 있습니다.

IDE가 조인된 테이블의 모든 컬럼을 자동완성해주므로 실수가 줄어듭니다. 복잡한 쿼리의 핵심 특징은 다음과 같습니다.

첫째, join() 메서드로 여러 테이블을 결합하고 조인 타입(INNER, LEFT 등)을 선택할 수 있습니다. 둘째, 집계 함수(count, sum, avg 등)를 Dart 메서드로 호출할 수 있습니다.

셋째, groupBy()orderBy()로 결과를 원하는 형태로 가공할 수 있습니다. 이러한 특징들이 복잡한 비즈니스 로직을 안전하게 구현할 수 있게 해줍니다.

코드 예제

// 두 테이블 조인: 할 일과 카테고리
class Categories extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get name => text()();
}

// 카테고리별 할 일 개수 조회
Future<List<CategoryWithCount>> getCategoryStats() async {
  final query = select(categories).join([
    leftOuterJoin(todos, todos.categoryId.equalsExp(categories.id))
  ]);

  // 카테고리별로 그룹화하고 개수 세기
  query.groupBy([categories.id]);
  query.addColumns([todos.id.count()]);

  final results = await query.get();
  return results.map((row) {
    return CategoryWithCount(
      category: row.readTable(categories),
      todoCount: row.read(todos.id.count()) ?? 0,
    );
  }).toList();
}

설명

이것이 하는 일: 여러 테이블의 데이터를 조합하고 집계하여 복잡한 비즈니스 요구사항을 충족하는 쿼리를 타입 안전하게 작성합니다. 첫 번째로, 조인할 테이블들을 정의합니다.

예제에서는 Categories 테이블을 추가했고, Todos 테이블에 categoryId 외래 키가 있다고 가정합니다. select(categories).join([])은 조인 쿼리를 시작하며, 배열 안에 조인할 테이블들을 지정합니다.

그 다음으로, 조인 타입과 조건을 지정합니다. leftOuterJoin()은 LEFT OUTER JOIN을 수행하며, 이는 카테고리에 할 일이 없어도 카테고리는 결과에 포함됨을 의미합니다.

todos.categoryId.equalsExp(categories.id)는 조인 조건으로, SQL의 ON todos.category_id = categories.id에 해당합니다. innerJoin()을 사용하면 매칭되는 행만 반환됩니다.

groupBy()는 SQL의 GROUP BY를 수행하여 카테고리별로 데이터를 그룹화합니다. addColumns()는 SELECT 절에 추가 컬럼을 더하며, 여기서는 각 카테고리의 할 일 개수를 세는 집계 함수 count()를 추가합니다.

todos.id.count()는 SQL의 COUNT(todos.id)로 변환됩니다. 결과를 읽을 때는 row.readTable(categories)로 테이블 전체를 읽거나, row.read(todos.id.count())로 집계 함수 결과를 읽을 수 있습니다.

조인 쿼리의 결과는 TypedResult 객체로 반환되며, 여기서 각 테이블이나 표현식의 값을 개별적으로 추출할 수 있습니다. NULL 가능성이 있는 컬럼은 `??

0` 같은 기본값 처리를 해주는 것이 안전합니다. 여러분이 이런 복잡한 쿼리를 마스터하면 백엔드 없이도 앱 내에서 강력한 데이터 분석과 통계 기능을 구현할 수 있습니다.

특히 오프라인 우선 앱에서는 이런 로컬 데이터 처리 능력이 핵심 경쟁력이 됩니다.

실전 팁

💡 조인이 많아질수록 쿼리 성능이 저하됩니다. 필요한 데이터만 조인하고, 자주 사용하는 쿼리에는 인덱스를 추가하세요.

💡 집계 함수는 count(), sum(), avg(), max(), min() 등을 지원합니다. 커스텀 SQL 표현식이 필요하면 customExpression()을 사용할 수 있습니다.

💡 서브쿼리는 subqueryExpression()으로 작성할 수 있습니다. "평균보다 비싼 상품" 같은 쿼리에 유용합니다.

💡 복잡한 조인 쿼리의 결과를 파싱하는 코드가 길어지면, 전용 데이터 클래스를 만들어 변환 로직을 캡슐화하세요. 코드 가독성이 크게 향상됩니다.

💡 orderBy()는 여러 컬럼으로 정렬할 수 있으며, OrderingMode.desc로 내림차순을 지정할 수 있습니다. 페이지네이션 구현 시 일관된 정렬이 필수입니다.


6. 마이그레이션_관리

시작하며

여러분의 앱이 이미 앱스토어에 출시되어 수천 명의 사용자가 사용하고 있다고 가정해봅시다. 이제 새 기능을 추가하려면 데이터베이스 스키마를 변경해야 합니다.

예를 들어, 할 일에 "우선순위" 컬럼을 추가해야 한다면 어떻게 할까요? 기존 사용자들의 데이터베이스는 구버전 스키마로 되어 있는데, 새 버전 앱을 설치하면 새로운 컬럼을 기대하는 코드가 실행됩니다.

마이그레이션이 없으면 앱이 크래시되거나 데이터가 손실될 수 있습니다. Drift의 마이그레이션 시스템은 데이터베이스 버전을 관리하고, 각 버전 간의 변경사항을 안전하게 적용하는 방법을 제공합니다.

자동 마이그레이션 검증 기능까지 있어서 실수를 사전에 방지할 수 있습니다.

개요

간단히 말해서, 마이그레이션은 데이터베이스 스키마가 변경될 때 기존 데이터를 보존하면서 새 스키마로 업그레이드하는 프로세스입니다. 실무에서는 앱이 여러 버전으로 진화하면서 수십 번의 스키마 변경이 발생합니다.

예를 들어, v1에서는 Todos 테이블만 있었는데, v2에서 Categories 추가, v3에서 Todos에 priority 컬럼 추가, v4에서 인덱스 추가 같은 변경이 이루어집니다. 사용자가 v1에서 v4로 직접 업데이트할 수도 있으므로 모든 중간 단계를 올바르게 처리해야 합니다.

기존에는 각 버전 변경마다 SQL 문을 직접 작성하고 테스트해야 했으며, 실수하면 사용자 데이터가 손실될 위험이 있었습니다. Drift는 스키마 버전을 명시적으로 관리하고, 각 업그레이드 단계를 메서드로 정의하며, 자동화된 테스트로 마이그레이션을 검증할 수 있습니다.

마이그레이션의 핵심 특징은 다음과 같습니다. 첫째, schemaVersion으로 현재 스키마 버전을 명시합니다.

둘째, MigrationStrategy에서 각 버전 업그레이드 로직을 정의합니다. 셋째, validateDatabaseSchema()로 마이그레이션이 올바르게 작동하는지 자동 검증합니다.

이러한 특징들이 데이터베이스 진화를 안전하게 관리할 수 있게 해줍니다.

코드 예제

@DriftDatabase(tables: [Todos, Categories])
class MyDatabase extends _$MyDatabase {
  MyDatabase(QueryExecutor e) : super(e);

  // 현재 스키마 버전 (변경 시 증가)
  @override
  int get schemaVersion => 3;

  @override
  MigrationStrategy get migration => MigrationStrategy(
    // 버전별 업그레이드 로직
    onUpgrade: (m, from, to) async {
      if (from < 2) {
        // v1 -> v2: Categories 테이블 추가
        await m.createTable(categories);
      }
      if (from < 3) {
        // v2 -> v3: Todos에 priority 컬럼 추가
        await m.addColumn(todos, todos.priority);
      }
    },
    // 개발 중 스키마 검증
    beforeOpen: (details) async {
      await validateDatabaseSchema();
    },
  );
}

설명

이것이 하는 일: 데이터베이스 스키마 변경을 버전별로 추적하고, 각 버전 간 업그레이드 로직을 실행하여 기존 데이터를 보존하면서 새 구조로 전환합니다. 첫 번째로, schemaVersion을 증가시킵니다.

이 숫자는 테이블 구조, 컬럼 추가/제거, 인덱스 변경 등 스키마가 바뀔 때마다 1씩 증가해야 합니다. Drift는 앱 실행 시 데이터베이스에 저장된 버전과 코드의 schemaVersion을 비교하여 마이그레이션이 필요한지 판단합니다.

버전을 건너뛰거나 감소시키면 안 됩니다. 그 다음으로, MigrationStrategyonUpgrade 콜백을 정의합니다.

이 함수는 from(기존 버전)과 to(새 버전) 파라미터를 받으며, 여러 버전을 건너뛰는 경우도 처리해야 합니다. 조건문을 사용하여 각 버전에서 필요한 변경사항을 순차적으로 적용합니다.

m.createTable()은 새 테이블을 생성하고, m.addColumn()은 기존 테이블에 컬럼을 추가합니다. 컬럼을 추가할 때는 기본값이나 nullable이 필요합니다.

기존 데이터가 있는 테이블에 NOT NULL 컬럼을 추가하려면 withDefault()를 사용하거나, 먼저 nullable로 추가한 후 데이터를 채우고 제약 조건을 변경해야 합니다. 복잡한 마이그레이션은 여러 단계로 나누어 안전하게 수행하세요.

beforeOpenvalidateDatabaseSchema()는 개발 중에 마이그레이션이 올바르게 작동하는지 검증합니다. 이 함수는 현재 테이블 구조가 Dart 코드에 정의된 스키마와 일치하는지 확인하고, 불일치가 있으면 예외를 던집니다.

프로덕션 빌드에서는 이 검증을 제거할 수 있지만, 개발 중에는 반드시 활성화하여 실수를 조기에 발견하세요. 여러분이 마이그레이션을 올바르게 구현하면 앱 업데이트 시 사용자 데이터 손실 없음, 모든 버전 간 안전한 업그레이드, 쉬운 스키마 변경 관리를 얻을 수 있습니다.

특히 프로덕션 앱에서는 마이그레이션 테스트가 필수입니다.

실전 팁

💡 마이그레이션 테스트를 작성하세요. Drift는 package:drift_dev/api/migrations.dart로 각 버전의 데이터베이스를 생성하고 업그레이드를 시뮬레이션할 수 있는 테스트 유틸리티를 제공합니다.

💡 복잡한 데이터 변환이 필요한 마이그레이션은 트랜잭션 내에서 수행하세요. 중간에 실패하면 모든 변경이 롤백되어 데이터 일관성이 보장됩니다.

💡 컬럼 이름을 변경할 때는 새 컬럼 추가 -> 데이터 복사 -> 이전 컬럼 삭제 순서로 진행하세요. SQLite는 컬럼 이름 변경을 직접 지원하지 않습니다.

💡 마이그레이션 로직은 절대 제거하지 마세요. 구버전에서 직접 최신 버전으로 업그레이드하는 사용자를 위해 모든 중간 단계가 필요합니다.

💡 대량 데이터 마이그레이션은 배치 처리로 나누어 실행하세요. 한 번에 너무 많은 데이터를 처리하면 앱이 멈춘 것처럼 보일 수 있습니다.


7. 트랜잭션_처리

시작하며

여러분이 은행 앱을 만든다고 상상해보세요. 사용자 A에서 사용자 B로 돈을 송금하는 기능을 구현할 때, A의 잔액을 감소시키고 B의 잔액을 증가시키는 두 작업이 필요합니다.

만약 첫 번째 작업은 성공했는데 두 번째 작업이 실패한다면 어떻게 될까요? 돈이 사라지는 심각한 버그가 발생합니다.

이처럼 여러 데이터베이스 작업이 모두 성공하거나 모두 실패해야 하는 경우가 실무에서 많습니다. 한 작업이라도 실패하면 이전 작업들을 모두 되돌려야 하는데, 이를 수동으로 관리하면 코드가 복잡해지고 에러가 발생하기 쉽습니다.

트랜잭션은 여러 작업을 하나의 원자적 단위로 묶어서 데이터 일관성을 보장합니다. Drift는 간단한 API로 트랜잭션을 지원하며, 예외 발생 시 자동으로 롤백됩니다.

개요

간단히 말해서, 트랜잭션은 여러 데이터베이스 작업을 하나의 논리적 단위로 묶어, 모두 성공하거나 모두 실패하도록 보장하는 메커니즘입니다. 실무에서는 복잡한 비즈니스 로직이 여러 테이블에 걸쳐 있는 경우가 많습니다.

예를 들어, 주문을 생성할 때 주문 테이블에 데이터를 추가하고, 주문 상품 테이블에 여러 행을 추가하며, 재고 테이블의 수량을 감소시켜야 합니다. 이 세 작업 중 하나라도 실패하면 전체를 취소해야 데이터 무결성이 유지됩니다.

트랜잭션 없이 구현하면 각 단계마다 성공/실패를 확인하고, 실패 시 이전 단계를 수동으로 되돌려야 합니다. 이는 코드를 복잡하게 만들고 실수하기 쉽습니다.

Drift의 트랜잭션을 사용하면 여러 작업을 함수 블록 안에 작성하기만 하면 되고, 예외가 발생하면 자동으로 모든 변경이 취소됩니다. 트랜잭션의 핵심 특징은 다음과 같습니다.

첫째, ACID 속성(원자성, 일관성, 고립성, 지속성)을 보장합니다. 둘째, transaction() 메서드로 간단하게 작성할 수 있습니다.

셋째, 중첩 트랜잭션도 지원하여 복잡한 시나리오를 처리할 수 있습니다. 이러한 특징들이 데이터 무결성을 지키면서 복잡한 비즈니스 로직을 안전하게 구현할 수 있게 합니다.

코드 예제

// 여러 작업을 트랜잭션으로 묶기
Future<void> transferTodo(int todoId, int fromUserId, int toUserId) async {
  await transaction(() async {
    // 1. 할 일의 소유자를 확인
    final todo = await (select(todos)..where((t) => t.id.equals(todoId))).getSingle();

    if (todo.userId != fromUserId) {
      throw Exception('권한이 없습니다');
    }

    // 2. 소유자 변경
    await (update(todos)..where((t) => t.id.equals(todoId)))
        .write(TodosCompanion(userId: Value(toUserId)));

    // 3. 로그 테이블에 기록
    await into(transferLogs).insert(TransferLogsCompanion(
      todoId: Value(todoId),
      fromUserId: Value(fromUserId),
      toUserId: Value(toUserId),
      transferredAt: Value(DateTime.now()),
    ));

    // 예외 발생 시 모든 변경사항이 자동으로 롤백됨
  });
}

설명

이것이 하는 일: 여러 데이터베이스 작업을 하나의 트랜잭션으로 감싸서, 모든 작업이 성공할 때만 커밋하고 하나라도 실패하면 전체를 롤백합니다. 첫 번째로, transaction() 메서드를 호출하고 내부에 비동기 함수를 전달합니다.

이 함수 안에서 수행하는 모든 데이터베이스 작업은 하나의 트랜잭션으로 묶입니다. 트랜잭션이 시작되면 SQLite는 변경사항을 임시로 저장하고, 함수가 정상적으로 완료되면 커밋하여 영구적으로 반영합니다.

그 다음으로, 여러 데이터베이스 작업을 순차적으로 수행합니다. 예제에서는 할 일을 조회하고, 권한을 확인하고, 소유자를 변경하며, 로그를 기록합니다.

각 작업은 이전 작업의 결과에 의존할 수 있으며, 모두 같은 트랜잭션 내에서 실행되므로 다른 데이터베이스 연결에서는 중간 상태를 볼 수 없습니다. 예외가 발생하면 자동으로 롤백됩니다.

예제에서 권한 확인에 실패하여 Exception을 던지거나, 데이터베이스 작업 중 오류가 발생하면 트랜잭션 내의 모든 변경사항이 취소됩니다. 수동으로 롤백 로직을 작성할 필요가 없으며, 이는 코드를 간결하고 안전하게 만듭니다.

트랜잭션은 중첩될 수 있습니다. 트랜잭션 내에서 다른 트랜잭션을 호출하면 Drift는 자동으로 savepoint를 생성하여 부분 롤백을 지원합니다.

복잡한 비즈니스 로직을 작은 함수로 나누고 각각 트랜잭션으로 보호할 때 유용합니다. 여러분이 트랜잭션을 적절히 사용하면 데이터 무결성 보장, 복잡한 작업의 안전한 실행, 코드 간소화를 얻을 수 있습니다.

특히 금융, 재고 관리, 예약 시스템 같이 정확성이 중요한 도메인에서는 필수적입니다.

실전 팁

💡 트랜잭션은 가능한 짧게 유지하세요. 긴 트랜잭션은 데이터베이스를 오래 잠그고 다른 작업을 블로킹할 수 있습니다.

💡 트랜잭션 내에서 네트워크 요청이나 무거운 계산을 하지 마세요. 데이터베이스 작업만 포함시키고 다른 작업은 밖에서 수행하세요.

💡 읽기 전용 트랜잭션이 필요하면 일반 쿼리를 사용하세요. SQLite는 읽기 작업을 병렬로 처리할 수 있지만, 트랜잭션으로 묶으면 직렬화됩니다.

💡 낙관적 잠금(Optimistic Locking)이 필요하면 버전 컬럼을 추가하세요. 업데이트 시 버전을 확인하여 동시 수정을 감지할 수 있습니다.

💡 트랜잭션 내에서 의도적으로 롤백하려면 예외를 던지세요. throw RollbackException()처럼 커스텀 예외를 만들어 정상적인 롤백과 에러를 구분할 수 있습니다.


8. 스트림_활용

시작하며

여러분이 할 일 목록 화면을 만들었는데, 사용자가 할 일을 완료로 표시하면 자동으로 UI가 업데이트되어야 합니다. 기존 방식대로라면 데이터를 수정한 후 다시 조회하여 setState()를 호출해야 하죠.

여러 화면에서 같은 데이터를 보여준다면 모든 곳을 수동으로 업데이트해야 할까요? 이런 수동 업데이트 방식은 코드 중복을 만들고 버그를 유발하기 쉽습니다.

한 곳에서 데이터를 변경했는데 다른 곳의 UI를 업데이트하는 것을 잊어버리면 사용자에게 잘못된 정보가 표시됩니다. 특히 실시간성이 중요한 앱에서는 치명적입니다.

Drift의 스트림 기반 쿼리는 데이터가 변경되면 자동으로 새 결과를 방출하여 UI를 업데이트합니다. watch() 메서드로 쿼리를 스트림으로 만들고 StreamBuilder와 함께 사용하면 완전히 리액티브한 앱을 쉽게 만들 수 있습니다.

개요

간단히 말해서, Drift의 스트림 쿼리는 데이터베이스 변경을 자동으로 감지하여 새로운 결과를 방출하는 리액티브 데이터 소스입니다. 실무에서는 여러 화면이 동일한 데이터를 다른 방식으로 보여주는 경우가 많습니다.

예를 들어, 메인 화면에서는 할 일 목록을, 통계 화면에서는 완료율을, 위젯에서는 오늘의 할 일 개수를 보여줍니다. 한 곳에서 할 일을 완료하면 모든 화면이 즉시 업데이트되어야 사용자 경험이 좋습니다.

기존에는 상태 관리 솔루션(Provider, Riverpod 등)을 사용하여 수동으로 상태를 전파해야 했다면, Drift 스트림은 데이터베이스 수준에서 자동으로 변경을 감지하고 알려줍니다. 개발자는 그저 스트림을 구독하기만 하면 되고, 데이터 변경 시 재조회나 상태 업데이트를 신경 쓸 필요가 없습니다.

스트림 쿼리의 핵심 특징은 다음과 같습니다. 첫째, watch() 메서드로 모든 쿼리를 스트림으로 변환할 수 있습니다.

둘째, 관련 테이블의 데이터가 변경되면 자동으로 쿼리를 재실행하여 새 결과를 방출합니다. 셋째, StreamBuilder와 완벽하게 통합되어 선언적 UI를 만들 수 있습니다.

이러한 특징들이 복잡한 상태 관리 없이도 항상 최신 데이터를 표시하는 앱을 만들 수 있게 합니다.

코드 예제

// DAO에서 스트림 쿼리 정의
class TodoDao extends DatabaseAccessor<MyDatabase> with _$TodoDaoMixin {
  TodoDao(MyDatabase db) : super(db);

  // 모든 할 일을 실시간으로 감시
  Stream<List<Todo>> watchAllTodos() => select(todos).watch();

  // 완료되지 않은 할 일 개수
  Stream<int> watchActiveTodoCount() {
    final query = selectOnly(todos)..addColumns([todos.id.count()]);
    query.where(todos.isCompleted.equals(false));
    return query.map((row) => row.read(todos.id.count()) ?? 0).watchSingle();
  }
}

// UI에서 StreamBuilder로 사용
StreamBuilder<List<Todo>>(
  stream: todoDao.watchAllTodos(),
  builder: (context, snapshot) {
    if (!snapshot.hasData) return CircularProgressIndicator();
    final todos = snapshot.data!;
    return ListView.builder(/* ... */);
  },
)

설명

이것이 하는 일: 쿼리 결과를 스트림으로 제공하여 데이터베이스 변경을 실시간으로 추적하고, UI를 자동으로 업데이트합니다. 첫 번째로, DAO에 스트림 쿼리 메서드를 정의합니다.

select(todos).watch()는 일반 쿼리의 get() 대신 watch()를 호출하여 Stream<List<Todo>>를 반환합니다. 이 스트림은 Todos 테이블에 데이터가 삽입, 수정, 삭제될 때마다 자동으로 쿼리를 재실행하고 새 결과를 방출합니다.

구독자가 많아도 Drift는 내부적으로 최적화하여 중복 쿼리를 방지합니다. 그 다음으로, 집계 함수를 사용하는 스트림 쿼리도 만들 수 있습니다.

selectOnly()는 특정 컬럼만 선택하며, addColumns()로 집계 함수를 추가합니다. watchSingle()은 단일 값을 방출하는 스트림을 반환하는데, 리스트가 아닌 개수나 합계 같은 스칼라 값에 유용합니다.

이 스트림도 관련 데이터가 변경되면 자동으로 재계산됩니다. UI에서는 StreamBuilder 위젯으로 스트림을 구독합니다.

stream 파라미터에 스트림을 전달하고, builder에서 최신 스냅샷을 받아 UI를 구성합니다. 데이터가 없으면 로딩 인디케이터를 보여주고, 데이터가 있으면 리스트를 렌더링합니다.

데이터베이스에서 할 일을 추가, 수정, 삭제하면 자동으로 builder가 재실행되어 UI가 업데이트됩니다. 스트림은 구독이 취소될 때까지 계속 활성화됩니다.

StreamBuilder는 위젯이 dispose될 때 자동으로 구독을 취소하므로 메모리 누수를 걱정할 필요가 없습니다. 하지만 수동으로 listen()을 사용하는 경우에는 StreamSubscription을 저장하고 적절한 시점에 cancel()을 호출해야 합니다.

여러분이 스트림 쿼리를 활용하면 자동 UI 업데이트, 복잡한 상태 관리 불필요, 항상 최신 데이터 보장을 얻을 수 있습니다. 특히 협업 앱이나 실시간 대시보드처럼 데이터가 자주 변경되는 앱에서는 개발 생산성을 크게 향상시킵니다.

실전 팁

💡 스트림은 필요한 곳에만 사용하세요. 한 번만 읽고 버리는 데이터에는 get()을 사용하는 것이 효율적입니다.

💡 복잡한 화면에서는 여러 스트림을 조합해야 할 수 있습니다. Rx 패키지의 combineLatestStreamBuilder를 중첩하여 여러 데이터 소스를 합칠 수 있습니다.

💡 스트림 쿼리는 관련 테이블만 추적합니다. @DriftAccessor에 선언한 테이블이나 쿼리에서 사용한 테이블의 변경만 감지하므로, 의도치 않은 재실행은 발생하지 않습니다.

💡 성능 최적화를 위해 distinct()를 사용하세요. 데이터가 실제로 변경되지 않았는데 이벤트가 발생하는 경우, distinct()로 중복을 제거하여 불필요한 UI 리빌드를 방지할 수 있습니다.

💡 Riverpod이나 Provider와 함께 사용할 때는 스트림을 Provider로 감싸세요. 이렇게 하면 의존성 주입이 쉬워지고 테스트가 간편해집니다.


#Flutter#Drift#SQLite#Database#LocalStorage