effidevFlutter · Cloudflare 엣지 · 클라우드 비용 최적화
한국어

Flutter Supabase 오프라인 퍼스트: Drift·PowerSync 동기화

Flutter와 Supabase 오프라인 퍼스트 Drift PowerSync 아키텍처

네트워크 접속이 불안정한 지하철, 엘리베이터, 음영 지역에서 모바일 앱이 “네트워크 오류” 스피너만 띄우며 멈춰버린다면 사용자는 즉시 앱을 삭제한다. 기존의 ‘네트워크 요청 ➔ 실패 시 캐시 읽기’ 구조는 잦은 로딩 스피너와 오래된 데이터(Stale State) 노출을 피할 수 없다.

2026년 모바일 앱 개발의 세계 표준은 오프라인 퍼스트(Offline-First / Local-First) 아키텍처다. 오프라인 퍼스트 앱에서는 브라우저나 디바이스 내부의 **로컬 SQLite 데이터베이스가 단일 진실 원천(Single Source of Truth)**이 되며, UI는 네트워크 상태를 전혀 몰라도 즉시 반응한다.

이 글은 Flutter와 Supabase(PostgreSQL) 환경에서 Drift SQLite, PowerSync 동기화 엔진, 그리고 Riverpod 3.0을 결합하여 오프라인 반응성과 실시간 서버 동기화를 100% 보장하는 엔터프라이즈 오프라인 퍼스트 아키텍처를 완벽 구현한다.

핵심 요약

  • 단일 진실 원천(Single Source of Truth): UI는 Supabase API를 직접 호출하지 않고 로컬 Drift SQLite 데이터베이스만 구독한다. 데이터 변경 시 0ms 반응성을 보장한다.
  • PowerSync 엔진의 역할: 로컬 SQLite와 Supabase Postgres 사이에서 부분 동기화(Partial Sync), 충돌 해결(Conflict Resolution), 비동기 아웃박스(Outbox) 처리를 완전 자동화한다.
  • Riverpod 3.0 결합: streamProvider를 통해 로컬 SQLite의 바인딩 스트림을 구독하여 오프라인 상태에서도 UI가 즉시 갱신된다.
  • 네트워크 투명성: 지하철 등에서 와이파이가 끊겨도 사용자는 데이터 쓰기/수정을 계속할 수 있으며, 재연결 시 백그라운드에서 백엔드로 자동 동기화된다.
  • 구형 아키텍처(Brick 등) 탈피: 단순 HTTP 캐싱이나 과도한 래퍼 라이브러리 대신, 2026년 표준인 Drift + PowerSync 조합으로 렌더링 랭크와 데이터 유실을 100% 방지한다.

1. 오프라인 퍼스트 패러다임: 네트워크 캐싱 vs 로컬 퍼스트

기존 캐싱 방식과 2026년 오프라인 퍼스트 아키텍처의 결정적 차이다.

[기존 네트워크 캐싱 방식] ❌
UI ──► Network Fetch (300ms 스피너) ──(실패 시)──► 로컬 캐시 읽기 (오래된 데이터)

[2026 오프라인 퍼스트 아키텍처] ⭕️
UI ──(0ms 즉시 읽기/쓰기)──► [Drift Local SQLite (Single Source of Truth)]

                                           │ (백그라운드 동기화 0ms~네트워크 연결 시)

                                   [PowerSync Engine]

                                           │ (Real-time Sync)

                                  [Supabase PostgreSQL]

오프라인 퍼스트 구조에서는 네트워크 연결이 끊어져도 앱의 동작에 일절 제약이 없다. 모든 데이터 읽기/쓰기는 디바이스 내부의 Drift SQLite로 즉시 수행되고 UI가 0ms만에 반응하며, PowerSync 엔진이 백그라운드 아웃박스 큐(Outbox Queue)에 변경사항을 기록해 두었다가 네트워크가復舊되는 즉시 Supabase로 동기화한다.

2. Drift SQLite + PowerSync + Riverpod 3.0 아키텍처 레이어

레이어 역할 및 구성 요소 주요 특징
UI & State Layer Flutter UI + Riverpod 3.0 (NotifierProvider) 네트워크 상태와 무관하게 로컬 Drift DB의 Stream만 구독
Local Persistence Drift (SQLite ORM) 단일 진실 원천. 타입 안정성이 뛰어난 Dart SQLite 데이터베이스
Sync Engine PowerSync Client 로컬 SQLite ↔ Supabase Postgres 간 실시간 차분 동기화 & 충돌 해결
Remote Database Supabase (PostgreSQL) RLS(Row Level Security) 기반 멀티테넌트 데이터 보관소

