LogoSkills

flutter-databases

Flutter 앱에서 데이터베이스를 다룹니다. SQLite, Drift 등 로컬 데이터베이스를 연동할 때 사용합니다.

flutter-data-layer-persistence#

한마디로#

앱이 데이터를 어디에, 어떻게 저장할지 정리해 주는 안내서입니다. 마치 물건을 종류에 따라 서랍, 캐비닛, 창고에 나눠 보관하듯이, 데이터의 성격에 맞는 저장 방식을 골라 줍니다. 또 앱의 화면이 저장소를 직접 건드리지 않고 '창고 관리인'(Repository) 한 명을 거치도록 정리해, 데이터가 꼬이지 않게 합니다.

무엇을·언제#

  • 무엇을: Flutter 앱이 데이터를 기기에 저장하고 다시 불러오는 부분(데이터 계층)을 튼튼하게 설계하고 만들어 줍니다.
  • 무엇을: 데이터의 종류(작은 설정값인지, 표 형태의 많은 데이터인지, 이미지인지 등)에 맞는 저장 방법을 추천해 줍니다.
  • 무엇을: 화면 코드가 저장소를 직접 만지지 못하게 막아, 한 곳(Repository)에서만 데이터를 다루도록 정리합니다.
  • 언제: Flutter 앱에 SQLite, Drift 같은 로컬 데이터베이스를 붙이거나, 앱 안에서 데이터를 저장·불러오는 기능을 만들 때 작동합니다.

핵심 용어#

용어쉬운 설명
데이터 계층 (data layer)앱에서 데이터를 저장하고 꺼내 오는 일을 담당하는 부분
Repository데이터를 한곳에서만 다루는 '창고 관리인' 같은 역할. 화면은 항상 이 사람을 통해서만 데이터를 주고받음
Service (서비스)데이터베이스나 외부 서버와 직접 대화하는 실무 담당자. 관리인(Repository) 뒤에 숨어서 일함
단일 진실 공급원 (Single Source of Truth) 데이터의 '진짜 정답'이 한 곳에만 있다는 원칙. 정보가 여러 곳에 흩어져 어긋나는 걸 막음
SQLite / sqflite / Drift기기 안에 표(테이블) 형태로 데이터를 저장하는 작은 데이터베이스 도구
shared_preferences테마 설정 같은 아주 작고 단순한 값을 저장하는 간단한 보관함
도메인 모델 (Domain Model)앱이 실제로 다루기 좋게 정리된 데이터 모양. 저장소의 날것 데이터를 이 형태로 바꿔서 화면에 전달
오프라인 우선 (offline-first)인터넷이 없어도 기기에 저장된 데이터로 먼저 동작하게 만드는 방식
SQL 인젝션 (SQL Injection)잘못 만든 검색문 틈으로 데이터가 조작·유출되는 보안 공격. 안전한 방식(파라미터 사용)으로 막음

Goal#

Architects and implements a robust, MVVM-compliant data layer in Flutter applications. Establishes a single source of truth using the Repository pattern, isolates external API and local database interactions into stateless Services, and implements optimal local caching strategies (e.g., SQLite via sqflite) based on data requirements. Assumes a pre-configured Flutter environment.

Decision Logic#

Evaluate the user's data persistence requirements using the following decision tree to select the appropriate caching strategy:

  • Is the data small, simple key-value pairs (e.g., user preferences, theme settings)?
    • Yes: Use shared_preferences.
  • Is the data a large, structured, relational dataset requiring fast inserts/queries?
    • Yes: Use On-device relational databases (sqflite or drift).
  • Is the data a large, unstructured/non-relational dataset?
    • Yes: Use On-device non-relational databases (hive_ce or isar_community).
  • Is the data primarily API response caching?
    • Yes: Use a lightweight remote caching system or interceptors.
  • Is the data primarily images?
    • Yes: Use cached_network_image to store images on the file system.
  • Is the data too large for shared_preferences but doesn't require querying?
    • Yes: Use direct File System I/O.