3. 오프라인 퍼스트 실전 구현 코드

1단계: Drift SQLite 테이블 정의 (lib/database/app_database.dart)

Drift 공식 문서 규격에 맞춘 타입 안정성 있는 로컬 DB 정의다.

import 'package:drift/drift.dart';
import 'package:drift/native.dart';

part 'app_database.g.dart';

class Todos extends Table {
  TextColumn get id => text()();
  TextColumn get title => text()();
  BoolColumn get isCompleted => boolean().withDefault(const Constant(false))();
  DateTimeColumn get updatedAt => dateTime()();

  @override
  Set<Column> get primaryKey => {id};
}

@DriftDatabase(tables: [Todos])
class AppDatabase extends _$AppDatabase {
  AppDatabase() : super(NativeDatabase.memory());

  @override
  int get schemaVersion => 1;

  // 로컬 DB 변경 사항을 0ms 스트림으로 노출
  Stream<List<Todo>> watchAllTodos() {
    return (select(todos)..orderBy([(t) => OrderingTerm.desc(t.updatedAt)])).watch();
  }
}

2단계: PowerSync 오프라인 스키마 & 충돌 해결 세팅 (lib/sync/powersync.dart)

PowerSync 공식 문서를 통한 Supabase 오프라인 백그라운드 동기화 커넥터 설정이다.

import 'package:powersync/powersync.dart';

final schema = Schema([
  Table('todos', [
    Column.text('title'),
    Column.integer('is_completed'),
    Column.text('updated_at'),
  ])
]);

late final PowerSyncDatabase db;

Future<void> initPowerSync(String supabaseUrl, String anonKey) async {
  db = PowerSyncDatabase(schema: schema, path: 'app_powersync.db');
  await db.initialize();

  // Supabase 커넥터 연결 (네트워크가 연결되면 백그라운드 동기화 수행)
  final connector = SupabaseConnector(url: supabaseUrl, anonKey: anonKey);
  await db.connect(connector: connector);
}

3단계: Riverpod 3.0 오프라인 반응형 UI 구독 (lib/providers/todo_provider.dart)

Riverpod 3.0 가이드에서 다룬 AsyncNotifier와 Stream 반응형 패턴을 결합하여, 네트워크 연결 유무에 관계없이 UI가 즉시 반응하도록 구성한다.

import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../database/app_database.dart';

final databaseProvider = Provider<AppDatabase>((ref) => AppDatabase());

// 로컬 Drift DB의 Stream을 실시간 구독하는 Riverpod 3.0 StreamProvider
final todoListProvider = StreamProvider.autoDispose<List<Todo>>((ref) {
  final db = ref.watch(databaseProvider);
  return db.watchAllTodos();
});

// UI에서 Todo 추가 시: 네트워크 요청 없이 로컬 DB에 즉시 Insert
class TodoNotifier extends AutoDisposeAsyncNotifier<void> {
  @override
  Future<void> build() async {}

  Future<void> addTodo(String title) async {
    final db = ref.read(databaseProvider);
    await db.into(db.todos).insert(
      TodosCompanion.insert(
        id: DateTime.now().millisecondsSinceEpoch.toString(),
        title: title,
        updatedAt: DateTime.now(),
      ),
    );
  }
}

4. 네트워크 연결 해제 및 재연결 실전 테스트 패턴

오프라인 퍼스트 앱의 정당성을 입증하려면 **네트워크 조작 테스트(Network Manipulation Test)**를 거쳐야 한다.

[테스트 시나리오]
1. 비행기 탑승 모드(오프라인) 활성화 ➔ 앱에서 새 메모 5개 작성 ➔ 0ms 즉시 화면 반영 확인 ⭕️
2. 앱 종료 후 재실행 (오프라인 상태) ➔ 작성한 데이터 100% 로컬 유효성 확인 ⭕️
3. 와이파이 재연결 ➔ PowerSync가 백그라운드에서 Supabase Postgres로 자동 Sync ⭕️
4. Supabase 대시보드 ➔ RLS 정책 적용 및 데이터 충돌 없이 전송 완료 확인 ⭕️