Instructions#

  1. Analyze Data Requirements STOP AND ASK THE USER: "What specific data entities need to be managed in the data layer, and what are their persistence requirements (e.g., size, relational complexity, offline-first capabilities)?" Wait for the user's response before proceeding to step 2.

  2. Configure Dependencies Based on the decision logic, add the required dependencies. For a standard SQLite implementation, execute:

        flutter pub add sqflite path
    
  3. Define Domain Models Create pure Dart data classes representing the domain models. These models should contain only the information needed by the rest of the app.

    class Todo {
      final int? id;
      final String title;
      final bool isCompleted;
    
      const Todo({this.id, required this.title, required this.isCompleted});
    
      Map<String, dynamic> toMap() {
        return {
          'id': id,
          'title': title,
          'isCompleted': isCompleted ? 1 : 0,
        };
      }
    
      factory Todo.fromMap(Map<String, dynamic> map) {
        return Todo(
          id: map['id'] as int?,
          title: map['title'] as String,
          isCompleted: map['isCompleted'] == 1,
        );
      }
    }
    
  4. Implement the Database Service Create a stateless service class to handle direct interactions with the SQLite database.

    import 'package:path/path.dart';
    import 'package:sqflite/sqflite.dart';
    
    class DatabaseService {
      Database? _database;
    
      Future<void> open() async {
        if (_database != null && _database!.isOpen) return;
           
        _database = await openDatabase(
          join(await getDatabasesPath(), 'app_database.db'),
          onCreate: (db, version) {
            return db.execute(
              'CREATE TABLE todos(id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, isCompleted INTEGER)',
            );
          },
          version: 1,
        );
      }
    
      bool get isOpen => _database != null && _database!.isOpen;
    
      Future<int> insertTodo(Todo todo) async {
        return await _database!.insert(
          'todos',
          todo.toMap(),
          conflictAlgorithm: ConflictAlgorithm.replace,
        );
      }
    
      Future<List<Todo>> fetchTodos() async {
        final List<Map<String, dynamic>> maps = await _database!.query('todos');
        return maps.map((map) => Todo.fromMap(map)).toList();
      }
    
      Future<void> deleteTodo(int id) async {
        await _database!.delete(
          'todos',
          where: 'id = ?',
          whereArgs: [id],
        );
      }
    }
    
  5. Implement the API Client Service (Optional/If Applicable) Create a stateless service for remote data fetching.

    class ApiClient {
      Future<List<dynamic>> fetchRawTodos() async {
        // Implementation for HTTP GET request
        return []; 
      }
    }
    
  6. Implement the Repository Create the Repository class. This is the single source of truth for the application data. It must encapsulate the services as private members.

    class TodoRepository {
      final DatabaseService _databaseService;
      final ApiClient _apiClient;
    
      TodoRepository({
        required DatabaseService databaseService,
        required ApiClient apiClient,
      })  : _databaseService = databaseService,
            _apiClient = apiClient;
    
      Future<List<Todo>> getTodos() async {
        await _ensureDbOpen();
        // Example of offline-first logic: fetch local, optionally sync with remote
        return await _databaseService.fetchTodos();
      }
    
      Future<void> createTodo(Todo todo) async {
        await _ensureDbOpen();
        await _databaseService.insertTodo(todo);
        // Trigger API sync here if necessary
      }
    
      Future<void> removeTodo(int id) async {
        await _ensureDbOpen();
        await _databaseService.deleteTodo(id);
      }
    
      Future<void> _ensureDbOpen() async {
        if (!_databaseService.isOpen) {
          await _databaseService.open();
        }
      }
    }
    
  7. Validate-and-Fix Review the generated implementation against the following checks:

    • Check: Are the services (_databaseService, _apiClient) private members of the Repository? If not, refactor to restrict UI layer access.
    • Check: Does the Repository explicitly ensure the database is open before executing queries? If not, inject the _ensureDbOpen() pattern.
    • Check: Are primary keys (id) used effectively in SQLite queries to optimize update/delete times?

Constraints#

  • Single Source of Truth: The UI layer MUST NEVER interact directly with a Service (e.g., DatabaseService or ApiClient). All data requests must route through the Repository.
  • Stateless Services: Service classes must remain stateless and contain no side effects outside of their specific external API/DB wrapper responsibilities.
  • Domain Model Isolation: Repositories must transform raw data (from APIs or DBs) into Domain Models before passing them to the UI layer.
  • SQL Injection Prevention: Always use parameterized queries (e.g., whereArgs: [id]) in sqflite operations. Never use string interpolation for SQL queries.
  • Database State: The Repository must guarantee the database connection is open before attempting any read/write operations.