이 구조를 적용하면 Flutter DevTools 메모리 누수 및 CPU 프로파일링 실전 가이드에서 강조한 UI 스레드 렌더링 랭크가 발생하지 않으며, 프레임 레이트 60~120fps가 완벽하게 유지된다.

5. SQLCipher 기반 로컬 SQLite 암호화 실전 세팅

금융, 개인정보, 기밀 데이터를 처리하는 앱은 디바이스 분실이나 루팅(Rooting)/탈옥(Jailbreak) 시 로컬 SQLite 파일이 외부로 유출될 위험이 있다. Drift와 sqflite_sqlcipher 파이프라인을 결합하면 256-bit AES 암호화 데이터베이스를 구성할 수 있다.

import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';

// 256-bit AES 로컬 DB 암호화 실행기
LazyDatabase _openEncryptedDatabase(String secretPassphrase) {
  return LazyDatabase(() async {
    final dbFolder = await getDatabasesPath();
    final file = File(p.join(dbFolder, 'app_encrypted.sqlite'));

    return NativeDatabase(
      file,
      setup: (rawDb) {
        // SQLCipher PRAGMA 암호화 키 설정
        rawDb.execute("PRAGMA key = '$secretPassphrase';");
      },
    );
  });
}

이 코드를 적용하면 디바이스의 데이터베이스 파일(app_encrypted.sqlite)을 직접 추출하더라도 암호화 키 없이는 내용을 절대 읽을 수 없으며, PowerSync 클라이언트 역시 암호화된 터널을 통해 안전하게 통신한다.

6. 2026 오프라인 퍼스트 라이브러리 선택 가이드

라이브러리 오프라인 퍼스트 적합성 평가 및 추천 사유
PowerSync + Drift ★★★★★ (추천) 2026 모범 사례. partial sync, 충돌 해결, Drift 타입 안정성 완벽 조화
Drift + Custom Outbox ★★★★☆ (커스텀) 서드파티 서비스 없이 직접 Sync 큐와 REST/GraphQL 커넥터를 만들 때 우수
Supabase Realtime 캐시 ★★☆☆☆ (비추천) 오프라인 작성 불가능. 네트워크가 있을 때의 실시간 구독에만 특화됨
Brick ORM ★☆☆☆☆ (비추천) 서버 측 삭제 보동기화 실패 및 복잡한 보일러플레이트로 2026년 유지보수 기피

자주 묻는 질문

오프라인 상태에서 여러 디바이스가 같은 데이터를 수정하면 충돌이 안 나나요?

PowerSync 엔진은 LWW(Last-Write-Wins) 시계열 규칙이나 CRDT(Conflict-free Replicated Data Type) 알골리즘을 통해 충돌을 백그라운드에서 자동 해결합니다. 특수 비즈니스 로직이 필요한 경우 Supabase Postgres 트리거 함수나 PowerSync 커스텀 충돌 핸들러를 정의하여 손쉽게 제어할 수 있습니다.

로컬 SQLite DB 용량이 너무 커지면 어떻게 하나요?

PowerSync의 부분 동기화(Partial Sync Rules) 기능을 활용하면 사용자가 현재 필요로 하는 데이터(예: 최근 30일 데이터 또는 본인 소유 데이터)만 선택적으로 로컬 디바이스에 다운로드할 수 있어, 디바이스 메모리와 저장공간을 최소화할 수 있습니다.

Supabase 오프라인 데이터 보안은 안전한가요?

로컬 Drift SQLite 데이터베이스 파일에 sqlcipher 암호화 엔진을 적용하면 디바이스 탈옥이나 루팅 환경에서도 데이터베이스 파일을 복호화할 수 없습니다. 또한 Supabase 서버 측 RLS(Row Level Security) 정책이 재연결 시 2차 검증을 수행하므로 보안이 철저히 유지됩니다.

웹(Flutter Web) 플랫폼에서도 동일하게 오프라인 퍼스트가 동작하나요?

동작합니다. Flutter Web 환경에서는 Drift가 Wasm + IndexedDB 백엔드로 자동 전환되어 브라우저 내부에 오프라인 SQLite 데이터베이스를 생성합니다. 웹 환경의 Wasm 렌더링 성능은 Flutter Web Wasm + Skwasm 가이드에서 상세히 다루었습니다.